mirror of
https://github.com/certctl-io/certctl.git
synced 2026-09-03 01:11:25 +02:00
Compare commits
856 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151107c969 | ||
|
|
b1ca046fdf | ||
|
|
28f93f1f46 | ||
|
|
569aea255f | ||
|
|
c70bb071f9 | ||
|
|
f7fcd1e187 | ||
|
|
9155ec9174 | ||
|
|
58a15e0b3d | ||
|
|
d64c1821a5 | ||
|
|
c8e77fdeca | ||
|
|
1b95709d4b | ||
|
|
35277c0f2c | ||
|
|
5c5bbedc7e | ||
|
|
d7546aedca | ||
|
|
5ea45a19b9 | ||
|
|
374ec574c5 | ||
|
|
4f2d865b51 | ||
|
|
578ac4ec68 | ||
|
|
7e2481b225 | ||
|
|
2e9262cfb7 | ||
|
|
5d7bc86451 | ||
|
|
c4ed3da30b | ||
|
|
663b14bfd8 | ||
|
|
43836aca7c | ||
|
|
8c2d3c844e | ||
|
|
c7f3ec6290 | ||
|
|
6acf3559a3 | ||
|
|
3e09401502 | ||
|
|
38f1200f26 | ||
|
|
e1ab1db65a | ||
|
|
c95685f8ab | ||
|
|
a0404f2d21 | ||
|
|
34d5200904 | ||
|
|
3ce05ab0a8 | ||
|
|
360eaa75bc | ||
|
|
b721596213 | ||
|
|
6a640ac3e7 | ||
|
|
15fedbaa06 | ||
|
|
c40690e42d | ||
|
|
657a699564 | ||
|
|
183c56f6c5 | ||
|
|
a485e31f63 | ||
|
|
8f2e5771db | ||
|
|
037876fa0f | ||
|
|
7d2e7043b9 | ||
|
|
037dab7b6f | ||
|
|
e6cfd756ac | ||
|
|
67dbd18fda | ||
|
|
5a1dbce6d5 | ||
|
|
76e9380389 | ||
|
|
7268d12a17 | ||
|
|
9ba5ee41be | ||
|
|
8e84527ba2 | ||
|
|
622c19cafe | ||
|
|
bc417fc458 | ||
|
|
ac5bb71b61 | ||
|
|
fc237de357 | ||
|
|
b22cdb3405 | ||
|
|
03f0e08a77 | ||
|
|
38f86bca86 | ||
|
|
af5c39252f | ||
|
|
6c00f7b0d3 | ||
|
|
49096914d2 | ||
|
|
aa1c12ae2d | ||
|
|
5231609f26 | ||
|
|
c146e8f75b | ||
|
|
a9e229bd2a | ||
|
|
700c399367 | ||
|
|
1fcb05181d | ||
|
|
508c7530e9 | ||
|
|
c9f932be65 | ||
|
|
868f1c25be | ||
|
|
9ce2d8ca8f | ||
|
|
0987e222dd | ||
|
|
e761ae40a4 | ||
|
|
1daae5d709 | ||
|
|
7c01f811a1 | ||
|
|
c1b581b047 | ||
|
|
e37403edf1 | ||
|
|
93e00f6a5e | ||
|
|
c8985cf868 | ||
|
|
155f1fec98 | ||
|
|
29cb13e7a2 | ||
|
|
9135c44908 | ||
|
|
952682ebec | ||
|
|
a41fc2d75c | ||
|
|
c8347d742d | ||
|
|
67f346cd87 | ||
|
|
558d350933 | ||
|
|
3094010880 | ||
|
|
cd374b243e | ||
|
|
fbe053aa0c | ||
|
|
b1fa4970be | ||
|
|
b503d27b4f | ||
|
|
de4f93b35e | ||
|
|
3f1344e806 | ||
|
|
7f57b1d3bf | ||
|
|
aaddd31d20 | ||
|
|
51f9cf13dc | ||
|
|
57d55b7390 | ||
|
|
c461ef3339 | ||
|
|
5d5bd02f3e | ||
|
|
45ddcb75a3 | ||
|
|
cd3205a66d | ||
|
|
51529ea609 | ||
|
|
1279172e9b | ||
|
|
0ad881c2bd | ||
|
|
ed60059e80 | ||
|
|
ba66748b5b | ||
|
|
8191b1ee64 | ||
|
|
d6f4d5c5e8 | ||
|
|
b2284ef2a4 | ||
|
|
09c29b9f40 | ||
|
|
d364ace02a | ||
|
|
921dac7e6b | ||
|
|
21aeed4f4e | ||
|
|
8c0c8aa69d | ||
|
|
5411c12841 | ||
|
|
9f14894868 | ||
|
|
25996f86fa | ||
|
|
c6602bcbe8 | ||
|
|
888e10cba0 | ||
|
|
3c81531398 | ||
|
|
1383fe419b | ||
|
|
02438ad9e1 | ||
|
|
69a2b5c55a | ||
|
|
95cb002905 | ||
|
|
de8fac24a3 | ||
|
|
0161bb201c | ||
|
|
57b539c378 | ||
|
|
072e2af198 | ||
|
|
476022ca59 | ||
|
|
5b151e74da | ||
|
|
4e8fb16fc2 | ||
|
|
264015059d | ||
|
|
596e675ec7 | ||
|
|
750478a6fe | ||
|
|
7fcdc73e20 | ||
|
|
47da13e7a1 | ||
|
|
a849c8b8cf | ||
|
|
d60a0ac297 | ||
|
|
96d4b1e623 | ||
|
|
58b14412a1 | ||
|
|
910097eb30 | ||
|
|
6d0f7747df | ||
|
|
b4378942fc | ||
|
|
aedf19d128 | ||
|
|
41706cc0fb | ||
|
|
9f7b5d89a5 | ||
|
|
255f61e6c5 | ||
|
|
3ede1b726f | ||
|
|
3fe511189f | ||
|
|
e3a9317693 | ||
|
|
0ab6bc4a73 | ||
|
|
a31cef34c5 | ||
|
|
ee2d6d3a7c | ||
|
|
7b3a57dfdf | ||
|
|
a103ccfe5c | ||
|
|
c029875196 | ||
|
|
ed833e80f6 | ||
|
|
0eb3d0310c | ||
|
|
46769fc7fa | ||
|
|
12705efe36 | ||
|
|
de53847f51 | ||
|
|
56e2ea1ad7 | ||
|
|
1b03d0c594 | ||
|
|
def4be9b38 | ||
|
|
aa1efd0676 | ||
|
|
360e7449ad | ||
|
|
1b529985be | ||
|
|
fefeccfa59 | ||
|
|
1cfa9f2e2a | ||
|
|
70ebef5d3a | ||
|
|
eee124efb6 | ||
|
|
80cbd2db59 | ||
|
|
8aeeec93c0 | ||
|
|
09bea664d5 | ||
|
|
a4b2919f59 | ||
|
|
9f617add29 | ||
|
|
ecba4112b7 | ||
|
|
54f535a007 | ||
|
|
f1219f8cd3 | ||
|
|
d5522debfb | ||
|
|
9a8130de32 | ||
|
|
dfdba5b260 | ||
|
|
90c7b5813f | ||
|
|
e92af14a22 | ||
|
|
64ad8e525c | ||
|
|
a923cf697c | ||
|
|
b8fac59200 | ||
|
|
ad69158405 | ||
|
|
11b145b641 | ||
|
|
4e31568d3d | ||
|
|
68af18d081 | ||
|
|
df53b80cb6 | ||
|
|
11a1f0babd | ||
|
|
027a5a1468 | ||
|
|
9af5dad2b0 | ||
|
|
92519436a1 | ||
|
|
f502da306f | ||
|
|
0152bdf567 | ||
|
|
cc8024932b | ||
|
|
78485f7429 | ||
|
|
a123263498 | ||
|
|
191384c1d2 | ||
|
|
172b30b8f1 | ||
|
|
e1e43c8924 | ||
|
|
ca31232ad2 | ||
|
|
532cae249d | ||
|
|
e005c004e1 | ||
|
|
b4b98799d5 | ||
|
|
2a1a0b347c | ||
|
|
2cd2a5c52f | ||
|
|
874419989d | ||
|
|
72b54ce850 | ||
|
|
e7c4654b16 | ||
|
|
9cce2ab043 | ||
|
|
630831aeac | ||
|
|
925523e06e | ||
|
|
ba0959ddc7 | ||
|
|
912ec3f547 | ||
|
|
2e97cc10b8 | ||
|
|
f5ba17114d | ||
|
|
90210c9334 | ||
|
|
0f340beb14 | ||
|
|
15435ca02b | ||
|
|
1697845493 | ||
|
|
739745e9fe | ||
|
|
f1d97710e1 | ||
|
|
00eace8068 | ||
|
|
ca1e135aa3 | ||
|
|
68ca42fef1 | ||
|
|
c03d18bb1c | ||
|
|
3f335af45e | ||
|
|
9b6294e83d | ||
|
|
130a65f3b6 | ||
|
|
5e2accbf5f | ||
|
|
f203a5372d | ||
|
|
2893f9b48e | ||
|
|
8de28a74ba | ||
|
|
b09bd0984a | ||
|
|
9143003e95 | ||
|
|
1d01c87663 | ||
|
|
3189f3cd71 | ||
|
|
9c679a5960 | ||
|
|
17b30c1f7f | ||
|
|
854135dfb7 | ||
|
|
95f1d6cf63 | ||
|
|
315e132981 | ||
|
|
b0ac24fbf8 | ||
|
|
2d9110b0c4 | ||
|
|
977cdbdf44 | ||
|
|
5d79e53ad0 | ||
|
|
3e91c7a1f0 | ||
|
|
51f55c5fc9 | ||
|
|
22c4971012 | ||
|
|
efea4d0e03 | ||
|
|
45122d7edb | ||
|
|
5313cd8492 | ||
|
|
e7a94b6080 | ||
|
|
06cea1ce0f | ||
|
|
cbb47aaf5d | ||
|
|
cfe76ad381 | ||
|
|
69a508dfcf | ||
|
|
af4fa12724 | ||
|
|
3ef45e2ad4 | ||
|
|
60a589ab96 | ||
|
|
7ff2e2de08 | ||
|
|
b169f258de | ||
|
|
d473398aba | ||
|
|
bd54d5f7fa | ||
|
|
19497eef87 | ||
|
|
99a012e3be | ||
|
|
71ebccb8ba | ||
|
|
ff6bf8f203 | ||
|
|
7a9ae3157f | ||
|
|
1720e11109 | ||
|
|
f40e975439 | ||
|
|
0e06f6c4fc | ||
|
|
ff75361553 | ||
|
|
e0aaa967c9 | ||
|
|
17455d2ea2 | ||
|
|
f2c77ba3fb | ||
|
|
d2b62880ce | ||
|
|
75097909e9 | ||
|
|
7c5cc57d75 | ||
|
|
9acf609ac9 | ||
|
|
622cd29f20 | ||
|
|
d809874fa1 | ||
|
|
5ea8fb48eb | ||
|
|
3275f9f1e0 | ||
|
|
ecb8896b1c | ||
|
|
f179eab071 | ||
|
|
969853ee53 | ||
|
|
082b8cf660 | ||
|
|
de06141ce5 | ||
|
|
fd94205cfa | ||
|
|
b452013dd9 | ||
|
|
fd4eb3b165 | ||
|
|
a364cd6990 | ||
|
|
12d7b1f51d | ||
|
|
19c8fafe84 | ||
|
|
426760d737 | ||
|
|
affaa11d14 | ||
|
|
dca1900815 | ||
|
|
633e440787 | ||
|
|
cee008207b | ||
|
|
e9b15108d9 | ||
|
|
f157c18368 | ||
|
|
b21c02a3d5 | ||
|
|
3a807ae37e | ||
|
|
cda957f302 | ||
|
|
0f81c1b956 | ||
|
|
ff6ffcda1b | ||
|
|
b0fc067317 | ||
|
|
c46a6aecbc | ||
|
|
9ef9f3cde3 | ||
|
|
a00b20cc97 | ||
|
|
b6a5278df1 | ||
|
|
439905e546 | ||
|
|
2b4d0069d9 | ||
|
|
d08982fc19 | ||
|
|
af3ca3935b | ||
|
|
e6919cdaba | ||
|
|
23c593089d | ||
|
|
e50ba168ac | ||
|
|
7d48bd0367 | ||
|
|
85649cf983 | ||
|
|
8908c8ff5c | ||
|
|
34adcfbbe5 | ||
|
|
ae597f7f8d | ||
|
|
62523fb845 | ||
|
|
fb54ebcb62 | ||
|
|
66d2af36a7 | ||
|
|
31e50d987f | ||
|
|
b601928e1c | ||
|
|
aebfd8bd7c | ||
|
|
19706e56b3 | ||
|
|
03c61f4c20 | ||
|
|
81632eb0f3 | ||
|
|
8043e2bbac | ||
|
|
2025275b43 | ||
|
|
69d4ada385 | ||
|
|
8b75e0311b | ||
|
|
2d22e08a1e | ||
|
|
cabe1aee45 | ||
|
|
b577f6f251 | ||
|
|
0729ee46e0 | ||
|
|
c8eb3e0399 | ||
|
|
9a7e818f3e | ||
|
|
8a56a78282 | ||
|
|
edf6bee7f8 | ||
|
|
109f32ff41 | ||
|
|
022caf39b4 | ||
|
|
869fc8f245 | ||
|
|
0792271dc6 | ||
|
|
a2a59a823e | ||
|
|
b0c4ed1ae2 | ||
|
|
d3bf2cc0cf | ||
|
|
81f6321326 | ||
|
|
39f065dda4 | ||
|
|
bee47f0318 | ||
|
|
9bfbac0f97 | ||
|
|
650f5a198f | ||
|
|
1e1bc9b3b4 | ||
|
|
f6ba5634fd | ||
|
|
4dc8d3fa5b | ||
|
|
62513ad12f | ||
|
|
9bc845304e | ||
|
|
45fae9952a | ||
|
|
f68fd00b7b | ||
|
|
c351bba41a | ||
|
|
a05a7d3dad | ||
|
|
44a85d6f85 | ||
|
|
ec88a61274 | ||
|
|
b8b7e1e3dd | ||
|
|
85d247455b | ||
|
|
b16e5b5e97 | ||
|
|
62f0a284be | ||
|
|
4142837cac | ||
|
|
c26cef37a1 | ||
|
|
fb88e0f8a8 | ||
|
|
b8293653a5 | ||
|
|
e292faafc6 | ||
|
|
08a86d355d | ||
|
|
eb390b2db4 | ||
|
|
60ae92b0e8 | ||
|
|
c222c8b57a | ||
|
|
636de7f6b5 | ||
|
|
da00ee0ca5 | ||
|
|
30daadbe81 | ||
|
|
b767f579ef | ||
|
|
febf50090b | ||
|
|
475421457f | ||
|
|
a22a1be962 | ||
|
|
35e18bfc56 | ||
|
|
3a665ae6ba | ||
|
|
fefa5a5fd7 | ||
|
|
2a384c690e | ||
|
|
0509790325 | ||
|
|
633a10aa4e | ||
|
|
711265b652 | ||
|
|
74d6b462a4 | ||
|
|
3b92048242 | ||
|
|
b0efdbe2f8 | ||
|
|
3669556e57 | ||
|
|
804a1b05ce | ||
|
|
590f654b0d | ||
|
|
b3aad02232 | ||
|
|
6a5cfb3d01 | ||
|
|
dcd82d062f | ||
|
|
2643a427ac | ||
|
|
a1c7741e1b | ||
|
|
e06447b763 | ||
|
|
482e952dde | ||
|
|
c4157fd196 | ||
|
|
1122f5a097 | ||
|
|
3b96b3561c | ||
|
|
c8624a7fae | ||
|
|
7e0a7deeff | ||
|
|
f7ee64bd79 | ||
|
|
a1fae33f40 | ||
|
|
bba425393b | ||
|
|
ffcd5e809a | ||
|
|
31ce64653d | ||
|
|
7b8cadcd02 | ||
|
|
7cb453a336 | ||
|
|
e2298c8222 | ||
|
|
30970ab8a1 | ||
|
|
59ba163c95 | ||
|
|
f20c0961aa | ||
|
|
b7a3162028 | ||
|
|
b9a63a2521 | ||
|
|
0157510d48 | ||
|
|
0f205a8cfd | ||
|
|
7a79537f35 | ||
|
|
86d92efd2b | ||
|
|
1caedd5fd3 | ||
|
|
f6fa898b9a | ||
|
|
c48a82c4c8 | ||
|
|
39497fec1b | ||
|
|
a2746c82a6 | ||
|
|
0834bc1ad5 | ||
|
|
526c4136e6 | ||
|
|
889c1a5a9e | ||
|
|
77abb7096c | ||
|
|
ffef2db00f | ||
|
|
8637131f80 | ||
|
|
b95a548f65 | ||
|
|
ad13ef3e4c | ||
|
|
135b271197 | ||
|
|
9f41b58b2f | ||
|
|
36d79cd1ff | ||
|
|
a7cce9afdd | ||
|
|
919a92bf1b | ||
|
|
12e5f97f59 | ||
|
|
7444df01e2 | ||
|
|
49f1a60762 | ||
|
|
30b251ea13 | ||
|
|
f5c67a51b2 | ||
|
|
9e6c57673e | ||
|
|
db4a9b7e69 | ||
|
|
13b29ca1bd | ||
|
|
faf580aa10 | ||
|
|
2d83342bbe | ||
|
|
8cba794723 | ||
|
|
47e37d6f68 | ||
|
|
db854ecc6f | ||
|
|
ed19312df6 | ||
|
|
40fd96a416 | ||
|
|
3d15a3e5af | ||
|
|
c98d83f596 | ||
|
|
6622883989 | ||
|
|
e9011caac8 | ||
|
|
5834e5b866 | ||
|
|
5a682db8e2 | ||
|
|
36885da2da | ||
|
|
43075a1b5c | ||
|
|
aa139ee0d9 | ||
|
|
8cc1153bd9 | ||
|
|
827b9cb6c8 | ||
|
|
a808948397 | ||
|
|
530593507b | ||
|
|
84fac19f98 | ||
|
|
506cff137d | ||
|
|
0be889ff1d | ||
|
|
5d080c86fd | ||
|
|
e0d00717c7 | ||
|
|
28e277a88e | ||
|
|
77e0281a0e | ||
|
|
7612da783a | ||
|
|
7e4d423561 | ||
|
|
a12a437664 | ||
|
|
b857bdc560 | ||
|
|
01f6eb9d09 | ||
|
|
23603f5174 | ||
|
|
b33b843908 | ||
|
|
7b40361bc4 | ||
|
|
b540d4421e | ||
|
|
a546a1bbef | ||
|
|
5c7c125d9d | ||
|
|
294f6cff52 | ||
|
|
fdd424bf5f | ||
|
|
105c307d62 | ||
|
|
2519da85f0 | ||
|
|
b4334edda1 | ||
|
|
fc3c7ad1e3 | ||
|
|
0594631e6a | ||
|
|
a4df1f86ae | ||
|
|
db71b47c24 | ||
|
|
1b211abcd4 | ||
|
|
77d6326803 | ||
|
|
dc1e0bfbaa | ||
|
|
dc326942db | ||
|
|
a0b7f7da9d | ||
|
|
30765ba1ed | ||
|
|
2d61c64118 | ||
|
|
a3183378e1 | ||
|
|
9039cef390 | ||
|
|
f276d8c069 | ||
|
|
3247fbcf92 | ||
|
|
c1aa0ebfa6 | ||
|
|
77b0452a2f | ||
|
|
127bb07c84 | ||
|
|
2024bb0f1a | ||
|
|
710ecca35d | ||
|
|
6cf7ae05d6 | ||
|
|
76be79661d | ||
|
|
0f43a04f43 | ||
|
|
e89549449f | ||
|
|
8326d95210 | ||
|
|
28debd6e96 | ||
|
|
4e773d31ac | ||
|
|
243ae71481 | ||
|
|
ad130eb03c | ||
|
|
5b03879025 | ||
|
|
f7ec21e50e | ||
|
|
633448b3b2 | ||
|
|
51e0999888 | ||
|
|
c77da88133 | ||
|
|
b0da522c97 | ||
|
|
1b0d9b33b3 | ||
|
|
96ebc7bf06 | ||
|
|
8e84f27f63 | ||
|
|
dfb083c9f4 | ||
|
|
04bf657548 | ||
|
|
018c99b90c | ||
|
|
9b17c5e215 | ||
|
|
6cb007eaaa | ||
|
|
7292fd8c3f | ||
|
|
879ed17879 | ||
|
|
c69d5bb07a | ||
|
|
95d0d85391 | ||
|
|
9383b2ce35 | ||
|
|
30ac7910c2 | ||
|
|
b911646e53 | ||
|
|
92afe359e9 | ||
|
|
86643cc4af | ||
|
|
03eecaa42c | ||
|
|
d9cc6dacb1 | ||
|
|
3a84432eeb | ||
|
|
5d96f965bc | ||
|
|
41a8f5853e | ||
|
|
e7f976408b | ||
|
|
9581fe85ce | ||
|
|
e453677038 | ||
|
|
0c1bccd2dc | ||
|
|
bdc9f71dec | ||
|
|
52b86a08f4 | ||
|
|
0d3e50da43 | ||
|
|
c22ce0fcd2 | ||
|
|
18e46f091e | ||
|
|
29d853d641 | ||
|
|
9a785e0534 | ||
|
|
834389621c | ||
|
|
a942ebd58d | ||
|
|
8fa61fd7ba | ||
|
|
d61b4f744a | ||
|
|
1fc3e688a6 | ||
|
|
0e21c1779c | ||
|
|
12adc97381 | ||
|
|
9fa022c80f | ||
|
|
52a9e4977c | ||
|
|
55f61d46e7 | ||
|
|
8fd2715e9b | ||
|
|
a4eee00bcf | ||
|
|
a5c4f42ec9 | ||
|
|
5d99229a65 | ||
|
|
00168e009e | ||
|
|
480feac7ad | ||
|
|
b676888242 | ||
|
|
894530beef | ||
|
|
876f6bd48d | ||
|
|
5fc25878b8 | ||
|
|
54d93e6376 | ||
|
|
585456f947 | ||
|
|
213b464d95 | ||
|
|
1b6d4af339 | ||
|
|
190a27e824 | ||
|
|
9e877d2fde | ||
|
|
ec3772d4e3 | ||
|
|
8dc58df1c1 | ||
|
|
ee25f00207 | ||
|
|
62fcf59604 | ||
|
|
e0a3d50f5e | ||
|
|
e9f809b7f9 | ||
|
|
2057e76706 | ||
|
|
0b58662e9a | ||
|
|
6b5af27546 | ||
|
|
0fbd5b850f | ||
|
|
389f6b8233 | ||
|
|
15140854de | ||
|
|
8aff1c16f8 | ||
|
|
6f4574409b | ||
|
|
12003f5ca5 | ||
|
|
87086fbe33 | ||
|
|
1b4de3fb2d | ||
|
|
f4fc83d8d6 | ||
|
|
e720474fb7 | ||
|
|
6cd3135f90 | ||
|
|
46800f3365 | ||
|
|
1500137bf1 | ||
|
|
62a412c488 | ||
|
|
e6422bc483 | ||
|
|
a172b6ed3b | ||
|
|
1530ff0ee9 | ||
|
|
45ba27693b | ||
|
|
212571463b | ||
|
|
30f9f1e712 | ||
|
|
f609270cea | ||
|
|
521802f824 | ||
|
|
8b218a9198 | ||
|
|
1dcc7455cd | ||
|
|
6a8654869a | ||
|
|
c63cba164a | ||
|
|
be52d72c88 | ||
|
|
1c3a83c4ba | ||
|
|
a03534d1e4 | ||
|
|
3292bd8877 | ||
|
|
e11cdda135 | ||
|
|
694e52eb3e | ||
|
|
81e62689f0 | ||
|
|
1d6c7a0552 | ||
|
|
a2a82a6cf8 | ||
|
|
1a845a9490 | ||
|
|
260a1af9a9 | ||
|
|
85e60b24ec | ||
|
|
018b705b91 | ||
|
|
0233f39e53 | ||
|
|
23411bd6fc | ||
|
|
9d769efbb9 | ||
|
|
2352dfa0a6 | ||
|
|
1c099071d1 | ||
|
|
d84ff36854 | ||
|
|
050b936fcf | ||
|
|
90bfa5d320 | ||
|
|
8fd11e024b | ||
|
|
7013227a34 | ||
|
|
c6a9a76147 | ||
|
|
a54805c63c | ||
|
|
0e29c416b1 | ||
|
|
8a3086c4ae | ||
|
|
d4c421b98d | ||
|
|
1bdab897ef | ||
|
|
94ca69554b | ||
|
|
c4d231e728 | ||
|
|
1c6009a920 | ||
|
|
a39f5af22a | ||
|
|
3e78ecb799 | ||
|
|
24f25353f8 | ||
|
|
25c34ace45 | ||
|
|
5e4eaa78b1 | ||
|
|
2419f8cd27 | ||
|
|
6f045293e9 | ||
|
|
530da674f8 | ||
|
|
555eef449e | ||
|
|
55eb7135be | ||
|
|
2edac7e78b | ||
|
|
b8a4318082 | ||
|
|
097995e503 | ||
|
|
3fc1a2222f | ||
|
|
f0865bb051 | ||
|
|
677524d9ec | ||
|
|
9dc0742e77 | ||
|
|
1440a30d28 | ||
|
|
a3d8b9c607 | ||
|
|
aa6fafdee9 | ||
|
|
86fffa305a | ||
|
|
e17788355b | ||
|
|
87213128cc | ||
|
|
697fa792ea | ||
|
|
9c1d446e40 | ||
|
|
3192cd15c5 | ||
|
|
af47d19ae2 | ||
|
|
cfc234ec42 | ||
|
|
a91197014f | ||
|
|
d6959a75c1 | ||
|
|
97b23e98d9 | ||
|
|
4cf5fcdb4f | ||
|
|
1ee67b7792 | ||
|
|
128d0eeaa8 | ||
|
|
9834b4e4a4 | ||
|
|
cab579368b | ||
|
|
4e5522a999 | ||
|
|
55ce86b132 | ||
|
|
52248be717 | ||
|
|
04c7eca615 | ||
|
|
6e646e0fe8 | ||
|
|
675b87ba63 | ||
|
|
707d8de4fb | ||
|
|
0725713e19 | ||
|
|
1ee77c89f8 | ||
|
|
4bc8b3e723 | ||
|
|
469611650c | ||
|
|
91642e2860 | ||
|
|
0200c7f4a4 | ||
|
|
fe7e766510 | ||
|
|
ff7357f889 | ||
|
|
3287e174dc | ||
|
|
a53a4b845b | ||
|
|
9143da5fa8 | ||
|
|
b3cc7cbdb2 | ||
|
|
eef1db0f0a | ||
|
|
72f5246ce3 | ||
|
|
cb308bb4c7 | ||
|
|
ad93e99158 | ||
|
|
9d0c3dfa15 | ||
|
|
2c9602db71 | ||
|
|
ef670fa6da | ||
|
|
5a6ec39cfd | ||
|
|
e3196e7b50 | ||
|
|
bea69efd12 | ||
|
|
283ec27ca4 | ||
|
|
a67a6b6c30 | ||
|
|
ccd89c348f | ||
|
|
478a141498 | ||
|
|
2497be496d | ||
|
|
25dd6c07f3 | ||
|
|
eb14236166 | ||
|
|
bbb628243f | ||
|
|
cdc9d03d5b | ||
|
|
e951d319d0 | ||
|
|
d14a45401b | ||
|
|
655e2879e6 | ||
|
|
e757ef1471 | ||
|
|
27afa4463d | ||
|
|
80450c7180 | ||
|
|
c655e0f8c5 | ||
|
|
5abeeb882b | ||
|
|
b1df6dab27 | ||
|
|
672e1d991d | ||
|
|
89b910a8f1 | ||
|
|
6315ef102a | ||
|
|
119986fa7e | ||
|
|
3853b7460c | ||
|
|
e9947dc0fe | ||
|
|
b813660c74 | ||
|
|
387fb555ac | ||
|
|
f549a7aa79 | ||
|
|
b219e5d68a | ||
|
|
1f6cf0eafa | ||
|
|
a49eae8155 | ||
|
|
1c7d085f16 | ||
|
|
cc6eec3608 | ||
|
|
86fb140414 | ||
|
|
13cd4d98ba | ||
|
|
84bc1245a1 | ||
|
|
e1bcde4cf1 | ||
|
|
3f619bcaac | ||
|
|
f3a85d6b08 | ||
|
|
596d86a206 | ||
|
|
f2e60b93a3 | ||
|
|
f16a9c767a | ||
|
|
3a27c87b3f | ||
|
|
0ed8676066 | ||
|
|
bcefb11e65 | ||
|
|
75cf8475f5 | ||
|
|
c015cab2f4 | ||
|
|
3da6584ab8 | ||
|
|
68f6fd474b | ||
|
|
614e4e636b | ||
|
|
370f856725 | ||
|
|
7382e5f03b | ||
|
|
5567d4b411 | ||
|
|
e5516d7286 | ||
|
|
fd94e0bd19 | ||
|
|
d0415d3b5e | ||
|
|
c6efa4ab39 | ||
|
|
dedf7fa3a9 | ||
|
|
4b5927dfff | ||
|
|
cc03f55006 | ||
|
|
93e1dc598c | ||
|
|
25f33b830f | ||
|
|
7d6ef44e21 | ||
|
|
dfa4dbbcbd | ||
|
|
f92c997a50 | ||
|
|
697c0be9f3 | ||
|
|
8f146e08d6 | ||
|
|
e6088c79a3 | ||
|
|
e19b8c95fe | ||
|
|
995b72df05 | ||
|
|
9954fd1100 | ||
|
|
2a14a1da01 | ||
|
|
5a53b648b1 | ||
|
|
cb72292b83 | ||
|
|
3a11e447cf | ||
|
|
bad02e6f23 | ||
|
|
4c3b7cbb16 | ||
|
|
e8c64b47dd | ||
|
|
9feb6c796d | ||
|
|
fd05bacb76 | ||
|
|
f51571297d | ||
|
|
9a41d0ca39 | ||
|
|
8b52da6aef | ||
|
|
adfb682754 | ||
|
|
0822f748a5 | ||
|
|
368ea681a5 | ||
|
|
b059ec930f | ||
|
|
2238f28610 | ||
|
|
bbba618beb | ||
|
|
cfc4d3f3e8 | ||
|
|
c06d23dd7a | ||
|
|
6c8d4eca40 | ||
|
|
836534f2a7 | ||
|
|
648e2f7ab1 | ||
|
|
6375909591 | ||
|
|
3e5ff4b9c3 | ||
|
|
76d0ce2a0f | ||
|
|
207f2c6879 | ||
|
|
46a58d518a | ||
|
|
c5be6d059f | ||
|
|
ec209c9736 | ||
|
|
d4f02c5f4b | ||
|
|
2409f2e464 | ||
|
|
225c7141b8 | ||
|
|
8807a7303d | ||
|
|
a6515b4323 | ||
|
|
11173a74c6 | ||
|
|
ec0e7a3560 | ||
|
|
a0b9285323 | ||
|
|
2655493ac8 | ||
|
|
a8fc177118 | ||
|
|
20378ea7bb | ||
|
|
bcf2c3ae92 | ||
|
|
5f81de3219 | ||
|
|
397d2a1588 | ||
|
|
65567d0d83 | ||
|
|
0abd984285 | ||
|
|
ec21c9bb29 | ||
|
|
cb2ef9d0e7 | ||
|
|
da79dde611 | ||
|
|
935ea1bf9f | ||
|
|
11e752ac01 | ||
|
|
03472072b8 | ||
|
|
63e6f3ef91 | ||
|
|
a00bb349c4 |
66
.env.example
66
.env.example
@@ -7,30 +7,78 @@
|
||||
# ==============================================================================
|
||||
POSTGRES_DB=certctl
|
||||
POSTGRES_USER=certctl
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
POSTGRES_PASSWORD=replace-with-openssl-rand-hex-32
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Server
|
||||
# All server vars use the CERTCTL_ prefix (see internal/config/config.go)
|
||||
# ==============================================================================
|
||||
CERTCTL_DATABASE_URL=postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
# IMPORTANT: keep the password segment of CERTCTL_DATABASE_URL in sync with
|
||||
# POSTGRES_PASSWORD above. If you deploy via `deploy/docker-compose.yml`,
|
||||
# this value is *overridden* by the compose file's
|
||||
# `postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/...`
|
||||
# interpolation — but if you run the binary directly with this .env loaded
|
||||
# (e.g. `set -a; source .env; ./certctl-server`), update *both* lines.
|
||||
# Background: editing POSTGRES_PASSWORD after the postgres data directory
|
||||
# has been initialized once does NOT rotate the password — initdb only
|
||||
# seeds pg_authid on first boot of an empty volume. See docs/quickstart.md
|
||||
# "Warning" callout and `internal/repository/postgres/db.go::wrapPingError`
|
||||
# for the SQLSTATE 28P01 diagnostic that fires when the two drift.
|
||||
CERTCTL_DATABASE_URL=postgres://certctl:replace-with-openssl-rand-hex-32@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST=0.0.0.0
|
||||
CERTCTL_SERVER_PORT=8443
|
||||
CERTCTL_LOG_LEVEL=info
|
||||
CERTCTL_LOG_FORMAT=json
|
||||
|
||||
# Auth type: "api-key", "jwt", or "none" (for demo/development)
|
||||
CERTCTL_AUTH_TYPE=none
|
||||
# Required when CERTCTL_AUTH_TYPE is "api-key" or "jwt"
|
||||
# Generate with: openssl rand -base64 32
|
||||
# CERTCTL_AUTH_SECRET=change-me-in-production
|
||||
# Auth type: "api-key" (production), "none" (demo/development), or
|
||||
# "oidc" (Auth Bundle 2 - native OIDC SSO via coreos/go-oidc/v3, ships
|
||||
# in Bundle 2 phases 5+6; setting CERTCTL_AUTH_TYPE=oidc on a build
|
||||
# without Bundle 2 wired triggers a clear refuse-to-start error rather
|
||||
# than a silent fallback to api-key). For JWT / SAML / LDAP, continue to
|
||||
# run an authenticating gateway in front of certctl (oauth2-proxy /
|
||||
# Envoy ext_authz / Traefik ForwardAuth / Pomerium) and set
|
||||
# CERTCTL_AUTH_TYPE=none on the upstream - see docs/architecture.md
|
||||
# "Authenticating-gateway pattern". G-1 removed the in-process "jwt"
|
||||
# option (no JWT middleware shipped - silent auth downgrade); see
|
||||
# docs/upgrade-to-v2-jwt-removal.md if you previously set
|
||||
# CERTCTL_AUTH_TYPE=jwt.
|
||||
#
|
||||
# Bundle 2 closure (2026-05-12): the docker-compose base file no longer
|
||||
# defaults to AUTH_TYPE=none. The base ships production-shaped; the demo
|
||||
# overlay (deploy/docker-compose.demo.yml) flips this baseline into the
|
||||
# populated-dashboard demo path.
|
||||
CERTCTL_AUTH_TYPE=api-key
|
||||
# Required when CERTCTL_AUTH_TYPE is "api-key". Generate with:
|
||||
# openssl rand -base64 32
|
||||
# The Bundle 2 fail-closed Validate() REFUSES TO START if this value
|
||||
# equals the placeholder string "change-me-in-production" outside of
|
||||
# demo mode (CERTCTL_DEMO_MODE_ACK=true).
|
||||
CERTCTL_AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
|
||||
# Bundle 2 closure: AES-256-GCM key for encrypting issuer/target config
|
||||
# secrets at rest. Required for any deployment that uses the dynamic
|
||||
# config GUI to store issuer credentials. Generate with:
|
||||
# openssl rand -base64 32
|
||||
# Minimum 32 bytes. The Bundle 2 fail-closed Validate() REFUSES TO
|
||||
# START if this value equals the placeholder string
|
||||
# "change-me-32-char-encryption-key" outside of demo mode.
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY=replace-with-openssl-rand-base64-32
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Agent
|
||||
# ==============================================================================
|
||||
CERTCTL_SERVER_URL=http://localhost:8443
|
||||
CERTCTL_API_KEY=change-me-in-production
|
||||
# HTTPS-only as of v2.2 (TLS 1.3 pinned). Agents reject http:// URLs at
|
||||
# startup. Use the docker-compose self-signed bootstrap CA bundle from
|
||||
# `deploy/test/certs/ca.crt` or supply your own via CERTCTL_SERVER_CA_BUNDLE_PATH.
|
||||
CERTCTL_SERVER_URL=https://localhost:8443
|
||||
# Matches one of the server's CERTCTL_AUTH_SECRET rotation values. The
|
||||
# placeholder is rejected outside demo mode (Bundle 2 fail-closed guard).
|
||||
CERTCTL_API_KEY=replace-with-openssl-rand-base64-32
|
||||
CERTCTL_AGENT_NAME=local-agent
|
||||
# Returned from `POST /api/v1/agents` during agent enrollment. The agent
|
||||
# fail-fasts at startup with "agent-id flag or CERTCTL_AGENT_ID env var
|
||||
# is required" if this is unset.
|
||||
# CERTCTL_AGENT_ID=agent-from-registration-response
|
||||
|
||||
# ==============================================================================
|
||||
# Optional: Scheduler Tuning (defaults are usually fine)
|
||||
|
||||
229
.github/coverage-thresholds.yml
vendored
Normal file
229
.github/coverage-thresholds.yml
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
# Coverage floors per gated package.
|
||||
#
|
||||
# Each entry: floor: <integer percentage>, why: <load-bearing context>.
|
||||
# Adding a new gated package: one entry here; CI's `Check Coverage Thresholds`
|
||||
# step auto-picks up. Lowering a floor REQUIRES corresponding code-side test
|
||||
# work — never lower the gate to make CI green.
|
||||
#
|
||||
# Per ci-pipeline-cleanup bundle Phase 2 / frozen decision 0.3.
|
||||
|
||||
internal/service:
|
||||
floor: 70
|
||||
why: |
|
||||
Bundle R-CI-extended raise (post-Bundle-N.C-extended): service
|
||||
55 → 70. HEAD 73.4% (3pp margin). Prescribed Bundle R target
|
||||
was 80; held lower to avoid false-positives on single low-
|
||||
coverage files dragging the global per-file-average down.
|
||||
|
||||
internal/api/handler:
|
||||
floor: 75
|
||||
why: |
|
||||
Bundle R-CI-extended raise: handler 60 → 75. HEAD 79.8% (4pp
|
||||
margin). Prescribed Bundle R target was 80; held lower for
|
||||
same reason as service layer.
|
||||
|
||||
internal/domain:
|
||||
floor: 40
|
||||
why: |
|
||||
Domain layer is mostly type definitions + validators; 40% is
|
||||
the load-bearing-paths floor.
|
||||
|
||||
internal/api/middleware:
|
||||
floor: 30
|
||||
why: |
|
||||
Middleware coverage is per-handler-test-driven. 30% is the
|
||||
floor that catches the wired-up middleware paths; the
|
||||
unwired paths (alternative auth providers not currently
|
||||
enabled) sit below.
|
||||
|
||||
internal/crypto:
|
||||
floor: 88
|
||||
why: |
|
||||
Bundle R closure CI checkpoint #3: crypto floor lifted 85 → 88.
|
||||
Post-Bundle-Q package-scoped coverage at HEAD: 88.2%. The
|
||||
remaining ~12% gap is platform-failure branches (rand.Reader /
|
||||
aes.NewCipher) that require interface seams the production
|
||||
code doesn't use; closing them is tracked as R-CI-extended,
|
||||
not Bundle R scope.
|
||||
|
||||
internal/connector/issuer/local:
|
||||
floor: 86
|
||||
why: |
|
||||
Bundle R closure CI checkpoint #3: local-issuer floor lifted
|
||||
85 → 86. Post-Bundle-Q package-scoped coverage at HEAD: 86.7%.
|
||||
The prescribed Bundle R target was 92, but reaching it
|
||||
requires interface seams for crypto/x509 signing-error
|
||||
branches — tracked as R-CI-extended.
|
||||
|
||||
internal/connector/issuer/acme:
|
||||
floor: 80
|
||||
why: |
|
||||
Bundle R-CI-extended threshold raise (post-Bundle-J-extended):
|
||||
ACME 50 → 80. The Pebble-style mock + per-CA failure tests
|
||||
lift package-scoped ACME to 85.4%; gate at 80 with 5pp margin
|
||||
to absorb the global-run per-file-average dip.
|
||||
|
||||
internal/connector/issuer/stepca:
|
||||
floor: 80
|
||||
why: |
|
||||
Bundle L.B / Coverage-Audit C-005 — StepCA failure-mode + JWE
|
||||
round-trip tests lift package from 52.1% to 90.4% (per-package
|
||||
run). Floor at 80 with margin.
|
||||
|
||||
internal/mcp:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle K / Coverage-Audit C-002 — MCP per-tool dispatch via
|
||||
in-memory transport lifts package from 28.0% to 93.1% (per-
|
||||
package run). Floor at 85.
|
||||
|
||||
internal/auth:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle 1 Phase 12 — RBAC primitive coverage gate.
|
||||
internal/auth ships keystore + middleware + RequirePermission +
|
||||
bootstrap + the Phase-3 context keys + the protocol-endpoint
|
||||
allowlist. Negative-test coverage (no actor → 401, no role →
|
||||
403, wrong scope → 403, bootstrap-token-wrong → 401, bootstrap-
|
||||
used-twice → 410, admin-already-exists → 410, zero-length token
|
||||
rejection) is now in place. Prescribed Bundle 1 target was 90;
|
||||
held at 85 to absorb the per-file-average dip from the
|
||||
middleware shim files (testfixtures.go) which CI runs but only
|
||||
test fixtures exercise. Sub-package internal/auth/bootstrap
|
||||
inherits this floor.
|
||||
|
||||
internal/service/auth:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle 1 Phase 12 — RBAC service-layer coverage gate.
|
||||
PermissionService + RoleService + ActorRoleService + Authorizer
|
||||
each have positive + negative tests covering the
|
||||
privilege-escalation guard (auth.role.assign required for
|
||||
Grant/Revoke), the reserved-actor invariant (actor-demo-anon
|
||||
cannot be mutated), the canonical-permission validation, the
|
||||
role-in-use guard on Delete, and every sentinel-error path
|
||||
(ErrUnauthenticated / ErrForbidden / ErrSelfRoleAssignment /
|
||||
ErrAuthReservedActor / ErrAuthUnknownPermission /
|
||||
ErrAuthRoleInUse).
|
||||
|
||||
internal/auth/oidc:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 3 — OIDC service coverage gate. Phase 3 spec
|
||||
pins the floor at 90 explicitly because every fail-closed
|
||||
branch is load-bearing for the security posture: alg pinning
|
||||
(deny-list HS*/none + allow-list RS*/ES*/EdDSA), audience
|
||||
re-check, azp enforcement on multi-aud tokens, at_hash
|
||||
REQUIRED-when-access-token-present (Phase 3 lifts the OIDC
|
||||
core "MAY" to a service-level "MUST"), iat-window window,
|
||||
nonce constant-time-compare, single-use state replay defense,
|
||||
PKCE-S256 mandatory, IdP downgrade-attack defense at
|
||||
provider-load + RefreshKeys time, JWKS-fail-closed semantics,
|
||||
group-claim resolution + userinfo-fallback fail-closed
|
||||
semantics, token-leak hygiene. A regression in any one of
|
||||
these branches is a security incident; the floor catches it
|
||||
before the commit lands. The mock-IdP fixture in
|
||||
service_test.go is the load-bearing harness.
|
||||
|
||||
internal/auth/oidc/groupclaim:
|
||||
floor: 95
|
||||
why: |
|
||||
Bundle 2 Phase 3 — group-claim resolver. Hand-rolled (no
|
||||
JSON-path dep per Decision 10); ~150 LOC, every branch
|
||||
exercised by 19 unit tests covering the documented IdP shapes
|
||||
(Okta string array, Keycloak realm_access.roles, Auth0
|
||||
namespaced URL claim, single-string normalization,
|
||||
deeply-nested 3-segment walks) plus every fail-closed branch
|
||||
(empty path, missing key, missing nested key, non-object
|
||||
intermediate, bool/number/object/nil values, array with
|
||||
non-string element, URL-shape with dots-in-path treated as
|
||||
literal). Resolver should be at 100%; floor at 95 leaves a
|
||||
1-statement margin for future error-message refactors.
|
||||
|
||||
internal/auth/oidc/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — OIDCProvider + GroupRoleMapping domain.
|
||||
Validation-heavy package; constructors + Validate methods
|
||||
cover all canonical IdP shapes (Okta / Azure AD / Google
|
||||
Workspace / Keycloak / Authentik / Auth0). Floor at 90 to
|
||||
catch any future field that ships without a validator.
|
||||
|
||||
internal/auth/session:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 4 — session lifecycle service. Phase 4 spec
|
||||
pins the floor at 90 because every fail-closed branch carries
|
||||
a security invariant: HMAC-SHA256 cookie signing with a
|
||||
LENGTH-PREFIXED canonical input (defeats the
|
||||
`<a, bc>`-vs-`<ab, c>` concatenation collision attack on the
|
||||
bare-concat form), v1. version-prefix lock, idle expiry,
|
||||
absolute expiry, revocation, retired-but-in-retention key
|
||||
success path, retired-past-retention failure path, CSRF
|
||||
constant-time compare against the SHA-256-hashed copy on the
|
||||
session row, optional IP/UA-bind defense-in-depth gates,
|
||||
fail-fatal initial-key bootstrap. A regression in any one of
|
||||
these branches is a security incident; the floor catches it
|
||||
before the commit lands. The 15-case negative-test matrix in
|
||||
service_test.go is the load-bearing harness; the in-memory
|
||||
stubs of SessionRepo + SigningKeyRepo + AuditRecorder let the
|
||||
state machine be exercised without the postgres testcontainer
|
||||
overhead (which Phase 2's integration tests already cover).
|
||||
|
||||
internal/auth/session/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — Session + SessionSigningKey domain. Both
|
||||
types ship Validate() with full invariant coverage: ID prefix
|
||||
enforcement (ses-/sk-), expiry-order CHECK (absolute > idle >
|
||||
created), CSRFTokenHash format pin (64 lowercase hex chars),
|
||||
KeyMaterialEncrypted non-empty, retired-before-created
|
||||
rejection, TenantID defaulting. Cookie naming constants are
|
||||
pinned by TestCookieNamingConstants because the GUI's
|
||||
web/src/api/client.ts will read `certctl_csrf` by string.
|
||||
Floor at 90 to catch any future field that ships without a
|
||||
validator.
|
||||
|
||||
internal/auth/breakglass:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 7.5 — break-glass admin service (Argon2id +
|
||||
lockout state machine + constant-time-via-verifyDummy). Phase
|
||||
13 Pre-merge audit: floor at 90 with no carve-out. Phase 7.5
|
||||
spec ships the package at 91.5%, validated by 8 mandated
|
||||
negatives + ~12 coverage-lift tests. Every fail-closed branch
|
||||
is load-bearing for the security surface (default-OFF posture
|
||||
only matters if every "disabled" path returns ErrDisabled
|
||||
BEFORE any DB lookup; constant-time defense only matters if
|
||||
every path goes through verifyDummy on the no-credential leg).
|
||||
A regression that drops a fail-closed branch's coverage below
|
||||
90 is a real security risk — gate trips, operator audits.
|
||||
|
||||
internal/auth/breakglass/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — BreakglassCredential domain. Argon2id PHC
|
||||
format pinned ($argon2id$ prefix), MinPasswordLengthBytes (12)
|
||||
+ MaxPasswordLengthBytes (256) constants pinned by dedicated
|
||||
test, IsLocked(now) state machine helper. The package ships
|
||||
at 100% coverage; floor at 90 is the standing-room floor for
|
||||
any future field added without a validator.
|
||||
|
||||
internal/auth/user/domain:
|
||||
floor: 90
|
||||
why: |
|
||||
Bundle 2 Phase 1 — User domain (federated-human identity).
|
||||
OIDCSubject + OIDCProviderID unique-index per the Phase 2
|
||||
schema, WebAuthnCredentials JSONB reserved for v3, Validate()
|
||||
enforces every on-disk invariant. The package ships at 96.4%
|
||||
coverage. Floor at 90 to catch any future field added without
|
||||
a validator.
|
||||
|
||||
Phase 13 prompt explicitly enumerates internal/auth/user/ at
|
||||
floor 90. The parent (non-domain) directory has no Go source —
|
||||
the user upsert lives in internal/auth/oidc/service.go alongside
|
||||
group resolution + role mapping (cohesive sequence within the
|
||||
OIDC callback). Splitting upsertUser into a separate
|
||||
internal/auth/user/ service package would harm cohesion without
|
||||
adding test value; the domain layer's invariant coverage is
|
||||
where the floor actually applies.
|
||||
118
.github/workflows/backup-restore.yml
vendored
Normal file
118
.github/workflows/backup-restore.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
# Acquisition-audit DEPL-005 + DATA-012 closure (Sprint 4 ACQ,
|
||||
# 2026-05-16). Weekly backup-restore smoke test.
|
||||
#
|
||||
# Why
|
||||
# ===
|
||||
# The Helm CronJob at deploy/helm/certctl/templates/backup-cronjob.yaml
|
||||
# and the operator runbook at docs/operator/runbooks/postgres-backup.md
|
||||
# both document a pg_dump -Fc -based backup strategy, but the dump has
|
||||
# never been restored end-to-end under CI. A backup procedure that has
|
||||
# never been restore-tested is not a backup procedure. This workflow
|
||||
# adds the missing assertion.
|
||||
#
|
||||
# What
|
||||
# ====
|
||||
# Each Monday at 07:00 UTC (1h offset from loadtest.yml's 06:00 UTC
|
||||
# slot so they don't fight for runners), boot a real Postgres
|
||||
# 16-alpine container against the same digest pin as the production
|
||||
# deploy/docker-compose.yml, exercise the audit_events hash chain
|
||||
# with a small synthetic workload, pg_dump the database, drop the
|
||||
# schema, pg_restore, and assert the chain head + row count
|
||||
# round-trip byte-for-byte.
|
||||
#
|
||||
# The chain head round-trip property is the load-bearing assertion.
|
||||
# Migration 000047 hashes each audit_events row's canonical payload
|
||||
# with `to_char(timestamp AT TIME ZONE 'UTC',
|
||||
# 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`. Any TIMESTAMPTZ-precision loss
|
||||
# in the dump→restore path (a real concern across major Postgres
|
||||
# upgrades or with --format=plain) would corrupt the hash. The whole
|
||||
# point of testing instead of trusting docs is to PROVE the property
|
||||
# under a real workload.
|
||||
#
|
||||
# Workflow boundaries
|
||||
# ===================
|
||||
# - Does not exercise PITR / WAL archiving (DR runbook owns that).
|
||||
# - Does not exercise the Helm CronJob's S3 sink or scheduling
|
||||
# (operator-side concern, not a property of the dump shape).
|
||||
# - Does not deploy or boot the certctl-server itself — the smoke
|
||||
# harness talks to Postgres directly; we're testing the dump,
|
||||
# not the server.
|
||||
|
||||
name: backup-restore-smoke
|
||||
|
||||
on:
|
||||
# Manual trigger from the Actions tab — useful before tagging a
|
||||
# release that touches the audit_events schema, or after a dep
|
||||
# bump that could affect canonical-payload formatting.
|
||||
workflow_dispatch:
|
||||
|
||||
schedule:
|
||||
# Mondays at 07:00 UTC. Off-peak, off-set 1h from loadtest.yml
|
||||
# (06:00 UTC) so the two jobs don't fight for runners on the
|
||||
# GitHub-hosted ubuntu-latest pool.
|
||||
- cron: '0 7 * * 1'
|
||||
|
||||
# Defense-in-depth: this job reads source and exercises a database;
|
||||
# it never needs write access to PRs, branches, releases, or
|
||||
# packages. Pin permissions to the minimum.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backup-restore:
|
||||
name: pg_dump / pg_restore smoke
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 15-minute hard cap. The actual workload + dump + restore + verify
|
||||
# cycle runs in well under a minute on a warm runner; 15 minutes
|
||||
# absorbs cold image pulls, slow runner provisioning, and the
|
||||
# Postgres service-container readiness wait without letting a stuck
|
||||
# job consume the runner indefinitely.
|
||||
timeout-minutes: 15
|
||||
|
||||
# Postgres service container. Pin to the same digest as
|
||||
# deploy/docker-compose.yml so the smoke runs against the exact
|
||||
# image the production deploy uses — a regression that surfaces
|
||||
# only on a specific Postgres minor bump shows up here on the
|
||||
# next image refresh in compose, not silently on a customer site.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine@sha256:890480b08124ce7f79960a9bb16fe39729aa302bd384bfd7c408fee6c8f7adb7
|
||||
env:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: certctl
|
||||
ports:
|
||||
- 5432:5432
|
||||
# GitHub's services-container health check. The smoke shell
|
||||
# also waits for pg_isready as a belt-and-suspenders guard.
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U certctl -d certctl"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
# Cache go-build + go-mod for the weekly run. Keep the
|
||||
# cache key bound to go.sum so a dep bump invalidates it.
|
||||
cache: true
|
||||
|
||||
- name: Run backup-restore smoke
|
||||
env:
|
||||
PGHOST: 127.0.0.1
|
||||
PGPORT: '5432'
|
||||
PGUSER: certctl
|
||||
PGPASSWORD: certctl
|
||||
PGDATABASE: certctl
|
||||
# Insert enough rows to exercise the chain over a non-trivial
|
||||
# length. 24 ≫ 1 — large enough to surface ordering bugs,
|
||||
# small enough that the dump finishes in seconds.
|
||||
SMOKE_ROWS: '24'
|
||||
run: bash deploy/test/backup-restore-smoke.sh
|
||||
747
.github/workflows/ci.yml
vendored
747
.github/workflows/ci.yml
vendored
@@ -14,12 +14,17 @@ jobs:
|
||||
name: Go Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.25.10'
|
||||
# Phase 3 TEST-L1 closure (2026-05-13): enable Go's module +
|
||||
# build cache so re-runs hit the cache instead of recompiling
|
||||
# the world. setup-go v5 cache: true by default; making it
|
||||
# explicit so a future setup-go upgrade can't silently flip it.
|
||||
cache: true
|
||||
|
||||
- name: Go Build
|
||||
run: |
|
||||
@@ -28,6 +33,28 @@ jobs:
|
||||
go build ./cmd/mcp-server/...
|
||||
go build ./cmd/cli/...
|
||||
|
||||
- name: gofmt drift (Makefile::verify parity)
|
||||
# ci-pipeline-cleanup Phase 4 / frozen decision 0.13: Makefile::verify
|
||||
# checks gofmt + vet + golangci-lint + go test. CI runs vet, lint, test
|
||||
# already — but NOT gofmt. This step closes the parity gap.
|
||||
# Mirrors the Makefile::verify shape: any gofmt output means the
|
||||
# source needs reformatting.
|
||||
run: |
|
||||
out=$(gofmt -l .)
|
||||
if [ -n "$out" ]; then
|
||||
echo "::error::gofmt would reformat these files (run 'gofmt -w' locally):"
|
||||
echo "$out"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: go mod tidy drift
|
||||
# ci-pipeline-cleanup Phase 4: catches PRs that import a package
|
||||
# without committing the go.mod / go.sum update. Standard Go-CI
|
||||
# gate; absent before this bundle.
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod go.sum
|
||||
|
||||
- name: Go Vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -41,72 +68,374 @@ jobs:
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Run govulncheck
|
||||
- name: Run govulncheck (M-024 hard gate)
|
||||
# Bundle-7 / D-001 partial: govulncheck distinguishes called-vs-uncalled
|
||||
# advisories. Default exit code is non-zero only when YOUR code calls
|
||||
# the vulnerable function — deferred-call advisories show up in the
|
||||
# output but don't fail the gate.
|
||||
#
|
||||
# Bundle F / Audit M-024 (NIST SSDF PW.7.2): the govulncheck step
|
||||
# is now a hard CI gate (no `continue-on-error`). Bundle E's
|
||||
# transitive bumps (x/net 0.42→0.47, x/crypto 0.41→0.45) cleared
|
||||
# the 5 deferred-call advisories that were previously on the
|
||||
# exception list, so the carve-out the original Bundle F prompt
|
||||
# designed is unnecessary — a clean `govulncheck ./...` is the
|
||||
# right gate. If a future advisory lands in a function our code
|
||||
# does call, this step fails the build until either upstream
|
||||
# ships a fix OR we cut the dep. Deferred-call advisories that
|
||||
# legitimately can't be remediated yet should be added to the
|
||||
# NIST SSDF deviation log in docs/operator/security.md, not silenced here.
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Install staticcheck (Bundle-7 / D-001)
|
||||
run: go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
||||
- name: Run staticcheck
|
||||
# Bundle-7 / D-001: Go static analysis additive to vet. Suppressed
|
||||
# rules live in staticcheck.conf with documented justifications;
|
||||
# adding a new entry requires an explicit security review.
|
||||
#
|
||||
# ci-pipeline-cleanup Phase 3 / frozen decision 0.7: HARD gate.
|
||||
# M-028 SA1019 sites verified closed at HEAD 1de61e91:
|
||||
# - middleware.NewAuth: zero callers (all migrated to
|
||||
# NewAuthWithNamedKeys in cmd/server/{main,main_test}.go)
|
||||
# - csr.Attributes (internal/api/handler/scep.go × 2): inline
|
||||
# //lint:ignore SA1019 with load-bearing rationale (RFC 2985
|
||||
# challengePassword has no non-deprecated stdlib API)
|
||||
# - elliptic.Marshal: only in bundle9_coverage_test.go × 1 as
|
||||
# deliberate byte-equivalence regression oracle, suppressed
|
||||
# with //lint:ignore SA1019
|
||||
run: staticcheck ./...
|
||||
|
||||
- name: Race Detection
|
||||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
|
||||
# Phase 3 TEST-H1 closure (2026-05-13): the pre-Phase-3 invocation
|
||||
# listed 9 explicit package roots, excluding internal/auth/*,
|
||||
# internal/repository/*, internal/mcp, internal/scep, internal/pkcs7,
|
||||
# internal/api/router, internal/api/acme, internal/cli, internal/cms,
|
||||
# internal/config, internal/deploy, internal/integration,
|
||||
# internal/ratelimit, internal/secret, internal/trustanchor, plus
|
||||
# all of cmd/. Audit finding TEST-H1 flagged this as silent
|
||||
# race-detection drift — packages added after the original list
|
||||
# was authored were never covered.
|
||||
#
|
||||
# Post-Phase-3: ./... with -short. The 76 testing.Short() guards
|
||||
# already in the integration-test surface (testcontainers, live-DB,
|
||||
# multi-process) gate behind this flag, so race detection runs
|
||||
# across every package without dragging in long-running suites.
|
||||
# Timeout doubled from 300s to 600s because ./... is broader; the
|
||||
# broader scope is what makes race coverage trustworthy.
|
||||
run: go test -race -short ./... -count=1 -timeout 600s
|
||||
|
||||
- name: Go Test with Coverage
|
||||
# internal/ciparity/... — post-v2.1.0 anti-rot item 2 surface-
|
||||
# parity tests; stdlib-only so they always pass in this job.
|
||||
run: |
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -count=1 -cover -coverprofile=coverage.out
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/api/router/... ./internal/auth/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... ./internal/ciparity/... -count=1 -cover -coverprofile=coverage.out
|
||||
|
||||
- name: Multi-replica rate-limit integration test (Phase 13 Sprint 13.2/13.3 — ARCH-M1 closure proof)
|
||||
# The falsifiable proof that CERTCTL_RATE_LIMIT_BACKEND=postgres
|
||||
# enforces caps cluster-wide. testcontainers-go spins one
|
||||
# Postgres container; 3 *PostgresSlidingWindowLimiter instances
|
||||
# share it; 100 concurrent Allow("test-key") with cap=10 must
|
||||
# see exactly 10 succeed + 90 ErrRateLimited. Failure here =
|
||||
# the row-lock arbitration broke; ARCH-M1 closure is invalid.
|
||||
run: |
|
||||
go test -tags=integration -race -count=1 -timeout=300s \
|
||||
-run TestRateLimit_PostgresBackend_CapEnforcedAcrossReplicas \
|
||||
./internal/integration/...
|
||||
|
||||
- name: Check Coverage Thresholds
|
||||
run: |
|
||||
# Extract per-package coverage from test output
|
||||
echo "=== Coverage Report ==="
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
# Check service layer coverage (target: 60%+)
|
||||
SERVICE_COV=$(go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Service layer coverage: ${SERVICE_COV}%"
|
||||
|
||||
# Check handler layer coverage (target: 60%+)
|
||||
HANDLER_COV=$(go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Handler layer coverage: ${HANDLER_COV}%"
|
||||
|
||||
# Check domain layer coverage (target: 40%+)
|
||||
DOMAIN_COV=$(go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Domain layer coverage: ${DOMAIN_COV}%"
|
||||
|
||||
# Check middleware layer coverage (target: 50%+)
|
||||
MIDDLEWARE_COV=$(go tool cover -func=coverage.out | grep 'internal/api/middleware' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||||
echo "Middleware layer coverage: ${MIDDLEWARE_COV}%"
|
||||
|
||||
# Fail if thresholds not met
|
||||
if [ "$(echo "$SERVICE_COV < 55" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Service layer coverage ${SERVICE_COV}% is below 55% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$HANDLER_COV < 60" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Handler layer coverage ${HANDLER_COV}% is below 60% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$DOMAIN_COV < 40" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Domain layer coverage ${DOMAIN_COV}% is below 40% threshold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(echo "$MIDDLEWARE_COV < 30" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold"
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage thresholds passed!"
|
||||
# ci-pipeline-cleanup Phase 2: per-package floors moved to
|
||||
# .github/coverage-thresholds.yml. Each entry has `floor:` +
|
||||
# `why:` (load-bearing context). Logic in
|
||||
# scripts/check-coverage-thresholds.sh — operator runs the same
|
||||
# script locally via `make verify`-equivalent loop.
|
||||
run: bash scripts/check-coverage-thresholds.sh
|
||||
|
||||
- name: Upload Coverage Report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: go-coverage
|
||||
path: coverage.out
|
||||
retention-days: 30
|
||||
|
||||
- name: Coverage PR comment
|
||||
# ci-pipeline-cleanup Phase 10 / frozen decision 0.9: self-hosted
|
||||
# alternative to Codecov / Coveralls. Posts a per-package coverage
|
||||
# delta as a PR comment; updates in place on subsequent pushes.
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: bash scripts/coverage-pr-comment.sh
|
||||
|
||||
# Bundle Q / I-001 closure — test-naming convention guard (informational).
|
||||
# The convention is `Test<Func>_<Scenario>_<ExpectedResult>`. This step
|
||||
# prints any non-conformant tests but does NOT fail the build until the
|
||||
# Bundle I-001-extended (2026-04-27) — promoted from informational
|
||||
# to hard-fail. The convention is now: every `func TestXxx(...)` MUST
|
||||
# match Go's standard test-runner pattern (`^func Test[A-Z]`). Tests
|
||||
# whose name starts with `func Test<lowercase>` are silently SKIPPED
|
||||
# by `go test` (Go only runs `Test[A-Z]...`) — those are the real
|
||||
# bugs this guard catches.
|
||||
#
|
||||
# The original audit's `Test<Func>_<Scenario>_<ExpectedResult>` triple-
|
||||
# token prescription has been relaxed: single-function pin tests like
|
||||
# `TestNewAgent` or `TestSplitPEMChain` are valid Go convention, with
|
||||
# internal scenarios expressed via `t.Run` subtests. Requiring the
|
||||
# underscore-Scenario-Result triple repo-wide would mean renaming
|
||||
# 167 legitimate tests for no observable behavior change. The
|
||||
# Test<Func>_<Scenario>_<ExpectedResult> form remains the
|
||||
# recommended pattern for parameterized scenarios, but is not gated.
|
||||
# Phase 4 DEPL-* prerequisite (2026-05-14): helm-templates-lint.sh
|
||||
# needs the `helm` CLI on PATH to run helm lint + helm template
|
||||
# against the chart. The official azure/setup-helm action installs
|
||||
# a SHA-pinned helm binary into the runner.
|
||||
- name: Install Helm (for helm-templates-lint guard)
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
with:
|
||||
version: v3.16.0
|
||||
|
||||
- name: Regression guards (extracted to scripts/ci-guards/)
|
||||
# All named regression guards live at scripts/ci-guards/<id>.sh per
|
||||
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
|
||||
# bash scripts/ci-guards/G-3-env-docs-drift.sh
|
||||
# Adding a new guard: drop a new <id>.sh; this loop auto-picks it up.
|
||||
# Contract: each guard MUST exit 0 on clean repo, non-zero with
|
||||
# ::error:: prefix on regression. See scripts/ci-guards/README.md.
|
||||
#
|
||||
run: |
|
||||
set -e
|
||||
fail=0
|
||||
for g in scripts/ci-guards/*.sh; do
|
||||
echo "::group::$(basename "$g")"
|
||||
if ! bash "$g"; then
|
||||
fail=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $fail
|
||||
|
||||
cross-platform-build:
|
||||
# Phase 3 TEST-H2 closure (2026-05-13): the pre-Phase-3 CI ran
|
||||
# exclusively on ubuntu-latest, leaving Windows-specific bugs
|
||||
# (path separators, file permissions, exec.Command semantics)
|
||||
# undetected. The agent + CLI binaries ship for Windows + macOS
|
||||
# users; this matrix asserts they at least BUILD on every OS we
|
||||
# claim to support.
|
||||
#
|
||||
# Build-only — no test run. Full test parity across OSes is a
|
||||
# larger investment (testcontainers is Linux-only on Windows CI
|
||||
# runners, file-permission tests differ, etc.). The build gate
|
||||
# is the minimum that catches the cross-platform regressions
|
||||
# we've seen in practice.
|
||||
name: Cross-platform build (ubuntu / windows / macos)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Build server + agent + CLI + mcp-server
|
||||
run: |
|
||||
go build ./cmd/server
|
||||
go build ./cmd/agent
|
||||
go build ./cmd/cli
|
||||
go build ./cmd/mcp-server
|
||||
|
||||
cold-db-compose-smoke:
|
||||
# Per post-v2.1.0 anti-rot item 6 (Auditable Codebase Bundle).
|
||||
#
|
||||
# Catches migration-on-cold-DB regressions: wipe the postgres
|
||||
# volume, bring the stack up cold, mint a day-0 admin, issue +
|
||||
# renew + revoke a test certificate, assert audit rows, tear down.
|
||||
# Targets the bug class that the warm-DB integration suite misses
|
||||
# (canonical case: 2026-05-09 migration 000045 broken INSERT,
|
||||
# fixed in commit 6444e13).
|
||||
name: Cold-DB compose smoke
|
||||
runs-on: ubuntu-latest
|
||||
needs: go-build-and-test
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Show Docker versions
|
||||
run: |
|
||||
docker --version
|
||||
docker compose version
|
||||
|
||||
- name: Cold-DB compose smoke
|
||||
# The smoke deliberately focuses on the bug class that ONLY a
|
||||
# cold boot can catch: stack-startup correctness against a
|
||||
# blank database. It is intentionally NOT a functional API
|
||||
# walkthrough — the integration test suite under
|
||||
# 'Go Test with Coverage' already covers issue / renew /
|
||||
# revoke / audit-row plumbing against a warm DB.
|
||||
#
|
||||
# The bugs this gate is uniquely positioned to catch:
|
||||
# - Missing required env vars that fail Config.Validate()
|
||||
# at startup (e.g. CERTCTL_DEMO_MODE_ACK gap, 2026-05-12).
|
||||
# - Non-idempotent migrations that crash on the second boot
|
||||
# (e.g. migration 000043 CHECK constraint, 2026-05-12).
|
||||
# - Documented manual flows that don't work end-to-end on
|
||||
# a clean compose (e.g. CERTCTL_BOOTSTRAP_TOKEN
|
||||
# interpolation gap, 2026-05-12).
|
||||
#
|
||||
# Bugs OUTSIDE the scope of this smoke (covered elsewhere):
|
||||
# - API request/response contract changes (integration suite).
|
||||
# - Cert lifecycle correctness (integration suite + handler
|
||||
# tests).
|
||||
# - Audit row plumbing (handler tests).
|
||||
#
|
||||
# 10-min wall-clock cap covers cold image pull + compose-up +
|
||||
# force-recreate + admin bootstrap + teardown. Increase only
|
||||
# if the underlying steps legitimately grow.
|
||||
#
|
||||
# The smoke is inlined here on purpose — it is NOT a script in
|
||||
# scripts/ci-guards/, because there is no value in a developer
|
||||
# running this locally. The whole point of the gate is that CI
|
||||
# owns the cold-DB state; the operator never has to remember to
|
||||
# run it.
|
||||
timeout-minutes: 10
|
||||
working-directory: deploy
|
||||
env:
|
||||
STARTUP_TIMEOUT_SECONDS: 300
|
||||
run: |
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SERVER_URL="https://localhost:8443"
|
||||
CACERT_PATH="${GITHUB_WORKSPACE}/deploy/test/certs/ca.crt"
|
||||
|
||||
log() { echo "[cold-db-smoke] $*"; }
|
||||
|
||||
wait_for_service_healthy() {
|
||||
local svc="$1" deadline=$(( $(date +%s) + STARTUP_TIMEOUT_SECONDS ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local state
|
||||
state="$(docker compose ps --format json "$svc" 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
line = sys.stdin.read().strip()
|
||||
if not line:
|
||||
print("not-up"); sys.exit(0)
|
||||
rows = json.loads(line) if line.startswith("[") else [json.loads(l) for l in line.splitlines() if l.strip()]
|
||||
if not rows:
|
||||
print("not-up")
|
||||
else:
|
||||
print(rows[0].get("Health", rows[0].get("State", "?")))
|
||||
except Exception as e:
|
||||
print(f"err: {e}")
|
||||
')"
|
||||
if [ "$state" = "healthy" ] || [ "$state" = "running" ]; then
|
||||
log " $svc → $state"; return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
log " $svc did NOT reach healthy within ${STARTUP_TIMEOUT_SECONDS}s (last: $state)"
|
||||
return 1
|
||||
}
|
||||
|
||||
http_call() {
|
||||
local method="$1" path="$2" data="${3:-}"
|
||||
local args=(--silent --show-error --max-time 30 -X "$method" "$SERVER_URL$path")
|
||||
[ -f "$CACERT_PATH" ] && args+=(--cacert "$CACERT_PATH") || args+=(--insecure)
|
||||
[ -n "$data" ] && args+=(-H "Content-Type: application/json" -d "$data")
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
# Bundle 2 closure (2026-05-12): the base compose is now
|
||||
# production-shaped — auth=api-key + agent-keygen + fail-closed
|
||||
# placeholder guards. The cold-DB smoke layers in the demo
|
||||
# overlay so the boot path remains zero-config: the overlay
|
||||
# supplies AUTH_TYPE=none + DEMO_MODE_ACK=true + the matching
|
||||
# placeholder creds the fail-closed guards accept under
|
||||
# DEMO_MODE_ACK. The agent service in the overlay also
|
||||
# pre-seeds CERTCTL_AGENT_ID=agent-demo-1 so the bundled
|
||||
# agent doesn't restart-loop. The smoke's purpose (catch
|
||||
# migration-on-cold-DB regressions + verify bootstrap-token
|
||||
# endpoint mints a day-0 admin against a freshly migrated
|
||||
# schema) is orthogonal to whether the auth posture is
|
||||
# demo-mode or api-key, so the overlay is acceptable here.
|
||||
COMPOSE_FILES=(-f docker-compose.yml -f docker-compose.demo.yml)
|
||||
|
||||
# Phase 2 SEC-H3 (2026-05-13): the demo overlay sets
|
||||
# CERTCTL_DEMO_MODE_ACK=true; the SEC-H3 fail-closed guard
|
||||
# requires a paired CERTCTL_DEMO_MODE_ACK_TS within the last
|
||||
# 24h (a static YAML value would rot). The overlay reads
|
||||
# ${CERTCTL_DEMO_MODE_ACK_TS:-} from the shell, so we mint a
|
||||
# fresh timestamp here and export it for every compose
|
||||
# invocation in this job (initial up-d AND the force-recreate
|
||||
# at step 4).
|
||||
export CERTCTL_DEMO_MODE_ACK_TS="$(date +%s)"
|
||||
|
||||
log "1/4 down -v --remove-orphans"
|
||||
docker compose "${COMPOSE_FILES[@]}" down -v --remove-orphans 2>&1 | tail -3 || true
|
||||
|
||||
log "2/4 up -d (cold boot)"
|
||||
docker compose "${COMPOSE_FILES[@]}" up -d 2>&1 | tail -3
|
||||
|
||||
log "3/4 wait for healthchecks"
|
||||
wait_for_service_healthy postgres
|
||||
wait_for_service_healthy certctl-server
|
||||
wait_for_service_healthy certctl-agent || log " (agent skipped)"
|
||||
|
||||
log "4/4 minting day-0 admin (proves migration ladder + bootstrap path)"
|
||||
TOKEN="$(openssl rand -base64 32 | tr -d '\n')"
|
||||
{
|
||||
echo "CERTCTL_BOOTSTRAP_TOKEN=$TOKEN"
|
||||
# Re-emit the demo-mode ACK TS into the --env-file so the
|
||||
# force-recreate at step 4 inherits it. `--env-file` REPLACES
|
||||
# the shell-env source for variable interpolation on compose
|
||||
# operations that use it, so omitting this line would re-trip
|
||||
# the SEC-H3 guard.
|
||||
echo "CERTCTL_DEMO_MODE_ACK_TS=$CERTCTL_DEMO_MODE_ACK_TS"
|
||||
} > /tmp/_smoke.env
|
||||
docker compose "${COMPOSE_FILES[@]}" --env-file /tmp/_smoke.env up -d --force-recreate certctl-server 2>&1 | tail -2
|
||||
sleep 5
|
||||
wait_for_service_healthy certctl-server
|
||||
BODY="$(http_call POST /api/v1/auth/bootstrap "{\"token\":\"$TOKEN\",\"actor_name\":\"smoke-admin\"}")"
|
||||
KEY="$(echo "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["key_value"])')"
|
||||
[ -n "$KEY" ] || { log "bootstrap failed: $BODY"; exit 1; }
|
||||
|
||||
log "PASS — cold boot + force-recreate + admin bootstrap all green"
|
||||
log "tearing down"
|
||||
docker compose "${COMPOSE_FILES[@]}" down -v 2>&1 | tail -2
|
||||
|
||||
- name: Dump compose logs on failure
|
||||
if: failure()
|
||||
working-directory: deploy
|
||||
run: |
|
||||
for svc in postgres certctl-server certctl-agent certctl-tls-init; do
|
||||
echo "==== $svc ===="
|
||||
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs --no-color --tail 200 "$svc" || true
|
||||
done
|
||||
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# ARCH-001-A closure (Sprint 5, 2026-05-16). The
|
||||
# openapi-version-tag-parity guard needs the v* tags to
|
||||
# be present locally so it can confirm openapi.yaml's
|
||||
# info.version matches the latest release. Without
|
||||
# fetch-tags, the guard falls back to the GitHub API —
|
||||
# works but adds a network round-trip per CI run.
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -114,6 +443,17 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: npm audit (production deps, high+critical)
|
||||
# Phase 1 TEST-L2 closure (2026-05-13):
|
||||
# Production frontend dependencies must not carry high or
|
||||
# critical CVEs. Dev-only deps (vitest, vite, eslint, etc.)
|
||||
# are excluded via --omit=dev since they never ship to
|
||||
# operators. If this gate fires, triage each finding via npm
|
||||
# overrides, dep upgrade, or a tracked --ignore with an issue
|
||||
# link. Do not mass-silence findings.
|
||||
working-directory: web
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: TypeScript Check
|
||||
working-directory: web
|
||||
run: npx tsc --noEmit
|
||||
@@ -125,3 +465,314 @@ jobs:
|
||||
- name: Build Frontend
|
||||
working-directory: web
|
||||
run: npx vite build
|
||||
|
||||
- name: Frontend bundle-size budget (size-limit)
|
||||
# Acquisition-audit SCALE-007 closure (Sprint 6 ACQ, 2026-05-16).
|
||||
# Per-chunk + per-tier budgets in web/.size-limit.json; brotli-
|
||||
# compressed sizes match real-world download cost. A regression
|
||||
# that bloats a chunk past its cap fails this step and forces
|
||||
# an explicit operator decision (fix vs raise cap with rationale).
|
||||
# The script wrapper at scripts/ci-guards/G-frontend-bundle-budget.sh
|
||||
# is the local-runnable counterpart; both invoke `npm run size`.
|
||||
working-directory: web
|
||||
run: npm run size
|
||||
|
||||
- name: Regression guards (extracted to scripts/ci-guards/)
|
||||
# All named regression guards live at scripts/ci-guards/<id>.sh per
|
||||
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
|
||||
# bash scripts/ci-guards/G-3-env-docs-drift.sh
|
||||
# Adding a new guard: drop a new <id>.sh; this loop auto-picks it up.
|
||||
# Contract: each guard MUST exit 0 on clean repo, non-zero with
|
||||
# ::error:: prefix on regression. See scripts/ci-guards/README.md.
|
||||
run: |
|
||||
set -e
|
||||
fail=0
|
||||
for g in scripts/ci-guards/*.sh; do
|
||||
echo "::group::$(basename "$g")"
|
||||
if ! bash "$g"; then
|
||||
fail=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $fail
|
||||
|
||||
helm-lint:
|
||||
name: Helm Chart Validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
|
||||
with:
|
||||
version: '3.13.0'
|
||||
|
||||
# HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is
|
||||
# configured. Every lint/template invocation below must pick exactly one
|
||||
# provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl
|
||||
# (certctl.tls.required) and docs/operator/tls.md.
|
||||
#
|
||||
# Bundle 3 closure (2026-05-12, commit f1fa311): the chart now ALSO
|
||||
# fails render when (a) server.auth.type=api-key + apiKey empty, or
|
||||
# (b) postgresql.enabled=true + postgresql.auth.password empty.
|
||||
# Every positive render below MUST pass both secrets; inverse tests
|
||||
# at the bottom of this job pin the fail-fast guards in place.
|
||||
- name: Lint Helm Chart
|
||||
run: |
|
||||
helm lint deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder \
|
||||
--set postgresql.auth.password=ci-postgres-placeholder
|
||||
|
||||
- name: Template Helm Chart (existingSecret mode)
|
||||
run: |
|
||||
helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder \
|
||||
--set postgresql.auth.password=ci-postgres-placeholder \
|
||||
> /dev/null
|
||||
|
||||
- name: Template Helm Chart (cert-manager mode)
|
||||
run: |
|
||||
helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.certManager.enabled=true \
|
||||
--set server.tls.certManager.issuerRef.name=letsencrypt-prod \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder \
|
||||
--set postgresql.auth.password=ci-postgres-placeholder \
|
||||
> /dev/null
|
||||
|
||||
- name: Template Helm Chart (external Postgres mode — Bundle 3 D2)
|
||||
run: |
|
||||
# Closes Bundle 3 D2: postgresql.enabled=false must (a) render
|
||||
# cleanly with externalDatabase.url and (b) emit ZERO postgres-*
|
||||
# templates. The render output is grep-checked below.
|
||||
out=$(helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=certctl-tls-ci \
|
||||
--set postgresql.enabled=false \
|
||||
--set externalDatabase.url='postgres://u:p@db.example.com:5432/certctl?sslmode=require' \
|
||||
--set server.auth.apiKey=ci-api-key-placeholder)
|
||||
# Bundled-Postgres resources must not appear when postgresql.enabled=false.
|
||||
if echo "$out" | grep -qE "^kind: StatefulSet$"; then
|
||||
echo "::error::Bundle 3 D2 regression: postgres StatefulSet rendered with postgresql.enabled=false"
|
||||
exit 1
|
||||
fi
|
||||
if echo "$out" | grep -q "postgres-secret.yaml"; then
|
||||
echo "::error::Bundle 3 D2 regression: postgres-secret rendered with postgresql.enabled=false"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails without TLS)
|
||||
run: |
|
||||
# Inverse test: the chart MUST refuse to render when no TLS source is
|
||||
# configured. If this ever renders successfully, the fail-loud guard
|
||||
# in certctl.tls.required has regressed.
|
||||
if helm template certctl deploy/helm/certctl/ > /dev/null 2>&1; then
|
||||
echo "::error::Helm chart rendered without a TLS source — fail-loud guard regressed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D7 TLS both-set)
|
||||
run: |
|
||||
# Bundle 3 D7: setting BOTH existingSecret AND certManager.enabled
|
||||
# creates two conflicting TLS sources of truth. Chart must refuse.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set server.tls.certManager.enabled=true \
|
||||
--set server.tls.certManager.issuerRef.name=foo \
|
||||
--set server.auth.apiKey=k \
|
||||
--set postgresql.auth.password=p \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D7 regression: chart rendered with BOTH TLS sources configured"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D1 missing apiKey)
|
||||
run: |
|
||||
# Bundle 3 D1: missing server.auth.apiKey when auth.type=api-key
|
||||
# must fail at template time, not silently render an empty Secret.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set postgresql.auth.password=p \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D1 regression: chart rendered with empty server.auth.apiKey"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D1 missing pg password)
|
||||
run: |
|
||||
# Bundle 3 D1: missing postgresql.auth.password when postgresql.enabled=true
|
||||
# must fail at template time, not silently use a fallback default.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set server.auth.apiKey=k \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D1 regression: chart rendered with empty postgresql.auth.password"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Template Helm Chart (guard fails — Bundle 3 D1 missing external DB URL)
|
||||
run: |
|
||||
# Bundle 3 D1: missing externalDatabase.url when postgresql.enabled=false
|
||||
# must fail at template time.
|
||||
if helm template certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=ci \
|
||||
--set postgresql.enabled=false \
|
||||
--set server.auth.apiKey=k \
|
||||
> /dev/null 2>&1; then
|
||||
echo "::error::Bundle 3 D1 regression: chart rendered with postgresql.enabled=false + empty externalDatabase.url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# deploy-vendor-e2e — single-job (collapsed from 12-job matrix)
|
||||
# =============================================================================
|
||||
# Per ci-pipeline-cleanup bundle Phase 5 / frozen decision 0.4 (revises
|
||||
# Bundle II decision 0.9): the per-vendor matrix produced 12 status-check
|
||||
# rows for ~1 real assertion (115/116 vendor-edge tests are t.Log
|
||||
# placeholders). Collapsed to one job that brings up all 11 sidecars
|
||||
# at once and runs the full VendorEdge_ test set.
|
||||
#
|
||||
# Skip-detection guard (scripts/vendor-e2e-skip-check.sh)
|
||||
# enforces that no test SKIPs except the documented allowlist
|
||||
# (windows-iis-requiring tests on Linux). If a sidecar fails to come
|
||||
# up, requireSidecar() in deploy/test/vendor_e2e_helpers.go calls
|
||||
# t.Skipf() — the guard catches that.
|
||||
#
|
||||
# RAM headroom on ubuntu-latest (16 GB ceiling) — operator-confirmed
|
||||
# in Phase 0 / frozen decision 0.14 prototype-branch run. If RAM
|
||||
# regresses, fall back to bucketed matrix per
|
||||
# the project's frozen-decisions log.
|
||||
#
|
||||
# The Windows matrix (deploy-vendor-e2e-windows) was deleted entirely
|
||||
# per Phase 6 / frozen decision 0.5 (revises Bundle II decision 0.4).
|
||||
# IIS + WinCertStore validation moved to the operator playbook at
|
||||
# docs/connector-iis.md::Operator validation playbook.
|
||||
deploy-vendor-e2e:
|
||||
name: deploy-vendor-e2e
|
||||
runs-on: ubuntu-latest
|
||||
needs: [go-build-and-test]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Build f5-mock-icontrol sidecar
|
||||
# The only sidecar without a published image; built from the in-tree
|
||||
# Go server at deploy/test/f5-mock-icontrol/.
|
||||
run: docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml build f5-mock-icontrol
|
||||
|
||||
- name: Bring up all vendor sidecars
|
||||
# Brings up the 11 deploy-e2e sidecars (apache-test, haproxy-test,
|
||||
# traefik-test, caddy-test, envoy-test, postfix-test, dovecot-test,
|
||||
# openssh-test, f5-mock-icontrol, k8s-kind-test, windows-iis-test
|
||||
# which is gated by a separate windows-only profile and won't
|
||||
# actually start) plus the always-on legacy nginx.
|
||||
run: |
|
||||
docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml up -d
|
||||
sleep 15
|
||||
|
||||
- name: Run all vendor-edge e2e
|
||||
# Captures test output for skip-count enforcement (next step).
|
||||
env:
|
||||
INTEGRATION: "1"
|
||||
run: |
|
||||
go test -tags integration -race -count=1 -run 'VendorEdge_' \
|
||||
./deploy/test/... 2>&1 | tee test-output.log
|
||||
|
||||
- name: Skip-count enforcement
|
||||
# ci-pipeline-cleanup Phase 5 / frozen decision 0.6:
|
||||
# requireSidecar uses t.Skipf (not t.Fatal) when a sidecar isn't
|
||||
# reachable — collapsing the per-vendor matrix removes the implicit
|
||||
# guard each per-job matrix entry provided. This step counts SKIP
|
||||
# lines in the test output and fails the build if it exceeds the
|
||||
# allowlist (windows-iis-requiring tests; legitimately skipped
|
||||
# on Linux per Phase 6 / frozen decision 0.5).
|
||||
run: bash scripts/vendor-e2e-skip-check.sh test-output.log
|
||||
|
||||
- name: Diagnostic dump on failure
|
||||
# Prints container status + last 200 log lines from the certctl-server
|
||||
# and base-stack containers when ANY previous step in this job fails.
|
||||
# The matrix-collapse (Phase 5) brings up ~18 containers concurrently
|
||||
# (vs 1 vendor sidecar at a time pre-collapse); transient failures
|
||||
# surface most often as "container certctl-test-server is unhealthy"
|
||||
# without any visible reason because compose only reports the
|
||||
# dependency-chain symptom, not the root cause. Dumping logs here
|
||||
# makes the underlying error (DB migration crash, port bind failure,
|
||||
# entrypoint stall, OOM kill) visible in the GitHub Actions log
|
||||
# without requiring a workstation reproduction.
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== docker compose ps -a ==="
|
||||
docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml ps -a || true
|
||||
echo ""
|
||||
echo "=== certctl-test-server logs (last 200 lines) ==="
|
||||
docker logs --tail 200 certctl-test-server 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-tls-init logs ==="
|
||||
docker logs certctl-test-tls-init 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-postgres logs (last 100 lines) ==="
|
||||
docker logs --tail 100 certctl-test-postgres 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-stepca logs (last 100 lines) ==="
|
||||
docker logs --tail 100 certctl-test-stepca 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-pebble logs (last 50 lines) ==="
|
||||
docker logs --tail 50 certctl-test-pebble 2>&1 || true
|
||||
echo ""
|
||||
echo "=== certctl-test-agent logs (last 100 lines) ==="
|
||||
docker logs --tail 100 certctl-test-agent 2>&1 || true
|
||||
|
||||
- name: Tear down sidecars
|
||||
if: always()
|
||||
run: docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml down -v
|
||||
|
||||
# =============================================================================
|
||||
# image-and-supply-chain — digest validity + Docker build smoke + OpenAPI parity
|
||||
# =============================================================================
|
||||
# Per ci-pipeline-cleanup bundle Phases 7-9 / frozen decision 0.8.
|
||||
# Three checks bundled into one job (parallel to go-build-and-test):
|
||||
# 1. Digest validity — every @sha256 ref in deploy/* + Dockerfiles must
|
||||
# resolve on its registry. Closes the H-001 lying-field gap (H-001
|
||||
# verifies digest *presence* but not *resolution* — Bundle II shipped
|
||||
# 11 fabricated digests that passed H-001 and failed `docker pull`).
|
||||
# 2. Docker build smoke — all 4 Dockerfiles in the repo must build.
|
||||
# Catches syntax errors / COPY path drift before tag-time release.yml.
|
||||
# 3. OpenAPI ↔ handler parity — every router route has a matching
|
||||
# operationId or is documented in api/openapi-handler-exceptions.yaml.
|
||||
image-and-supply-chain:
|
||||
name: image-and-supply-chain
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Digest validity (every @sha256 ref must resolve)
|
||||
run: bash scripts/ci-guards/digest-validity.sh
|
||||
|
||||
- name: Docker build smoke (all 4 Dockerfiles)
|
||||
# Per frozen decision 0.10: build all 4 Dockerfiles in the repo,
|
||||
# not just production server + agent. The test-sidecar Dockerfiles
|
||||
# are load-bearing for vendor-e2e — a syntax error there silently
|
||||
# breaks the e2e suite.
|
||||
run: |
|
||||
set -e
|
||||
docker build -f Dockerfile -t certctl:smoke .
|
||||
docker build -f Dockerfile.agent -t certctl-agent:smoke .
|
||||
docker build -f deploy/test/f5-mock-icontrol/Dockerfile -t f5-mock:smoke .
|
||||
docker build -f deploy/test/libest/Dockerfile -t libest:smoke .
|
||||
echo "All 4 Dockerfiles build clean."
|
||||
|
||||
- name: OpenAPI ↔ handler operationId parity
|
||||
run: bash scripts/ci-guards/openapi-handler-parity.sh
|
||||
|
||||
81
.github/workflows/codeql.yml
vendored
Normal file
81
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: CodeQL
|
||||
|
||||
# Public-facing SAST baseline that complements the existing security-deep-scan
|
||||
# workflow (gosec, osv-scanner, trivy, ZAP, semgrep, schemathesis, nuclei,
|
||||
# testssl) with cross-file Go and JavaScript dataflow analysis. Results land
|
||||
# in the repository's Security → Code scanning tab as a public signal — any
|
||||
# operator/security team auditing certctl can see the scan history and
|
||||
# triage state without asking.
|
||||
#
|
||||
# Why CodeQL in addition to gosec:
|
||||
# - gosec is single-file pattern matching (catches obvious issues like
|
||||
# `os/exec.Command(userInput)`); CodeQL does interprocedural taint
|
||||
# tracking (catches the same issue when the userInput is laundered
|
||||
# through several function calls or struct fields).
|
||||
# - GitHub-native; no third-party SaaS license gate (works for BSL 1.1
|
||||
# and other source-available licenses, unlike Aikido / Snyk / SonarCloud
|
||||
# free tiers which require OSI-approved licenses).
|
||||
# - SARIF results auto-deduplicate and persist on PRs, so reviewers see
|
||||
# "this PR introduces N new findings" rather than re-running ad hoc.
|
||||
#
|
||||
# Findings that are intentional (e.g., the SSH connector's
|
||||
# InsecureIgnoreHostKey, ACME DNS solver's intentional shell-out to operator-
|
||||
# supplied scripts) get suppressed via inline `// codeql[<rule-id>]`
|
||||
# comments OR via a `.github/codeql/codeql-config.yml` query-pack tweak —
|
||||
# document the rationale in the same commit that adds the suppression so
|
||||
# the public scan-tab readers see the threat-model justification.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
# Weekly Sunday 06:00 UTC, in addition to push/PR coverage. Catches
|
||||
# rule-pack updates from CodeQL upstream (their Go/JS rulesets ship
|
||||
# new queries on a roughly-monthly cadence).
|
||||
- cron: '0 6 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # SARIF upload to GitHub code scanning
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [go, javascript-typescript]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
# Match ci.yml + release.yml + security-deep-scan.yml.
|
||||
go-version: '1.25.10'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# Use the security-and-quality query suite — security finds plus
|
||||
# maintainability/correctness issues that the smaller security-extended
|
||||
# suite skips. Comparable scope to what Aikido / SonarCloud run.
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7fd177fa680c9881b53cdab4d346d32574c9f7f4 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# SARIF upload is implicit (and is what populates the Security tab).
|
||||
112
.github/workflows/e2e.yml
vendored
Normal file
112
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# Phase 8 closure (TEST-H1 + TEST-H2): browser-driven E2E + visual
|
||||
# regression.
|
||||
#
|
||||
# TEST-003 closure (Sprint 5, 2026-05-16): the suite has accumulated
|
||||
# the empirical green-run evidence the Phase 8 prompt required. 14
|
||||
# consecutive green runs across 2026-05-14 to 2026-05-15 (sampled
|
||||
# via api.github.com/repos/certctl-io/certctl/actions/runs) during
|
||||
# heavy Sprint 1-4 frontend churn confirm stability. The job is
|
||||
# now part of the merge gate (continue-on-error: false below).
|
||||
#
|
||||
# Operator action still required AFTER this commit pushes:
|
||||
# - Add this job's "id" to the branch-protection required-checks
|
||||
# list at https://github.com/certctl-io/certctl/settings/branches.
|
||||
# Without that, the workflow's failure-blocks-merge contract
|
||||
# only fires on PRs whose author is configured to honour the
|
||||
# status check; configured required-checks make it universal.
|
||||
#
|
||||
# Visual regression: the 04-visual-regression.spec.ts file uses
|
||||
# Playwright `toHaveScreenshot()`. First-run on a new branch
|
||||
# regenerates baselines via the `--update-snapshots` flag; the
|
||||
# operator commits the resulting PNG bytes to git. Subsequent runs
|
||||
# pixel-diff. The dispatch input below provides an explicit knob
|
||||
# for that initial baseline pass without needing to edit the
|
||||
# workflow file. See docs/operator/runbooks/e2e-snapshot-update.md
|
||||
# for the snapshot-bump workflow.
|
||||
|
||||
name: Frontend E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'web/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'web/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
update_snapshots:
|
||||
description: 'Regenerate visual-regression baselines (use sparingly)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E + visual regression
|
||||
runs-on: ubuntu-latest
|
||||
# TEST-003 closure (Sprint 5, 2026-05-16): flipped from
|
||||
# continue-on-error: true after 14 consecutive green runs across
|
||||
# 2026-05-14 to 2026-05-15 confirmed stability. Failures here
|
||||
# now fail the workflow, which (combined with the branch
|
||||
# protection update the operator owns post-merge) blocks merge.
|
||||
continue-on-error: false
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: web
|
||||
# --with-deps installs OS packages (libnss3, libatk1.0-0, etc.)
|
||||
# the chromium browser needs. Skipping this is the #1 source
|
||||
# of "tests pass locally but fail on CI" for new Playwright
|
||||
# users. The browser binary downloads to ~/.cache/ms-playwright;
|
||||
# the actions/setup-node cache key does NOT include it, so each
|
||||
# CI run re-downloads. Add an actions/cache step targeting
|
||||
# ~/.cache/ms-playwright keyed by the @playwright/test version
|
||||
# in package-lock.json once the suite is stable.
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright E2E + visual regression
|
||||
working-directory: web
|
||||
# The webServer block in playwright.config.ts boots `npm run dev`
|
||||
# automatically and waits for http://localhost:5173 to be
|
||||
# responsive before the first test fires. No separate "start
|
||||
# server" step needed.
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.update_snapshots }}" == "true" ]]; then
|
||||
echo "::warning::Regenerating visual-regression baselines"
|
||||
npx playwright test --update-snapshots
|
||||
else
|
||||
npx playwright test
|
||||
fi
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: web/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload visual-regression diffs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
|
||||
with:
|
||||
name: visual-regression-diffs
|
||||
path: web/test-results/
|
||||
retention-days: 7
|
||||
139
.github/workflows/loadtest.yml
vendored
Normal file
139
.github/workflows/loadtest.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
# Load-test workflow — closes the #8 acquisition-readiness blocker from
|
||||
# the 2026-05-01 issuer coverage audit (see
|
||||
# the 2026-05-01 issuer coverage audit).
|
||||
#
|
||||
# CADENCE: workflow_dispatch + weekly cron, NOT per-push. Load tests
|
||||
# are minutes long and don't provide useful per-PR signal — per-push
|
||||
# pressure goes through ci.yml. This workflow exists to (a) catch
|
||||
# gradual regressions from cumulative changes that no single PR
|
||||
# triggered, and (b) give an operator a one-click way to capture
|
||||
# numbers before tagging a release.
|
||||
#
|
||||
# THRESHOLDS: defined in deploy/test/loadtest/k6.js (p99 < 5s for
|
||||
# issuance-acceptance, p99 < 2s for list, error rate < 1%). k6 exits
|
||||
# non-zero on any breach, which propagates through `docker compose up
|
||||
# --exit-code-from k6` → `make loadtest` → this workflow's exit.
|
||||
|
||||
name: loadtest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Manual trigger from the Actions tab. Use before tagging a
|
||||
# release or after a meaningful tuning commit.
|
||||
|
||||
schedule:
|
||||
# Mondays at 06:00 UTC. Off-peak; catches regressions accumulated
|
||||
# over the previous week's merges. Once a baseline is committed
|
||||
# in deploy/test/loadtest/README.md, drift relative to that
|
||||
# baseline is the signal — diff the captured summary.json
|
||||
# against the committed numbers.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
# Reduce permissions — this workflow doesn't write to PRs or push tags.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
k6:
|
||||
name: k6 throughput run
|
||||
runs-on: ubuntu-latest
|
||||
# 25-minute hard cap. Pre-Bundle-10: 15min was enough for the API
|
||||
# tier alone (~7 minutes total). Post-Bundle-10 the harness boots
|
||||
# four additional target sidecars (nginx, apache, haproxy, f5-mock)
|
||||
# before the k6 run; their healthchecks add ~30-60s. The k6 scenarios
|
||||
# themselves are still 5 minutes (run in parallel with the API
|
||||
# scenarios, not serially). 25 minutes absorbs that plus slow CI
|
||||
# runners and cold image caches without letting a stuck container
|
||||
# consume the runner indefinitely.
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
# The compose stack builds the certctl image from the repo
|
||||
# root Dockerfile. Buildx gives the build a usable cache and
|
||||
# works with newer compose versions.
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Run loadtest
|
||||
run: make loadtest
|
||||
env:
|
||||
# Disable BuildKit progress noise so the run log is
|
||||
# diff-able against past runs.
|
||||
BUILDKIT_PROGRESS: plain
|
||||
|
||||
- name: Upload summary
|
||||
# Always upload the summary so a regression has a diffable
|
||||
# artifact even when k6 exited non-zero. summary.json is the
|
||||
# authoritative machine-readable form; summary.txt is the
|
||||
# human-readable text the README baseline tracks.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: k6-summary-${{ github.run_id }}
|
||||
path: deploy/test/loadtest/results/
|
||||
retention-days: 90
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 8 SCALE-H2 — scale-tier scenarios. Three new k6 drivers:
|
||||
# - bulk-renewal: 10K-cert seed + criteria-mode POST /bulk-renew
|
||||
# - acme-burst: 200 concurrent VUs against directory/nonce/ARI
|
||||
# - agent-storm: 5K-agent seed + 167 heartbeats/sec sustained
|
||||
#
|
||||
# Matrix dispatch so each scenario runs on its own runner and a
|
||||
# regression in one doesn't mask another. The matrix runs in parallel,
|
||||
# which keeps total wall time around the existing 25-minute cap rather
|
||||
# than ~70 minutes serialised. Each scenario brings up the full
|
||||
# loadtest compose stack independently — there's no shared state
|
||||
# between scenarios that would benefit from a single-runner serial
|
||||
# invocation.
|
||||
#
|
||||
# Cadence: same as the API + connector tier job above (workflow_dispatch
|
||||
# + Mondays 06:00 UTC). The scale scenarios DO produce useful per-PR
|
||||
# signal in theory, but the per-run cost (image build + 5min run × 3)
|
||||
# is too high to gate on every PR; weekly is the right trade-off.
|
||||
# ---------------------------------------------------------------------------
|
||||
k6-scale:
|
||||
name: k6 scale tier (${{ matrix.scenario }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: k6
|
||||
strategy:
|
||||
# Parallel: a failure in one scenario shouldn't cancel the others.
|
||||
# Each scenario's threshold breach is independent diagnostic data.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
scenario:
|
||||
- bulk-renewal
|
||||
- acme-burst
|
||||
- agent-storm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Run scale loadtest (${{ matrix.scenario }})
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
run: |
|
||||
case "${{ matrix.scenario }}" in
|
||||
bulk-renewal) make loadtest-scale-bulk ;;
|
||||
acme-burst) make loadtest-scale-acme ;;
|
||||
agent-storm) make loadtest-scale-agent ;;
|
||||
*) echo "::error::unknown scenario ${{ matrix.scenario }}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Upload summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
# Per-scenario artifact name so the three matrix runs don't
|
||||
# collide on upload.
|
||||
name: k6-scale-${{ matrix.scenario }}-${{ github.run_id }}
|
||||
path: deploy/test/loadtest/results/
|
||||
retention-days: 90
|
||||
399
.github/workflows/release.yml
vendored
399
.github/workflows/release.yml
vendored
@@ -1,5 +1,12 @@
|
||||
name: Release
|
||||
|
||||
# Override the auto-generated run name (which would otherwise default to
|
||||
# the most recent commit subject + a #NN run number) so the Actions tab
|
||||
# shows "Release v2.0.69" instead of "chore: rename Go module path... #73".
|
||||
# `github.ref_name` resolves to the tag name (e.g., `v2.0.69`) for tag-triggered
|
||||
# workflows, which is the only trigger we set below.
|
||||
run-name: Release ${{ github.ref_name }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
@@ -7,20 +14,244 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# Keep in lock-step with .github/workflows/ci.yml (M-3).
|
||||
GO_VERSION: '1.25.10'
|
||||
IMAGE_NAMESPACE: certctl-io
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# ----------------------------------------------------------------------
|
||||
# build-binaries (M-3): matrix build every (binary × OS × arch) tuple.
|
||||
# For each tuple we produce: the binary, a SPDX-JSON SBOM, a keyless
|
||||
# Cosign signature + certificate bundle, and a single-line sha256sum
|
||||
# file. All artefacts are uploaded to a workflow-scoped artifact; the
|
||||
# aggregate-checksums job fans them back in for release upload.
|
||||
# ----------------------------------------------------------------------
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.binary }} (${{ matrix.os }}/${{ matrix.arch }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
binary: [agent, server, cli, mcp-server]
|
||||
os: [linux, darwin]
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install govulncheck
|
||||
# Bundle D / Audit L-008: release.yml previously had no vulnerability
|
||||
# scan, so a release tag could in principle ship a binary with a
|
||||
# known CVE in transitive deps that ci.yml's govulncheck would have
|
||||
# caught on master. Pre-build scan blocks the release if anything
|
||||
# surfaced post-merge. Pinned to the same major as ci.yml.
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Run govulncheck (release gate)
|
||||
# govulncheck distinguishes called-vs-uncalled vulnerable functions.
|
||||
# Default exit code (0 unless an actual call site lands in a vuln
|
||||
# function) is the right gate for release; deferred-call advisories
|
||||
# are tracked separately on master via L-021. If a release-time
|
||||
# scan surfaces a NEW called-vuln, the release is blocked until the
|
||||
# bump lands on master and a new tag is cut.
|
||||
run: govulncheck ./...
|
||||
|
||||
- name: Build binary
|
||||
id: build
|
||||
env:
|
||||
GOOS: ${{ matrix.os }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
CGO_ENABLED: '0'
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OUTPUT_NAME="certctl-${{ matrix.binary }}-${{ matrix.os }}-${{ matrix.arch }}"
|
||||
mkdir -p dist
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-w -s -X main.Version=${VERSION}" \
|
||||
-o "dist/${OUTPUT_NAME}" \
|
||||
"./cmd/${{ matrix.binary }}"
|
||||
ls -lh "dist/${OUTPUT_NAME}"
|
||||
echo "output_name=${OUTPUT_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate SBOM (SPDX-JSON)
|
||||
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
|
||||
with:
|
||||
file: dist/${{ steps.build.outputs.output_name }}
|
||||
format: spdx-json
|
||||
output-file: dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
|
||||
upload-artifact: false
|
||||
upload-release-assets: false
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Keyless-sign binary with Cosign
|
||||
env:
|
||||
OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Cosign v3.0 (shipped by cosign-installer@v4.1.1 default
|
||||
# cosign-release=v3.0.5) removed --output-signature/--output-certificate
|
||||
# on sign-blob. The replacement is --bundle, which emits a unified
|
||||
# Sigstore bundle (signature + cert chain + Rekor inclusion proof) as
|
||||
# a single .sigstore.json artefact. M-11.
|
||||
cosign sign-blob \
|
||||
--yes \
|
||||
--bundle "dist/${OUTPUT_NAME}.sigstore.json" \
|
||||
"dist/${OUTPUT_NAME}"
|
||||
|
||||
- name: Compute SHA-256 sidecar
|
||||
env:
|
||||
OUTPUT_NAME: ${{ steps.build.outputs.output_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
sha256sum "${OUTPUT_NAME}" > "${OUTPUT_NAME}.sha256"
|
||||
cat "${OUTPUT_NAME}.sha256"
|
||||
|
||||
- name: Upload build artefacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: binary-${{ steps.build.outputs.output_name }}
|
||||
path: |
|
||||
dist/${{ steps.build.outputs.output_name }}
|
||||
dist/${{ steps.build.outputs.output_name }}.sigstore.json
|
||||
dist/${{ steps.build.outputs.output_name }}.sbom.spdx.json
|
||||
dist/${{ steps.build.outputs.output_name }}.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# aggregate-checksums (M-3): fan in every matrix artefact, produce a
|
||||
# single checksums.txt (sha256sum format, compatible with `sha256sum
|
||||
# -c`), sign it with Cosign, upload everything to the GitHub Release,
|
||||
# and emit a base64-encoded hash manifest for the SLSA generator.
|
||||
# ----------------------------------------------------------------------
|
||||
aggregate-checksums:
|
||||
name: Aggregate checksums & sign
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
outputs:
|
||||
hashes: ${{ steps.hashes.outputs.hashes }}
|
||||
steps:
|
||||
- name: Download binary artefacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Aggregate SHA-256 sums
|
||||
id: hashes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd artifacts
|
||||
: > checksums.txt
|
||||
for f in certctl-*; do
|
||||
case "$f" in
|
||||
*.sigstore.json|*.sbom.spdx.json|*.sha256|checksums.txt)
|
||||
continue ;;
|
||||
esac
|
||||
sha256sum "$f" >> checksums.txt
|
||||
done
|
||||
echo "=== checksums.txt ==="
|
||||
cat checksums.txt
|
||||
# base64 hashes (single line, no wrapping) for SLSA generator.
|
||||
HASHES=$(base64 -w0 < checksums.txt)
|
||||
echo "hashes=${HASHES}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Keyless-sign checksums.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd artifacts
|
||||
# Cosign v3.0 --bundle replaces the removed v2 flag pair
|
||||
# --output-signature / --output-certificate. See M-11.
|
||||
cosign sign-blob \
|
||||
--yes \
|
||||
--bundle checksums.txt.sigstore.json \
|
||||
checksums.txt
|
||||
|
||||
- name: Upload artefacts to GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
artifacts/certctl-*
|
||||
artifacts/checksums.txt
|
||||
artifacts/checksums.txt.sigstore.json
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# provenance-binaries (M-3): SLSA Level 3 provenance for every binary.
|
||||
# The SLSA generic generator reusable workflow runs in a hermetic
|
||||
# workflow run, producing multiple.intoto.jsonl from the base64 hash
|
||||
# manifest and uploading it as a release asset.
|
||||
# ----------------------------------------------------------------------
|
||||
provenance-binaries:
|
||||
name: SLSA provenance (binaries)
|
||||
needs: [aggregate-checksums]
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
|
||||
with:
|
||||
base64-subjects: "${{ needs.aggregate-checksums.outputs.hashes }}"
|
||||
upload-assets: true
|
||||
provenance-name: multiple.intoto.jsonl
|
||||
# Phase 1 RED-2 compat (2026-05-14): the SLSA reusable workflow's
|
||||
# default path downloads a pre-built generator binary from a
|
||||
# GitHub *release* of slsa-framework/slsa-github-generator —
|
||||
# releases are keyed by tag name (vX.Y.Z), and the workflow
|
||||
# rejects SHA-form refs with "Expected ref of the form
|
||||
# refs/tags/vX.Y.Z". Phase 1 RED-2 SHA-pinned every Actions
|
||||
# uses: line, so the default path errors out. Setting
|
||||
# compile-generator: true instead builds the generator from the
|
||||
# pinned-SHA source inside the workflow run — preserves
|
||||
# supply-chain integrity (SHA pin retained), adds ~1 min build
|
||||
# time. This is the SLSA project's documented escape hatch for
|
||||
# SHA-pinned reusable-workflow consumers.
|
||||
compile-generator: true
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# build-and-push-docker: push container images to GHCR with native
|
||||
# SLSA L3 provenance (mode=max) and SBOM attestations emitted by
|
||||
# docker/build-push-action@v6, plus a keyless Cosign signature on the
|
||||
# image digest for identity-bound verification. The M-4 proxy-propagation
|
||||
# build-args block is retained verbatim — M-3 only adds supply-chain
|
||||
# steps; it never touches M-4 wiring.
|
||||
# ----------------------------------------------------------------------
|
||||
build-and-push-docker:
|
||||
name: Build & Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write # Cosign keyless OIDC identity token
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -28,52 +259,178 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Build and push server image
|
||||
uses: docker/build-push-action@v6
|
||||
id: server-push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-server:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server:latest
|
||||
# Proxy propagation (M-4, Issue #9) — forwards runner-level proxy
|
||||
# secrets into the Docker build so self-hosted runners behind
|
||||
# corporate proxies can reach public registries. GitHub-hosted
|
||||
# runners don't need proxies, so the secrets are optional and
|
||||
# resolve to empty strings when unset — byte-identical to the
|
||||
# pre-fix behaviour for the public-runner path.
|
||||
build-args: |
|
||||
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
||||
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
||||
NO_PROXY=${{ secrets.NO_PROXY }}
|
||||
# Supply-chain hardening (M-3): emit native SLSA L3 provenance
|
||||
# and SBOM attestations bound to the image manifest.
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Keyless-sign server image with Cosign
|
||||
env:
|
||||
DIGEST: ${{ steps.server-push.outputs.digest }}
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign --yes "${IMAGE}@${DIGEST}"
|
||||
|
||||
- name: Build and push agent image
|
||||
uses: docker/build-push-action@v6
|
||||
id: agent-push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.agent
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/shankar0123/certctl-agent:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent:latest
|
||||
# Proxy propagation (M-4, Issue #9) — see server-image step for
|
||||
# rationale. Empty secrets resolve to empty build args, leaving
|
||||
# the un-proxied code path byte-identical to the pre-fix tree.
|
||||
build-args: |
|
||||
HTTP_PROXY=${{ secrets.HTTP_PROXY }}
|
||||
HTTPS_PROXY=${{ secrets.HTTPS_PROXY }}
|
||||
NO_PROXY=${{ secrets.NO_PROXY }}
|
||||
# Supply-chain hardening (M-3): emit native SLSA L3 provenance
|
||||
# and SBOM attestations bound to the image manifest.
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Keyless-sign agent image with Cosign
|
||||
env:
|
||||
DIGEST: ${{ steps.agent-push.outputs.digest }}
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/certctl-agent
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign --yes "${IMAGE}@${DIGEST}"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# create-release: stamp the release body. The actual asset uploads are
|
||||
# handled by aggregate-checksums (binaries, SBOMs, sigs, certs,
|
||||
# checksums.txt + signature) and the SLSA generator (multiple.intoto.jsonl).
|
||||
# ----------------------------------------------------------------------
|
||||
create-release:
|
||||
name: Create Release Notes
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, aggregate-checksums, provenance-binaries, build-and-push-docker]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release with notes
|
||||
# generate_release_notes: true asks GitHub to auto-generate the
|
||||
# "What's Changed" section from PRs+commits between this tag and the
|
||||
# previous one. The hardcoded body below appends a per-release
|
||||
# supply-chain verification block (Cosign / SLSA / SBOM steps with the
|
||||
# current version baked into the commands) plus a single link to the
|
||||
# README's Quick Start section for install/upgrade instructions.
|
||||
# We deliberately do NOT duplicate install instructions here — the
|
||||
# README is the source of truth for those, and inlining them in every
|
||||
# release page produces the kind of "every release looks identical"
|
||||
# noise that gives operators no signal about what actually changed.
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
# Pin the release title to the tag name. softprops/action-gh-release@v2
|
||||
# falls back to the most recent commit subject when `name:` is omitted,
|
||||
# which produces ugly titles like "chore: rename Go module path..." on
|
||||
# the Releases page. `github.ref_name` evaluates to the tag (`v2.0.69`).
|
||||
name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## Docker Images
|
||||
> **Install / upgrade:** see the [Quick Start section in the README](https://github.com/certctl-io/certctl/blob/master/README.md#quick-start) for Docker Compose, agent install, Helm, and binary download instructions.
|
||||
|
||||
## Verifying this release
|
||||
|
||||
Every binary, `checksums.txt`, and container image is signed with Cosign
|
||||
keyless OIDC. Each binary ships with a SPDX-JSON SBOM. Binaries are covered
|
||||
by SLSA Level 3 provenance; container images carry native SLSA L3 provenance
|
||||
and SBOM attestations (docker/build-push-action `provenance: mode=max`,
|
||||
`sbom: true`) in addition to a Cosign signature on the digest.
|
||||
|
||||
**1. Verify SHA-256 checksums:**
|
||||
|
||||
```bash
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||
sha256sum -c checksums.txt
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
**2. Verify the Cosign signature on checksums.txt (keyless OIDC):**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
cp deploy/.env.example deploy/.env
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
cosign verify-blob \
|
||||
--bundle checksums.txt.sigstore.json \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
checksums.txt
|
||||
```
|
||||
|
||||
Replace `checksums.txt` with any individual binary name to verify that
|
||||
artefact directly (each binary ships with its own `.sigstore.json`
|
||||
bundle, e.g. `cosign verify-blob --bundle certctl-agent-linux-amd64.sigstore.json …`).
|
||||
|
||||
**3. Verify SLSA Level 3 provenance (binaries):**
|
||||
|
||||
```bash
|
||||
slsa-verifier verify-artifact \
|
||||
--provenance-path multiple.intoto.jsonl \
|
||||
--source-uri github.com/certctl-io/certctl \
|
||||
--source-tag ${{ steps.version.outputs.VERSION }} \
|
||||
certctl-agent-linux-amd64
|
||||
```
|
||||
|
||||
**4. Verify container image signature and attestations:**
|
||||
|
||||
```bash
|
||||
IMAGE=ghcr.io/certctl-io/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||
cosign verify \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/\.github/workflows/release\.yml@refs/tags/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
|
||||
# SBOM attestation (SPDX-JSON) emitted by docker/build-push-action
|
||||
cosign verify-attestation --type spdxjson \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
|
||||
# SLSA provenance attestation (mode=max)
|
||||
cosign verify-attestation --type slsaprovenance \
|
||||
--certificate-identity-regexp '^https://github\.com/certctl-io/certctl/' \
|
||||
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
|
||||
"$IMAGE"
|
||||
```
|
||||
|
||||
240
.github/workflows/security-deep-scan.yml
vendored
Normal file
240
.github/workflows/security-deep-scan.yml
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
name: security-deep-scan
|
||||
|
||||
# Bundle-7 / Audit D-001..D-007:
|
||||
# Slow / containerized scans on a daily schedule + manual dispatch.
|
||||
# Per-PR fast gates live in ci.yml; this workflow runs the heavyweight
|
||||
# tools that need docker, network egress to scanner registries, or
|
||||
# longer wall-clock budgets than a per-PR check tolerates.
|
||||
#
|
||||
# Scope:
|
||||
# trivy image container CVE + secret scan
|
||||
# syft SBOM CycloneDX SBOM artefact upload
|
||||
# ZAP baseline DAST baseline against a live deploy_test stack (D-004)
|
||||
# nuclei template-based vuln scan against the same stack
|
||||
# schemathesis OpenAPI fuzz against the running server
|
||||
# testssl.sh TLS configuration audit (D-005)
|
||||
# race detector x10 full -count=10 race run on the entire test suite (D-002)
|
||||
# gosec Go security static analysis (slow first run)
|
||||
# go-mutesting mutation testing on crypto cluster (D-003)
|
||||
# semgrep p/react-security frontend XSS / dangerouslySetInnerHTML / target=_blank ruleset (D-007)
|
||||
#
|
||||
# Each step is best-effort — failures are uploaded as artefacts but do
|
||||
# NOT block the workflow. Triage happens via the Bundle-7 receipt
|
||||
# the project's comprehensive-audit tool-output directory.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *' # daily 06:00 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # SARIF upload to GitHub code scanning
|
||||
|
||||
jobs:
|
||||
deep-scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Install Go-based tools
|
||||
run: bash scripts/install-security-tools.sh
|
||||
continue-on-error: true
|
||||
|
||||
# --- Static analysis (slow paths) ---
|
||||
|
||||
- name: gosec (G201/G202/G304/G108 subset — Phase 3 TEST-M2 hard gate)
|
||||
# Phase 3 TEST-M2 closure (2026-05-13): gosec promoted from
|
||||
# continue-on-error (advisory) to blocking on the 4 high-signal
|
||||
# rule subset that targets real prod-bug classes:
|
||||
# G201 = SQL string formatting (SQL injection)
|
||||
# G202 = SQL string concatenation (SQL injection)
|
||||
# G304 = file-path traversal via tainted input
|
||||
# G108 = profiling endpoint exposed
|
||||
# Other gosec rules (G1xx-G7xx broadly) remain in the SARIF
|
||||
# report but don't gate the build — they have higher false-
|
||||
# positive rates than these 4.
|
||||
run: $(go env GOPATH)/bin/gosec -fmt sarif -out gosec.sarif -include=G201,G202,G304,G108 ./...
|
||||
|
||||
- name: osv-scanner (multi-ecosystem CVE — Phase 3 TEST-M2 hard gate)
|
||||
# Phase 3 TEST-M2 closure (2026-05-13): osv-scanner promoted from
|
||||
# advisory to blocking. Complements govulncheck (already blocking
|
||||
# in ci.yml) by covering non-Go dependencies (npm under web/,
|
||||
# any docker base image deps). Findings fail the build; the
|
||||
# exact CVE list lands in osv-scanner.json as a receipt either way.
|
||||
run: $(go env GOPATH)/bin/osv-scanner -r --format json --output osv-scanner.json .
|
||||
|
||||
# --- Race detector at -count=10 (D-002) ---
|
||||
|
||||
- name: go test -race -count=10 (full suite)
|
||||
run: |
|
||||
go test -race -count=10 -short ./... 2>&1 | tee go-test-race.txt
|
||||
continue-on-error: true
|
||||
|
||||
# --- Coverage receipts for crypto cluster (H-005) ---
|
||||
|
||||
- name: go test -cover (crypto cluster)
|
||||
run: |
|
||||
go test -cover -covermode=atomic \
|
||||
./internal/crypto/... \
|
||||
./internal/pkcs7/... \
|
||||
./internal/connector/issuer/local/... \
|
||||
2>&1 | tee go-test-cover.txt
|
||||
|
||||
# --- Mutation testing on crypto cluster (D-003) ---
|
||||
#
|
||||
# Operator runbook: docs/testing-strategy.md::Mutation testing.
|
||||
# Tool: go-mutesting (https://github.com/zimmski/go-mutesting). Each
|
||||
# package is mutated independently; the per-package summary line
|
||||
# (`The mutation score is X.YZ`) is grep-extracted into the receipt.
|
||||
# Acceptance threshold: ≥80% kill ratio per package; surviving
|
||||
# mutants get triaged in the project's comprehensive-audit notes/
|
||||
# d003-mutation-results.md (per-mutant action item or
|
||||
# equivalent-mutation justification).
|
||||
|
||||
- name: Install go-mutesting
|
||||
run: go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest
|
||||
continue-on-error: true
|
||||
|
||||
- name: go-mutesting (crypto cluster — Phase 3 TEST-M1 hard gate at 55%)
|
||||
# Phase 3 TEST-M1 closure (2026-05-13): go-mutesting promoted
|
||||
# from advisory (continue-on-error + per-package `|| true`) to
|
||||
# blocking with an explicit mutation-score floor of 55%.
|
||||
# Per-package summary lines emit `The mutation score is X.YZ`;
|
||||
# the awk filter extracts each, and the post-loop check fails
|
||||
# the step if any package drops below 0.55.
|
||||
#
|
||||
# Floor rationale: 55% is the starter ratio that catches major
|
||||
# regressions without rejecting the audit's "this is OK" steady
|
||||
# state. Raise quarterly as the test suite hardens; the floor
|
||||
# change ships in the same commit that adds the strengthening
|
||||
# tests so the ratchet is documented.
|
||||
run: |
|
||||
set -e
|
||||
: > go-mutesting.txt
|
||||
for pkg in ./internal/crypto/... ./internal/pkcs7/... ./internal/connector/issuer/local/...; do
|
||||
echo "=== $pkg ===" | tee -a go-mutesting.txt
|
||||
$(go env GOPATH)/bin/go-mutesting "$pkg" 2>&1 | tee -a go-mutesting.txt
|
||||
done
|
||||
# Extract every "The mutation score is X.YZ" line; fail on any
|
||||
# score below 0.55. The check works against floats via awk so
|
||||
# 0.55 is the literal threshold (not a percentage).
|
||||
floor=0.55
|
||||
fail=0
|
||||
while IFS= read -r score; do
|
||||
ok=$(awk -v s="$score" -v f="$floor" 'BEGIN{print (s>=f) ? 1 : 0}')
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::mutation score $score below floor $floor"
|
||||
fail=1
|
||||
fi
|
||||
done < <(grep -oE "The mutation score is [0-9.]+" go-mutesting.txt | awk '{print $NF}')
|
||||
exit $fail
|
||||
|
||||
# --- Container + supply chain (D-001 partial, D-006 partial) ---
|
||||
|
||||
- name: Build certctl image
|
||||
run: docker build -t certctl:deep-scan .
|
||||
continue-on-error: true
|
||||
|
||||
- name: trivy image scan (HIGH+CRITICAL — Phase 3 TEST-M2 hard gate)
|
||||
# Phase 3 TEST-M2 closure (2026-05-13): trivy promoted from
|
||||
# advisory to blocking. --severity filter keeps the gate
|
||||
# noise-free (LOW + MEDIUM findings stay in the JSON receipt
|
||||
# but don't fail the build); --exit-code 1 makes HIGH+CRITICAL
|
||||
# findings the actual gate. Trivy is the third hard deep-scan
|
||||
# gate (alongside gosec + osv-scanner); ZAP / schemathesis /
|
||||
# nuclei / testssl stay advisory because their false-positive
|
||||
# rates on https://localhost:8443-targeted DAST runs are high.
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src aquasec/trivy:latest image \
|
||||
--format json --output /src/trivy.json \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
certctl:deep-scan
|
||||
|
||||
- name: syft SBOM
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src anchore/syft:latest dir:/src \
|
||||
-o cyclonedx-json > syft.cyclonedx.json || true
|
||||
continue-on-error: true
|
||||
|
||||
# --- DAST against a live stack (D-004) ---
|
||||
|
||||
- name: docker compose up (test stack)
|
||||
run: |
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
sleep 20
|
||||
continue-on-error: true
|
||||
|
||||
- name: ZAP baseline
|
||||
uses: zaproxy/action-baseline@1e1871e84428617b969d4a1f981a8255630d54b0 # v0.10.0
|
||||
with:
|
||||
target: 'https://localhost:8443'
|
||||
continue-on-error: true
|
||||
|
||||
- name: schemathesis (OpenAPI fuzz)
|
||||
run: |
|
||||
pip install schemathesis
|
||||
schemathesis run --base-url https://localhost:8443 \
|
||||
--hypothesis-max-examples=50 api/openapi.yaml || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: nuclei
|
||||
run: |
|
||||
docker run --rm --network host projectdiscovery/nuclei:latest \
|
||||
-u https://localhost:8443 -j -o nuclei.json || true
|
||||
continue-on-error: true
|
||||
|
||||
# --- TLS audit (D-005) ---
|
||||
|
||||
- name: testssl.sh
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/data drwetter/testssl.sh:latest \
|
||||
--jsonfile /data/testssl.json https://localhost:8443 || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: docker compose down
|
||||
run: docker compose -f deploy/docker-compose.yml down || true
|
||||
if: always()
|
||||
|
||||
# --- Frontend XSS / unsafe-link ruleset (D-007) ---
|
||||
#
|
||||
# Operator runbook: docs/testing-strategy.md::Frontend semgrep.
|
||||
# Bundle 8 already verified `dangerouslySetInnerHTML` count at
|
||||
# zero and the `target="_blank"` rel-noopener pin via grep
|
||||
# guards in ci.yml — semgrep p/react-security adds defence in
|
||||
# depth (it catches escape patterns the grep guards don't see,
|
||||
# e.g., href={user_input}, eval, document.write).
|
||||
|
||||
- name: semgrep p/react-security (frontend)
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src returntocorp/semgrep:latest \
|
||||
semgrep --config=p/react-security --json /src/web/src \
|
||||
> semgrep-react.json 2>semgrep-react.stderr || true
|
||||
continue-on-error: true
|
||||
|
||||
# --- Upload everything as artefacts ---
|
||||
|
||||
- name: Upload deep-scan receipts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: security-deep-scan-${{ github.run_id }}
|
||||
path: |
|
||||
gosec.sarif
|
||||
osv-scanner.json
|
||||
go-test-race.txt
|
||||
go-test-cover.txt
|
||||
go-mutesting.txt
|
||||
trivy.json
|
||||
syft.cyclonedx.json
|
||||
nuclei.json
|
||||
testssl.json
|
||||
semgrep-react.json
|
||||
semgrep-react.stderr
|
||||
retention-days: 30
|
||||
41
.gitignore
vendored
41
.gitignore
vendored
@@ -10,6 +10,7 @@ bin/
|
||||
# Frontend
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.storybook-static/
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -43,6 +44,11 @@ vendor/
|
||||
tmp/
|
||||
temp/
|
||||
*.log
|
||||
*.bak
|
||||
|
||||
# Private keys (agent-generated, never commit)
|
||||
cmd/agent/*.key
|
||||
cmd/agent/*.pem
|
||||
|
||||
# Database
|
||||
*.db
|
||||
@@ -57,12 +63,43 @@ certctl-agent
|
||||
certctl-cli
|
||||
/server
|
||||
/agent
|
||||
/cli
|
||||
/mcp-server
|
||||
|
||||
# Private strategy docs
|
||||
roadmap.md
|
||||
SECURITY_REMEDIATION.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
mcp-server
|
||||
|
||||
# Local Go build/module caches (session-scoped, never committed)
|
||||
/.gocache/
|
||||
/.gomodcache/
|
||||
/.gopath/
|
||||
/.gomodcache-gopath/
|
||||
|
||||
# Design scratch files (session-scoped)
|
||||
/.i004-design.md
|
||||
/.i005-design.md
|
||||
|
||||
# HTTPS-Everywhere (M-007) Phase 6: the docker-compose.test.yml tls-init
|
||||
# container writes ca.crt / server.crt / server.key into this directory so
|
||||
# the host-side integration_test.go binary can pin the CA via
|
||||
# CERTCTL_TEST_CA_BUNDLE=./certs/ca.crt. Material is regenerated on every
|
||||
# `docker compose up` and never belongs in git.
|
||||
/deploy/test/certs/
|
||||
|
||||
# Phase 1 RED-1 closure (2026-05-13): the f5-mock-icontrol Dockerfile
|
||||
# rebuilds from source via multi-stage build (deploy/test/f5-mock-icontrol/
|
||||
# Dockerfile line 13). The compiled ELF must not be tracked.
|
||||
deploy/test/f5-mock-icontrol/f5-mock-icontrol
|
||||
|
||||
# Phase 0 closure (2026-05-13): cowork/ holds the operator's internal
|
||||
# legal / audit / strategy artifacts (counsel-signed AI-authorship
|
||||
# declaration, filter-repo callback, pre-rewrite bundle, audit HTML
|
||||
# scratch). It is private operator scratch space and must never
|
||||
# accidentally land in the public repo. See
|
||||
# docs/history-normalization.md for the public-facing description of
|
||||
# the Phase 0 git-history rewrite.
|
||||
cowork/
|
||||
|
||||
@@ -6,6 +6,7 @@ run:
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- contextcheck
|
||||
- govet
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
21
.govulnignore
Normal file
21
.govulnignore
Normal file
@@ -0,0 +1,21 @@
|
||||
# Bundle-7 / Audit D-001 / govulncheck suppressions.
|
||||
#
|
||||
# Format: one OSV ID per line, with a comment justifying the suppression.
|
||||
# Every entry needs:
|
||||
# - the OSV ID (GO-YYYY-NNNN)
|
||||
# - one-line "what is it"
|
||||
# - one-line "why we're not affected" (must reference call-graph evidence)
|
||||
# - "review-by" date (YYYY-MM-DD) — re-triage on/after this date
|
||||
#
|
||||
# Triage rule: only suppress an advisory if `govulncheck ./...` (NOT
|
||||
# verbose) reports it as a deferred-call vulnerability ("packages you
|
||||
# import" or "modules you require", not "Your code is affected by").
|
||||
#
|
||||
# At Bundle-7 time (2026-04-26): the 5 advisories surfaced are all in
|
||||
# transitive deps and govulncheck confirms our code does not call them.
|
||||
# Documented here for tracking; no entries needed because the default
|
||||
# fail-on-non-zero gate already passes (govulncheck distinguishes
|
||||
# called vs uncalled and only exits non-zero when the latter calls in).
|
||||
#
|
||||
# Example (do not enable unless the advisory becomes call-affected):
|
||||
# GO-2026-4441 # transitive: golang.org/x/crypto pre-v0.40 — net/ssh terrapin downgrade; we don't use net/ssh; review 2026-07-01
|
||||
825
CHANGELOG.md
Normal file
825
CHANGELOG.md
Normal file
@@ -0,0 +1,825 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes (scheduled for v2.2.0)
|
||||
|
||||
- **SEC-H1 staged: `CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY` opt-in flag.**
|
||||
Phase 2 of the architecture diligence remediation (2026-05-13) introduces
|
||||
a new env var that, when set to `true`, makes the server refuse to start
|
||||
unless `CERTCTL_AGENT_BOOTSTRAP_TOKEN` is also set to a real value.
|
||||
Default in this release: `false` (preserves the v2.1.x warn-mode
|
||||
pass-through behavior for backward compatibility). Default flip to
|
||||
`true` is scheduled for v2.2.0 per `WORKSPACE-ROADMAP.md`.
|
||||
|
||||
**Operator action before the v2.2.0 upgrade:** generate a real
|
||||
bootstrap token (`openssl rand -base64 32`) and set
|
||||
`CERTCTL_AGENT_BOOTSTRAP_TOKEN` in your env. When v2.2.0 ships, the
|
||||
deny-empty default flips to `true` and a missing or empty token will
|
||||
fail closed at boot. Operators with the token already set: no action
|
||||
required.
|
||||
|
||||
- **SEC-M4: `CERTCTL_ACME_INSECURE` now requires explicit ACK.**
|
||||
Pre-Phase-2, `CERTCTL_ACME_INSECURE=true` produced only a boot-time
|
||||
WARN log. Post-Phase-2 (THIS release), the server refuses to start
|
||||
unless `CERTCTL_ACME_INSECURE_ACK=true` is set alongside it. ACME
|
||||
directory TLS verification is the load-bearing defense against a
|
||||
network attacker intercepting ACME enrollment; the existing flag was
|
||||
too easy to flip via a copy-pasted Pebble runbook.
|
||||
|
||||
**Operator action:** if you intentionally run against a self-signed
|
||||
ACME server (Pebble, step-ca, internal dev), add
|
||||
`CERTCTL_ACME_INSECURE_ACK=true` to your env. Production deploys
|
||||
MUST never set either flag.
|
||||
|
||||
- **SEC-H3: `CERTCTL_DEMO_MODE_ACK` is no longer sticky — 24h re-ack required.**
|
||||
Pre-Phase-2, setting `CERTCTL_DEMO_MODE_ACK=true` was sticky for the
|
||||
lifetime of the container. Post-Phase-2, operators must ALSO set
|
||||
`CERTCTL_DEMO_MODE_ACK_TS=$(date +%s)` to a unix epoch within the
|
||||
last 24h. The next container restart past 24h refuses to start
|
||||
unless a fresh TS is supplied. Catches the "forgotten demo deployment
|
||||
promoted to production" failure mode.
|
||||
|
||||
**Operator action:** demo deploys must set `CERTCTL_DEMO_MODE_ACK_TS`
|
||||
at every `docker compose up`. The demo Compose helper script handles
|
||||
this automatically when wired; standalone demo deploys add it
|
||||
manually. Production deploys: this guard is irrelevant
|
||||
(`CERTCTL_DEMO_MODE_ACK` should not be set in production).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **GitHub #13 / Hotfix #19 — GUI "Something went wrong" after browser
|
||||
refresh on a real (non-demo) install.** Refresh-after-login wipes the
|
||||
in-memory `apiKey` (deliberate — the GUI never persists it to
|
||||
localStorage as a security posture). The next API call returns a
|
||||
bare 401 with no `WWW-Authenticate` header. Pre-Hotfix-19 the
|
||||
AuthProvider 401 handler only hard-navigated to `/login` when `cause`
|
||||
was a recognised OIDC session-expiry category (`idle_timeout` /
|
||||
`absolute_timeout` / `back_channel_revoked`); bare 401s
|
||||
(`cause === ''`) and `invalid_token` causes fell through to an
|
||||
in-place `AuthGate` state flip that unmounted `BrowserRouter` under
|
||||
an in-flight `<Link>`, triggering a `react-router-dom` invariant
|
||||
that surfaced via `ErrorBoundary` as the "Something went wrong"
|
||||
screen. **Fix:** every 401 now hard-navigates to `/login` regardless
|
||||
of cause; the cause-aware UX is preserved by forwarding
|
||||
`?session_expired=<cause>` only when cause is non-empty (bare 401s
|
||||
redirect to plain `/login`). Three-line change in
|
||||
`web/src/components/AuthProvider.tsx`; 4 regression tests added to
|
||||
`AuthProvider.test.tsx` (empty cause from `/targets`, `invalid_token`
|
||||
cause, `idle_timeout` cause, already-on-`/login` no-op guard).
|
||||
Closes #13.
|
||||
|
||||
### Security
|
||||
|
||||
- **Alg-downgrade defense relaxed for Keycloak-shape IdPs (v2.1.0 pre-tag fix).**
|
||||
Pre-fix, the IdP-bind alg-downgrade check at `internal/auth/oidc/service.go`
|
||||
refused to load any OIDC provider whose discovery doc advertised HS256 /
|
||||
HS384 / HS512 / `none` in `id_token_signing_alg_values_supported` —
|
||||
even if RS256 was ALSO advertised. This broke binding against
|
||||
Keycloak 26.x (and a handful of other real IdPs) which list every alg
|
||||
the codebase is capable of in their discovery doc, regardless of which
|
||||
one the realm actually signs with. The v2.1.0 Phase-10 live-IdP smoke
|
||||
surfaced the regression: 6 testcontainers-Keycloak integration tests
|
||||
failed with `oidc: IdP advertises weak signing algorithms (HS*/none); refusing to use as defense against downgrade attacks: HS256`.
|
||||
**Fix:** the check now refuses only when the intersection of advertised
|
||||
vs `DefaultAllowedAlgs` is EMPTY — an IdP advertising HS256 alongside
|
||||
RS256 binds successfully, but an IdP advertising HS-only / none-only
|
||||
still fails closed. The per-token alg pin at sig-verify time
|
||||
(`isDisallowedAlg`, service.go ~L1177) remains the load-bearing defense
|
||||
against the actual algorithm-confusion attack (forged HS256 token
|
||||
signed with the IdP's RS256 pubkey as HMAC secret) — go-oidc/v3's
|
||||
verifier rejects any token whose `alg` header isn't in the configured
|
||||
allow-list, regardless of what the discovery doc claims. Updates:
|
||||
`Service.getOrLoad` alg-check loop rewritten to compute intersection;
|
||||
`ErrIdPDowngradeAdvertised` docstring reflects new semantics;
|
||||
`TestDiscovery` dry-run validator surfaces HS*/none alongside RS* as
|
||||
an informational note (not a hard fail); `docs/operator/auth-threat-model.md`
|
||||
alg-allow-list section updated to call out the load-bearing-defense
|
||||
hierarchy. Tests: `TestService_IdPDowngradeDefense_RS256PlusHS256_BindsSuccessfully`
|
||||
(positive — Keycloak-shape) + `TestService_IdPDowngradeDefense_RejectsHSOnlyAdvertised`
|
||||
(negative — pathological intersection-empty case) +
|
||||
`TestService_RefreshKeys_CatchesPostLoadDowngrade` updated to assert
|
||||
intersection-empty post-rotation; `TestTestDiscovery_AlgDowngrade_HS256AlongsideRS256_BindsWithNote`
|
||||
+ `TestTestDiscovery_AlgDowngrade_HSOnly_StillTrips_HardFail` pin the
|
||||
dry-run validator's new behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Vitest coverage for the 2026-05-10/11 GUI batch (Audit 2026-05-11 Fix 12).**
|
||||
The original GUI-batch commit `661b6db` claimed `npx tsc --noEmit PASS`
|
||||
but shipped no Vitest cases for the new surfaces. The regression-
|
||||
prevention layer was missing — a future refactor of `KeysPage`'s
|
||||
assign modal could silently drop scope_type handling, the LOW-1 demo
|
||||
banner could be hidden by a stray predicate flip, the LOW-11 hide of
|
||||
the delete button on default roles could disappear and let operators
|
||||
click straight into a backend 409, and nothing would surface in CI.
|
||||
This closure adds 35 new test cases across five files:
|
||||
`web/src/pages/auth/UsersPage.test.tsx` (new, 8 cases pinning the
|
||||
active/deactivated/reactivate flow + provider filter + empty state +
|
||||
loading state), `web/src/pages/auth/AuthSettingsPage.test.tsx`
|
||||
(extended +4 cases pinning the MED-12 runtime-config panel —
|
||||
alphabetical sort, `(empty)` placeholder, 403 silent-hide),
|
||||
`web/src/pages/auth/KeysPage.test.tsx` (extended +8 cases pinning
|
||||
the HIGH-10 GUI half — scope_type=global/profile/issuer body shape,
|
||||
expires_at omission vs RFC3339 promotion, whitespace-only scope_id
|
||||
rejection, demo-anon row mutation-button hide),
|
||||
`web/src/pages/auth/RoleDetailPage.test.tsx` (new, 9 cases pinning
|
||||
the MED-8 scope picker + the LOW-11 default-role delete-button hide
|
||||
via the `DEFAULT_ROLE_IDS` set against `r-admin` + `r-auditor`),
|
||||
`web/src/components/AuthProvider.test.tsx` (new, 5 cases pinning the
|
||||
LOW-1 demo-banner visibility predicate — `authType==='none' &&
|
||||
!loading` — across happy/api-key/oidc/loading/rejected branches; the
|
||||
rejected-fetch path keeps the banner visible because the catch
|
||||
treats it as an old-server-fallback to demo-mode, and that behavior
|
||||
is pinned here so a future change surfaces in the diff). 40/40
|
||||
test-file-scoped pass; `tsc --noEmit` clean.
|
||||
|
||||
### Security
|
||||
|
||||
- **CSRF rotation on logout closes HIGH-2 fourth call site (Audit 2026-05-11 Fix 13).**
|
||||
The HIGH-2 closure (`dev/auth-bundle-2`) documented four
|
||||
`RotateCSRFTokenForActor` call sites: login completion (fresh by
|
||||
construction), Assign/RevokeRole on role-mutation (wired), Logout, and
|
||||
an explicit operator endpoint. The 2026-05-11 review verified only 3
|
||||
of the 4 — Logout did NOT rotate the actor's sibling sessions
|
||||
post-revoke, leaving a window where a token captured pre-logout
|
||||
(browser DevTools, malicious extension, session-storage leak) could
|
||||
be replayed against the user's other-device/other-browser sessions
|
||||
until those sessions hit their own idle/absolute expiry.
|
||||
`SessionMinter` interface extended with `RotateCSRFTokenForActor`;
|
||||
`Logout` invokes it after `Revoke(sess.ID)` succeeds. The
|
||||
`auth.session_revoked` audit row gains a `csrf_rotated` detail key
|
||||
carrying the rotated count so SOC / SIEM can correlate logout events
|
||||
with CSRF churn. The no-cookie + invalid-cookie 204 short-circuit
|
||||
paths skip rotation (no session row to rotate against). 3 regression
|
||||
tests in `internal/api/handler/auth_session_oidc_test.go` pin the
|
||||
happy path + the two short-circuit branches. The explicit operator
|
||||
endpoint (4) remains intentionally unbuilt — the three automatic
|
||||
triggers (login + role-mutation + logout) cover the threat model;
|
||||
operators who want a nuclear option can use the existing
|
||||
`RevokeAllForActor` flow which forces re-login → fresh session →
|
||||
fresh CSRF. **HIGH-2 fully closed across all four documented call
|
||||
sites.**
|
||||
|
||||
- **Demo-mode residual-grants detector + cleanup endpoint + CI guard (Audit 2026-05-11 A-8).**
|
||||
HIGH-12 (closure `b81588e`) added a fail-closed bind-address guard
|
||||
that refuses startup when `CERTCTL_AUTH_TYPE=none` binds non-loopback
|
||||
without `CERTCTL_DEMO_MODE_ACK=true`. The Phase 2 leg of that spec —
|
||||
production-startup banner when `actor-demo-anon` has residual role
|
||||
grants in `actor_roles` plus a CI guard banning new synthetic-admin
|
||||
code paths — was deferred. This closure lands all three deferred
|
||||
legs. (1) `cmd/server/preflight_demo_residual.go` runs after the DB
|
||||
is open + audit service is constructed, before the HTTPS listener
|
||||
starts; under any non-`none` auth type it queries `actor_roles` for
|
||||
`actor-demo-anon` and emits a WARN log + `auth.demo_residual_grants_detected`
|
||||
audit row when the row is present. The migration 000029 baseline
|
||||
unconditionally seeds the `ar-demo-anon-admin` row at install time,
|
||||
so EVERY production deploy will see this WARN on first boot — the
|
||||
intended cutover workflow is documented at `docs/operator/security.md`.
|
||||
(2) `POST /api/v1/auth/demo-residual/cleanup` is an admin-class
|
||||
(`auth.role.assign`) cleanup endpoint that removes every
|
||||
`actor-demo-anon` row from `actor_roles` and returns
|
||||
`{"removed": <int64>}`; idempotent (a second call returns
|
||||
`removed:0`), refuses 503 under `Auth.Type=none` (deleting the row
|
||||
would break the demo path), audit-logs every invocation. (3) New
|
||||
env var `CERTCTL_DEMO_MODE_RESIDUAL_STRICT` (default `false`)
|
||||
pivots the WARN to fail-closed startup refusal for operators who
|
||||
want a paranoid hostile-environment posture. (4) CI guard
|
||||
`scripts/ci-guards/no-new-synthetic-admin.sh` pins the 17-entry
|
||||
allowlist of source files that may reference the `actor-demo-anon`
|
||||
literal; new runtime code paths that resolve to the synthetic actor
|
||||
are rejected at PR time so the credibility gap stays closed. The
|
||||
closure was framed as "credibility gap, not exploitable
|
||||
vulnerability" — the residue requires a regression elsewhere in the
|
||||
middleware chain to be exploitable. After this fix, the canonical
|
||||
acquisition-readiness narrative ("RBAC primitive with no
|
||||
synthetic-admin fallback") is fully true. Operator runbook at
|
||||
`docs/operator/security.md#demo-to-production-cutover-audit-2026-05-11-a-8`.
|
||||
|
||||
- **OIDC provider "Test connection" panel (Audit 2026-05-11 Fix 09 — MED-5 GUI half).**
|
||||
MED-5's backend dry-run endpoint (`POST /api/v1/auth/oidc/test`, gated
|
||||
`auth.oidc.create`) shipped on `dev/auth-bundle-2` but had no GUI caller —
|
||||
the `authOIDCTestProvider` function in `web/src/api/client.ts` was dead
|
||||
code. Operators had to complete the create form blind, save, then click
|
||||
"Refresh" to discover whether the issuer URL worked; failures left a
|
||||
broken provider row in the database that had to be deleted before
|
||||
retrying. New shared component
|
||||
`web/src/pages/auth/OIDCTestConnectionPanel.tsx` calls the backend
|
||||
against the live form state and renders a four-row status panel inline:
|
||||
Discovery fetched, JWKS reachable, supported algs (warns when the IdP
|
||||
advertises none), and RFC 9207 iss-parameter advertisement (informational
|
||||
`·` glyph, not ✗, because the spec is SHOULD). Backend per-leg `errors[]`
|
||||
flow into an inline bullet list. The panel is mounted in the
|
||||
OIDCProvidersPage create modal AND the OIDCProviderDetailPage edit form —
|
||||
the edit-form half is load-bearing for verifying IdP rotations (Keycloak
|
||||
realm rename, Okta tenant move) without committing first. Run button is
|
||||
disabled until the issuer URL is non-empty (whitespace-trimmed); the
|
||||
component is read-only — safe to run repeatedly. 8 Vitest tests pin the
|
||||
glyph-vs-glyph contract (✓/✗/⚠/·), the button-disabled-without-issuer
|
||||
shape, and the test-id-suffix collision-prevention when the panel is
|
||||
mounted twice on the same page.
|
||||
|
||||
- **OIDC JWKS health panel + Refresh-now button (Audit 2026-05-11 Fix 10 — MED-7 GUI half).**
|
||||
MED-7's backend endpoint `GET /api/v1/auth/oidc/providers/{id}/jwks-status`
|
||||
(commit `d85114f`) shipped the per-provider verifier counters on
|
||||
`dev/auth-bundle-2` but the GUI never called it. The audit doc had
|
||||
prematurely flipped the row to CLOSED; `authOIDCJWKSStatus` in the
|
||||
API client was dead code. Operators investigating "why is login
|
||||
failing for this IdP" couldn't see `last_refresh_at`,
|
||||
`rejected_jws_count`, or `last_error` from the GUI — they had to
|
||||
drop to curl. New shared component
|
||||
`web/src/pages/auth/OIDCJWKSStatusPanel.tsx` queries the endpoint
|
||||
via TanStack Query (30s `staleTime`, `retry: 0` so a 403 hides the
|
||||
panel silently for callers without `auth.oidc.list`) and renders
|
||||
six dt/dd rows: Last refresh (with `(never — cold cache)` sentinel
|
||||
when the timestamp is empty), Refresh count, Rejected JWS count,
|
||||
Last error (red treatment when non-empty, `(none)` sentinel
|
||||
otherwise), RFC 9207 iss param ("supported by IdP" / "not
|
||||
advertised"), and Current KIDs (`(not exposed — query jwks_uri
|
||||
directly)` sentinel when the backend declines to expose the list).
|
||||
A "Refresh now" button invokes the existing
|
||||
`POST .../refresh` (RefreshKeys path) and invalidates the panel's
|
||||
query so the freshly-updated counters render without a page
|
||||
reload. The button is hidden for callers without `auth.oidc.edit`
|
||||
via the panel's optional `canRefresh` prop. Mounted on
|
||||
`OIDCProviderDetailPage.tsx` between the read-only field display
|
||||
and the Actions section. 9 Vitest tests pin: loading state,
|
||||
happy-path-all-six-rows, 403-hides-panel, refresh-invalidates-
|
||||
query, refresh-failure-surfaces-inline-without-hiding-panel,
|
||||
never-refreshed-cold-cache-sentinel, current-kids-empty-not-
|
||||
exposed-sentinel, last-error-red-treatment, and canRefresh=false-
|
||||
hides-the-button.
|
||||
|
||||
- **UsersPage sidebar nav entry (Audit 2026-05-11 Fix 11 — MED-11
|
||||
discoverability).** The MED-11 closure shipped `UsersPage.tsx` + wired
|
||||
the `/auth/users` route in `web/src/main.tsx`, but the sidebar
|
||||
navigation never gained a corresponding entry. Operators reached the
|
||||
federated-user-admin surface (used during compliance audits — "show
|
||||
me last login for every IdP-federated user") only by knowing the URL.
|
||||
A page that exists but isn't navigable is a half-finished page. New
|
||||
Users entry under the Auth section in `web/src/components/Layout.tsx`
|
||||
sits between Sessions and Roles (federated-identity grouping). Three
|
||||
Vitest tests in `Layout.test.tsx` pin the link's presence, the
|
||||
`/auth/users` destination, and the DOM ordering relative to Sessions
|
||||
so a future refactor that re-orders or removes the entry surfaces in
|
||||
the diff.
|
||||
|
||||
- **Scope-aware actor-role revoke (Audit 2026-05-11 A-4).**
|
||||
HIGH-10 made it possible to grant the same role to the same actor at
|
||||
multiple scopes (e.g. `r-operator` on `profile=p-acme` AND `profile=p-globex`)
|
||||
via the unique constraint extension on `actor_roles`, but
|
||||
`ActorRoleRepository.Revoke` ignored `(scope_type, scope_id)` and
|
||||
unconditionally deleted every variant. Operators who wanted to drop
|
||||
one scoped grant had to nuke them all and re-grant the remainder —
|
||||
a race window where the actor's access was briefly different. The
|
||||
`DELETE /v1/auth/keys/{id}/roles/{role_id}` endpoint now accepts
|
||||
optional `?scope_type=` / `?scope_id=` query params that narrow the
|
||||
revoke to a single variant; no-match returns 404. The legacy "revoke
|
||||
every variant" semantic is preserved when the query params are
|
||||
absent, so existing CLI / GUI buttons keep working unchanged. The
|
||||
audit row's `details` payload records which mode fired so SOC / SIEM
|
||||
can distinguish wide cleanups from targeted demotions. MCP tool
|
||||
`certctl_auth_revoke_role_from_key` gains optional `scope_type` +
|
||||
`scope_id` input fields with matching semantics. Documented in
|
||||
`docs/operator/rbac.md` under "Revoke: legacy 'all variants' vs
|
||||
scope-selective."
|
||||
|
||||
### Security (BREAKING — silent-elevation closure)
|
||||
|
||||
- **HIGH-10 actor-role scope is now enforced (Audit 2026-05-11 A-1).**
|
||||
Pre-fix, `actor_roles.scope_type` / `scope_id` (added in migration 000043
|
||||
by the HIGH-10 closure) were persisted by Grant + accepted on the handler
|
||||
body + surfaced through the GUI/MCP — but the load-bearing
|
||||
`EffectivePermissions` SQL never read them. A profile-scoped grant
|
||||
silently elevated to global at authorization time. Canonical CRIT-5
|
||||
lying-field shape, replicated. **The post-fix authorization narrows
|
||||
correctly**: every existing `actor_roles` row with `scope_type != 'global'`
|
||||
now takes effect.
|
||||
|
||||
> **Operator advisory:** if you used the HIGH-10 scope-bound role-grant
|
||||
> API between commit `551812b` and the v2.1.0 tag (the column was
|
||||
> populated but ignored), the grants were silently global. After
|
||||
> upgrading, audit `SELECT actor_id, role_id, scope_type, scope_id FROM
|
||||
> actor_roles WHERE scope_type != 'global'` and confirm the narrowing
|
||||
> reflects intent. If an actor was granted a scoped role but expected
|
||||
> global behavior, re-grant with `scope_type=global`.
|
||||
|
||||
### Security (BREAKING)
|
||||
|
||||
- **Federated-user deactivation now actually blocks login (Audit 2026-05-11 A-2).**
|
||||
The MED-11 closure shipped `users.deactivated_at` + `DELETE /api/v1/auth/users/{id}`
|
||||
+ cascade-session-revoke, but the column was a "lying field" three legs over: the
|
||||
postgres user repository never SELECTed it (so `User.DeactivatedAt` always read
|
||||
nil), the `Update` SQL never wrote it (so the handler's mutation was a no-op),
|
||||
and the OIDC `upsertUser` path never checked it (so the next login under the
|
||||
same `(provider, subject)` tuple re-minted a session and re-elevated the user).
|
||||
The cascade-revoke remained correct for the current cookie only. **Operator
|
||||
advisory: if you deactivated a federated user between the MED-11 closure
|
||||
(Bundle 2 merge `dea5053`) and the v2.1.0 release tag, verify the user cannot
|
||||
OIDC-log-in after upgrading — the column took no effect at login time before
|
||||
this fix. If needed, re-run the deactivation against the upgraded server.**
|
||||
Closure: `userColumns` + `scanUser` now read `deactivated_at` via `sql.NullTime`;
|
||||
`Create` + `Update` write it explicitly; `upsertUser` returns the new
|
||||
`ErrUserDeactivated` sentinel before mutating fields (preserves `last_login_at`
|
||||
forensics on rejected logins); `classifyOIDCFailure` surfaces the rejection
|
||||
as audit category `user_deactivated`. Self-deactivate guard on
|
||||
`DELETE /api/v1/auth/users/{id}` returns HTTP 409 + audit row
|
||||
`auth.user_deactivate_self_rejected` (prevents an admin from one-way-door
|
||||
locking themselves out via the standard handler — break-glass remains the
|
||||
recovery path). New inverse endpoint `POST /api/v1/auth/users/{id}/reactivate`
|
||||
(gated `auth.user.deactivate` — reactivation is the inverse op, not a separate
|
||||
privilege) clears `deactivated_at`; emits audit row `auth.user_reactivated`.
|
||||
Sessions revoked at deactivation stay revoked across reactivation — the user
|
||||
must complete a fresh OIDC login. GUI: `UsersPage.tsx` now renders a Reactivate
|
||||
button on deactivated rows. CWE-862 (missing authorization at the user-state
|
||||
boundary). SOC 2 CC6.3 + ISO 27001 A.9.2.6 compliance-table-flipping fix.
|
||||
- **`__Host-` cookie prefix on all three auth cookies (Audit 2026-05-10 MED-14).**
|
||||
The session cookie, CSRF cookie, and OIDC pre-login cookie are renamed from
|
||||
`certctl_session` / `certctl_csrf` / `certctl_oidc_pending` to
|
||||
`__Host-certctl_session` / `__Host-certctl_csrf` / `__Host-certctl_oidc_pending`
|
||||
to gain browser-enforced subdomain-takeover protection (a `__Host-*` cookie can
|
||||
only be set with `Path=/` + `Secure` + no `Domain` attribute, and the browser
|
||||
rejects subdomain attempts to overwrite it). **Active sessions invalidate on
|
||||
the rolling deploy that lands this change** — operators must re-authenticate
|
||||
once after upgrading. The GUI's CSRF cookie reader was updated in lockstep.
|
||||
See `docs/migration/oidc-enable.md` for operator-facing detail.
|
||||
|
||||
### Security
|
||||
|
||||
- **OIDC `allowed_email_domains` now editable in the GUI (Audit 2026-05-11 A-3).**
|
||||
The backend gate that rejects logins whose email domain is outside the
|
||||
configured allowlist landed in v2.1.0 (CRIT-5 closure, 2026-05-10), but the
|
||||
GUI never exposed the field — GUI-driven operators had to use the API
|
||||
directly to configure tenant isolation against multi-tenant IdPs (Auth0,
|
||||
Azure AD common endpoint, Google Workspace). The OIDCProvidersPage create
|
||||
modal and OIDCProviderDetailPage detail view now render a chip-style
|
||||
multi-input with client-side validation that mirrors the backend rules
|
||||
(no `@`, no whitespace, no wildcards, lowercase-only FQDNs). The read-only
|
||||
view renders an explicit "any (no gate configured)" sentinel when the list
|
||||
is empty so operators can tell "not configured" apart from "field is
|
||||
invisible." A "Clear all" button on the edit form is gated by a confirm
|
||||
dialog that warns about removing the tenant gate. **Operator advisory: if
|
||||
you provisioned OIDC providers via the GUI between v2.1.0 and this fix,
|
||||
verify `allowed_email_domains` matches your tenant policy — the field was
|
||||
configurable only via API / MCP / direct SQL during that window.** Per-IdP
|
||||
runbooks for multi-tenant IdPs in `docs/operator/oidc-runbooks/` already
|
||||
documented the field; the GUI now matches.
|
||||
|
||||
- **Approval payload preview (Audit 2026-05-11 A-5).**
|
||||
The MED-10 closure claim ("PARTIAL: raw JSON preview; diff library
|
||||
deferred") was inaccurate — `ApprovalsPage.tsx` rendered no payload
|
||||
at all, so approvers were clicking Approve / Reject without seeing
|
||||
the change they were authorizing. That defeats the entire four-eyes
|
||||
primitive: an approver who can't see what they're approving is
|
||||
rubber-stamping. Each row now carries a Preview toggle that expands
|
||||
an inline panel dispatching by kind: `profile_edit` shows a
|
||||
field-level before/after diff (changed-only rows, red/green cells,
|
||||
`(unset)` sentinel for added/removed fields); `cert_issuance` shows
|
||||
a definition list of CN / SANs / profile / key algo / must-staple /
|
||||
validity (catches the wildcard-against-corp-internal-profile attack
|
||||
at review time); unknown kinds render a generic JSON preview for
|
||||
forward-compat with future approval kinds. The base64-encoded JSON
|
||||
payload is decoded via the new `decodePayload` helper; malformed
|
||||
inputs render an explicit decode-error fallback — silent failure on
|
||||
the payload preview is what produced this bug in the first place.
|
||||
|
||||
- **Strict pre-login UA/IP binding (Audit 2026-05-11 A-6).**
|
||||
The MED-16 closure left a request-side empty-header bypass: when the
|
||||
pre-login row carried a User-Agent or client-IP binding but the
|
||||
`/auth/oidc/callback` request omitted the corresponding value, the
|
||||
binding check was silently skipped. `curl` doesn't send User-Agent
|
||||
by default; many programmatic clients omit it. An attacker who
|
||||
acquired a pre-login cookie could replay it without the bound
|
||||
header and bypass the RFC 9700 §4.7.1 defense. The check is now
|
||||
strict-when-stored — an empty request-side value with a non-empty
|
||||
stored binding rejects with HTTP 400 and the new audit failure
|
||||
categories `prelogin_ua_missing` / `prelogin_ip_missing` (distinct
|
||||
from the existing `*_mismatch` categories so SIEM rules can alert
|
||||
specifically on bypass attempts). **Operator advisory:** environments
|
||||
where the User-Agent is stripped in transit (some debug proxies, a
|
||||
handful of CDN configurations) must set
|
||||
`CERTCTL_OIDC_PRELOGIN_REQUIRE_UA=false` to keep logins working;
|
||||
symmetric `CERTCTL_OIDC_PRELOGIN_REQUIRE_IP=false` exists for the
|
||||
IP-side. The legacy-row compat window — pre-migration rows with no
|
||||
stored binding — still passes through unchecked, but that window is
|
||||
bounded by the 10-minute pre-login TTL.
|
||||
|
||||
- **OIDC provider Advanced fields are now editable in the GUI (Audit 2026-05-11 A-7).**
|
||||
The MED-4 row had been DEFERRED to v3 with the rationale "backend
|
||||
already accepts these fields." The verifier hit the GUI and found
|
||||
that the read-only display claimed the values were editable, but the
|
||||
edit form had no inputs — the save handler passed `provider.scopes`
|
||||
/ `provider.groups_claim_path` / `provider.groups_claim_format` /
|
||||
`provider.iat_window_seconds` / `provider.jwks_cache_ttl_seconds`
|
||||
unchanged from the loaded object. Operators who wanted to bump the
|
||||
IAT window or change the groups-claim path had to drop to curl /
|
||||
MCP and trust the GUI's display matched what they'd set elsewhere.
|
||||
Lying UX. The OIDCProviderDetailPage edit form now has a collapsible
|
||||
Advanced section with five inputs (scopes as a space-separated text
|
||||
field; groups-claim path; groups-claim format select with the
|
||||
backend's `string-array` / `json-path` enum; IAT window number input
|
||||
bounded 1–600; JWKS cache TTL number input with floor 60). Client-side
|
||||
validation mirrors the backend `Validate` rules so common operator
|
||||
mistakes (IAT > 600, JWKS TTL < 60, empty scopes, empty groups-claim-path)
|
||||
reject inline instead of round-tripping a 400. The read-only `<dl>`
|
||||
also gained the previously-invisible `jwks_cache_ttl_seconds` row.
|
||||
|
||||
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
|
||||
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
|
||||
cookie lifetime is unchanged (10 minutes) and only the callback handler
|
||||
consumes it; the wider path scope is harmless.
|
||||
|
||||
- **RFC 9207 `iss` URL parameter check on OIDC callback (Audit 2026-05-10
|
||||
MED-17).** When the matched IdP's discovery doc advertises
|
||||
`authorization_response_iss_parameter_supported: true`, certctl now requires
|
||||
the `iss` query parameter on `/auth/oidc/callback` and enforces a
|
||||
constant-time compare against the configured provider's `IssuerURL`. Mismatch
|
||||
rejects with HTTP 400; the audit row's `failure_category` distinguishes
|
||||
`iss_param_missing` / `iss_param_mismatch` (RFC 9207 leg) from the existing
|
||||
`id_token_iss_mismatch` (in-token iss claim leg). Closes the mix-up-attack
|
||||
defense for modern Keycloak, Authentik, and public-trust CAs that ship
|
||||
RFC-9207 discovery. Providers that don't advertise support (the majority
|
||||
today) keep pre-fix behavior — back-compat is preserved.
|
||||
|
||||
- **Auth GUI batch (Audit 2026-05-10 MED-4/7/8/10/11/12 + LOW-1/11/12 +
|
||||
HIGH-10 GUI).** New backend endpoints land alongside their GUI
|
||||
consumers: `GET /api/v1/auth/users` + `DELETE /api/v1/auth/users/{id}`
|
||||
(auth.user.read / auth.user.deactivate; migration 000045 adds
|
||||
`users.deactivated_at` plus the two new permissions); `GET
|
||||
/api/v1/auth/runtime-config` (auth.role.assign) returning a sanitized
|
||||
flat-map of deployed CERTCTL_* values (no secrets leaked — only
|
||||
set/unset booleans and counts); `GET
|
||||
/api/v1/auth/oidc/providers/{id}/jwks-status` (auth.oidc.list)
|
||||
returning the per-provider verifier counters (refresh count, last
|
||||
refresh / error timestamps, rejected JWS count, RFC 9207 iss-param
|
||||
flag). New `UsersPage` lists federated identities + soft-deactivates.
|
||||
`AuthSettingsPage` gains the runtime-config panel. `KeysPage`'s
|
||||
assign-role modal now collects `scope_type` / `scope_id` /
|
||||
`expires_at`. `RoleDetailPage`'s add-permission form gains the same
|
||||
scope picker, and the Delete button is hidden on the 7 default
|
||||
system roles (server already rejected, this is pure UX).
|
||||
`AuthProvider` renders a sticky red demo-mode banner when
|
||||
`auth_type=none`. `actor-demo-anon` rows on `KeysPage` already had
|
||||
buttons disabled.
|
||||
|
||||
- **11 new MCP tools (Audit 2026-05-10 MED-13).** Approval workflow
|
||||
(`certctl_approval_list` / `_get` / `_approve` / `_reject`), break-glass
|
||||
credential admin (`certctl_breakglass_list` / `_set_password` /
|
||||
`_unlock` / `_remove`), bootstrap status + consume
|
||||
(`certctl_bootstrap_status` / `_consume`), and audit category filter
|
||||
(`certctl_audit_list_with_category`). All route through the existing
|
||||
HTTP client so server-side permission gates fire unchanged.
|
||||
`certctl_bootstrap_consume`'s tool description carries an explicit
|
||||
"NEVER WIRE THIS TO AUTONOMOUS OPERATION" warning — a leaked
|
||||
bootstrap token mints a fresh admin API key bypassing every other
|
||||
access-control gate, so the tool is for one-shot manual operator
|
||||
invocation only.
|
||||
|
||||
- **JWKS auto-refresh on cache-miss (Audit 2026-05-10 MED-6).** When
|
||||
the IdP rotates its signing key between pre-login + callback, the
|
||||
cached JWKS no longer contains the kid referenced by the inbound ID
|
||||
token's JWS header. Pre-fix, the verify failed with a generic error
|
||||
and the operator had to manually call `POST
|
||||
/api/v1/auth/oidc/providers/{id}/refresh`. The service now detects
|
||||
the kid-not-in-cache shape (`isKidMismatchError`) and runs a
|
||||
one-shot `RefreshKeys` (evict cache → re-fetch discovery + JWKS →
|
||||
re-run alg-downgrade defense) before retrying the verify exactly
|
||||
once. Bounded recovery: a second failure surfaces as
|
||||
`ErrJWKSUnreachable` per the original branches; no retry loop. A
|
||||
separate matcher (`isKidMismatchError`) is intentionally narrow
|
||||
so generic signature failures don't trigger refresh.
|
||||
|
||||
- **OIDC provider test endpoint (Audit 2026-05-10 MED-5).** New
|
||||
`POST /api/v1/auth/oidc/test` dry-runs an OIDC provider configuration
|
||||
without persisting: fetches the discovery doc, runs the alg-downgrade
|
||||
defense, detects RFC 9207 iss-parameter advertisement, and confirms
|
||||
JWKS reachability. Returns `TestDiscoveryResult{discovery_succeeded,
|
||||
jwks_reachable, supported_alg_values, iss_param_supported, errors[]}`
|
||||
so the GUI (forthcoming) can render per-check status rows. Per-leg
|
||||
failures ride in the response body's `errors` array; only a malformed
|
||||
request body trips 400. Gate: `auth.oidc.create`. Audit row
|
||||
`auth.oidc_provider_tested` carries the success/failure summary.
|
||||
|
||||
- **Pre-login UA / source-IP binding on OIDC callback (Audit 2026-05-10
|
||||
MED-16).** RFC 9700 §4.7.1 defense against stolen-pre-login-cookie replay
|
||||
by a different browser / source. Migration `000044_prelogin_uaip` adds
|
||||
`client_ip` + `user_agent` to `oidc_pre_login_sessions`; values captured at
|
||||
`/auth/oidc/login` are constant-time compared at `/auth/oidc/callback`.
|
||||
Mismatches return HTTP 400 with audit `failure_category` =
|
||||
`prelogin_ua_mismatch` or `prelogin_ip_mismatch`. Two operator escape
|
||||
hatches: `CERTCTL_OIDC_PRELOGIN_REQUIRE_UA` and
|
||||
`CERTCTL_OIDC_PRELOGIN_REQUIRE_IP` (both default `true`) — operators on
|
||||
enterprise proxies that rewrite UA, or dual-stack v4/v6 environments where
|
||||
source IP routinely flips, can disable the affected leg. The binding column
|
||||
is persisted even when enforcement is off, so retroactive forensics remain
|
||||
possible. Empty values on either side pass through (rolling-deploy +
|
||||
headless-proxy compat).
|
||||
|
||||
## v2.1.0 - Auth Bundles 1 + 2: RBAC primitive + OIDC SSO + sessions ⚠️
|
||||
|
||||
> **SECURITY: AUDIT YOUR API KEYS.**
|
||||
>
|
||||
> Bundle 1 ships role-based authorization. Every existing API key
|
||||
> configured via `CERTCTL_API_KEYS_NAMED` (or the legacy
|
||||
> `CERTCTL_AUTH_SECRET`) is mapped to the **r-admin role on the first
|
||||
> upgrade boot** so existing automation keeps working unchanged. Most
|
||||
> keys do NOT need full admin power; downgrade them before tagging
|
||||
> the next release.
|
||||
>
|
||||
> Recommended post-upgrade flow:
|
||||
>
|
||||
> ```bash
|
||||
> # 1. List every key with its current role:
|
||||
> certctl-cli auth keys list
|
||||
>
|
||||
> # 2. Walk an interactive prompt that downgrades each key:
|
||||
> certctl-cli auth keys scope-down
|
||||
>
|
||||
> # 3. Or get a heuristic suggestion based on 30 days of audit history:
|
||||
> certctl-cli auth keys scope-down --suggest
|
||||
> certctl-cli auth keys scope-down --suggest --apply # applies the suggestion
|
||||
>
|
||||
> # 4. Or drive scope-down from a JSON config (Helm post-upgrade hook):
|
||||
> certctl-cli auth keys scope-down --non-interactive ./scope-down.json
|
||||
> ```
|
||||
>
|
||||
> The synthetic `actor-demo-anon` actor (used when
|
||||
> `CERTCTL_AUTH_TYPE=none` is configured) is system-managed and
|
||||
> excluded from the prompt loop.
|
||||
|
||||
What else changed in v2.1.0:
|
||||
|
||||
- **Audit 2026-05-10 CRIT-1 closure — wire-layer RBAC enforcement.**
|
||||
The Bundle 1 + Bundle 2 audit surfaced that the permission catalogue
|
||||
was enforced on ~24 admin-only routes only; the bulk of state-changing
|
||||
routes (`POST /api/v1/certificates`, `PUT /api/v1/profiles/{id}`,
|
||||
`DELETE /api/v1/issuers/{id}`, `POST /api/v1/agents/{id}/csr`, even
|
||||
`POST /api/v1/auth/roles` + `POST /api/v1/auth/keys/{id}/roles`) had
|
||||
no `rbacGate` wrap. A `r-viewer` Bearer was essentially `r-admin`
|
||||
minus five fine-grained verbs at the wire layer (CWE-862). This
|
||||
release wraps every state-changing + read endpoint with
|
||||
`rbacGate` (global scope) or `rbacGateScoped` (per-profile / per-
|
||||
issuer scope-bound grants), and adds an AST-level CI guard
|
||||
(`TestRouterRBACGateCoverage`) that fails when a new route is
|
||||
registered without enforcement. Catalogue extended via migration
|
||||
000039 with 30 permissions covering `cert.edit`, `job.*`,
|
||||
`approval.*`, `policy.*`, `team.*`, `owner.*`, `notification.*`,
|
||||
`discovery.*`, `network_scan.*`, `healthcheck.*`, `digest.*`,
|
||||
`verification.*`, `stats.read`, `metrics.read`. **AUDIT YOUR
|
||||
KEYS** (the scope-down call-out above) now translates to real
|
||||
reduction in blast radius. Auditor pin preserved at exactly
|
||||
`{audit.read, audit.export}`.
|
||||
|
||||
- **RBAC primitive shipped.** `tenants`, `roles`, `permissions`,
|
||||
`role_permissions`, `actor_roles` tables (migration 000029); 33-permission
|
||||
canonical catalogue; 7 default roles (`admin`, `operator`, `viewer`,
|
||||
`agent`, `mcp`, `cli`, `auditor`); per-handler permission gates via
|
||||
`auth.RequirePermission` middleware (replaces the legacy
|
||||
`IsAdmin` boolean check on the 5 admin-only handlers).
|
||||
- **Day-0 admin bootstrap.** Set `CERTCTL_BOOTSTRAP_TOKEN` on a fresh
|
||||
deploy and POST a single curl call against `/api/v1/auth/bootstrap` to
|
||||
mint the first admin API key; one-shot, never logged, and locks
|
||||
closed once any admin actor exists. Migration 000031 ships the
|
||||
`api_keys` table that stores the SHA-256 hash; the plaintext is
|
||||
shown in the response body once and never persisted.
|
||||
- **Auditor role split.** New `auditor` role holds only `audit.read`
|
||||
+ `audit.export`. Compliance reviewers can read the audit trail
|
||||
without holding mutation power. Migration 000032 adds
|
||||
`audit_events.event_category` so auditors can filter to
|
||||
authentication-related events specifically.
|
||||
- **`/v1/auth/check` enrichment.** Response now includes the actor's
|
||||
standing roles and effective permissions, so the GUI gates
|
||||
affordances from a single fetch on app boot.
|
||||
- **Approval-bypass closure.** Edits to a profile that has (or
|
||||
would have) `RequiresApproval=true` now route through the
|
||||
`ApprovalService` two-person integrity gate (Phase 9). Migration
|
||||
000033 adds `approval_kind` + `payload` to
|
||||
`issuance_approval_requests` so cert-issuance and profile-edit
|
||||
approvals share the same workflow. Same-actor self-approve is
|
||||
rejected with `ErrApproveBySameActor` for both kinds. Closes the
|
||||
flip-flop loophole where an admin could disable approval, mutate,
|
||||
re-enable. Documented at
|
||||
[`docs/reference/profiles.md`](docs/reference/profiles.md).
|
||||
- **GUI: Roles / API Keys / Auth Settings / Approvals queue.**
|
||||
Four new pages under `/auth/*` consume `/v1/auth/me` for
|
||||
permission-aware rendering. The Approvals queue blocks
|
||||
self-approve at the client layer (Approve/Reject buttons hidden
|
||||
when requested_by == current actor_id) on top of the server-side
|
||||
enforcement. AuditPage gains a category filter (cert_lifecycle /
|
||||
auth / config) for the auditor view.
|
||||
- **MCP server gains 12 RBAC tools.** Operators driving certctl
|
||||
from Claude / VS Code / any MCP client get parity with the GUI
|
||||
+ CLI. Each tool routes through the same HTTP handler; permission
|
||||
gates fire server-side.
|
||||
- **OpenAPI catalogues every new route.** Every Bundle 1 endpoint
|
||||
ships with an `operationId`; the parity test guards against drift.
|
||||
- **Coverage gates.** `internal/auth/` and `internal/service/auth/`
|
||||
now have ≥85% coverage floors in `.github/coverage-thresholds.yml`.
|
||||
The 12-path negative-test list from the Bundle 1 prompt is
|
||||
fully covered (path #12 deferred with in-tree TODO).
|
||||
- **Protocol-endpoint allowlist pinned at three layers.** The
|
||||
middleware bypass (`auth.IsProtocolEndpoint`), the router-level
|
||||
`AuthExemptRouterRoutes` constant, and a new
|
||||
`phase12_protocol_allowlist_test.go` AST scan all guard against
|
||||
accidentally wrapping ACME / SCEP / EST / OCSP / CRL routes in
|
||||
`rbacGate`.
|
||||
- **Bundle 2: OIDC + sessions + back-channel logout + break-glass.**
|
||||
Auth Bundle 2 ships in the same v2.1.0 release. Operators get OIDC
|
||||
SSO support for Keycloak / Authentik / Okta / Auth0 / Microsoft
|
||||
Entra ID / Google Workspace (via Keycloak broker), HMAC-signed
|
||||
session cookies with idle/absolute timeouts + CSRF defense,
|
||||
back-channel logout per OpenID Connect Back-Channel Logout 1.0,
|
||||
and a default-OFF break-glass admin path with Argon2id passwords
|
||||
for SSO-broken incidents. API-key auth keeps working unchanged
|
||||
alongside; existing automation needs no changes. Migration walkthrough
|
||||
at [`docs/migration/oidc-enable.md`](docs/migration/oidc-enable.md);
|
||||
per-IdP setup guides at
|
||||
[`docs/operator/oidc-runbooks/index.md`](docs/operator/oidc-runbooks/index.md).
|
||||
- **OIDC token validation pinned at three layers.** Algorithm
|
||||
allow-list (RS256/RS512/ES256/ES384/EdDSA only) with HS-family + `none`
|
||||
rejected at the service-layer sentinel; IdP-downgrade-attack defense
|
||||
at provider creation AND every JWKS RefreshKeys (intersects the IdP's
|
||||
advertised `id_token_signing_alg_values_supported` against the allow-
|
||||
list, rejects providers that advertise weak algs even before any
|
||||
token is signed); OIDC Core §3.1.3.7 re-verification of `iss` /
|
||||
`aud` / `azp` / `at_hash` (REQUIRED-when-access_token-present per
|
||||
Phase 3 tightening of the spec MAY → MUST) / `exp` / `iat` window
|
||||
/ `nonce` constant-time-compare. PKCE-S256 mandatory; `plain`
|
||||
rejected. Single-use state + nonce via atomic `DELETE...RETURNING`
|
||||
on consume.
|
||||
- **Session cookies use length-prefixed HMAC.** The cookie wire format
|
||||
is `v1.<session_id>.<signing_key_id>.<base64url-no-pad(HMAC-SHA256)>`
|
||||
with HMAC input `len:sid:len:kid` (NOT bare-concat) to defeat
|
||||
concatenation collisions. `HttpOnly` + `Secure` + `SameSite=Lax`
|
||||
default; `SameSite=Strict` configurable via `CERTCTL_SESSION_SAMESITE`.
|
||||
Idle timeout 1h / absolute 8h defaults; scheduler GC sweeps expired
|
||||
rows hourly. Signing keys rotate via the new `RotateSigningKey`
|
||||
primitive; the old key stays valid for `CERTCTL_SESSION_SIGNING_KEY_RETENTION`
|
||||
(default 24h) so existing cookies validate during rollover.
|
||||
- **CSRF defense via double-submit-cookie + hashed-token-on-row.**
|
||||
Plaintext CSRF token in the JS-readable `certctl_csrf` cookie
|
||||
(intentionally `HttpOnly=false` for the GUI to echo into the
|
||||
`X-CSRF-Token` header); SHA-256 hash on the session row;
|
||||
`subtle.ConstantTimeCompare` in the new `CSRFMiddleware`. API-key
|
||||
actors are CSRF-exempt (no session row in context).
|
||||
- **OIDC `client_secret` encrypted at rest.** AES-256-GCM v3 blob
|
||||
format (magic 0x03 + salt(16) + nonce(12) + ciphertext+tag) using
|
||||
the existing `CERTCTL_CONFIG_ENCRYPTION_KEY`. Encryption invariant
|
||||
pinned by an integration test asserting ciphertext != plaintext +
|
||||
v3 blob shape + round-trip recovery + wrong-passphrase fails.
|
||||
- **OIDC first-admin bootstrap.** New `CERTCTL_BOOTSTRAP_ADMIN_GROUPS`
|
||||
+ `CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID` env vars: the first
|
||||
OIDC-authenticated user with a matching group claim becomes admin
|
||||
per tenant. Coexists with the Bundle 1 env-var-token bootstrap;
|
||||
the admin-existence probe ensures only one wins. Audit row
|
||||
(`bootstrap.oidc_first_admin`) on every grant.
|
||||
- **Break-glass admin (default-OFF).** New `CERTCTL_BREAKGLASS_ENABLED`
|
||||
env var (default `false`). When enabled, the local Argon2id-password
|
||||
admin path bypasses OIDC + group-claim layers — intended ONLY for
|
||||
SSO-broken incidents. Argon2id with OWASP 2024 params (m=64 MiB,
|
||||
t=3, p=4); lockout after 5 failures (configurable); constant-time
|
||||
across all failure paths via `verifyDummy`; surface invisibility
|
||||
(HTTP 404 on every endpoint when disabled, NOT 403). WARN log at
|
||||
server boot when enabled. WebAuthn/FIDO2 second factor pairing on
|
||||
the v3 roadmap (Decision 12).
|
||||
- **GUI: OIDC Providers + Group → Role Mappings + Sessions + login
|
||||
buttons.** Four new pages under `/auth/*` consume the Bundle 2 API
|
||||
surface. Login page renders one "Sign in with X" button per
|
||||
configured OIDC provider (in addition to the API-key form, which
|
||||
remains as a fallback for Bearer-mode + break-glass paths). Sessions
|
||||
page exposes own-sessions + admin all-actors view. Every actionable
|
||||
element is permission-gated server-side via `auth.oidc.*` and
|
||||
`auth.session.*` perms; client-side hide is UX layer. Logout button
|
||||
in the sidebar fires `POST /auth/logout` to clear the session
|
||||
server-side before redirecting to login.
|
||||
- **MCP server gains 11 OIDC + session tools.** `certctl_auth_list_oidc_providers`,
|
||||
`_get_oidc_provider`, `_create_oidc_provider`, `_update_oidc_provider`,
|
||||
`_delete_oidc_provider`, `_refresh_oidc_provider`,
|
||||
`_list_group_mappings`, `_add_group_mapping`, `_remove_group_mapping`,
|
||||
`_list_sessions`, `_revoke_session`. Operator-facing MCP tool count
|
||||
goes 12 (Bundle 1 RBAC) → 23 across the auth surface. Total MCP
|
||||
tool count: `grep -cE 'mcp\.AddTool\(' internal/mcp/tools*.go` ≈ 150.
|
||||
- **Per-IdP runbooks: 6 production-tier setup guides** at
|
||||
`docs/operator/oidc-runbooks/`. Each runbook follows a consistent
|
||||
five-section layout (Prerequisites / IdP-side config / certctl-side
|
||||
config / Verification / Troubleshooting + Validation checklist with
|
||||
operator sign-off line). Keycloak is the canonical reference;
|
||||
Authentik / Okta / Auth0 / Entra ID / Google Workspace document the
|
||||
IdP-specific deltas (Auth0's namespaced custom claims; Entra ID's
|
||||
group OBJECT IDs; Google Workspace's missing-groups-claim limitation
|
||||
+ the recommended Keycloak broker pattern).
|
||||
- **Threat model extended.** [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md)
|
||||
ships 5 new "Defenses Bundle 2 ships" subsections + 8 new threat-
|
||||
catalogue subsections (OIDC token forgery / session hijacking / IdP
|
||||
compromise / back-channel logout failure modes / group-claim
|
||||
manipulation / bootstrap risks / break-glass risks / token-leak
|
||||
hygiene). 6 new SQL-shaped operator-facing checks. New "Threats
|
||||
Bundle 2 does NOT close" section enumerating the 8 v3-backlog items
|
||||
(WebAuthn / JIT elevation / SAML / multi-tenant activation /
|
||||
HSM-FIPS / OIDC RP-initiated logout / Playwright / per-IdP
|
||||
external-tester sign-off).
|
||||
- **Performance baselines documented.** [`docs/operator/auth-benchmarks.md`](docs/operator/auth-benchmarks.md)
|
||||
ships four benchmarks with measured baselines on a 4 vCPU /
|
||||
8 GiB / Postgres 16 / Go 1.25 floor: `BenchmarkSession_SteadyState`
|
||||
p99 5 µs (target < 1 ms; 200× under), `BenchmarkSession_ColdProcess`
|
||||
p99 7.1 ms (target < 10 ms), `BenchmarkOIDC_SteadyState` p99 1.5 ms
|
||||
(target < 5 ms), `BenchmarkOIDC_ColdCache` operator-runs against
|
||||
live Keycloak via `make benchmark-auth-coldcache`.
|
||||
- **Standards + RFC implementation table.** [`docs/reference/auth-standards-implemented.md`](docs/reference/auth-standards-implemented.md)
|
||||
ships 13 RFC / standard rows + 14 CWE rows with concrete file paths
|
||||
+ negative-test anchors per row. NOT a compliance-mapping doc per
|
||||
the operator's 2026-05-05 retired-compliance-docs decision; the
|
||||
doc explicitly says "build the framework mapping yourself against
|
||||
the rows here using the framework-mapping methodology your audit
|
||||
firm prescribes; this project does not own that mapping."
|
||||
- **Coverage gates held at floor 90 across all four Bundle 2
|
||||
packages.** `internal/auth/oidc/` 93.7%, `internal/auth/session/`
|
||||
94.9%, `internal/auth/breakglass/` 91.5%, `internal/auth/user/domain/`
|
||||
96.4%. NO held-low-with-rationale entry — the Phase 13 prompt's
|
||||
anti-Bundle-1-mistake rule held. Bundle 1's existing 85% floors
|
||||
for `internal/auth/` + `internal/service/auth/` stay 85
|
||||
(already-shipped-and-accepted) per the prompt's explicit
|
||||
inheritance rule.
|
||||
- **Multi-tenant query CI guard.** New `scripts/ci-guards/multi-tenant-query-coverage.sh`
|
||||
(ratchet-style, baseline 32 at v2.1.0 close): greps every
|
||||
SELECT/UPDATE/DELETE in `internal/repository/postgres/` against
|
||||
10 tenant-aware tables, fails on regression OR improvement (forces
|
||||
the operator to lift / lower the baseline visibly). Forward-compat
|
||||
protection so a future Bundle 3 / managed-service multi-tenant
|
||||
activation can flip the switch without finding silent
|
||||
tenant-data-leak bugs in shipped queries.
|
||||
- **Phase 10 Keycloak testcontainers integration test.** New build-tag-
|
||||
gated suite at `internal/auth/oidc/testfixtures/` + `integration_keycloak_test.go`
|
||||
drives the full OIDC flow against a live Keycloak container booted
|
||||
by testcontainers-go. 5-test matrix: discovery + JWKS load, full
|
||||
PKCE auth-code happy path with HTTP form scraping, logout-revokes-
|
||||
session, JWKS rotation, unmapped-groups-fails-closed. Reuses one
|
||||
container across the matrix to amortize the 60-90s boot. Optional
|
||||
Okta smoke test (build-tagged `integration && okta_smoke`) for live
|
||||
tenant validation. New Makefile targets: `make keycloak-integration-test`
|
||||
+ `make okta-smoke-test` + `make benchmark-auth-coldcache`.
|
||||
- **OpenAPI surface extended.** New `cookieAuth` security scheme
|
||||
(apiKey/cookie/`certctl_session`) alongside the existing
|
||||
`bearerAuth`. 13 new Bundle 2 endpoints across the OIDC + session
|
||||
+ group-mapping CRUD surface; 4 break-glass endpoints with
|
||||
surface-invisibility framing. The N-bundle-2-security-empty-preserved
|
||||
CI guard locks the `security: []` opt-out count at ≥ 14 so existing
|
||||
public endpoints stay public.
|
||||
- **Bundle-1-only compat regression CI guard.** New
|
||||
`scripts/ci-guards/bundle-1-compat-regression.sh` asserts the
|
||||
load-bearing invariants that protect the Bundle-1-only-deploy
|
||||
case (session middleware defers-to-next, CSRF passthrough on
|
||||
missing session row, ChainAuthSessionThenBearer wired, public
|
||||
OIDC routes in AuthExempt allowlist, AuthInfo guards on
|
||||
OIDCProvidersResolver != nil). Sibling
|
||||
`bundle-1-to-2-upgrade-regression.sh` asserts the upgrade-path
|
||||
invariants (migrations 000034..000038 are CREATE TABLE IF NOT EXISTS
|
||||
+ BEGIN/COMMIT-wrapped + no DROP TABLE / ALTER...DROP COLUMN
|
||||
against 19 protected Bundle-1 tables + ON CONFLICT DO NOTHING on
|
||||
permission seed).
|
||||
|
||||
Migration ordering, idempotency, and downgrade are documented in
|
||||
[`docs/migration/api-keys-to-rbac.md`](docs/migration/api-keys-to-rbac.md)
|
||||
(API-key → RBAC, Bundle 1) and [`docs/migration/oidc-enable.md`](docs/migration/oidc-enable.md)
|
||||
(API-key → OIDC, Bundle 2). The threat model lives at
|
||||
[`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md).
|
||||
Day-2 RBAC operations live at [`docs/operator/rbac.md`](docs/operator/rbac.md).
|
||||
RFC + CWE evidence at [`docs/reference/auth-standards-implemented.md`](docs/reference/auth-standards-implemented.md).
|
||||
|
||||
## v2.0.68 - Image registry path changed ⚠️
|
||||
|
||||
> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:<tag>` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever - only the container-registry path changed.
|
||||
|
||||
This is the only operator-action-required change in v2.0.68. Other changes in this release are cosmetic URL refreshes after the GitHub-org transfer from `shankar0123/certctl` to `certctl-io/certctl` (HTTP redirects mean no other operator action is required) plus an internal contextcheck lint fix in the agent. Full commit list is on the [GitHub release page](https://github.com/certctl-io/certctl/releases/tag/v2.0.68).
|
||||
|
||||
---
|
||||
|
||||
certctl no longer maintains a hand-edited per-version changelog. Per-release
|
||||
notes are auto-generated from commit messages between consecutive tags.
|
||||
|
||||
**Where to find what changed in a given release:**
|
||||
|
||||
- **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** - every
|
||||
tag has an auto-generated "What's Changed" section pulled from the commits
|
||||
between that tag and the previous one, plus per-release supply-chain
|
||||
verification instructions (Cosign / SLSA / SBOM).
|
||||
- **`git log <prev-tag>..<this-tag> --oneline`** - same content, locally.
|
||||
|
||||
**Why no hand-edited CHANGELOG.md:**
|
||||
|
||||
certctl is solo-developed and pushes directly to master. Maintaining a
|
||||
hand-edited CHANGELOG meant the file drifted (entries piled into
|
||||
`[unreleased]` and never got promoted to per-version sections when tags were
|
||||
cut). A stale CHANGELOG is worse than no CHANGELOG - it signals abandoned
|
||||
maintenance to security-conscious operators doing diligence.
|
||||
|
||||
The auto-generated release notes work here because commit messages follow a
|
||||
descriptive convention: `<area>: <summary>` with a longer body for non-trivial
|
||||
changes (see `git log v2.0.50..HEAD` for the established pattern). Anyone
|
||||
reading the GitHub Releases page can see exactly what landed in each version
|
||||
without depending on the author to manually update a separate file.
|
||||
|
||||
**For the historical record:** earlier versions (pre-v2.2.0 and the [2.2.0]
|
||||
tag itself) had a hand-edited CHANGELOG. That content is preserved in
|
||||
[git history](https://github.com/certctl-io/certctl/blob/v2.2.0/CHANGELOG.md)
|
||||
at the v2.2.0 tag.
|
||||
105
Dockerfile
105
Dockerfile
@@ -1,18 +1,80 @@
|
||||
# Multi-stage build for certctl server
|
||||
#
|
||||
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
|
||||
# immutable digest in addition to the human-readable tag. The tag is
|
||||
# advisory; the digest is what Docker actually pulls. A registry-side
|
||||
# tag swap (the documented prior-art for tag-only pulls being unsafe)
|
||||
# can no longer change the build.
|
||||
#
|
||||
# Bump procedure (operator):
|
||||
# 1. Quarterly cadence (or sooner if a CVE lands on a base image).
|
||||
# 2. For each FROM:
|
||||
# docker pull <image>:<tag>
|
||||
# docker manifest inspect <image>:<tag> | grep -m1 digest
|
||||
# OR via Docker Hub Registry API:
|
||||
# curl -sSL https://hub.docker.com/v2/repositories/library/<image>/tags/<tag> \
|
||||
# | jq -r .digest
|
||||
# 3. Replace the @sha256:... portion of the FROM line.
|
||||
# 4. Run `docker build` locally + verify CI.
|
||||
# 5. Commit with the bump procedure cited in the message body.
|
||||
#
|
||||
# The CI step "Forbidden bare FROM regression guard (H-001)" rejects
|
||||
# any future commit that lands a FROM without an @sha256 pin.
|
||||
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine AS frontend
|
||||
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS frontend
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
||||
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||
# `NO_PROXY` are forwarded via `docker build --build-arg` (or compose
|
||||
# `build.args`), they are re-exported as ENV with both upper- and lower-case
|
||||
# names because npm/apk/curl read the lowercase variants while Go, Node, and
|
||||
# most HTTP libraries read the uppercase ones.
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG NO_PROXY=
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${HTTP_PROXY} \
|
||||
https_proxy=${HTTPS_PROXY} \
|
||||
no_proxy=${NO_PROXY}
|
||||
|
||||
WORKDIR /app/web
|
||||
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY web/ .
|
||||
RUN npm run build
|
||||
# Bundle A / Audit M-014: explicit retry loop for `npm ci`. Pre-bundle
|
||||
# this was `npm ci || npm ci && tsc && build` — the bash precedence is
|
||||
# `A || (B && C && D)` so the second `npm ci` only ran on the failure
|
||||
# path of the first, but the `tsc && build` chain only ran on the
|
||||
# success path of the second. Net effect: a transient registry blip
|
||||
# turned the build into a silent skip of the production step.
|
||||
#
|
||||
# New shape: a deterministic 3-attempt retry with 5-second backoff and
|
||||
# an explicit `[ -d node_modules ]` post-check so a silent failure is
|
||||
# impossible.
|
||||
RUN for i in 1 2 3; do \
|
||||
npm ci --include=dev && break; \
|
||||
echo "npm ci attempt $i failed; sleeping 5s before retry"; \
|
||||
sleep 5; \
|
||||
done && \
|
||||
[ -d node_modules ] || (echo "ERROR: npm ci failed after 3 attempts; node_modules missing" && exit 1) && \
|
||||
node_modules/.bin/tsc --version && \
|
||||
npm run build
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG NO_PROXY=
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${HTTP_PROXY} \
|
||||
https_proxy=${HTTPS_PROXY} \
|
||||
no_proxy=${NO_PROXY}
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
@@ -31,7 +93,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
./cmd/server
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.19
|
||||
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata curl
|
||||
|
||||
@@ -50,7 +112,34 @@ USER certctl
|
||||
|
||||
EXPOSE 8443
|
||||
|
||||
# Image-level HEALTHCHECK for bare `docker run` / Docker Swarm / Nomad / ECS.
|
||||
#
|
||||
# U-2 (P1, cat-u-healthcheck_protocol_mismatch): pre-U-2 this probe used
|
||||
# `curl -f http://localhost:8443/health`, which always failed against the
|
||||
# HTTPS-only listener (HTTPS-Everywhere milestone, v2.2 / tag v2.0.47 —
|
||||
# `cmd/server/main.go::ListenAndServeTLS`, no plaintext fallback, TLS 1.3
|
||||
# pinned). Operators outside docker-compose / Helm saw permanent
|
||||
# `unhealthy` status and a restart-loop the first time they pulled the
|
||||
# image. The compose stack overrides this HEALTHCHECK with `--cacert` to
|
||||
# the bootstrap CA bundle (deploy/docker-compose.yml:126); the Helm chart
|
||||
# uses explicit `httpGet` probes with `scheme: HTTPS` and ignores Docker's
|
||||
# HEALTHCHECK; every example compose file in `examples/*/docker-compose.yml`
|
||||
# overrides with `curl -sfk https://localhost:8443/health`. This image-
|
||||
# level probe is for the bare-`docker run` consumer ONLY.
|
||||
#
|
||||
# `-k` (insecure) is acceptable here because the probe is localhost-to-
|
||||
# localhost: the same process serving the cert is being probed; the probe
|
||||
# never traverses a network. Pinning a `--cacert` is not viable for the
|
||||
# published image because the bootstrap cert is per-deploy (generated into
|
||||
# the `certs` named volume on first up; operator-supplied via Helm's
|
||||
# `existingSecret` or cert-manager). Compose / Helm / examples already
|
||||
# perform full cert-chain validation and are unaffected.
|
||||
#
|
||||
# CI grep guardrail at .github/workflows/ci.yml ("Forbidden plaintext
|
||||
# HEALTHCHECK regression guard (U-2)") blocks reintroduction of the
|
||||
# `http://` shape. Image-level integration test in
|
||||
# deploy/test/healthcheck_test.go pins the contract end-to-end.
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=5 \
|
||||
CMD curl -f http://localhost:8443/health || exit 1
|
||||
CMD curl -fsk https://localhost:8443/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
# Multi-stage build for certctl agent
|
||||
#
|
||||
# Bundle A / Audit H-001 (CWE-829): every FROM line is pinned to an
|
||||
# immutable digest. See Dockerfile (server) for the bump-procedure
|
||||
# operator runbook; the pins here MUST be bumped in the same pass.
|
||||
|
||||
# Stage 1: Build
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
||||
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||
# `NO_PROXY` are forwarded via `docker build --build-arg` (or compose
|
||||
# `build.args`), they are re-exported as ENV with both upper- and lower-case
|
||||
# names because apk and curl read the lowercase variants while Go reads the
|
||||
# uppercase ones.
|
||||
ARG HTTP_PROXY=
|
||||
ARG HTTPS_PROXY=
|
||||
ARG NO_PROXY=
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${HTTP_PROXY} \
|
||||
https_proxy=${HTTPS_PROXY} \
|
||||
no_proxy=${NO_PROXY}
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
@@ -18,9 +39,16 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
./cmd/agent
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:3.19
|
||||
FROM alpine:3.19@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
# U-2: `procps` ships pgrep, which the HEALTHCHECK below uses to verify the
|
||||
# agent process is alive. Pre-U-2 the deploy/docker-compose.yml agent
|
||||
# HEALTHCHECK called `pgrep -f certctl-agent` against this image but
|
||||
# pgrep wasn't installed — the compose probe was a latent always-fail.
|
||||
# Adding procps here fixes both the new image-level HEALTHCHECK and the
|
||||
# pre-existing compose override. Adds ~250KB to the image; acceptable for
|
||||
# observability parity with the server image.
|
||||
RUN apk add --no-cache ca-certificates curl procps
|
||||
|
||||
RUN addgroup -g 1000 certctl && \
|
||||
adduser -D -u 1000 -G certctl certctl
|
||||
@@ -35,4 +63,19 @@ RUN mkdir -p /var/lib/certctl/keys && \
|
||||
|
||||
USER certctl
|
||||
|
||||
# Image-level HEALTHCHECK for bare `docker run` / Docker Swarm / Nomad / ECS.
|
||||
#
|
||||
# U-2 (P1, cat-u-healthcheck_protocol_mismatch — adjacent fix): the agent
|
||||
# has no HTTP listener (it polls the server via outbound HTTPS), so a
|
||||
# process-presence check is the correct primitive. Pre-U-2 the agent image
|
||||
# shipped with no HEALTHCHECK at all, so bare-`docker run` operators got
|
||||
# zero health signal and orchestrators that key off Docker's HEALTHCHECK
|
||||
# (Swarm, Nomad, ECS) saw the container reported as `none`. The compose
|
||||
# override at deploy/docker-compose.yml:173 used the same `pgrep -f
|
||||
# certctl-agent` shape; we mirror it here so the published image has
|
||||
# parity with the compose stack and the override on docker-compose.yml
|
||||
# becomes redundant-but-correct rather than load-bearing.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD pgrep -f certctl-agent > /dev/null || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/agent"]
|
||||
|
||||
148
LICENSE
148
LICENSE
@@ -2,24 +2,72 @@ Business Source License 1.1
|
||||
|
||||
Parameters
|
||||
|
||||
Licensor: Shankar Reddy
|
||||
Licensor: certctl LLC
|
||||
Licensed Work: certctl
|
||||
The Licensed Work is (c) 2026 Shankar Reddy.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Certificate
|
||||
Management Service. A "Certificate Management Service"
|
||||
is a commercial offering that allows third parties
|
||||
(other than your employees and contractors acting on
|
||||
your behalf) to access and/or use the Licensed Work's
|
||||
certificate lifecycle management functionality as part
|
||||
of a hosted or managed service.
|
||||
The Licensed Work is © 2026 certctl LLC.
|
||||
|
||||
Change Date: March 14, 2033
|
||||
Additional Use Grant: You may make use of the Licensed Work, including in
|
||||
production for your internal business operations and
|
||||
for operations that provide products or services to
|
||||
your own customers, provided that you may not offer
|
||||
the Licensed Work as a Commercial Certificate Service.
|
||||
|
||||
A "Commercial Certificate Service" is any product
|
||||
or service that provides third parties with access
|
||||
to or control of any substantial set of the
|
||||
certificate management functionality of the Licensed
|
||||
Work — including but not limited to lifecycle
|
||||
management, discovery, monitoring, alerting, renewal
|
||||
automation, deployment, revocation, certificate
|
||||
authority operation, certificate issuance,
|
||||
certificate signing, or any combination thereof —
|
||||
where compensation, in any form, is received in
|
||||
connection with such access or control. This
|
||||
restriction applies irrespective of whether such
|
||||
functionality is the principal, ancillary,
|
||||
supporting, or one of several values provided by the
|
||||
product or service, and irrespective of whether the
|
||||
Licensed Work is presented under its original name,
|
||||
a modified name, or no name at all.
|
||||
|
||||
For the avoidance of doubt:
|
||||
|
||||
(a) you may run the Licensed Work in production to
|
||||
manage certificates for products or services
|
||||
that you offer to your customers, where the
|
||||
principal value of those products or services is
|
||||
something other than the Licensed Work's
|
||||
certificate management functionality (for
|
||||
example, you operate a banking application and
|
||||
use the Licensed Work internally to manage TLS
|
||||
certificates for that application);
|
||||
|
||||
(b) for the purposes of this Additional Use Grant,
|
||||
"third party" excludes (i) your employees, (ii)
|
||||
your contractors acting on your behalf, and
|
||||
(iii) your Affiliates. "Affiliate" means any
|
||||
entity that (1) directly or indirectly controls
|
||||
you, (2) is directly or indirectly controlled by
|
||||
you, or (3) is directly or indirectly under
|
||||
common control with you, where "control" means
|
||||
either (A) ownership of more than fifty percent
|
||||
(50%) of the voting interests of the entity, or
|
||||
(B) the power to direct the management and
|
||||
policies of the entity, whether through voting
|
||||
securities, contract, or otherwise;
|
||||
|
||||
(c) the restriction on offering a Commercial
|
||||
Certificate Service applies regardless of whether
|
||||
the Licensed Work is hosted, managed, embedded,
|
||||
bundled, or integrated with another product or
|
||||
service.
|
||||
|
||||
Change Date: March 14, 2076
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Licensed Work,
|
||||
please contact: skreddy040@gmail.com
|
||||
please contact: certctl@proton.me
|
||||
|
||||
Notice
|
||||
|
||||
@@ -32,16 +80,34 @@ works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited production
|
||||
use.
|
||||
|
||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||
available distribution of a specific version of the Licensed Work under this
|
||||
License, whichever comes first, the Licensor hereby grants you rights under
|
||||
Effective on the Change Date, the Licensor hereby grants you rights under
|
||||
the terms of the Change License, and the rights granted in the paragraph
|
||||
above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
resellers, or you must refrain from using the Licensed Work. Rights granted
|
||||
under any commercial license from the Licensor are personal to the licensee
|
||||
and may not be sublicensed, transferred, assigned, or resold to any third
|
||||
party without the Licensor's prior written consent. Any attempted sublicense,
|
||||
transfer, assignment, or resale in violation of this provision is void.
|
||||
|
||||
Restricted Activities. Notwithstanding any other provision of this License,
|
||||
you may not:
|
||||
|
||||
(i) provide the Licensed Work or substantially similar functionality
|
||||
to third parties as a hosted, managed, embedded, bundled, or
|
||||
integrated service, except as expressly permitted in the
|
||||
Additional Use Grant;
|
||||
|
||||
(ii) move, change, disable, circumvent, or work around any license,
|
||||
security, attribution, audit-trail, or feature-gating
|
||||
functionality contained in the Licensed Work; or
|
||||
|
||||
(iii) alter or remove any license, copyright, attribution, trademark,
|
||||
or other notice from the Licensed Work, its derivatives, or any
|
||||
substantial portion thereof.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
@@ -53,13 +119,51 @@ of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in this
|
||||
License apply to your use of that work.
|
||||
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
Patent non-assertion. During the term of this License, Licensor covenants
|
||||
not to assert any patent claim that Licensor controls against any person
|
||||
whose use of the Licensed Work complies with this License, with respect to
|
||||
the Licensed Work as distributed by Licensor. This covenant terminates with
|
||||
respect to any person who initiates a patent infringement action against
|
||||
the Licensor or against any contributor to the Licensed Work.
|
||||
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||
Licensor as expressly required by this License).
|
||||
Termination and reinstatement. Any use of the Licensed Work in violation of
|
||||
this License will automatically terminate your rights under this License
|
||||
for the current and all other versions of the Licensed Work. Your rights
|
||||
are reinstated automatically if you cease the violation and provide written
|
||||
notice to the Licensor at the contact address above within thirty (30) days
|
||||
of becoming aware of the violation. If you violate this License a second
|
||||
time after such reinstatement, your rights are not subject to further
|
||||
reinstatement.
|
||||
|
||||
Contributions. The Licensor does not accept third-party contributions to
|
||||
the Licensed Work. Any code, documentation, or other material submitted to
|
||||
the Licensor or to any repository hosting the Licensed Work is provided at
|
||||
the submitter's sole risk, confers no rights or obligations on the
|
||||
Licensor, and is not incorporated into the Licensed Work.
|
||||
|
||||
Trademark and naming. This License does not grant you any right in any
|
||||
trademark, service mark, trade name, or logo of the Licensor or its
|
||||
Affiliates. Forks, derivative works, and modifications of the Licensed Work
|
||||
must not use the name "certctl," any name confusingly similar to "certctl,"
|
||||
or any Licensor trademark in their distributed form, marketing materials,
|
||||
package metadata, or service offerings.
|
||||
|
||||
Governing law and venue. This License shall be governed by and construed in
|
||||
accordance with the laws of the State of Florida, USA, without giving
|
||||
effect to any choice or conflict of law provision or rule. Any dispute
|
||||
arising from or relating to this License shall be brought exclusively in
|
||||
the state or federal courts located in the State of Florida, and the
|
||||
parties consent to the personal jurisdiction of such courts.
|
||||
|
||||
Severability. If any provision of this License is held to be invalid,
|
||||
illegal, or unenforceable in any jurisdiction, that holding does not
|
||||
affect the validity, legality, or enforceability of any other provision of
|
||||
this License, which remains in full force and effect.
|
||||
|
||||
Survival. The disclaimers of warranty, the patent non-assertion provisions
|
||||
(with respect to acts occurring before termination), the governing-law and
|
||||
venue provisions, and this survival provision survive any termination of
|
||||
this License.
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
|
||||
213
Makefile
213
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help build run test lint clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build
|
||||
.PHONY: help build run test lint verify verify-deploy loadtest loadtest-scale loadtest-scale-bulk loadtest-scale-acme loadtest-scale-agent acme-cert-manager-test acme-rfc-conformance-test keycloak-integration-test okta-smoke-test benchmark-auth benchmark-auth-coldcache clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build e2e-test qa-stats
|
||||
|
||||
# Default target - show help
|
||||
help:
|
||||
@@ -15,6 +15,9 @@ help:
|
||||
@echo " make test-verbose Run tests with verbose output"
|
||||
@echo " make lint Run linter (golangci-lint)"
|
||||
@echo " make fmt Format code with gofmt"
|
||||
@echo " make verify Pre-commit gate: fmt + vet + lint + test (CI-parity)"
|
||||
@echo " make verify-deploy Pre-push gate: digest validity + OpenAPI parity + docker build smoke"
|
||||
@echo " make loadtest k6 throughput run against postgres + certctl (NOT in verify; manual + cron only)"
|
||||
@echo ""
|
||||
@echo "Database:"
|
||||
@echo " make migrate-up Run migrations (requires DB_URL)"
|
||||
@@ -97,6 +100,179 @@ vet:
|
||||
@echo "Running go vet..."
|
||||
go vet ./...
|
||||
|
||||
# verify: aggregate pre-commit gate. Mirrors what CI enforces, so
|
||||
# running `make verify` locally before committing prevents the
|
||||
# class of breakages that ship green-locally / red-on-CI (e.g.
|
||||
# Bundle-9's ST1018 invisible-Unicode-literal hits, which `go vet`
|
||||
# alone cannot catch — staticcheck under golangci-lint does).
|
||||
verify:
|
||||
@echo "==> fmt"
|
||||
@go fmt ./... | { ! grep -q '.'; } || (echo "gofmt produced changes — commit them" && exit 1)
|
||||
@echo "==> go vet ./..."
|
||||
@go vet ./...
|
||||
@echo "==> golangci-lint run ./... (incl. staticcheck ST*)"
|
||||
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
|
||||
@golangci-lint run ./... --timeout 5m
|
||||
@echo "==> go test -short ./..."
|
||||
@go test -short -count=1 ./...
|
||||
@echo ""
|
||||
@echo "verify: PASS — safe to commit"
|
||||
|
||||
# verify-deploy: optional pre-push gate. Runs the digest-validity check,
|
||||
# the OpenAPI ↔ handler parity check, and a Docker build smoke for the
|
||||
# production images (server + agent only — fast subset for local; CI
|
||||
# builds all 4 Dockerfiles per ci-pipeline-cleanup Phase 8 / frozen
|
||||
# decision 0.10).
|
||||
#
|
||||
# Per ci-pipeline-cleanup bundle Phase 11 / frozen decision 0.13.
|
||||
verify-deploy:
|
||||
@echo "==> Digest validity"
|
||||
@bash scripts/ci-guards/digest-validity.sh
|
||||
@echo "==> OpenAPI ↔ handler parity"
|
||||
@bash scripts/ci-guards/openapi-handler-parity.sh
|
||||
@echo "==> Docker build smoke (server + agent — fast subset)"
|
||||
@docker build -f Dockerfile -t certctl:verify .
|
||||
@docker build -f Dockerfile.agent -t certctl-agent:verify .
|
||||
@echo ""
|
||||
@echo "verify-deploy: PASS — safe to push"
|
||||
|
||||
# Load-test harness — closes the #8 acquisition-readiness blocker from
|
||||
# the 2026-05-01 issuer coverage audit. Boots a minimal certctl stack
|
||||
# (postgres + tls-init + certctl-server) and runs k6 against the API
|
||||
# tier for ~5 minutes. Exits non-zero on any threshold breach.
|
||||
#
|
||||
# NOT in `make verify` — load tests take minutes, not seconds, and
|
||||
# don't gate per-PR signal. CI gates this behind workflow_dispatch +
|
||||
# weekly cron in .github/workflows/loadtest.yml. See
|
||||
# deploy/test/loadtest/README.md for thresholds, baseline, and how to
|
||||
# interpret a regression.
|
||||
loadtest:
|
||||
@echo "==> spinning up postgres + certctl + k6 driver (this takes ~7m)"
|
||||
@cd deploy/test/loadtest && docker compose up --build --abort-on-container-exit --exit-code-from k6
|
||||
@echo ""
|
||||
@echo "==> results landed in deploy/test/loadtest/results/"
|
||||
@if [ -f deploy/test/loadtest/results/summary.txt ]; then cat deploy/test/loadtest/results/summary.txt; fi
|
||||
|
||||
# Phase 8 SCALE-H2 — scale-tier load tests. Profile-gated in the
|
||||
# loadtest compose so the default `make loadtest` stays fast and
|
||||
# focused on the per-PR regression scope (API tier + connector tier).
|
||||
#
|
||||
# loadtest-scale-bulk runs the 10K-cert bulk-renew scenario.
|
||||
# loadtest-scale-acme runs the 200-VU ACME directory/nonce/ARI burst.
|
||||
# loadtest-scale-agent runs the 5K-agent heartbeat storm.
|
||||
#
|
||||
# Each target uses --exit-code-from <scenario-driver> so a threshold
|
||||
# breach surfaces as a non-zero make exit. The scale-seed init runs
|
||||
# once per invocation (idempotent via ON CONFLICT) so re-running a
|
||||
# target against the same compose stack is fine.
|
||||
loadtest-scale-bulk:
|
||||
@echo "==> Phase 8 SCALE-H2: bulk-renewal scenario (10K cert fixture, ~6m)"
|
||||
@cd deploy/test/loadtest && docker compose --profile scale up --build \
|
||||
--abort-on-container-exit --exit-code-from k6-scale-bulk
|
||||
@echo ""
|
||||
@echo "==> results: deploy/test/loadtest/results/summary-bulk-renewal.{json,txt}"
|
||||
@if [ -f deploy/test/loadtest/results/summary-bulk-renewal.txt ]; then \
|
||||
cat deploy/test/loadtest/results/summary-bulk-renewal.txt; fi
|
||||
|
||||
loadtest-scale-acme:
|
||||
@echo "==> Phase 8 SCALE-H2: ACME enrollment burst (200 VU, ~6m)"
|
||||
@cd deploy/test/loadtest && docker compose --profile scale up --build \
|
||||
--abort-on-container-exit --exit-code-from k6-scale-acme
|
||||
@echo ""
|
||||
@echo "==> results: deploy/test/loadtest/results/summary-acme-burst.{json,txt}"
|
||||
@if [ -f deploy/test/loadtest/results/summary-acme-burst.txt ]; then \
|
||||
cat deploy/test/loadtest/results/summary-acme-burst.txt; fi
|
||||
|
||||
loadtest-scale-agent:
|
||||
@echo "==> Phase 8 SCALE-H2: agent heartbeat storm (5K agent fixture, ~6m)"
|
||||
@cd deploy/test/loadtest && docker compose --profile scale up --build \
|
||||
--abort-on-container-exit --exit-code-from k6-scale-agent
|
||||
@echo ""
|
||||
@echo "==> results: deploy/test/loadtest/results/summary-agent-storm.{json,txt}"
|
||||
@if [ -f deploy/test/loadtest/results/summary-agent-storm.txt ]; then \
|
||||
cat deploy/test/loadtest/results/summary-agent-storm.txt; fi
|
||||
|
||||
# All three Phase 8 scenarios serially. Use the matrix in
|
||||
# .github/workflows/loadtest.yml for parallel CI runs.
|
||||
loadtest-scale: loadtest-scale-bulk loadtest-scale-acme loadtest-scale-agent
|
||||
|
||||
# Auth Bundle 2 Phase 10 — Keycloak end-to-end OIDC integration test.
|
||||
# Boots a Keycloak container via testcontainers-go (quay.io/keycloak:25.0),
|
||||
# imports a canned realm with two groups + two users, and drives the
|
||||
# full OIDC flow against the certctl service: discovery + JWKS,
|
||||
# auth-code login, group-claim parsing, group-role mapping, session
|
||||
# mint, and JWKS rotation.
|
||||
#
|
||||
# Build-tag-gated under `integration` so `make verify` (which runs
|
||||
# go test -short) NEVER pulls in the 60-90s Keycloak boot. Requires a
|
||||
# local Docker daemon. Skips cleanly with t.Skip() when -short is set.
|
||||
keycloak-integration-test:
|
||||
@echo "==> running Keycloak OIDC integration test (requires Docker)"
|
||||
@go test -tags=integration -count=1 -timeout=10m \
|
||||
./internal/auth/oidc/...
|
||||
|
||||
# Auth Bundle 2 Phase 10 — optional Okta smoke test. Gated behind TWO
|
||||
# build tags (integration + okta_smoke) so it only runs when invoked
|
||||
# manually against the operator's own Okta dev tenant. Requires the
|
||||
# OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars; the test
|
||||
# t.Skip's with a clear message when any are missing. Documented in
|
||||
# internal/auth/oidc/integration_okta_smoke_test.go.
|
||||
okta-smoke-test:
|
||||
@echo "==> running Okta smoke test (requires OKTA_ISSUER / _CLIENT_ID / _CLIENT_SECRET env vars)"
|
||||
@go test -tags='integration okta_smoke' -count=1 -timeout=2m \
|
||||
./internal/auth/oidc/...
|
||||
|
||||
# Auth Bundle 2 Phase 14 — auth performance benchmarks. Three default-
|
||||
# tag benchmarks (session steady-state + session cold-process + oidc
|
||||
# steady-state) producing p50/p95/p99/max numbers per the auth-
|
||||
# benchmarks.md operator-doc table.
|
||||
benchmark-auth:
|
||||
@echo "==> running auth performance benchmarks (session + oidc steady-state)"
|
||||
@go test -bench='BenchmarkSession_|BenchmarkOIDC_SteadyState' -benchmem \
|
||||
-benchtime=2000x -run='^$$' \
|
||||
./internal/auth/session/ ./internal/auth/oidc/
|
||||
|
||||
# Auth Bundle 2 Phase 14 — OIDC cold-cache benchmark against a live
|
||||
# Keycloak container (requires Docker). Build-tag-gated so the
|
||||
# default-tag benchmarks above never pull in the 60-90s container
|
||||
# boot. Runs the integration test FIRST to populate the
|
||||
# sharedKeycloak fixture, then runs the benchmark.
|
||||
benchmark-auth-coldcache:
|
||||
@echo "==> running OIDC cold-cache benchmark against live Keycloak (requires Docker)"
|
||||
@go test -tags integration -count=1 -timeout=10m \
|
||||
-run TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS \
|
||||
-bench BenchmarkOIDC_ColdCache -benchmem -benchtime=10x \
|
||||
./internal/auth/oidc/
|
||||
|
||||
# Phase 5 — kind-driven cert-manager integration test. Requires
|
||||
# `kind`, `kubectl`, `helm`, and a local Docker daemon. Sets
|
||||
# KIND_AVAILABLE=1 so the test runs (it skips cleanly when unset, which
|
||||
# is the CI default — kind is too heavy for per-PR CI). The test
|
||||
# brings up a fresh cluster, installs cert-manager 1.15, helm-installs
|
||||
# certctl-test, applies a ClusterIssuer + Certificate, and asserts the
|
||||
# Secret lands.
|
||||
acme-cert-manager-test:
|
||||
@echo "==> running cert-manager integration test (requires kind/kubectl/helm)"
|
||||
@KIND_AVAILABLE=1 go test -tags=integration -count=1 -timeout=15m \
|
||||
./deploy/test/acme-integration/...
|
||||
|
||||
# Phase 5 — RFC 8555 conformance against `lego` driving the certctl
|
||||
# server. Hermetic: brings up a single certctl-server via docker
|
||||
# compose, points lego at it, runs the conformance scenarios. Skips
|
||||
# when the operator hasn't built the test image (`make docker-build`
|
||||
# first).
|
||||
acme-rfc-conformance-test:
|
||||
@echo "==> running RFC 8555 conformance via lego"
|
||||
@if ! command -v lego >/dev/null 2>&1; then \
|
||||
echo "lego not installed — go install github.com/go-acme/lego/v4/cmd/lego@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@cd deploy/test/loadtest && docker compose up -d certctl postgres
|
||||
@sleep 8
|
||||
@CERTCTL_ACME_DIR=https://localhost:8443/acme/profile/prof-test/directory \
|
||||
bash deploy/test/acme-integration/conformance-lego.sh
|
||||
@cd deploy/test/loadtest && docker compose down
|
||||
|
||||
# Database targets (requires migrate tool)
|
||||
migrate-up:
|
||||
@echo "Running migrations..."
|
||||
@@ -162,6 +338,41 @@ frontend-build:
|
||||
cd web && npm ci && npx vite build
|
||||
@echo "Frontend build complete"
|
||||
|
||||
# Phase 3 TEST-M3 closure (2026-05-13): browser-driven E2E smoke
|
||||
# target. The full 15-flow suite from web/src/__tests__/e2e/README.md
|
||||
# ships in frontend-design-audit Phase 8; this target is the harness
|
||||
# wiring that lets `make e2e-test` work today.
|
||||
#
|
||||
# First-time setup: `cd web && npm install && npx playwright install --with-deps chromium`.
|
||||
# The webServer block in web/playwright.config.ts boots `npm run dev`
|
||||
# automatically; no separate `make docker-up` needed.
|
||||
e2e-test:
|
||||
@echo "Running Playwright E2E (smoke + any *.spec.ts under web/src/__tests__/e2e/)..."
|
||||
cd web && npx playwright test
|
||||
@echo "E2E run complete"
|
||||
|
||||
# qa-stats: snapshot of the test-suite size at the current commit.
|
||||
# Backend Go tests + subtests + fuzz targets + skipped sites, plus the
|
||||
# seed-data counts in migrations/seed_demo.sql. Useful before a release
|
||||
# to spot-check that no whole layer dropped off.
|
||||
qa-stats:
|
||||
@echo "=== certctl QA Suite Stats ==="
|
||||
@echo "Date: $$(date +%Y-%m-%d)"
|
||||
@echo "HEAD: $$(git rev-parse HEAD 2>/dev/null || echo 'not-a-git-repo')"
|
||||
@echo ""
|
||||
@echo "Backend test files: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "Backend Test functions: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c '^func Test' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
|
||||
@echo "Backend t.Run subtests: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c 't\.Run(' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
|
||||
@echo "Frontend test files: $$(find web/src -name '*.test.ts' -o -name '*.test.tsx' 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "Fuzz targets: $$(grep -rE 'func Fuzz[A-Z]' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)"
|
||||
@echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 13 incl. agent-demo-1 + 3 cloud sentinels + server-scanner)"
|
||||
@echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)"
|
||||
@echo "Seed unique tgt-* IDs: $$(grep -oE "tgt-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
@echo "Seed unique nst-* IDs: $$(grep -oE "nst-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
|
||||
18
NOTICE
Normal file
18
NOTICE
Normal file
@@ -0,0 +1,18 @@
|
||||
certctl
|
||||
Copyright 2026 certctl LLC.
|
||||
|
||||
This product is distributed under the Business Source License 1.1.
|
||||
See LICENSE at the repository root for the full license text and
|
||||
the Additional Use Grant carve-outs.
|
||||
|
||||
This product links third-party Go modules and JavaScript packages
|
||||
whose own license terms apply to those components. The full
|
||||
inventory of third-party dependencies and their respective licenses
|
||||
is enumerated in THIRD_PARTY_NOTICES.md at the repository root.
|
||||
|
||||
Effective March 14, 2076, the BSL 1.1 license converts to the
|
||||
Apache License 2.0 per the Change Date in LICENSE.
|
||||
|
||||
For inquiries about commercial licensing terms outside the
|
||||
Additional Use Grant — including the Commercial Certificate
|
||||
Service restriction — contact certctl@proton.me.
|
||||
569
README.md
569
README.md
@@ -2,494 +2,197 @@
|
||||
<img src="docs/screenshots/logo/certctl-logo.png" alt="certctl logo" width="450">
|
||||
</p>
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=89db181e-76e0-45cc-b9c0-790c3dfdfc73" />
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=b9379aff-9e5c-4d01-8f2d-9e4ffa09d126" />
|
||||
|
||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||
|
||||
```mermaid
|
||||
timeline
|
||||
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
||||
2015 : 5 years
|
||||
2018 : 825 days
|
||||
2020 : 398 days
|
||||
March 2026 : 200 days
|
||||
March 2027 : 100 days
|
||||
March 2029 : 47 days
|
||||
```
|
||||
|
||||
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
||||
|
||||
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||
[](https://github.com/shankar0123/certctl/releases)
|
||||
[](https://goreportcard.com/report/github.com/certctl-io/certctl)
|
||||
[](https://github.com/certctl-io/certctl/releases)
|
||||
[](https://github.com/certctl-io/certctl/stargazers)
|
||||
|
||||
certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. Twelve native CA connectors plus an OpenSSL / shell-script adapter for custom CAs; fourteen production-ready native deployment-target connectors plus Kubernetes Secrets (preview) and a proxy-agent pattern for network appliances and agentless targets. In agent-mode (the default), private keys stay on the host they were generated on and never touch the control plane; a demo-only `CERTCTL_KEYGEN_MODE=server` flag mints keys server-side, refuses to start without an explicit `CERTCTL_DEMO_MODE_ACK=true` acknowledgement. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for.
|
||||
|
||||
The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice.
|
||||
|
||||
> **Status: Early-access — actively looking for design partners.**
|
||||
|
||||
> The certificate lifecycle core is production-quality today: Local CA, ACME, agent deployment, audit, [role-based access control](docs/operator/rbac.md) with auditor split and four-eyes approval. v2.1.0 adds federated identity on top — [OIDC SSO](docs/operator/oidc-runbooks/index.md), server-side sessions, back-channel logout, and a break-glass admin path for SSO-outage recovery.
|
||||
|
||||
> If your team runs PKI infrastructure that could use real automation, we'd love to have you on certctl. Lab and dev deployments are great. Production is welcome too — especially on the federated-identity surface, where real-world IdP shapes are exactly the exposure we can't manufacture in CI. Battle-testing certctl in your environment is genuinely valuable to us.
|
||||
|
||||
> [File issues](https://github.com/certctl-io/certctl/issues) liberally. Every IdP quirk, every connector edge, every doc gap you hit — that's how the platform earns the right to drop the "early-access" label. The faster the loop, the faster everyone benefits.
|
||||
|
||||
> **Actively maintained, shipping weekly.** [Open an issue](https://github.com/certctl-io/certctl/issues) if something breaks. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
|
||||
|
||||
**Ready to try it?** Jump to the [Quick Start](#quick-start). For the marketing site, see [certctl.io](https://certctl.io).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Why certctl?](docs/why-certctl.md) | Competitive positioning — how certctl compares to open-source and enterprise certificate management platforms |
|
||||
| [Concepts](docs/concepts.md) | TLS certificates explained from scratch — for beginners who know nothing about certs |
|
||||
| [Quick Start](docs/quickstart.md) | Get running in 5 minutes — dashboard, API, CLI, discovery, stakeholder demo flow |
|
||||
| [Advanced Demo](docs/demo-advanced.md) | Issue a certificate end-to-end with technical deep-dives |
|
||||
| [Architecture](docs/architecture.md) | System design, data flow diagrams, security model |
|
||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||
The full audience-organized index lives at [`docs/README.md`](docs/README.md). Top-level entry points:
|
||||
|
||||
## Why certctl Exists
|
||||
| Audience | Start here |
|
||||
|---|---|
|
||||
| New to certctl | [Concepts](docs/getting-started/concepts.md) → [Quickstart](docs/getting-started/quickstart.md) → [Examples](docs/getting-started/examples.md) |
|
||||
| Production operator | [Architecture](docs/reference/architecture.md) → [Security posture](docs/operator/security.md) → [Disaster recovery runbook](docs/operator/runbooks/disaster-recovery.md) |
|
||||
| PKI engineer | [ACME server](docs/reference/protocols/acme-server.md) → [SCEP server](docs/reference/protocols/scep-server.md) → [EST server](docs/reference/protocols/est.md) → [CA hierarchy](docs/reference/intermediate-ca-hierarchy.md) |
|
||||
| Migrating from another tool | [from certbot](docs/migration/from-certbot.md) / [from acme.sh](docs/migration/from-acmesh.md) / [cert-manager coexistence](docs/migration/cert-manager-coexistence.md) |
|
||||
|
||||
Certificate lifecycle tooling today falls into two camps: expensive enterprise platforms (Venafi, Keyfactor, Sectigo) that cost six figures and take months to deploy, or single-purpose tools (cert-manager, certbot) that handle one slice of the problem. If you run a mixed infrastructure — some NGINX, some Apache, a few HAProxy nodes, maybe an F5 — and you need to manage certificates from multiple CAs, there's nothing self-hosted that covers the full lifecycle without vendor lock-in.
|
||||
For the connector reference (12 issuers, 15 targets, 6 notifiers) see [`docs/reference/connectors/index.md`](docs/reference/connectors/index.md).
|
||||
|
||||
certctl fills that gap. It's **CA-agnostic** — the issuer connector interface means you can plug in any certificate authority: a self-signed local CA for dev, Let's Encrypt via ACME for public certs, Smallstep step-ca for your private PKI, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. You're never locked to a single CA vendor, and you can run multiple issuers simultaneously for different certificate types.
|
||||
|
||||
It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, and Caddy — all using the same pluggable connector model for any server that accepts cert files. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments.
|
||||
|
||||
For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venafi, Keyfactor), see [Why certctl?](docs/why-certctl.md)
|
||||
|
||||
## What It Does
|
||||
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||
|
||||
- **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
|
||||
- **REST API** — 93 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
||||
- **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers)
|
||||
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents
|
||||
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||
- **Approval workflows** — require human sign-off on renewals before deployment
|
||||
- **Background scheduler** — watches expiration dates and triggers renewals automatically, handling constant rotation at 47-day lifespans without human involvement
|
||||
|
||||
For the full capability breakdown — revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
|
||||
|
||||
## Supported Integrations
|
||||
|
||||
### Certificate Issuers
|
||||
| Issuer | Status | Type |
|
||||
|--------|--------|------|
|
||||
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01 + DNS-PERSIST-01) | `ACME` |
|
||||
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
||||
| step-ca | Implemented | `StepCA` |
|
||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||
| Vault PKI | Future | — |
|
||||
| DigiCert | Future | — |
|
||||
|
||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
||||
|
||||
### Deployment Targets
|
||||
| Target | Status | Type |
|
||||
|--------|--------|------|
|
||||
| NGINX | Implemented | `NGINX` |
|
||||
| Apache httpd | Implemented | `Apache` |
|
||||
| HAProxy | Implemented | `HAProxy` |
|
||||
| Traefik | Implemented | `Traefik` |
|
||||
| Caddy | Implemented | `Caddy` |
|
||||
| F5 BIG-IP | Interface only | `F5` |
|
||||
| Microsoft IIS | Interface only | `IIS` |
|
||||
|
||||
### Notifiers
|
||||
| Notifier | Status | Type |
|
||||
|----------|--------|------|
|
||||
| Email (SMTP) | Implemented | `Email` |
|
||||
| Webhooks | Implemented | `Webhook` |
|
||||
| Slack | Implemented | `Slack` |
|
||||
| Microsoft Teams | Implemented | `Teams` |
|
||||
| PagerDuty | Implemented | `PagerDuty` |
|
||||
| OpsGenie | Implemented | `OpsGenie` |
|
||||
|
||||
All connectors are pluggable — build your own by implementing the [connector interface](docs/connectors.md).
|
||||
|
||||
### Screenshots
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-dashboard.png"><img src="docs/screenshots/v2-dashboard.png" width="270" alt="Dashboard"></a><br><b>Dashboard</b><br><sub>Stats, expiration heatmap, renewal trends</sub></td>
|
||||
<td><a href="docs/screenshots/v2-certificates.png"><img src="docs/screenshots/v2-certificates.png" width="270" alt="Certificates"></a><br><b>Certificates</b><br><sub>Inventory with status, owner, team filters</sub></td>
|
||||
<td><a href="docs/screenshots/v2-agents.png"><img src="docs/screenshots/v2-agents.png" width="270" alt="Agents"></a><br><b>Agents</b><br><sub>Fleet health, OS/arch, IP, version</sub></td>
|
||||
<td><a href="docs/screenshots/v2-dashboard.png"><img src="docs/screenshots/v2-dashboard.png" width="400" alt="Dashboard"></a><br><b>Dashboard</b><br><sub>Stats, expiration heatmap, renewal trends, issuance rate</sub></td>
|
||||
<td><a href="docs/screenshots/v2-certificates.png"><img src="docs/screenshots/v2-certificates.png" width="400" alt="Certificates"></a><br><b>Certificates</b><br><sub>Inventory with bulk ops, status filters, owner/team columns</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-fleet.png"><img src="docs/screenshots/v2-fleet.png" width="270" alt="Fleet Overview"></a><br><b>Fleet Overview</b><br><sub>OS distribution, status breakdown</sub></td>
|
||||
<td><a href="docs/screenshots/v2-jobs.png"><img src="docs/screenshots/v2-jobs.png" width="270" alt="Jobs"></a><br><b>Jobs</b><br><sub>Issuance, renewal, deployment queue</sub></td>
|
||||
<td><a href="docs/screenshots/v2-notifications.png"><img src="docs/screenshots/v2-notifications.png" width="270" alt="Notifications"></a><br><b>Notifications</b><br><sub>Expiration warnings, renewal results</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
||||
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-owners.png"><img src="docs/screenshots/v2-owners.png" width="270" alt="Owners"></a><br><b>Owners</b><br><sub>Cert ownership with team assignment</sub></td>
|
||||
<td><a href="docs/screenshots/v2-teams.png"><img src="docs/screenshots/v2-teams.png" width="270" alt="Teams"></a><br><b>Teams</b><br><sub>Org grouping for notification routing</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/v2-agent-groups.png"><img src="docs/screenshots/v2-agent-groups.png" width="270" alt="Agent Groups"></a><br><b>Agent Groups</b><br><sub>Dynamic grouping by OS, arch, CIDR</sub></td>
|
||||
<td><a href="docs/screenshots/v2-audit-trail.png"><img src="docs/screenshots/v2-audit-trail.png" width="270" alt="Audit Trail"></a><br><b>Audit Trail</b><br><sub>Immutable log, CSV/JSON export</sub></td>
|
||||
<td><a href="docs/screenshots/v2-short-lived.png"><img src="docs/screenshots/v2-short-lived.png" width="270" alt="Short-Lived"></a><br><b>Short-Lived Creds</b><br><sub>Ephemeral certs with live TTL countdown</sub></td>
|
||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="400" alt="Issuers"></a><br><b>Issuers</b><br><sub>Catalog with 12 CA types, GUI config, test connection</sub></td>
|
||||
<td><a href="docs/screenshots/v2-jobs.png"><img src="docs/screenshots/v2-jobs.png" width="400" alt="Jobs"></a><br><b>Jobs</b><br><sub>Issuance, renewal, deployment queue with approval workflow</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**[See all screenshots →](docs/screenshots/)**
|
||||
|
||||
## Why certctl
|
||||
|
||||
Certificate lifecycle tooling has historically split into two camps. Enterprise platforms charge six-figure annual licenses, take months to deploy, and bill professional-services hours at $250 to $400 per hour to write integration code that should ship with the product. Single-purpose tools handle one slice of the problem and leave the operator to glue the rest together. certctl fills the gap — full lifecycle automation, self-hosted, free, CA-agnostic, target-agnostic. If you're stitching together cron jobs across a fleet, manually renewing certs, or writing custom integration scripts to bridge a commercial CLM platform to your actual infrastructure, certctl replaces all of that.
|
||||
|
||||
Built for **platform engineering and DevOps teams** managing 10 to 500+ certificates, **security teams** who need audit trails and policy enforcement, and **small teams without enterprise budgets** who need enterprise-grade automation for a 50-server environment. For the detailed positioning argument and when not to use certctl, see [Why certctl?](docs/getting-started/why-certctl.md).
|
||||
|
||||
## What it does
|
||||
|
||||
certctl handles the full certificate lifecycle in one self-hosted control plane:
|
||||
|
||||
- **Issue and renew** from any CA. Let's Encrypt and any ACME provider, an embedded ACME server you can point cert-manager / certbot / lego at directly, a built-in local CA with sub-CA mode (chains under your enterprise root like ADCS), step-ca, Vault PKI, EJBCA, AWS ACM PCA, Google CAS, DigiCert, Sectigo, GlobalSign, Entrust, plus an OpenSSL / shell-script adapter for anything custom. Twelve native issuer connectors. See the [connector reference](docs/reference/connectors/index.md).
|
||||
- **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. **Fourteen production-ready native target connectors plus Kubernetes Secrets (preview).** File-based targets share an atomic-write + SHA-256 idempotency + on-failure rollback + per-target Prometheus counters primitive (the `deploy.Apply` path covers 12 of 13 file-based connectors). Cloud / API targets (AWS ACM, Azure Key Vault) use vendor-SDK semantics rather than the file primitive; F5 uses iControl REST transactions. The Kubernetes Secrets connector is shipped as preview because the production `client-go` integration is incomplete — see [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md) for the per-target guarantee matrix. The reload / validate commands operators configure for shell-using targets (NGINX, Apache, HAProxy, Postfix, JavaKeystore, SSH) are validated server-side AND agent-side against shell-metacharacter injection before execution (see [`internal/connector/target/configcheck`](internal/connector/target/configcheck)).
|
||||
- **Run as an ACME server** so existing client tooling plugs in directly. RFC 8555 + RFC 9773 ARI, two per-profile auth modes (public-trust-style validation or trust_authenticated for internal PKI), doubly-signed key rollover, revoke-cert on both kid path and jwk path, per-account rate limiting. Cert-manager / certbot / lego all work pointed at it. See [`docs/reference/protocols/acme-server.md`](docs/reference/protocols/acme-server.md).
|
||||
- **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md).
|
||||
- **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md).
|
||||
- **Manage multi-level CA hierarchies** with name constraints, path-length enforcement, and end-to-end RFC 5280 path validation. Root → intermediate → issuing chains, admin-gated CRUD, drain-first retirement. Patterns documented for 4-level boundary CAs, 3-level policy CAs with per-BU `PermittedDNSDomains`, and 2-level internal PKI. See [`docs/reference/intermediate-ca-hierarchy.md`](docs/reference/intermediate-ca-hierarchy.md).
|
||||
- **Gate high-stakes issuance** behind two-person-integrity approval. Flag a profile as `RequiresApproval`, the request lands in a queue, a non-requester approves, the scheduler dispatches. Profile-edit changes on approval-tier profiles route through the same gate so the flip-flop bypass is closed. See [`docs/operator/approval-workflow.md`](docs/operator/approval-workflow.md).
|
||||
- **Authorize with role-based access control.** Seven default roles (admin, operator, viewer, agent, mcp, cli, auditor) over a fine-grained permission catalogue with global / per-profile / per-issuer scope. Auditor role is read-only on the audit trail (`audit.read` + `audit.export`, nothing else) so a regulator's key cannot read certificates or mutate config. Day-0 admin via a one-shot `CERTCTL_BOOTSTRAP_TOKEN` endpoint that closes itself the moment any admin lands. Privilege-escalation guard requires `auth.role.assign` to grant or revoke a role. See [`docs/operator/rbac.md`](docs/operator/rbac.md), [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md), and the v2.0.x → v2.1.0 [migration guide](docs/migration/api-keys-to-rbac.md).
|
||||
- **Sign in with OIDC SSO** against any standards-compliant identity provider. Per-IdP setup runbooks for Keycloak, Authentik, Okta, Auth0, Microsoft Entra ID, and Google Workspace. Group-claim → role mapping for automatic provisioning; client_secret encrypted at rest (AES-256-GCM); JWKS auto-refresh on `kid` miss; PKCE-S256 required; RFC 9700 §4.7.1 pre-login UA/IP binding; RFC 9207 `iss` URL-param check on callback. Server mints HMAC-signed session cookies with the `__Host-` prefix (browser-enforced subdomain-takeover defense), CSRF rotation on every privileged write, and idle + absolute expiry. [RFC OIDC Back-Channel Logout 1.0](docs/reference/auth-standards-implemented.md) revokes sessions on IdP-driven logout. Argon2id break-glass admin path for SSO-outage recovery — disabled by default; 404-invisible to scanners when `CERTCTL_BREAKGLASS_ENABLED=false`. See [`docs/operator/oidc-runbooks/index.md`](docs/operator/oidc-runbooks/index.md) for the per-IdP onboarding guides and [`docs/migration/oidc-enable.md`](docs/migration/oidc-enable.md) for enabling SSO on an existing deploy.
|
||||
- **Discover** existing certs across your fleet via filesystem scanning on agents, network TLS probing across CIDR ranges, and cloud secret manager imports (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager). Triage workflow for claim / dismiss / investigate.
|
||||
- **Revoke** with full RFC 5280 reason codes, DER CRL generation per issuer (scheduler-pre-generated and ETag-cached), and an embedded RFC 6960 OCSP responder with dedicated per-issuer responder certs. Single + bulk revocation. See [`docs/reference/protocols/crl-ocsp.md`](docs/reference/protocols/crl-ocsp.md).
|
||||
- **Alert** via Slack, Microsoft Teams, PagerDuty, OpsGenie, email, webhooks. Per-policy multi-channel routing matrix with severity tiers and fault-isolating per-channel dispatch. See [`docs/operator/runbooks/expiry-alerts.md`](docs/operator/runbooks/expiry-alerts.md).
|
||||
- **Drive the platform from natural language** via the bundled MCP (Model Context Protocol) server. The bulk of the REST API surface is exposed as MCP tools — ask your AI client "show me all expiring certificates", "revoke the VPN cert, key compromised", or "what agents are offline?" and it translates to API calls. Stateless stdio-transport binary at `cmd/mcp-server/`; same auth as the REST API; no extra attack surface. MCP-vs-REST parity (162 tools covering 221 routes; the gap is a small allowlist of streaming + protocol-conformance endpoints that don't fit the request-response tool shape) is tracked in [`docs/reference/mcp-coverage.md`](docs/reference/mcp-coverage.md) with a CI guard that fails the build if a new REST route lands without either an MCP tool or an explicit allowlist entry. See [`docs/reference/mcp.md`](docs/reference/mcp.md).
|
||||
|
||||
## Architecture and security
|
||||
|
||||
Go 1.25 control plane with handler → service → repository layering. PostgreSQL 16 backend with idempotent migrations. Pull-only deployment model — the server never initiates outbound connections. **In agent-keygen mode (the production default), agents poll for work and generate ECDSA P-256 keys locally, so private keys never touch the control plane.** The opposite path (`CERTCTL_KEYGEN_MODE=server`) is demo-only and refuses to boot in production without an explicit `CERTCTL_DEMO_MODE_ACK=true` acknowledgement. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). See the [Architecture Guide](docs/reference/architecture.md) for full system diagrams.
|
||||
|
||||
Security: three authentication paths — API keys (SHA-256 hashed + constant-time compared), [OIDC SSO](docs/operator/oidc-runbooks/index.md) (Keycloak / Authentik / Okta / Auth0 / Entra ID / Google Workspace), and Argon2id [break-glass admin](docs/operator/security.md) for SSO-outage recovery. Successful OIDC login mints an HMAC-signed server-side session with `__Host-` cookies, CSRF rotation on every privileged write, and [RFC OIDC Back-Channel Logout](docs/reference/auth-standards-implemented.md) for IdP-driven session revoke. Role-based authorization on every gated handler with global / per-profile / per-issuer scope. Auditor split keeps regulator-class actors strictly read-only on the audit trail. Day-0 admin via a one-shot bootstrap token; granting or revoking roles requires the dedicated `auth.role.assign` permission. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Issuer + target + OIDC client_secret credentials encrypted at rest with AES-256-GCM. HTTPS-only control plane with TLS 1.3 pinned and a fail-closed startup gate that refuses to boot if the TLS bundle is unusable. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, static analysis, and vulnerability scanning on every commit. See [`docs/operator/security.md`](docs/operator/security.md) for the full posture and [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md) for what's defended vs deferred.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker Pull
|
||||
### Docker Compose (recommended)
|
||||
|
||||
**Demo path — zero config, populated dashboard:**
|
||||
|
||||
```bash
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-server
|
||||
docker pull shankar0123.docker.scarf.sh/certctl-agent
|
||||
git clone https://github.com/certctl-io/certctl.git
|
||||
cd certctl
|
||||
./deploy/demo-up.sh -d --build
|
||||
```
|
||||
|
||||
### Docker Compose (Recommended)
|
||||
Wait ~30 seconds, then open **https://localhost:8443** in your browser. The `demo-up.sh` wrapper exports a fresh `CERTCTL_DEMO_MODE_ACK_TS=$(date +%s)` and forwards the remaining args to `docker compose -f docker-compose.yml -f docker-compose.demo.yml up`. The timestamp export is required by the Phase 2 SEC-H3 fail-closed guard in `internal/config/config.go::Validate` — demo deploys must re-ACK every 24h so a forgotten demo container never silently ends up serving production traffic with `auth-type=none`. The bare `docker compose ... up` command without the timestamp refuses to boot; the wrapper script is the supported entry point.
|
||||
|
||||
The demo overlay flips the base into demo-mode auth (every request served as the synthetic admin actor `actor-demo-anon` — the server emits a prominent ⚠ DEMO MODE banner at boot reminding you this posture is for evaluation only) and seeds 180 days of realistic history across 13 issuers, 8 agents, managed + discovered certs, jobs, deploys, audit, and notification events. The `certctl-tls-init` init container self-signs an ECDSA-P256 cert on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.
|
||||
|
||||
**Production path — `.env` required, fail-closed on placeholders:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shankar0123/certctl.git
|
||||
cd certctl
|
||||
cp .env.example deploy/.env # or root .env if running outside compose
|
||||
"${EDITOR:-nano}" deploy/.env # set POSTGRES_PASSWORD, CERTCTL_AUTH_SECRET,
|
||||
# CERTCTL_API_KEY, CERTCTL_CONFIG_ENCRYPTION_KEY,
|
||||
# CERTCTL_AGENT_ID — all via openssl rand
|
||||
# (replace nano with your preferred editor)
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
||||
The base compose alone (no demo overlay) ships production-shaped: default `auth-type=api-key`, default `keygen-mode=agent`, no demo seed, no demo-mode synthetic admin. The fail-closed startup guards in `internal/config/config.go::Validate` refuse to boot when any of the change-me-... placeholder credentials reach config outside of demo mode (Bundle 2 closure, 2026-05-12). The four compose files (`docker-compose.yml` base, `docker-compose.demo.yml` overlay, `docker-compose.dev.yml` for PgAdmin + debug logging, `docker-compose.test.yml` for integration tests) are documented at [`deploy/ENVIRONMENTS.md`](deploy/ENVIRONMENTS.md).
|
||||
|
||||
The dashboard comes pre-loaded with 15 demo certificates, 5 agents, policy rules, audit events, and notifications — a realistic snapshot of a certificate inventory so you can explore immediately.
|
||||
|
||||
Verify the API:
|
||||
```bash
|
||||
curl http://localhost:8443/health
|
||||
curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||
# 15
|
||||
```
|
||||
|
||||
### Manual Build
|
||||
The control plane is HTTPS-only with TLS 1.3 pinned. See [`docs/operator/tls.md`](docs/operator/tls.md) for cert provisioning patterns.
|
||||
|
||||
### Agent install (one-liner)
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.25+, PostgreSQL 16+
|
||||
go mod download
|
||||
make build
|
||||
|
||||
# Set up database
|
||||
export CERTCTL_DATABASE_URL="postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable"
|
||||
export CERTCTL_AUTH_TYPE=none
|
||||
make migrate-up
|
||||
|
||||
# Start server
|
||||
./bin/server
|
||||
|
||||
# Start agent (separate terminal)
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=change-me-in-production
|
||||
export CERTCTL_AGENT_NAME=local-agent
|
||||
export CERTCTL_AGENT_ID=agent-local-01
|
||||
./bin/agent --agent-id=agent-local-01
|
||||
curl -sSL https://raw.githubusercontent.com/certctl-io/certctl/master/install-agent.sh | bash
|
||||
```
|
||||
|
||||
## Architecture
|
||||
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh).
|
||||
|
||||
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 6 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||
### Helm chart (Kubernetes)
|
||||
|
||||
### Key Design Decisions
|
||||
```bash
|
||||
# Required: TLS (pick one), server API key, and Postgres password.
|
||||
# The chart fail-fasts at template time if any required value is missing.
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name> \
|
||||
--set server.auth.apiKey=$(openssl rand -base64 32) \
|
||||
--set postgresql.auth.password=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
- **Private keys isolated from the control plane.** Agents generate ECDSA P-256 keys locally and submit CSRs (public key only). The server signs the CSR and returns the certificate — private keys never touch the control plane. Server-side keygen is available via `CERTCTL_KEYGEN_MODE=server` for demo/development only.
|
||||
- **TEXT primary keys, not UUIDs.** IDs are human-readable prefixed strings (`mc-api-prod`, `t-platform`, `o-alice`) so you can identify resource types at a glance in logs and queries.
|
||||
- **Handler → Service → Repository layering.** Handlers define their own service interfaces for clean dependency inversion. No global service singletons.
|
||||
- **Idempotent migrations.** All schema uses `IF NOT EXISTS` and seed data uses `ON CONFLICT (id) DO NOTHING`, safe for repeated execution.
|
||||
Production-ready chart with Server Deployment, PostgreSQL StatefulSet (or external Postgres), Agent DaemonSet, health probes, container-scope security hardening (read-only rootfs, drop-all capabilities, non-root UID), optional PodDisruptionBudget, NetworkPolicy, Prometheus ServiceMonitor, and Ingress. See [values.yaml](deploy/helm/certctl/values.yaml) and the [external-Postgres example](deploy/helm/examples/values-external-db.yaml).
|
||||
|
||||
PostgreSQL 16 with 21 tables covering certificates, versions, policies, issuers, targets, agents, jobs, teams, owners, profiles, agent groups, revocations, discovery, network scans, and audit events. See the [Architecture Guide](docs/architecture.md) for the full schema.
|
||||
### Container images
|
||||
|
||||
## Configuration
|
||||
```bash
|
||||
docker pull ghcr.io/certctl-io/certctl-server:latest
|
||||
docker pull ghcr.io/certctl-io/certctl-agent:latest
|
||||
```
|
||||
|
||||
All environment variables use the `CERTCTL_` prefix. Full reference below (39 variables across server, agent, and connector config).
|
||||
## Examples
|
||||
|
||||
### Server — Core
|
||||
Pick the scenario closest to your setup and have it running in 2 minutes:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||
| `CERTCTL_SERVER_PORT` | `8080` | Server listen port (1–65535) |
|
||||
| `CERTCTL_DATABASE_URL` | `postgres://localhost/certctl` | PostgreSQL connection string (required) |
|
||||
| `CERTCTL_DATABASE_MAX_CONNS` | `25` | PostgreSQL connection pool size (min 1) |
|
||||
| `CERTCTL_DATABASE_MIGRATIONS_PATH` | `./migrations` | Path to migration SQL files |
|
||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max HTTP request body in bytes (default 1MB) |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_LOG_FORMAT` | `json` | Log format: `json` (structured) or `text` (human-readable) |
|
||||
| Example | Scenario |
|
||||
|---------|----------|
|
||||
| [`examples/acme-nginx/`](examples/acme-nginx/) | Let's Encrypt + NGINX, HTTP-01 challenges |
|
||||
| [`examples/acme-wildcard-dns01/`](examples/acme-wildcard-dns01/) | Wildcard certs via DNS-01 (Cloudflare hook included) |
|
||||
| [`examples/private-ca-traefik/`](examples/private-ca-traefik/) | Local CA (self-signed or sub-CA) + Traefik file provider |
|
||||
| [`examples/step-ca-haproxy/`](examples/step-ca-haproxy/) | Smallstep step-ca + HAProxy combined PEM |
|
||||
| [`examples/multi-issuer/`](examples/multi-issuer/) | ACME for public + Local CA for internal, one dashboard |
|
||||
|
||||
### Server — Auth, CORS, Rate Limiting
|
||||
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `jwt`, or `none` (demo only) |
|
||||
| `CERTCTL_AUTH_SECRET` | — | Required for `api-key` and `jwt` auth types |
|
||||
| `CERTCTL_CORS_ORIGINS` | *(empty = deny all)* | Comma-separated allowed origins, or `*` for dev |
|
||||
| `CERTCTL_RATE_LIMIT_ENABLED` | `true` | Enable token bucket rate limiting |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `50` | Requests per second per client |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `100` | Max burst size |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo only) |
|
||||
## Verifying a release
|
||||
|
||||
### Server — Scheduler
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL` | `1h` | How often to check expiring certs (min 1m) |
|
||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often to process pending jobs (min 1s) |
|
||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | Agent heartbeat check frequency (min 1s) |
|
||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | Notification send frequency (min 1s) |
|
||||
|
||||
### Server — Sub-CA Mode
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_CA_CERT_PATH` | — | PEM-encoded CA certificate for sub-CA mode |
|
||||
| `CERTCTL_CA_KEY_PATH` | — | PEM-encoded CA private key (RSA, ECDSA, PKCS#8) |
|
||||
|
||||
### Server — Feature Flags
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST enrollment endpoints |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||
| `CERTCTL_EST_PROFILE_ID` | — | Constrain EST to a specific certificate profile |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side TLS network scanning |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often scheduled scans run |
|
||||
| `CERTCTL_VERIFY_DEPLOYMENT` | `true` | TLS verification after certificate deployment |
|
||||
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||
| `CERTCTL_VERIFY_DELAY` | `2s` | Delay before verification probe |
|
||||
|
||||
### Server — Notification Connectors
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL (enables Slack) |
|
||||
| `CERTCTL_SLACK_CHANNEL` | — | Override default webhook channel |
|
||||
| `CERTCTL_SLACK_USERNAME` | `certctl` | Bot display name |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams webhook URL (enables Teams) |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 key (enables PagerDuty) |
|
||||
| `CERTCTL_PAGERDUTY_SEVERITY` | `warning` | Event severity: `info`, `warning`, `error`, `critical` |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` | — | OpsGenie Alert API key (enables OpsGenie) |
|
||||
| `CERTCTL_OPSGENIE_PRIORITY` | `P3` | Alert priority: `P1`–`P5` |
|
||||
|
||||
### Agent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | `http://localhost:8080` | Control plane URL |
|
||||
| `CERTCTL_API_KEY` | — | Agent API key for authentication |
|
||||
| `CERTCTL_AGENT_ID` | — | Registered agent ID (required) |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Private key storage directory (0600 perms) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | — | Directories to scan for existing certs (comma-separated) |
|
||||
|
||||
Docker Compose overrides for the demo stack are in `deploy/docker-compose.yml`.
|
||||
Every `v*` tag publishes signed, attested artefacts (Cosign keyless OIDC + SLSA Level 3 provenance + SPDX-JSON SBOMs). For the verification procedure, see [`docs/reference/release-verification.md`](docs/reference/release-verification.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev tools (golangci-lint, migrate CLI, air)
|
||||
make install-tools
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run tests with race detection (same as CI)
|
||||
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/...
|
||||
|
||||
# Run with coverage
|
||||
make test-coverage
|
||||
|
||||
# Lint (runs golangci-lint with project config)
|
||||
make lint
|
||||
|
||||
# Vulnerability scan
|
||||
govulncheck ./...
|
||||
|
||||
# Format
|
||||
make fmt
|
||||
make build # Build server + agent binaries
|
||||
make test # Run tests
|
||||
make lint # golangci-lint (govet + staticcheck + contextcheck + unused)
|
||||
govulncheck ./... # Vulnerability scan
|
||||
make docker-up # Start Docker Compose stack
|
||||
```
|
||||
|
||||
### CI Pipeline
|
||||
|
||||
Every push and PR runs: `go vet`, `go test -race` (race detection), `golangci-lint` (11 linters including gosec and bodyclose), `govulncheck` (dependency CVE scanning), and per-layer coverage thresholds (service 60%, handler 60%, domain 40%, middleware 50%). Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build. See `.github/workflows/ci.yml` for details.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
make docker-up # Start stack (server + postgres + agent)
|
||||
make docker-down # Stop stack
|
||||
make docker-logs-server # Server logs
|
||||
make docker-logs-agent # Agent logs
|
||||
make docker-clean # Stop + remove volumes
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Private Key Management
|
||||
- **Agent keygen mode (default)**: Agents generate ECDSA P-256 keys locally and store them with 0600 permissions in `CERTCTL_KEY_DIR` (default `/var/lib/certctl/keys`). Only the CSR (public key) is sent to the control plane. Private keys never leave agent infrastructure.
|
||||
- **Server keygen mode (demo only)**: Set `CERTCTL_KEYGEN_MODE=server` for development/demo with Local CA. The control plane generates RSA-2048 keys server-side. A log warning is emitted at startup.
|
||||
|
||||
### Authentication
|
||||
- Agent-to-server: API key (registered at agent creation)
|
||||
- API key and JWT auth types supported; `none` for demo/development
|
||||
- Auth type and secret configured via `CERTCTL_AUTH_TYPE` and `CERTCTL_AUTH_SECRET`
|
||||
|
||||
### CORS
|
||||
- **Deny-by-default**: Empty `CERTCTL_CORS_ORIGINS` blocks all cross-origin requests. Operators must explicitly list allowed origins (comma-separated) or set `*` for development.
|
||||
|
||||
### Input Validation
|
||||
- Shell command injection prevention on all connector scripts (strict character whitelist, no metacharacters)
|
||||
- RFC 1123 domain name validation, base64url ACME token validation
|
||||
- SSRF protection in network scanner (loopback, link-local, multicast, broadcast ranges filtered)
|
||||
|
||||
### Concurrency Safety
|
||||
- Scheduler loops protected by `sync/atomic.Bool` idempotency guards — duplicate ticks are skipped
|
||||
- Graceful shutdown waits up to 30 seconds for in-flight work before database close
|
||||
|
||||
### Audit Trail
|
||||
- Immutable append-only log in PostgreSQL (`audit_events` table)
|
||||
- Every lifecycle action attributed to an actor with timestamp and resource reference
|
||||
- No update or delete operations on audit records
|
||||
- Every API call recorded to audit trail with method, path, actor, SHA-256 body hash, response status, and latency
|
||||
|
||||
## API Overview
|
||||
|
||||
93 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Key Endpoints
|
||||
```
|
||||
# Certificate lifecycle
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
|
||||
# Agent operations
|
||||
POST /api/v1/agents/{id}/csr Submit CSR for issuance
|
||||
GET /api/v1/agents/{id}/work Poll for pending deployment jobs
|
||||
POST /api/v1/agents/{id}/discoveries Submit certificate discovery scan results
|
||||
|
||||
# Discovery & network scanning
|
||||
GET /api/v1/discovered-certificates List discovered certs (?agent_id, ?status)
|
||||
POST /api/v1/discovered-certificates/{id}/claim Link to managed cert
|
||||
POST /api/v1/network-scan-targets/{id}/scan Trigger immediate TLS scan
|
||||
|
||||
# Jobs & approval
|
||||
POST /api/v1/jobs/{id}/approve Approve interactive renewal
|
||||
POST /api/v1/jobs/{id}/reject Reject interactive renewal
|
||||
|
||||
# Post-deployment verification
|
||||
POST /api/v1/jobs/{id}/verify Submit TLS verification result
|
||||
GET /api/v1/jobs/{id}/verification Get verification status
|
||||
|
||||
# Observability
|
||||
GET /api/v1/metrics/prometheus Prometheus exposition format
|
||||
GET /api/v1/stats/summary Dashboard summary
|
||||
|
||||
# EST enrollment (RFC 7030)
|
||||
POST /.well-known/est/simpleenroll Device certificate enrollment
|
||||
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
|
||||
```
|
||||
|
||||
Full CRUD is available for certificates, agents, issuers, targets, teams, owners, policies, profiles, agent groups, notifications, and audit events. See the [OpenAPI spec](api/openapi.yaml) or [Feature Inventory](docs/features.md) for the complete endpoint reference.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/cli@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Certificate commands
|
||||
certctl-cli certs list # List all certificates
|
||||
certctl-cli certs get mc-api-prod # Get certificate details
|
||||
certctl-cli certs renew mc-api-prod # Trigger renewal
|
||||
certctl-cli certs revoke mc-api-prod --reason keyCompromise
|
||||
|
||||
# Agent and job commands
|
||||
certctl-cli agents list # List registered agents
|
||||
certctl-cli jobs list # List jobs
|
||||
certctl-cli jobs cancel job-123 # Cancel a pending job
|
||||
|
||||
# Operations
|
||||
certctl-cli status # Server health + summary stats
|
||||
certctl-cli import certs.pem # Bulk import from PEM file
|
||||
|
||||
# Output formats
|
||||
certctl-cli certs list --format json # JSON output (default: table)
|
||||
```
|
||||
|
||||
## MCP Server (AI Integration)
|
||||
|
||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
|
||||
|
||||
# Configure
|
||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
||||
export CERTCTL_API_KEY=your-api-key
|
||||
|
||||
# Run (stdio transport — add to your AI client config)
|
||||
mcp-server
|
||||
```
|
||||
|
||||
**Claude Desktop** (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"certctl": {
|
||||
"command": "mcp-server",
|
||||
"env": {
|
||||
"CERTCTL_SERVER_URL": "http://localhost:8443",
|
||||
"CERTCTL_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0)
|
||||
Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR.
|
||||
|
||||
### V2: Operational Maturity
|
||||
|
||||
18 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
|
||||
**What shipped (all ✅):**
|
||||
|
||||
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing)
|
||||
- **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
|
||||
- **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
|
||||
- **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
|
||||
- **Discovery** — filesystem scanning (PEM/DER) + network TLS scanning (CIDR ranges), triage workflow (claim/dismiss), network scan target management
|
||||
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
||||
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
|
||||
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
|
||||
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
|
||||
- **Notifications** — Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
|
||||
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
|
||||
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
|
||||
|
||||
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match
|
||||
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based)
|
||||
|
||||
**Coming next:**
|
||||
|
||||
- **Certificate Export** (v2.1.x) — single-cert download in PFX/PKCS12, DER, and PEM formats
|
||||
- **S/MIME Support** (v2.2.x) — profile EKU constraints for S/MIME (emailProtection), code signing, and custom EKUs
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations.
|
||||
|
||||
### V4+: Cloud, Scale & Passive Discovery
|
||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Vault PKI, Google CAS, EJBCA), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||
CI runs `go vet`, `go test -race`, `golangci-lint`, `govulncheck`, and per-package coverage thresholds (service 70%, handler 75%, crypto 88%, auth packages 85-95%) on every push. The thresholds-as-data file is `.github/coverage-thresholds.yml`; lowering a floor requires corresponding test work, not a config flip. Frontend CI runs TypeScript type checking, Vitest tests, and Vite production build.
|
||||
|
||||
## License
|
||||
|
||||
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties.
|
||||
Licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not use certctl's certificate management functionality as part of a commercial certificate-management offering to third parties. See the LICENSE file for the full Additional Use Grant.
|
||||
|
||||
For licensing inquiries: certctl@proton.me
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
go list -m all | wc -l # total module count (direct + transitive)
|
||||
go mod why <path> # explain why a module is pulled in
|
||||
govulncheck ./... # vulnerability scan (CI runs this on every commit)
|
||||
```
|
||||
|
||||
The release-time SBOM is published as an SPDX-JSON file alongside each release artifact.
|
||||
|
||||
---
|
||||
|
||||
If certctl solves a problem you have, [star the repo](https://github.com/certctl-io/certctl) to help others find it. Questions, bugs, or feature requests: [open an issue](https://github.com/certctl-io/certctl/issues).
|
||||
|
||||
161
THIRD_PARTY_NOTICES.md
Normal file
161
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Third-Party Notices
|
||||
|
||||
certctl is distributed under the Business Source License 1.1
|
||||
(see [LICENSE](LICENSE)). The binaries built from this source link
|
||||
third-party Go and JavaScript libraries listed below; certctl LLC
|
||||
acknowledges each library's authors and reproduces their copyright
|
||||
and license terms here in compliance with each library's license.
|
||||
|
||||
Full license text for each library lives in that library's upstream
|
||||
repository. The license type is provided per-row; for the canonical
|
||||
notice, refer to the upstream source.
|
||||
|
||||
- **Last reviewed:** 2026-05-13
|
||||
- **Holder:** certctl LLC
|
||||
- **License:** BSL 1.1 (Apache 2.0 effective March 14, 2076)
|
||||
|
||||
## Go Modules (binary-link dependencies)
|
||||
|
||||
Generated by walking `go list -deps ./...` against the certctl
|
||||
server, agent, CLI, and MCP-server build paths. Excludes the Go
|
||||
standard library and the certctl-io/certctl module itself.
|
||||
|
||||
**Count:** see commit; generate via `go list -deps -f '{{if .Module}}{{.Module.Path}} {{.Module.Version}}{{end}}' ./...`
|
||||
|
||||
| Module | Version | License |
|
||||
|---|---|---|
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/azcore` | v1.20.0 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/azidentity` | v1.13.1 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/internal` | v1.11.2 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates` | v1.4.0 | MIT |
|
||||
| `github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal` | v1.2.0 | MIT |
|
||||
| `github.com/Azure/go-ntlmssp` | v0.1.1 | MIT |
|
||||
| `github.com/AzureAD/microsoft-authentication-library-for-go` | v1.6.0 | MIT |
|
||||
| `github.com/ChrisTrenkamp/goxpath` | v0.0.0-20210404020558-97928f7e12b6 | MIT |
|
||||
| `github.com/aws/aws-sdk-go-v2` | v1.41.7 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/config` | v1.32.17 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/credentials` | v1.19.16 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` | v1.18.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/configsources` | v1.4.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/endpoints/v2` | v2.7.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/v4a` | v1.4.24 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/acm` | v1.38.3 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/acmpca` | v1.46.14 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding` | v1.13.9 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/presigned-url` | v1.13.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/signin` | v1.0.11 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/sso` | v1.30.17 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/ssooidc` | v1.35.21 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/sts` | v1.42.1 | Apache-2.0 |
|
||||
| `github.com/aws/smithy-go` | v1.25.1 | Apache-2.0 |
|
||||
| `github.com/bodgit/ntlmssp` | v0.0.0-20240506230425-31973bb52d9b | BSD-2/3-Clause |
|
||||
| `github.com/bodgit/windows` | v1.0.1 | BSD-2/3-Clause |
|
||||
| `github.com/coreos/go-oidc/v3` | v3.18.0 | Apache-2.0 |
|
||||
| `github.com/go-jose/go-jose/v4` | v4.1.4 | Apache-2.0 |
|
||||
| `github.com/go-logr/logr` | v1.4.3 | Apache-2.0 |
|
||||
| `github.com/gofrs/uuid` | v4.4.0+incompatible | MIT |
|
||||
| `github.com/golang-jwt/jwt/v5` | v5.3.0 | MIT |
|
||||
| `github.com/google/jsonschema-go` | v0.4.2 | MIT |
|
||||
| `github.com/google/uuid` | v1.6.0 | BSD-2/3-Clause |
|
||||
| `github.com/hashicorp/go-cleanhttp` | v0.5.2 | MPL-2.0 |
|
||||
| `github.com/hashicorp/go-uuid` | v1.0.3 | MPL-2.0 |
|
||||
| `github.com/jcmturner/aescts/v2` | v2.0.0 | Apache-2.0 |
|
||||
| `github.com/jcmturner/dnsutils/v2` | v2.0.0 | Apache-2.0 |
|
||||
| `github.com/jcmturner/gofork` | v1.7.6 | BSD-2/3-Clause |
|
||||
| `github.com/jcmturner/goidentity/v6` | v6.0.1 | Apache-2.0 |
|
||||
| `github.com/jcmturner/gokrb5/v8` | v8.4.4 | Apache-2.0 |
|
||||
| `github.com/jcmturner/rpc/v2` | v2.0.3 | Apache-2.0 |
|
||||
| `github.com/kr/fs` | v0.1.0 | BSD-2/3-Clause |
|
||||
| `github.com/kylelemons/godebug` | v1.1.0 | Apache-2.0 |
|
||||
| `github.com/lib/pq` | v1.10.9 | MIT |
|
||||
| `github.com/masterzen/simplexml` | v0.0.0-20190410153822-31eea3082786 | Apache-2.0 |
|
||||
| `github.com/masterzen/winrm` | v0.0.0-20250927112105-5f8e6c707321 | Apache-2.0 |
|
||||
| `github.com/modelcontextprotocol/go-sdk` | v1.4.1 | Apache-2.0 |
|
||||
| `github.com/pkg/browser` | v0.0.0-20240102092130-5ac0b6a4141c | BSD-2/3-Clause |
|
||||
| `github.com/pkg/sftp` | v1.13.10 | BSD-2/3-Clause |
|
||||
| `github.com/segmentio/asm` | v1.1.3 | MIT |
|
||||
| `github.com/segmentio/encoding` | v0.5.4 | MIT |
|
||||
| `github.com/tidwall/transform` | v0.0.0-20201103190739-32f242e2dbde | ISC |
|
||||
| `github.com/yosida95/uritemplate/v3` | v3.0.2 | BSD-2/3-Clause |
|
||||
| `golang.org/x/crypto` | v0.50.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/net` | v0.53.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/oauth2` | v0.36.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/sync` | v0.20.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/sys` | v0.43.0 | BSD-2/3-Clause |
|
||||
| `golang.org/x/text` | v0.36.0 | BSD-2/3-Clause |
|
||||
| `software.sslmate.com/src/go-pkcs12` | v0.7.0 | BSD-2/3-Clause |
|
||||
|
||||
## JavaScript Packages (production transitive closure)
|
||||
|
||||
Generated by walking the `dependencies` graph from `web/package.json`
|
||||
through `node_modules/`. Excludes devDependencies (Vitest, Playwright,
|
||||
Vite, etc.) since they don't ship in the distributed frontend bundle.
|
||||
|
||||
| Package | Version | License |
|
||||
|---|---|---|
|
||||
| `@reduxjs/toolkit` | 2.11.2 | MIT |
|
||||
| `@remix-run/router` | 1.23.2 | MIT |
|
||||
| `@standard-schema/spec` | 1.1.0 | MIT |
|
||||
| `@standard-schema/utils` | 0.3.0 | MIT |
|
||||
| `@tanstack/query-core` | 5.90.20 | MIT |
|
||||
| `@tanstack/react-query` | 5.90.21 | MIT |
|
||||
| `@types/d3-array` | 3.2.2 | MIT |
|
||||
| `@types/d3-color` | 3.1.3 | MIT |
|
||||
| `@types/d3-ease` | 3.0.2 | MIT |
|
||||
| `@types/d3-interpolate` | 3.0.4 | MIT |
|
||||
| `@types/d3-path` | 3.1.1 | MIT |
|
||||
| `@types/d3-scale` | 4.0.9 | MIT |
|
||||
| `@types/d3-shape` | 3.1.8 | MIT |
|
||||
| `@types/d3-time` | 3.0.4 | MIT |
|
||||
| `@types/d3-timer` | 3.0.2 | MIT |
|
||||
| `@types/use-sync-external-store` | 0.0.6 | MIT |
|
||||
| `clsx` | 2.1.1 | MIT |
|
||||
| `d3-array` | 3.2.4 | ISC |
|
||||
| `d3-color` | 3.1.0 | ISC |
|
||||
| `d3-ease` | 3.0.1 | BSD-3-Clause |
|
||||
| `d3-format` | 3.1.2 | ISC |
|
||||
| `d3-interpolate` | 3.0.1 | ISC |
|
||||
| `d3-path` | 3.1.0 | ISC |
|
||||
| `d3-scale` | 4.0.2 | ISC |
|
||||
| `d3-shape` | 3.2.0 | ISC |
|
||||
| `d3-time` | 3.1.0 | ISC |
|
||||
| `d3-time-format` | 4.1.0 | ISC |
|
||||
| `d3-timer` | 3.0.1 | ISC |
|
||||
| `decimal.js-light` | 2.5.1 | MIT |
|
||||
| `es-toolkit` | 1.45.1 | MIT |
|
||||
| `eventemitter3` | 5.0.4 | MIT |
|
||||
| `immer` | 10.2.0 | MIT |
|
||||
| `internmap` | 2.0.3 | ISC |
|
||||
| `js-tokens` | 4.0.0 | MIT |
|
||||
| `loose-envify` | 1.4.0 | MIT |
|
||||
| `react` | 18.3.1 | MIT |
|
||||
| `react-dom` | 18.3.1 | MIT |
|
||||
| `react-redux` | 9.2.0 | MIT |
|
||||
| `react-router` | 6.30.3 | MIT |
|
||||
| `react-router-dom` | 6.30.3 | MIT |
|
||||
| `recharts` | 3.8.0 | MIT |
|
||||
| `redux` | 5.0.1 | MIT |
|
||||
| `redux-thunk` | 3.1.0 | MIT |
|
||||
| `reselect` | 5.1.1 | MIT |
|
||||
| `scheduler` | 0.23.2 | MIT |
|
||||
| `tiny-invariant` | 1.3.3 | MIT |
|
||||
| `use-sync-external-store` | 1.6.0 | MIT |
|
||||
| `victory-vendor` | 37.3.6 | MIT AND ISC |
|
||||
|
||||
## Test-fixture-only dependencies
|
||||
|
||||
**Cisco libest.** The certctl integration test suite exercises the EST
|
||||
(RFC 7030) endpoints against Cisco's libest reference client. libest
|
||||
runs as a sidecar container (`certctl-test-libest`) only when the
|
||||
`est-e2e` Docker Compose profile is active — it is **not** vendored
|
||||
into the certctl source tree and **not** linked into any distributed
|
||||
release artifact (server, agent, CLI, MCP-server, container images,
|
||||
or release tarballs). For libest's own license terms, see
|
||||
<https://github.com/cisco/libest>.
|
||||
|
||||
**f5-mock-icontrol.** The F5 deployment-target integration test
|
||||
ships a small Go program at `deploy/test/f5-mock-icontrol/main.go`
|
||||
under the same BSL 1.1 license as the rest of certctl. The compiled
|
||||
ELF was removed from the tracked tree in Phase 1 closure (commit
|
||||
eda3b48, 2026-05-13); it now rebuilds via the Dockerfile's
|
||||
multi-stage build on demand.
|
||||
1
api/openapi-handler-exceptions-baseline.txt
Normal file
1
api/openapi-handler-exceptions-baseline.txt
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
201
api/openapi-handler-exceptions.yaml
Normal file
201
api/openapi-handler-exceptions.yaml
Normal file
@@ -0,0 +1,201 @@
|
||||
# Routes registered in internal/api/router/router.go that are intentionally
|
||||
# NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification
|
||||
# AND a required `category:` field (added in Phase 13 Sprint 13.1,
|
||||
# 2026-05-14, architecture diligence audit ARCH-H1).
|
||||
#
|
||||
# Adding a new entry requires PR-time review.
|
||||
#
|
||||
# OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here.
|
||||
# This list is for protocol-shaped (SCEP/ACME/EST wire endpoints) and
|
||||
# operational (health, metrics, pprof) routes only.
|
||||
#
|
||||
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# The two-bucket contract (Phase 13 Sprint 13.1)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# category: wire-protocol
|
||||
# The route's wire shape is dictated by an IETF RFC (SCEP RFC 8894,
|
||||
# ACME RFC 8555, ACME ARI RFC 9773, EST RFC 7030) or it's a
|
||||
# sibling/shorthand variant of such a route (same wire semantics,
|
||||
# different cosmetic path — e.g. trailing-slash forms, default-
|
||||
# profile shorthands). Documenting these as REST operations in
|
||||
# openapi.yaml would duplicate the RFC with no information gain;
|
||||
# the canonical operator references live in docs/acme-server.md +
|
||||
# docs/operator/scep.md + docs/operator/est.md. These entries
|
||||
# NEVER burn down — they're protocol contracts, not gaps.
|
||||
#
|
||||
# category: rest-deferred
|
||||
# The route is REST-shaped (resource CRUD, JSON request/response,
|
||||
# RBAC-gated) but its OpenAPI operation was deferred when the
|
||||
# handler shipped. These MUST monotonically decrease to zero.
|
||||
# Phase 13 Sprints 13.4-13.6 author the OpenAPI ops + delete the
|
||||
# corresponding exception entries; the
|
||||
# openapi-rest-deferred-monotonic.sh CI guard fails any PR that
|
||||
# grows the rest-deferred bucket vs the checked-in baseline at
|
||||
# api/openapi-handler-exceptions-baseline.txt.
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Phase 13 Sprint 13.1 categorization (2026-05-14)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Current split, re-derived by the parity script's bucket-reporting
|
||||
# subcommand (post-Sprint-13.6 / 2026-05-14):
|
||||
#
|
||||
# total entries: 36
|
||||
# wire-protocol: 36
|
||||
# rest-deferred: 0 ← THE FLOOR — ARCH-H1 substantive close
|
||||
#
|
||||
# Burn-down progress:
|
||||
#
|
||||
# Sprint 13.4 SHIPPED — 28 - 13 = 15 (auth/sessions cluster 3 ops +
|
||||
# auth/oidc CRUD + JWKS + test + refresh
|
||||
# + group-mappings cluster, 10 ops)
|
||||
# Sprint 13.5 SHIPPED — 15 - 8 = 7 (auth/breakglass admin 4 ops +
|
||||
# auth/users 3 ops + auth/runtime-config
|
||||
# 1 op, 8 ops total)
|
||||
# Sprint 13.6 SHIPPED — 7 - 7 = 0 (audit/export 1 op + demo-
|
||||
# residual/cleanup 1 op + auth/logout 1 op +
|
||||
# auth/breakglass/login 1 op + 3 OIDC
|
||||
# browser-flow endpoints, 7 ops total)
|
||||
#
|
||||
# Sprint 13.7 next tightens the parity-script's rest-deferred floor
|
||||
# from monotonic-decrease to a hard zero-exact pin. After that, any
|
||||
# new REST route MUST land with an OpenAPI op or fail CI — no escape
|
||||
# hatch via `category: rest-deferred`.
|
||||
#
|
||||
# Each authored OpenAPI op needs request/response schemas (not
|
||||
# placeholders) so the generated client at web/orval.config.ts emits
|
||||
# typed signatures. When an op lands, delete the corresponding entry
|
||||
# below + bump api/openapi-handler-exceptions-baseline.txt downward.
|
||||
|
||||
documented_exceptions:
|
||||
- route: "GET /scep"
|
||||
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource."
|
||||
category: wire-protocol
|
||||
- route: "POST /scep"
|
||||
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource."
|
||||
category: wire-protocol
|
||||
- route: "GET /scep/"
|
||||
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
||||
category: wire-protocol
|
||||
- route: "POST /scep/"
|
||||
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
||||
category: wire-protocol
|
||||
- route: "GET /scep-mtls"
|
||||
why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant."
|
||||
category: wire-protocol
|
||||
- route: "POST /scep-mtls"
|
||||
why: "SCEP-mTLS sibling endpoint, POST variant."
|
||||
category: wire-protocol
|
||||
- route: "GET /scep-mtls/"
|
||||
why: "SCEP-mTLS sibling endpoint, trailing-slash variant."
|
||||
category: wire-protocol
|
||||
- route: "POST /scep-mtls/"
|
||||
why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant."
|
||||
category: wire-protocol
|
||||
|
||||
# ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface.
|
||||
# Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose
|
||||
# semantics are dictated by the RFC, not by an OpenAPI schema.
|
||||
# Documenting every endpoint in openapi.yaml would duplicate
|
||||
# RFC 8555 §7.1 + §7.2 + §7.3 with no information gain. The
|
||||
# canonical operator-facing reference is docs/acme-server.md.
|
||||
# Phases 2-4 will extend this list as new-order, finalize, authz,
|
||||
# challenge, cert, key-change, revoke-cert, renewal-info routes land.
|
||||
- route: "GET /acme/profile/{id}/directory"
|
||||
why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "HEAD /acme/profile/{id}/new-nonce"
|
||||
why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "GET /acme/profile/{id}/new-nonce"
|
||||
why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/new-account"
|
||||
why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/account/{acc_id}"
|
||||
why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "GET /acme/directory"
|
||||
why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set."
|
||||
category: wire-protocol
|
||||
- route: "HEAD /acme/new-nonce"
|
||||
why: "ACME server default-profile shorthand for new-nonce HEAD."
|
||||
category: wire-protocol
|
||||
- route: "GET /acme/new-nonce"
|
||||
why: "ACME server default-profile shorthand for new-nonce GET."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/new-account"
|
||||
why: "ACME server default-profile shorthand for new-account."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/account/{acc_id}"
|
||||
why: "ACME server default-profile shorthand for account update + deactivation."
|
||||
category: wire-protocol
|
||||
|
||||
# Phase 2 — orders + finalize + authz + cert.
|
||||
- route: "POST /acme/profile/{id}/new-order"
|
||||
why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/order/{ord_id}"
|
||||
why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/order/{ord_id}/finalize"
|
||||
why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/authz/{authz_id}"
|
||||
why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/challenge/{chall_id}"
|
||||
why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/cert/{cert_id}"
|
||||
why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/new-order"
|
||||
why: "Phase 2 default-profile shorthand for new-order."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/order/{ord_id}"
|
||||
why: "Phase 2 default-profile shorthand for order POST-as-GET."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/order/{ord_id}/finalize"
|
||||
why: "Phase 2 default-profile shorthand for finalize."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/authz/{authz_id}"
|
||||
why: "Phase 2 default-profile shorthand for authz POST-as-GET."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/challenge/{chall_id}"
|
||||
why: "Phase 3 default-profile shorthand for challenge response."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/cert/{cert_id}"
|
||||
why: "Phase 2 default-profile shorthand for cert download."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/key-change"
|
||||
why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/profile/{id}/revoke-cert"
|
||||
why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "GET /acme/profile/{id}/renewal-info/{cert_id}"
|
||||
why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/key-change"
|
||||
why: "Phase 4 default-profile shorthand for key rollover."
|
||||
category: wire-protocol
|
||||
- route: "POST /acme/revoke-cert"
|
||||
why: "Phase 4 default-profile shorthand for revoke-cert."
|
||||
category: wire-protocol
|
||||
- route: "GET /acme/renewal-info/{cert_id}"
|
||||
why: "Phase 4 default-profile shorthand for ARI."
|
||||
category: wire-protocol
|
||||
|
||||
# =============================================================================
|
||||
# Auth Bundle 2 + audit-2026-05-10/11 fix bundle — REST endpoints not yet
|
||||
# represented in api/openapi.yaml. These are operator-facing REST endpoints
|
||||
# (not protocol-shaped); the OpenAPI surface is scheduled to land pre-v2.2.0
|
||||
# alongside the GUI E2E coverage push. Documented here so the parity guard
|
||||
# stays green for the v2.1.0 release tag. Threat model + handler contracts
|
||||
# live in docs/operator/{rbac.md,auth-threat-model.md,oidc-runbooks/*}.
|
||||
# =============================================================================
|
||||
4716
api/openapi.yaml
4716
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
1716
cmd/agent/agent_test.go
Normal file
1716
cmd/agent/agent_test.go
Normal file
File diff suppressed because it is too large
Load Diff
458
cmd/agent/deploy.go
Normal file
458
cmd/agent/deploy.go
Normal file
@@ -0,0 +1,458 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/connector/target"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/apache"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/awsacm"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/azurekv"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/caddy"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/envoy"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/f5"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/haproxy"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/iis"
|
||||
jks "github.com/certctl-io/certctl/internal/connector/target/javakeystore"
|
||||
k8s "github.com/certctl-io/certctl/internal/connector/target/k8ssecret"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/nginx"
|
||||
pf "github.com/certctl-io/certctl/internal/connector/target/postfix"
|
||||
sshconn "github.com/certctl-io/certctl/internal/connector/target/ssh"
|
||||
"github.com/certctl-io/certctl/internal/connector/target/traefik"
|
||||
wcs "github.com/certctl-io/certctl/internal/connector/target/wincertstore"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 12 (2026-05-14): extracted from
|
||||
// cmd/agent/main.go via the Option B sibling-file pattern.
|
||||
//
|
||||
// This file holds the DEPLOYMENT executor + the target connector
|
||||
// factory + the deploy-only helpers:
|
||||
//
|
||||
// - executeDeploymentJob: handles Pending deployment jobs by
|
||||
// fetching the cert PEM from the control plane, loading the
|
||||
// locally-held private key (in agent keygen mode), instantiating
|
||||
// the appropriate target connector via createTargetConnector,
|
||||
// calling DeployCertificate on it, and reporting Completed or
|
||||
// Failed back to the control plane.
|
||||
// - createTargetConnector: the big switch over target_type that
|
||||
// instantiates one of 14 target connectors (apache / awsacm /
|
||||
// azurekv / caddy / envoy / f5 / haproxy / iis / javakeystore /
|
||||
// k8ssecret / nginx / postfix / ssh / traefik / wincertstore).
|
||||
// Context is threaded into SDK-driven connectors (AWSACM,
|
||||
// AzureKeyVault) so credential resolution honors caller
|
||||
// cancellation per the contextcheck linter — see CI commit
|
||||
// 502823d.
|
||||
// - splitPEMChain: split a PEM chain into (first cert, rest).
|
||||
// - fetchCertificate: pull the PEM chain from
|
||||
// GET /api/v1/certificates/{certID}/version.
|
||||
//
|
||||
// All 14 target-connector imports were used ONLY by
|
||||
// createTargetConnector; moving the factory here also moved the
|
||||
// 14 connector imports out of main.go, leaving the surviving
|
||||
// cmd/agent/main.go with the minimal stdlib surface its lifecycle
|
||||
// + HTTP infrastructure needs.
|
||||
|
||||
// executeDeploymentJob executes a deployment job by fetching the certificate and deploying it
|
||||
// to the target system using the appropriate connector (NGINX, F5 BIG-IP, or IIS).
|
||||
//
|
||||
// For agent keygen mode, the private key is read from the local key store (keyDir/certID.key)
|
||||
// rather than fetched from the server. The deployment includes the locally-held key.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Report job as Running
|
||||
// 2. Fetch the certificate PEM from the control plane
|
||||
// 3. Load local private key if it exists (agent keygen mode)
|
||||
// 4. Instantiate the target connector based on target_type from the work response
|
||||
// 5. Call DeployCertificate on the connector
|
||||
// 6. Report job as Completed (or Failed)
|
||||
func (a *Agent) executeDeploymentJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing deployment job",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"target_type", job.TargetType)
|
||||
|
||||
// Report job as running
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Running", ""); err != nil {
|
||||
a.logger.Error("failed to report job running", "error", err)
|
||||
}
|
||||
|
||||
// Fetch the certificate from the control plane
|
||||
certPEM, err := a.fetchCertificate(ctx, job.CertificateID)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to fetch certificate",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("cert fetch failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("certificate fetched for deployment",
|
||||
"job_id", job.ID,
|
||||
"cert_length", len(certPEM))
|
||||
|
||||
// Split PEM into cert and chain (separated by double newline between PEM blocks)
|
||||
certOnly, chainPEM := splitPEMChain(certPEM)
|
||||
|
||||
// Check for locally-stored private key (agent keygen mode).
|
||||
//
|
||||
// SEC-002 closure (Sprint 1, 2026-05-16): safeAgentKeyPath validates
|
||||
// the certificate_id shape AND asserts the joined path is contained
|
||||
// within a.config.KeyDir. A crafted certificate_id (path traversal,
|
||||
// absolute path, NUL byte, Windows separators) fails closed before
|
||||
// any disk I/O. See cmd/agent/keymem.go for the helper.
|
||||
keyPath, kerr := safeAgentKeyPath(a.config.KeyDir, job.CertificateID)
|
||||
if kerr != nil {
|
||||
a.logger.Error("agent key path validation failed for deployment",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"error", kerr)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key path validation failed: %v", kerr)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
var keyPEM string
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to read local private key for deployment",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key read failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
keyPEM = string(keyData)
|
||||
a.logger.Info("loaded local private key for deployment",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath)
|
||||
|
||||
// Deploy to the target using the appropriate connector
|
||||
if job.TargetType != "" {
|
||||
connector, err := a.createTargetConnector(ctx, job.TargetType, job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create target connector",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("connector init failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Bundle 1 / RT-C1 closure (2026-05-12): defense in depth. The server
|
||||
// runs internal/connector/target/configcheck.Validate on the way IN
|
||||
// (Create/Update), and rejects shell metacharacters in command-bearing
|
||||
// fields. Re-run the connector's full ValidateConfig here on the way
|
||||
// OUT, before any DeployCertificate call. This catches (a) configs
|
||||
// that pre-date the server-side guard, (b) corruption/tampering of
|
||||
// the encrypted config blob, and (c) per-connector filesystem
|
||||
// invariants (cert dir exists, paths writable) that the server can't
|
||||
// check because the filesystem is on the agent host.
|
||||
if err := connector.ValidateConfig(ctx, job.TargetConfig); err != nil {
|
||||
a.logger.Error("connector config validation failed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("%s config validation failed: %v", job.TargetType, err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
deployReq := target.DeploymentRequest{
|
||||
CertPEM: certOnly,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
TargetConfig: job.TargetConfig,
|
||||
Metadata: map[string]string{
|
||||
"certificate_id": job.CertificateID,
|
||||
"job_id": job.ID,
|
||||
},
|
||||
}
|
||||
|
||||
// Phase 2 of the deploy-hardening I master bundle:
|
||||
// per-target deploy mutex. Acquire BEFORE
|
||||
// DeployCertificate so two concurrent renewals against
|
||||
// the same target ID serialize. The lock is held for the
|
||||
// full Deploy duration including PreCommit (validate),
|
||||
// PostCommit (reload), and post-deploy verify (Phases
|
||||
// 4-9). Released on every return path via defer.
|
||||
var targetID string
|
||||
if job.TargetID != nil {
|
||||
targetID = *job.TargetID
|
||||
}
|
||||
if mu := a.targetDeployMutex(targetID); mu != nil {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, deployReq)
|
||||
if err != nil {
|
||||
a.logger.Error("deployment failed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("deployment failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("target connector deployment completed",
|
||||
"job_id", job.ID,
|
||||
"target_type", job.TargetType,
|
||||
"success", result.Success,
|
||||
"message", result.Message)
|
||||
|
||||
// If verification is enabled, verify the deployment by probing the live TLS endpoint
|
||||
targetHost, targetPort, err := extractTargetHostAndPort(job.TargetConfig)
|
||||
if err != nil {
|
||||
a.logger.Warn("could not extract target host/port for verification",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
} else {
|
||||
a.verifyAndReportDeployment(ctx, job, targetHost, targetPort, certOnly)
|
||||
}
|
||||
} else {
|
||||
a.logger.Info("no target type specified, skipping connector invocation",
|
||||
"job_id", job.ID)
|
||||
}
|
||||
|
||||
// Report job as completed
|
||||
if err := a.reportJobStatus(ctx, job.ID, "Completed", ""); err != nil {
|
||||
a.logger.Error("failed to report job completed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("deployment job completed", "job_id", job.ID)
|
||||
}
|
||||
|
||||
// createTargetConnector instantiates the appropriate target connector based on type.
|
||||
// ctx is threaded into SDK-driven connectors (AWSACM, AzureKeyVault) so credential
|
||||
// resolution honors caller cancellation / deadlines instead of using a fresh
|
||||
// context.Background() (the contextcheck linter enforces this — the original Rank 5
|
||||
// implementation used Background() and tripped CI on commit 502823d).
|
||||
func (a *Agent) createTargetConnector(ctx context.Context, targetType string, configJSON json.RawMessage) (target.Connector, error) {
|
||||
switch targetType {
|
||||
case "NGINX":
|
||||
var cfg nginx.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid NGINX config: %w", err)
|
||||
}
|
||||
}
|
||||
return nginx.New(&cfg, a.logger), nil
|
||||
|
||||
case "Apache":
|
||||
var cfg apache.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Apache config: %w", err)
|
||||
}
|
||||
}
|
||||
return apache.New(&cfg, a.logger), nil
|
||||
|
||||
case "HAProxy":
|
||||
var cfg haproxy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid HAProxy config: %w", err)
|
||||
}
|
||||
}
|
||||
return haproxy.New(&cfg, a.logger), nil
|
||||
|
||||
case "F5":
|
||||
var cfg f5.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid F5 config: %w", err)
|
||||
}
|
||||
}
|
||||
conn, err := f5.New(&cfg, a.logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create F5 connector: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
case "IIS":
|
||||
var cfg iis.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid IIS config: %w", err)
|
||||
}
|
||||
}
|
||||
return iis.New(&cfg, a.logger)
|
||||
|
||||
case "Traefik":
|
||||
var cfg traefik.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Traefik config: %w", err)
|
||||
}
|
||||
}
|
||||
return traefik.New(&cfg, a.logger), nil
|
||||
|
||||
case "Caddy":
|
||||
var cfg caddy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Caddy config: %w", err)
|
||||
}
|
||||
}
|
||||
return caddy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Envoy":
|
||||
var cfg envoy.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Envoy config: %w", err)
|
||||
}
|
||||
}
|
||||
return envoy.New(&cfg, a.logger), nil
|
||||
|
||||
case "Postfix":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "postfix"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Postfix config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "Dovecot":
|
||||
var cfg pf.Config
|
||||
cfg.Mode = "dovecot"
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Dovecot config: %w", err)
|
||||
}
|
||||
}
|
||||
return pf.New(&cfg, a.logger), nil
|
||||
|
||||
case "SSH":
|
||||
var cfg sshconn.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid SSH config: %w", err)
|
||||
}
|
||||
}
|
||||
return sshconn.New(&cfg, a.logger)
|
||||
|
||||
case "WinCertStore":
|
||||
var cfg wcs.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid WinCertStore config: %w", err)
|
||||
}
|
||||
}
|
||||
return wcs.New(&cfg, a.logger)
|
||||
|
||||
case "JavaKeystore":
|
||||
var cfg jks.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid JavaKeystore config: %w", err)
|
||||
}
|
||||
}
|
||||
return jks.New(&cfg, a.logger), nil
|
||||
|
||||
case "KubernetesSecrets":
|
||||
var cfg k8s.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid KubernetesSecrets config: %w", err)
|
||||
}
|
||||
}
|
||||
return k8s.New(&cfg, a.logger)
|
||||
|
||||
case "AWSACM":
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
// AWS Certificate Manager target — SDK-driven (no file I/O).
|
||||
// LoadDefaultConfig handles the standard AWS credential chain
|
||||
// (IRSA / EC2 instance profile / SSO / env vars) without any
|
||||
// long-lived creds in connector Config.
|
||||
var cfg awsacm.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid AWSACM config: %w", err)
|
||||
}
|
||||
}
|
||||
return awsacm.New(ctx, &cfg, a.logger)
|
||||
|
||||
case "AzureKeyVault":
|
||||
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
|
||||
// Azure Key Vault target — SDK-driven (no file I/O).
|
||||
// DefaultAzureCredential handles the standard Azure credential
|
||||
// chain (managed identity / workload identity / env vars / az
|
||||
// CLI fallback). Long-lived service-principal secrets are
|
||||
// supported but discouraged via the credential_mode config.
|
||||
var cfg azurekv.Config
|
||||
if len(configJSON) > 0 {
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid AzureKeyVault config: %w", err)
|
||||
}
|
||||
}
|
||||
return azurekv.New(ctx, &cfg, a.logger)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported target type: %s", targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// splitPEMChain splits a PEM chain into the first certificate (cert) and the rest (chain).
|
||||
// The control plane returns the full chain as a single string with PEM blocks concatenated.
|
||||
func splitPEMChain(pemChain string) (string, string) {
|
||||
data := []byte(pemChain)
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil {
|
||||
return pemChain, ""
|
||||
}
|
||||
cert := string(pem.EncodeToMemory(block))
|
||||
|
||||
// Skip whitespace between cert and chain
|
||||
chain := strings.TrimSpace(string(rest))
|
||||
if chain == "" {
|
||||
return cert, ""
|
||||
}
|
||||
return cert, chain
|
||||
}
|
||||
|
||||
// fetchCertificate retrieves the certificate PEM chain from the control plane.
|
||||
// GET /api/v1/agents/{agentID}/certificates/{certID}
|
||||
func (a *Agent) fetchCertificate(ctx context.Context, certID string) (string, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/certificates/%s", a.config.AgentID, certID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var certResp struct {
|
||||
CertificatePEM string `json:"certificate_pem"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&certResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return certResp.CertificatePEM, nil
|
||||
}
|
||||
143
cmd/agent/deploy_mutex_test.go
Normal file
143
cmd/agent/deploy_mutex_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Phase 2 of the deploy-hardening I master bundle: per-target
|
||||
// deploy mutex serializes concurrent deploys to the same target
|
||||
// at the agent dispatch layer.
|
||||
|
||||
// TestAgent_ConcurrentDeploysToSameTarget_Serialize spawns N
|
||||
// goroutines acquiring the same target's mutex and asserts that
|
||||
// only one is in the critical section at a time. The "critical
|
||||
// section" is simulated as an atomic-counter increment + sleep +
|
||||
// decrement; if the lock works, max-in-flight is 1.
|
||||
func TestAgent_ConcurrentDeploysToSameTarget_Serialize(t *testing.T) {
|
||||
a := &Agent{}
|
||||
|
||||
const N = 10
|
||||
var inFlight, maxInFlight int32
|
||||
var done int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mu := a.targetDeployMutex("target-A")
|
||||
if mu == nil {
|
||||
t.Errorf("expected non-nil mutex for non-empty target id")
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n := atomic.AddInt32(&inFlight, 1)
|
||||
for {
|
||||
m := atomic.LoadInt32(&maxInFlight)
|
||||
if n <= m || atomic.CompareAndSwapInt32(&maxInFlight, m, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Brief work simulating the connector's Deploy.
|
||||
for j := 0; j < 1000; j++ {
|
||||
_ = j * j
|
||||
}
|
||||
atomic.AddInt32(&inFlight, -1)
|
||||
atomic.AddInt32(&done, 1)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if done != N {
|
||||
t.Errorf("done = %d, want %d (some goroutines didn't run)", done, N)
|
||||
}
|
||||
if maxInFlight > 1 {
|
||||
t.Errorf("max concurrent critical sections = %d, want 1 (mutex broken)", maxInFlight)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_DifferentTargetIDs_ParallelizeIndependently verifies
|
||||
// the per-target granularity: deploys to target-A and target-B
|
||||
// proceed in parallel (no global serialization point).
|
||||
func TestAgent_DifferentTargetIDs_ParallelizeIndependently(t *testing.T) {
|
||||
a := &Agent{}
|
||||
|
||||
muA := a.targetDeployMutex("target-A")
|
||||
muB := a.targetDeployMutex("target-B")
|
||||
|
||||
if muA == nil || muB == nil {
|
||||
t.Fatal("nil mutexes")
|
||||
}
|
||||
if muA == muB {
|
||||
t.Error("target-A and target-B share the same mutex (broken granularity)")
|
||||
}
|
||||
|
||||
// Acquire A; B should still be acquirable concurrently.
|
||||
muA.Lock()
|
||||
defer muA.Unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
muB.Lock()
|
||||
close(acquired)
|
||||
muB.Unlock()
|
||||
}()
|
||||
<-acquired // would deadlock if B were blocked by A
|
||||
}
|
||||
|
||||
// TestAgent_EmptyTargetID_ReturnsNilMutex pins the
|
||||
// "no-targetID = no-lock" contract. Defends against the
|
||||
// pathological case where every targetless deploy serializes on a
|
||||
// shared empty-string mutex.
|
||||
func TestAgent_EmptyTargetID_ReturnsNilMutex(t *testing.T) {
|
||||
a := &Agent{}
|
||||
if mu := a.targetDeployMutex(""); mu != nil {
|
||||
t.Errorf("empty targetID returned non-nil mutex: %p", mu)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_TargetMutex_IsStable verifies sync.Map LoadOrStore
|
||||
// semantics: same target ID returns the same *sync.Mutex pointer
|
||||
// across calls (so the lock actually works across goroutines that
|
||||
// look up the mutex independently).
|
||||
func TestAgent_TargetMutex_IsStable(t *testing.T) {
|
||||
a := &Agent{}
|
||||
mu1 := a.targetDeployMutex("target-X")
|
||||
mu2 := a.targetDeployMutex("target-X")
|
||||
if mu1 != mu2 {
|
||||
t.Errorf("targetMutex returned %p then %p for same id (stability broken)", mu1, mu2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgent_TargetMutex_RaceLookup pins the race-detector
|
||||
// invariant: many goroutines calling targetDeployMutex
|
||||
// concurrently for the same key all get the same pointer (no
|
||||
// torn read).
|
||||
func TestAgent_TargetMutex_RaceLookup(t *testing.T) {
|
||||
a := &Agent{}
|
||||
const N = 50
|
||||
results := make(chan *sync.Mutex, N)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < N; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- a.targetDeployMutex("target-shared")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
var first *sync.Mutex
|
||||
for got := range results {
|
||||
if first == nil {
|
||||
first = got
|
||||
continue
|
||||
}
|
||||
if got != first {
|
||||
t.Errorf("goroutine got different mutex (%p vs %p)", got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
275
cmd/agent/discovery.go
Normal file
275
cmd/agent/discovery.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 12 (2026-05-14): extracted from
|
||||
// cmd/agent/main.go via the Option B sibling-file pattern.
|
||||
//
|
||||
// This file holds the filesystem DISCOVERY scan — the agent's
|
||||
// outbound surface for reporting pre-existing certificates it
|
||||
// finds on disk back to the control plane (POST /api/v1/agents/
|
||||
// {id}/discoveries, a machine-to-machine flow NOT exposed via the
|
||||
// MCP surface per the comment in
|
||||
// internal/mcp/tools.go::RegisterTools):
|
||||
//
|
||||
// - runDiscoveryScan: walks each configured discovery directory,
|
||||
// dispatches each candidate file to parsePEMFile or parseDERFile
|
||||
// depending on extension, batches the parsed entries, and POSTs
|
||||
// them in one report.
|
||||
// - parsePEMFile / parseDERFile: extract every X.509 certificate
|
||||
// from a candidate file in either encoding.
|
||||
// - certToEntry: project a parsed *x509.Certificate into the
|
||||
// discoveredCertEntry shape the control plane expects.
|
||||
// - discoveredCertEntry struct + sha256Sum + certKeyInfo helpers
|
||||
// consumed only by the discovery path; co-locating them keeps
|
||||
// this file self-contained.
|
||||
|
||||
// runDiscoveryScan walks configured directories, parses certificate files, and reports
|
||||
// discovered certificates to the control plane.
|
||||
// Supports PEM and DER encoded X.509 certificates.
|
||||
func (a *Agent) runDiscoveryScan(ctx context.Context) {
|
||||
a.logger.Info("starting filesystem certificate discovery scan",
|
||||
"directories", a.config.DiscoveryDirs)
|
||||
|
||||
startTime := time.Now()
|
||||
var certs []discoveredCertEntry
|
||||
var scanErrors []string
|
||||
|
||||
for _, dir := range a.config.DiscoveryDirs {
|
||||
a.logger.Debug("scanning directory", "path", dir)
|
||||
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("walk error at %s: %v", path, err))
|
||||
return nil // continue walking
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files larger than 1MB (unlikely to be a certificate)
|
||||
if info.Size() > 1*1024*1024 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".pem", ".crt", ".cer", ".cert":
|
||||
found := a.parsePEMFile(path)
|
||||
certs = append(certs, found...)
|
||||
case ".der":
|
||||
if entry, err := a.parseDERFile(path); err == nil {
|
||||
certs = append(certs, entry)
|
||||
} else {
|
||||
a.logger.Debug("skipping non-cert DER file", "path", path, "error", err)
|
||||
}
|
||||
default:
|
||||
// Try PEM parsing for extensionless files or unknown extensions
|
||||
if ext == "" || ext == ".key" {
|
||||
return nil // skip key files and extensionless
|
||||
}
|
||||
found := a.parsePEMFile(path)
|
||||
if len(found) > 0 {
|
||||
certs = append(certs, found...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
scanErrors = append(scanErrors, fmt.Sprintf("failed to walk %s: %v", dir, err))
|
||||
}
|
||||
}
|
||||
|
||||
scanDuration := time.Since(startTime)
|
||||
a.logger.Info("discovery scan completed",
|
||||
"certificates_found", len(certs),
|
||||
"errors", len(scanErrors),
|
||||
"duration_ms", scanDuration.Milliseconds())
|
||||
|
||||
if len(certs) == 0 && len(scanErrors) == 0 {
|
||||
a.logger.Debug("no certificates found and no errors, skipping report")
|
||||
return
|
||||
}
|
||||
|
||||
// Build report payload
|
||||
entries := make([]map[string]interface{}, len(certs))
|
||||
for i, c := range certs {
|
||||
entries[i] = map[string]interface{}{
|
||||
"fingerprint_sha256": c.FingerprintSHA256,
|
||||
"common_name": c.CommonName,
|
||||
"sans": c.SANs,
|
||||
"serial_number": c.SerialNumber,
|
||||
"issuer_dn": c.IssuerDN,
|
||||
"subject_dn": c.SubjectDN,
|
||||
"not_before": c.NotBefore,
|
||||
"not_after": c.NotAfter,
|
||||
"key_algorithm": c.KeyAlgorithm,
|
||||
"key_size": c.KeySize,
|
||||
"is_ca": c.IsCA,
|
||||
"pem_data": c.PEMData,
|
||||
"source_path": c.SourcePath,
|
||||
"source_format": c.SourceFormat,
|
||||
}
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"agent_id": a.config.AgentID,
|
||||
"directories": a.config.DiscoveryDirs,
|
||||
"certificates": entries,
|
||||
"errors": scanErrors,
|
||||
"scan_duration_ms": int(scanDuration.Milliseconds()),
|
||||
}
|
||||
|
||||
// Submit to control plane
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/discoveries", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, path, report)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit discovery report", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("discovery report rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("discovery report submitted successfully",
|
||||
"certificates", len(certs),
|
||||
"errors", len(scanErrors))
|
||||
}
|
||||
|
||||
// discoveredCertEntry holds parsed certificate metadata for reporting.
|
||||
type discoveredCertEntry struct {
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256"`
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerDN string `json:"issuer_dn"`
|
||||
SubjectDN string `json:"subject_dn"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PEMData string `json:"pem_data"`
|
||||
SourcePath string `json:"source_path"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
}
|
||||
|
||||
// parsePEMFile reads a file and extracts all X.509 certificates from PEM blocks.
|
||||
func (a *Agent) parsePEMFile(path string) []discoveredCertEntry {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to read file", "path", path, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []discoveredCertEntry
|
||||
rest := data
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
a.logger.Debug("failed to parse certificate in PEM", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
pemStr := string(pem.EncodeToMemory(block))
|
||||
entries = append(entries, certToEntry(cert, path, "PEM", pemStr))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// parseDERFile reads a DER-encoded certificate file.
|
||||
func (a *Agent) parseDERFile(path string) (discoveredCertEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("read failed: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(data)
|
||||
if err != nil {
|
||||
return discoveredCertEntry{}, fmt.Errorf("parse failed: %w", err)
|
||||
}
|
||||
|
||||
// Convert to PEM for storage
|
||||
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: data}))
|
||||
return certToEntry(cert, path, "DER", pemStr), nil
|
||||
}
|
||||
|
||||
// certToEntry converts a parsed x509.Certificate into a discoveredCertEntry.
|
||||
func certToEntry(cert *x509.Certificate, path, format, pemData string) discoveredCertEntry {
|
||||
// Compute SHA-256 fingerprint
|
||||
fingerprint := fmt.Sprintf("%x", sha256Sum(cert.Raw))
|
||||
|
||||
// Determine key algorithm and size
|
||||
keyAlg, keySize := certKeyInfo(cert)
|
||||
|
||||
return discoveredCertEntry{
|
||||
FingerprintSHA256: fingerprint,
|
||||
CommonName: cert.Subject.CommonName,
|
||||
SANs: cert.DNSNames,
|
||||
SerialNumber: cert.SerialNumber.Text(16),
|
||||
IssuerDN: cert.Issuer.String(),
|
||||
SubjectDN: cert.Subject.String(),
|
||||
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
|
||||
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
|
||||
KeyAlgorithm: keyAlg,
|
||||
KeySize: keySize,
|
||||
IsCA: cert.IsCA,
|
||||
PEMData: pemData,
|
||||
SourcePath: path,
|
||||
SourceFormat: format,
|
||||
}
|
||||
}
|
||||
|
||||
// sha256Sum returns the SHA-256 hash of data.
|
||||
func sha256Sum(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// certKeyInfo extracts key algorithm name and size from a certificate.
|
||||
func certKeyInfo(cert *x509.Certificate) (string, int) {
|
||||
switch pub := cert.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return "ECDSA", pub.Curve.Params().BitSize
|
||||
case *rsa.PublicKey:
|
||||
return "RSA", pub.N.BitLen()
|
||||
default:
|
||||
switch cert.PublicKeyAlgorithm {
|
||||
case x509.Ed25519:
|
||||
return "Ed25519", 256
|
||||
default:
|
||||
return cert.PublicKeyAlgorithm.String(), 0
|
||||
}
|
||||
}
|
||||
}
|
||||
638
cmd/agent/dispatch_test.go
Normal file
638
cmd/agent/dispatch_test.go
Normal file
@@ -0,0 +1,638 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bundle 0.7-extended: cmd/agent dispatch coverage for executeCSRJob,
|
||||
// executeDeploymentJob, verifyAndReportDeployment, markRetired, getEnvDefault,
|
||||
// getEnvBoolDefault — the previously-uncovered code paths flagged by the
|
||||
// audit's per-function coverage report.
|
||||
//
|
||||
// Strategy: same httptest-backed pattern as the existing agent_test.go
|
||||
// (Heartbeat / PollWork tests). Each test:
|
||||
// - constructs a mock control-plane HTTP server (httptest.NewServer)
|
||||
// - configures an Agent pointing at that server via NewAgent
|
||||
// - invokes the function under test
|
||||
// - asserts on the requests the mock server received
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// executeCSRJob
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_ExecuteCSRJob_HappyPath(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var csrSubmitted atomic.Bool
|
||||
var statusUpdates atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/csr") && r.Method == http.MethodPost:
|
||||
csrSubmitted.Store(true)
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["csr_pem"] == "" || !strings.Contains(body["csr_pem"], "CERTIFICATE REQUEST") {
|
||||
t.Errorf("CSR submission missing PEM body: %v", body)
|
||||
}
|
||||
if body["certificate_id"] != "mc-test-cert" {
|
||||
t.Errorf("CSR submission missing certificate_id: %v", body)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
statusUpdates.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, err := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewAgent: %v", err)
|
||||
}
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-csr-1",
|
||||
CertificateID: "mc-test-cert",
|
||||
Type: "csr",
|
||||
CommonName: "test.example.com",
|
||||
SANs: []string{"test.example.com", "alt.example.com", "alice@example.com"},
|
||||
}
|
||||
|
||||
agent.executeCSRJob(context.Background(), job)
|
||||
|
||||
if !csrSubmitted.Load() {
|
||||
t.Errorf("expected CSR to be submitted to control plane")
|
||||
}
|
||||
|
||||
// Key file should exist with mode 0600
|
||||
keyPath := filepath.Join(keyDir, "mc-test-cert.key")
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected key file at %s: %v", keyPath, err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("expected key file mode 0600, got %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Read back and verify it parses as an ECDSA key
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read key file: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil || block.Type != "EC PRIVATE KEY" {
|
||||
t.Errorf("expected EC PRIVATE KEY PEM, got %v", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteCSRJob_EmptyCommonName_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-csr-empty-cn",
|
||||
CertificateID: "mc-empty-cn",
|
||||
Type: "csr",
|
||||
CommonName: "", // empty CN — should be rejected
|
||||
}
|
||||
|
||||
agent.executeCSRJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected last status 'Failed', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteCSRJob_CSRSubmissionRejected_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/csr") && r.Method == http.MethodPost:
|
||||
// Server rejects the CSR with 400 Bad Request
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"CSR validation failed"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-csr-rejected",
|
||||
CertificateID: "mc-rejected",
|
||||
Type: "csr",
|
||||
CommonName: "rejected.example.com",
|
||||
}
|
||||
|
||||
agent.executeCSRJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected last status 'Failed' after CSR rejection, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// executeDeploymentJob
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// generateTestCertAndKey builds an ephemeral self-signed cert + ECDSA P-256 key
|
||||
// for use as test fixture data in deployment tests.
|
||||
func generateTestCertAndKey(t *testing.T, cn string) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalECPrivateKey: %v", err)
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteDeploymentJob_FetchFails_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/certificates/") && r.Method == http.MethodGet:
|
||||
// Fail the certificate fetch
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-deploy-fetch-fail",
|
||||
CertificateID: "mc-fetch-fail",
|
||||
Type: "deployment",
|
||||
TargetType: "nginx",
|
||||
}
|
||||
|
||||
agent.executeDeploymentJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected status 'Failed' after fetch failure, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteDeploymentJob_KeyMissing_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
certPEM, _ := generateTestCertAndKey(t, "deploy-test.example.com")
|
||||
// Note: key file is intentionally NOT written to keyDir — exercises the
|
||||
// "local private key missing" failure path in executeDeploymentJob.
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/certificates/") && r.Method == http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": "mc-no-key",
|
||||
"common_name": "deploy-test.example.com",
|
||||
"pem_content": certPEM,
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-deploy-no-key",
|
||||
CertificateID: "mc-no-key",
|
||||
Type: "deployment",
|
||||
TargetType: "nginx",
|
||||
}
|
||||
|
||||
agent.executeDeploymentJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected status 'Failed' after key-missing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteDeploymentJob_UnknownTargetType_ReportsFailed(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, "deploy-test.example.com")
|
||||
keyPath := filepath.Join(keyDir, "mc-unknown-tgt.key")
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil {
|
||||
t.Fatalf("WriteFile key: %v", err)
|
||||
}
|
||||
|
||||
var lastStatus atomic.Value
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/certificates/") && r.Method == http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": "mc-unknown-tgt",
|
||||
"common_name": "deploy-test.example.com",
|
||||
"pem_content": certPEM,
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, "/status") && r.Method == http.MethodPost:
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
lastStatus.Store(body["status"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-unknown-target",
|
||||
CertificateID: "mc-unknown-tgt",
|
||||
Type: "deployment",
|
||||
TargetType: "frobnicator-9000", // unknown connector type
|
||||
}
|
||||
|
||||
agent.executeDeploymentJob(context.Background(), job)
|
||||
|
||||
if got := lastStatus.Load(); got != "Failed" {
|
||||
t.Errorf("expected status 'Failed' after unknown target type, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// markRetired — single-shot retirement signal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_MarkRetired_ClosesSignalOnce(t *testing.T) {
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: "http://example.invalid",
|
||||
APIKey: "k",
|
||||
AgentID: "a-retired-test",
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
// First mark — channel should close
|
||||
agent.markRetired("test-source-1", 410, "agent retired")
|
||||
select {
|
||||
case <-agent.retiredSignal:
|
||||
// expected — closed channel reads return zero immediately
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatalf("expected retiredSignal to be closed after markRetired")
|
||||
}
|
||||
|
||||
// Second mark — must not panic (sync.Once guards the close)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("second markRetired panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
agent.markRetired("test-source-2", 410, "agent retired again")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getEnvDefault / getEnvBoolDefault
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetEnvDefault_FallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_NONEXISTENT_VAR", "")
|
||||
got := getEnvDefault("TESTONLY_AGENT_NONEXISTENT_VAR", "fallback")
|
||||
if got != "fallback" {
|
||||
t.Errorf("expected fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvDefault_UsesEnvWhenSet(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_VAR", "from-env")
|
||||
got := getEnvDefault("TESTONLY_AGENT_VAR", "fallback")
|
||||
if got != "from-env" {
|
||||
t.Errorf("expected from-env, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_TruthyValues(t *testing.T) {
|
||||
for _, v := range []string{"1", "t", "true", "yes", "on", "TRUE", "True"} {
|
||||
t.Run(v, func(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", v)
|
||||
if !getEnvBoolDefault("TESTONLY_AGENT_BOOL", false) {
|
||||
t.Errorf("expected true for %q", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_FalsyValues(t *testing.T) {
|
||||
for _, v := range []string{"0", "f", "false", "no", "off"} {
|
||||
t.Run(v, func(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", v)
|
||||
if getEnvBoolDefault("TESTONLY_AGENT_BOOL", true) {
|
||||
t.Errorf("expected false for %q", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_UnrecognizedReturnsDefault(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", "frobnicate")
|
||||
if !getEnvBoolDefault("TESTONLY_AGENT_BOOL", true) {
|
||||
t.Errorf("expected default(true) for unrecognized value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBoolDefault_EmptyReturnsDefault(t *testing.T) {
|
||||
t.Setenv("TESTONLY_AGENT_BOOL", "")
|
||||
if !getEnvBoolDefault("TESTONLY_AGENT_BOOL", true) {
|
||||
t.Errorf("expected default(true) for empty value")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Run() — graceful shutdown via context cancellation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_Run_ContextCancelExitsCleanly(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/agents/a-run-test/heartbeat":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/api/v1/agents/a-run-test/work":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(WorkResponse{Jobs: []JobItem{}, Count: 0})
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-run-test",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, err := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewAgent: %v", err)
|
||||
}
|
||||
// Speed up tickers so the test exits in <500ms
|
||||
agent.heartbeatInterval = 50 * time.Millisecond
|
||||
agent.pollInterval = 50 * time.Millisecond
|
||||
agent.discoveryInterval = 24 * time.Hour
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- agent.Run(ctx)
|
||||
}()
|
||||
|
||||
// Let one heartbeat + poll fire, then cancel.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != context.Canceled {
|
||||
t.Errorf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Run did not exit within 2s after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// verifyAndReportDeployment
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAgent_VerifyAndReportDeployment_ProbeFailure_ReportsError(t *testing.T) {
|
||||
// Server with no TLS listener at the target — probe will fail.
|
||||
var verificationReported atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/verify") || strings.Contains(r.URL.Path, "/verification") {
|
||||
verificationReported.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
tgtID := "tgt-test"
|
||||
job := JobItem{
|
||||
ID: "j-verify",
|
||||
TargetID: &tgtID,
|
||||
}
|
||||
|
||||
// Probe a closed port — will fail quickly.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Should not panic; failure surfaces via reportVerificationResult.
|
||||
agent.verifyAndReportDeployment(ctx, job, "127.0.0.1", 1, "")
|
||||
// Test passes if no panic.
|
||||
}
|
||||
|
||||
func TestAgent_VerifyAndReportDeployment_NilTargetID_LogsAndReturns(t *testing.T) {
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: "http://example.invalid",
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-test",
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
job := JobItem{
|
||||
ID: "j-no-tgt",
|
||||
TargetID: nil, // nil target — should short-circuit cleanly
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Should not panic and should return without making any HTTP call.
|
||||
agent.verifyAndReportDeployment(ctx, job, "127.0.0.1", 1, "")
|
||||
}
|
||||
|
||||
func TestAgent_Run_RetiredSignalExitsWithErrAgentRetired(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
if err := os.Chmod(keyDir, 0700); err != nil {
|
||||
t.Fatalf("chmod keyDir: %v", err)
|
||||
}
|
||||
|
||||
// Server returns 410 Gone on heartbeat — the documented retirement signal.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/agents/a-retired/heartbeat":
|
||||
w.WriteHeader(http.StatusGone)
|
||||
_, _ = w.Write([]byte(`{"error":"agent retired"}`))
|
||||
case "/api/v1/agents/a-retired/work":
|
||||
w.WriteHeader(http.StatusGone)
|
||||
default:
|
||||
w.WriteHeader(http.StatusGone)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &AgentConfig{
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
AgentID: "a-retired",
|
||||
KeyDir: keyDir,
|
||||
}
|
||||
agent, _ := NewAgent(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
agent.heartbeatInterval = 30 * time.Millisecond
|
||||
agent.pollInterval = 30 * time.Millisecond
|
||||
agent.discoveryInterval = 24 * time.Hour
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- agent.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != ErrAgentRetired {
|
||||
t.Errorf("expected ErrAgentRetired, got %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("Run did not surface ErrAgentRetired within 2s")
|
||||
}
|
||||
}
|
||||
159
cmd/agent/keymem.go
Normal file
159
cmd/agent/keymem.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bundle-9 / Audit L-002 + L-003 (agent edition).
|
||||
//
|
||||
// The agent generates an ECDSA P-256 key locally and writes it to disk with
|
||||
// mode 0600 in a directory it expects to be 0700. The duplication of the
|
||||
// local-issuer helpers (instead of importing from internal/...) is deliberate:
|
||||
//
|
||||
// - cmd/agent is a separate binary with its own threat model (runs on every
|
||||
// deployment target, not just the control plane). Coupling it to
|
||||
// internal/connector/issuer/local would pull deployment-target footprint
|
||||
// into a connector that's only relevant on the server.
|
||||
// - The behavior is small and self-contained; copy-paste is cheaper than
|
||||
// a refactor that introduces an internal/keystore package.
|
||||
//
|
||||
// If a third call site emerges, lift these into internal/keystore.
|
||||
|
||||
// marshalAgentKeyAndZeroize marshals an ECDSA private key to DER and invokes
|
||||
// onDER with the bytes; the buffer is zeroized via builtin clear() after
|
||||
// onDER returns. Caller must NOT retain the slice.
|
||||
func marshalAgentKeyAndZeroize(priv *ecdsa.PrivateKey, onDER func([]byte) error) error {
|
||||
if priv == nil {
|
||||
return fmt.Errorf("marshalAgentKeyAndZeroize: nil private key")
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal EC private key: %w", err)
|
||||
}
|
||||
defer clear(der)
|
||||
return onDER(der)
|
||||
}
|
||||
|
||||
// SEC-002 closure (Sprint 1, 2026-05-16). The agent derives an on-disk
|
||||
// key path from job.CertificateID via filepath.Join. Pre-fix, a
|
||||
// crafted certificate_id ("../../etc/passwd", "/absolute/path",
|
||||
// "abc\x00d", "..\\Windows\\path") would drive arbitrary file
|
||||
// write/read on the agent host. The shape regex below mirrors the
|
||||
// server-side internal/validation.ValidateCertificateID gate — both
|
||||
// ends MUST hold for the load-bearing defense (the server can't be
|
||||
// trusted in isolation; a compromised control plane could deliver a
|
||||
// crafted job).
|
||||
//
|
||||
// agentCertIDPattern accepts ASCII letters, digits, ".", "_", "-",
|
||||
// bounded to 128 chars. Existing prefixed IDs (mc-..., cert-..., etc.)
|
||||
// satisfy this trivially. Deliberately rejects path separators (POSIX
|
||||
// and Windows), NUL byte, whitespace, control characters, and the
|
||||
// bare relative-path tokens "." and "..".
|
||||
var agentCertIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
|
||||
|
||||
// validateAgentCertID returns an error if id is not a well-formed
|
||||
// certificate identifier. Mirrors internal/validation.ValidateCertificateID
|
||||
// — the duplication is deliberate per the package-level comment
|
||||
// ("cmd/agent is a separate binary; copy-paste cheaper than lifting
|
||||
// a shared internal/keystore for a single shape check").
|
||||
func validateAgentCertID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("certificate_id is required")
|
||||
}
|
||||
if len(id) > 128 {
|
||||
return fmt.Errorf("certificate_id length %d exceeds 128", len(id))
|
||||
}
|
||||
if !agentCertIDPattern.MatchString(id) {
|
||||
return fmt.Errorf("certificate_id %q contains disallowed characters", id)
|
||||
}
|
||||
if id == "." || id == ".." {
|
||||
return fmt.Errorf("certificate_id %q is a relative-path token", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeAgentKeyPath returns the on-disk key path for the given
|
||||
// certificateID, after validating the ID shape AND asserting the
|
||||
// joined path is contained within keyDir. Containment is the
|
||||
// authoritative guard — even if validateAgentCertID is bypassed (e.g.
|
||||
// a future refactor removes it), the post-Clean rel-path check below
|
||||
// rejects any path that escapes keyDir.
|
||||
//
|
||||
// The two-leg defense:
|
||||
//
|
||||
// leg 1: shape check (validateAgentCertID) → cheap up-front fail
|
||||
// leg 2: containment check (filepath.Rel) → load-bearing guard
|
||||
//
|
||||
// Returns the joined path on success, or a non-nil error describing
|
||||
// the rejected vector.
|
||||
func safeAgentKeyPath(keyDir, certificateID string) (string, error) {
|
||||
if err := validateAgentCertID(certificateID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if keyDir == "" {
|
||||
return "", fmt.Errorf("safeAgentKeyPath: empty keyDir")
|
||||
}
|
||||
cleanDir, err := filepath.Abs(filepath.Clean(keyDir))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("safeAgentKeyPath: resolve keyDir: %w", err)
|
||||
}
|
||||
joined := filepath.Join(cleanDir, certificateID+".key")
|
||||
cleanJoined := filepath.Clean(joined)
|
||||
rel, err := filepath.Rel(cleanDir, cleanJoined)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("safeAgentKeyPath: rel(%q,%q): %w", cleanDir, cleanJoined, err)
|
||||
}
|
||||
// Reject any path that escapes the directory: a leading ".." in the
|
||||
// relative form means the joined path resolved outside keyDir.
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("safeAgentKeyPath: %q escapes keyDir %q (rel=%q)", certificateID, cleanDir, rel)
|
||||
}
|
||||
// Belt-and-suspenders: the rel form must also not contain a NUL.
|
||||
if strings.ContainsRune(rel, 0) {
|
||||
return "", fmt.Errorf("safeAgentKeyPath: NUL byte in computed path")
|
||||
}
|
||||
return cleanJoined, nil
|
||||
}
|
||||
|
||||
// ensureAgentKeyDirSecure creates dir (and ancestors) with mode 0700 or
|
||||
// asserts an existing dir is owner-only. If a pre-existing dir is more
|
||||
// permissive than 0700 we tighten it to 0700 (logging-free; this is a
|
||||
// startup-style invariant, not a per-request check).
|
||||
func ensureAgentKeyDirSecure(dir string) error {
|
||||
if dir == "" || dir == "." || dir == "/" {
|
||||
return fmt.Errorf("ensureAgentKeyDirSecure: refuse empty/root dir %q", dir)
|
||||
}
|
||||
clean := filepath.Clean(dir)
|
||||
info, err := os.Stat(clean)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
if mkErr := os.MkdirAll(clean, 0o700); mkErr != nil {
|
||||
return fmt.Errorf("create agent key dir %q: %w", clean, mkErr)
|
||||
}
|
||||
info, err = os.Stat(clean)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat newly-created agent key dir %q: %w", clean, err)
|
||||
}
|
||||
fallthrough
|
||||
case err == nil:
|
||||
mode := info.Mode().Perm()
|
||||
if mode == 0o700 || mode&0o077 == 0 {
|
||||
return nil
|
||||
}
|
||||
if chmodErr := os.Chmod(clean, 0o700); chmodErr != nil {
|
||||
return fmt.Errorf("tighten agent key dir %q from %#o to 0700: %w", clean, mode, chmodErr)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("stat agent key dir %q: %w", clean, err)
|
||||
}
|
||||
}
|
||||
828
cmd/agent/keymem_test.go
Normal file
828
cmd/agent/keymem_test.go
Normal file
@@ -0,0 +1,828 @@
|
||||
package main
|
||||
|
||||
// Bundle 0.7 (Coverage Audit Closure) — cmd/agent key-handling regression coverage.
|
||||
//
|
||||
// Closes finding C-008 (CRTCTL-COVAUDIT-2026-04-27-0034). The two functions in
|
||||
// keymem.go are the agent's defense-in-depth for ECDSA P-256 private-key
|
||||
// memory hygiene (Bundle 9 / Audit L-002 + L-003 — agent edition). They
|
||||
// shipped with regression-test coverage of 0.0% / 11.1% respectively. This
|
||||
// file pins:
|
||||
//
|
||||
// - marshalAgentKeyAndZeroize: rejects nil keys, propagates onDER errors,
|
||||
// and ZEROIZES the DER backing buffer after onDER returns regardless of
|
||||
// whether onDER errored. The zeroization invariant is verified observably
|
||||
// (capture the slice header inside onDER, then assert every byte is 0x00
|
||||
// after the function returns) — NOT just asserted in prose.
|
||||
//
|
||||
// - ensureAgentKeyDirSecure: refuses empty / "." / "/", creates missing
|
||||
// dirs with mode 0700 (incl. nested ancestors), accepts existing 0700
|
||||
// and any owner-only-no-write mode (mode&0o077 == 0), tightens any other
|
||||
// mode to 0700, normalizes paths via filepath.Clean, is idempotent, is
|
||||
// safe under concurrent invocation, and propagates the documented error
|
||||
// messages from os.Stat / os.MkdirAll / os.Chmod failures.
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustGenAgentECDSAKey(t *testing.T) *ecdsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// marshalAgentKeyAndZeroize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_HappyPath confirms onDER receives well-formed
|
||||
// DER bytes that the caller can use during the closure (e.g. to PEM-encode).
|
||||
func TestMarshalAgentKeyAndZeroize_HappyPath(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
called := false
|
||||
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
called = true
|
||||
if len(der) == 0 {
|
||||
t.Fatalf("der is empty inside onDER")
|
||||
}
|
||||
// First byte of an ECPrivateKey DER blob is the ASN.1 SEQUENCE tag 0x30.
|
||||
if der[0] != 0x30 {
|
||||
t.Errorf("expected DER to start with SEQUENCE tag 0x30, got %#x", der[0])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("onDER was never invoked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_NilKey confirms the early-return guard;
|
||||
// onDER must NOT be invoked when priv is nil.
|
||||
func TestMarshalAgentKeyAndZeroize_NilKey(t *testing.T) {
|
||||
called := false
|
||||
err := marshalAgentKeyAndZeroize(nil, func([]byte) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nil private key") {
|
||||
t.Errorf("expected error mentioning %q, got: %v", "nil private key", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("onDER must not be invoked when priv is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_OnDERReturnsError confirms upstream errors
|
||||
// are propagated verbatim via errors.Is.
|
||||
func TestMarshalAgentKeyAndZeroize_OnDERReturnsError(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
sentinel := errors.New("simulated downstream failure")
|
||||
got := marshalAgentKeyAndZeroize(k, func([]byte) error { return sentinel })
|
||||
if !errors.Is(got, sentinel) {
|
||||
t.Errorf("expected upstream sentinel via errors.Is; got: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_BackingBufferZeroizedAfterReturn is the
|
||||
// CRITICAL invariant test. It captures the slice header (NOT a deep copy)
|
||||
// inside onDER and re-inspects after the function returns. Because Go slices
|
||||
// share their backing array, the captured slice observes the zeroization
|
||||
// performed by `defer clear(der)` in marshalAgentKeyAndZeroize.
|
||||
//
|
||||
// A future refactor that drops the `defer clear(der)` would break this test
|
||||
// even if HappyPath / NilKey / OnDERReturnsError still pass.
|
||||
func TestMarshalAgentKeyAndZeroize_BackingBufferZeroizedAfterReturn(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
var captured []byte
|
||||
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
// SHARE the backing array — do NOT take a defensive copy.
|
||||
captured = der
|
||||
if len(der) == 0 {
|
||||
t.Fatal("der is empty inside onDER")
|
||||
}
|
||||
// Sanity check: while still inside onDER, the bytes are live
|
||||
// (defer clear has NOT run yet).
|
||||
nonZero := false
|
||||
for _, b := range der {
|
||||
if b != 0 {
|
||||
nonZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !nonZero {
|
||||
t.Fatal("DER is all-zero INSIDE onDER; that should be impossible (clear hasn't run yet)")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
if len(captured) == 0 {
|
||||
t.Fatal("captured slice is empty post-return")
|
||||
}
|
||||
// After return, defer clear(der) has run. The captured slice shares the
|
||||
// backing array, so every byte must read 0x00.
|
||||
for i, b := range captured {
|
||||
if b != 0 {
|
||||
t.Errorf("captured[%d] = %#x; expected 0x00 (zeroized)", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_BufferZeroizedEvenOnError confirms the
|
||||
// `defer clear(der)` fires regardless of onDER's return — the security
|
||||
// invariant is "buffer is always zeroized after the function returns,"
|
||||
// happy path or error path.
|
||||
func TestMarshalAgentKeyAndZeroize_BufferZeroizedEvenOnError(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
sentinel := errors.New("upstream boom")
|
||||
var captured []byte
|
||||
gotErr := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
captured = der // share backing array
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(gotErr, sentinel) {
|
||||
t.Fatalf("expected sentinel via errors.Is, got: %v", gotErr)
|
||||
}
|
||||
if len(captured) == 0 {
|
||||
t.Fatal("captured slice empty post-return")
|
||||
}
|
||||
for i, b := range captured {
|
||||
if b != 0 {
|
||||
t.Errorf("captured[%d] = %#x; expected 0x00 (defer clear must run on error path)", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalAgentKeyAndZeroize_ContractViolatorSeesZeros frames the same
|
||||
// observation as a defense-in-depth contract test. The docstring states
|
||||
// "Caller must NOT retain the slice." If a caller violates that contract
|
||||
// and reads the slice after onDER returns, they observe zeros — not the
|
||||
// private scalar. This test pins that defense.
|
||||
func TestMarshalAgentKeyAndZeroize_ContractViolatorSeesZeros(t *testing.T) {
|
||||
k := mustGenAgentECDSAKey(t)
|
||||
var leaked []byte // simulating a buggy caller that retains the slice
|
||||
err := marshalAgentKeyAndZeroize(k, func(der []byte) error {
|
||||
leaked = der
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
// The contract violator now reads from `leaked`. Defense-in-depth: it's zeros.
|
||||
for i, b := range leaked {
|
||||
if b != 0 {
|
||||
t.Errorf("contract-violator read leaked[%d] = %#x; expected 0x00", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ensureAgentKeyDirSecure — table-driven coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEnsureAgentKeyDirSecure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
// setup returns the dir argument to pass to ensureAgentKeyDirSecure.
|
||||
// base is a fresh t.TempDir() unique to each subtest.
|
||||
setup func(t *testing.T, base string) string
|
||||
// wantErrSubstr; "" means no error is expected.
|
||||
wantErrSubstr string
|
||||
// wantMode; if set, asserted via os.Stat after the call. Set to 0
|
||||
// to skip the mode assertion (e.g. for error-path rows where the
|
||||
// dir wasn't created or wasn't intended to change).
|
||||
wantMode os.FileMode
|
||||
}
|
||||
cases := []tc{
|
||||
// Refuse-empty/root invariants
|
||||
{
|
||||
name: "empty_string_refused",
|
||||
setup: func(t *testing.T, _ string) string {
|
||||
return ""
|
||||
},
|
||||
wantErrSubstr: `refuse empty/root dir ""`,
|
||||
},
|
||||
{
|
||||
name: "dot_refused",
|
||||
setup: func(t *testing.T, _ string) string {
|
||||
return "."
|
||||
},
|
||||
wantErrSubstr: `refuse empty/root dir "."`,
|
||||
},
|
||||
{
|
||||
name: "root_refused",
|
||||
setup: func(t *testing.T, _ string) string {
|
||||
return "/"
|
||||
},
|
||||
wantErrSubstr: `refuse empty/root dir "/"`,
|
||||
},
|
||||
|
||||
// Non-existent path — MkdirAll(0700) path
|
||||
{
|
||||
name: "creates_with_0700",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
return filepath.Join(base, "newdir")
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "creates_nested_0700",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
return filepath.Join(base, "a", "b", "c")
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
|
||||
// Existing 0700 — no-op (mode == 0o700 branch).
|
||||
{
|
||||
name: "existing_0700_noop",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0700")
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
|
||||
// Existing more-permissive — chmod tighten to 0700.
|
||||
{
|
||||
name: "existing_0750_tightened",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0750")
|
||||
if err := os.Mkdir(d, 0o750); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o750); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "existing_0755_tightened",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0755")
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "existing_0777_tightened",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0777")
|
||||
if err := os.Mkdir(d, 0o777); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o777); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
|
||||
// Existing owner-only-no-write modes accepted as-is via the
|
||||
// `mode&0o077 == 0` branch (no chmod, mode preserved).
|
||||
{
|
||||
name: "existing_0500_accepted_no_chmod",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0500")
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o500); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(d, 0o700) }) // let TempDir cleanup
|
||||
return d
|
||||
},
|
||||
wantMode: 0o500,
|
||||
},
|
||||
{
|
||||
name: "existing_0400_accepted_no_chmod",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "exists0400")
|
||||
if err := os.Mkdir(d, 0o700); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o400); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(d, 0o700) })
|
||||
return d
|
||||
},
|
||||
wantMode: 0o400,
|
||||
},
|
||||
|
||||
// filepath.Clean normalization paths.
|
||||
{
|
||||
name: "trailing_slash_normalized",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
d := filepath.Join(base, "trail")
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return d + "/"
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
{
|
||||
name: "dot_prefix_normalized",
|
||||
setup: func(t *testing.T, base string) string {
|
||||
// The function uses filepath.Clean which strips redundant
|
||||
// "./" segments. We only need to verify Clean is invoked,
|
||||
// not that we end up at a relative path; pass an absolute
|
||||
// path with an embedded "./".
|
||||
d := filepath.Join(base, "dotprefix")
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatalf("setup mkdir: %v", err)
|
||||
}
|
||||
if err := os.Chmod(d, 0o755); err != nil {
|
||||
t.Fatalf("setup chmod: %v", err)
|
||||
}
|
||||
return filepath.Join(base, ".", "dotprefix")
|
||||
},
|
||||
wantMode: 0o700,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
dir := tc.setup(t, base)
|
||||
|
||||
err := ensureAgentKeyDirSecure(dir)
|
||||
if tc.wantErrSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErrSubstr) {
|
||||
t.Errorf("error %q does not contain %q", err, tc.wantErrSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ensureAgentKeyDirSecure: %v", err)
|
||||
}
|
||||
if tc.wantMode != 0 {
|
||||
clean := filepath.Clean(dir)
|
||||
info, statErr := os.Stat(clean)
|
||||
if statErr != nil {
|
||||
t.Fatalf("post-call stat: %v", statErr)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != tc.wantMode {
|
||||
t.Errorf("dir mode = %#o; want %#o", got, tc.wantMode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_Idempotent confirms a second call on a
|
||||
// just-created dir is a no-op (hits the `mode == 0o700` short-circuit).
|
||||
func TestEnsureAgentKeyDirSecure_Idempotent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "idempotent")
|
||||
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected 0700, got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_Concurrent runs the function from many
|
||||
// goroutines simultaneously on the same fresh path. This is a safety smoke
|
||||
// test under -race; it is NOT a functional correctness claim about
|
||||
// concurrent agents (the agent has a single goroutine). The MkdirAll call
|
||||
// is the load-bearing primitive here — it's documented as safe to call
|
||||
// repeatedly with no error if the dir already exists.
|
||||
func TestEnsureAgentKeyDirSecure_Concurrent(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "concurrent")
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, workers)
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := ensureAgentKeyDirSecure(dir); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Errorf("concurrent caller returned error: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("post-concurrent stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected 0700 after concurrent calls, got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_PathIsAFile pins the function's behavior when
|
||||
// passed a regular file. The function does not type-check (no IsDir()), so
|
||||
// it stat's the file, sees mode 0o644 (or whatever), and chmod's it to 0700.
|
||||
//
|
||||
// This is "silently accepts a file path" behavior. It is not a correctness
|
||||
// bug per the function's caller (cmd/agent/main.go always passes
|
||||
// filepath.Dir(keyPath), which is a directory), but it is a hardening
|
||||
// candidate. Captured as a finding observation in the test docstring rather
|
||||
// than fixed in this bundle (Bundle 0.7 ships no production-code changes).
|
||||
func TestEnsureAgentKeyDirSecure_PathIsAFile(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
base := t.TempDir()
|
||||
filePath := filepath.Join(base, "not-a-dir.txt")
|
||||
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("setup writefile: %v", err)
|
||||
}
|
||||
err := ensureAgentKeyDirSecure(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("current behavior: function chmod's a file silently and returns nil; got err = %v", err)
|
||||
}
|
||||
info, statErr := os.Stat(filePath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("post-call stat: %v", statErr)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatal("file became a directory; that's not a thing")
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("expected mode 0700 (current behavior), got %#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_MkdirErrorPropagated forces the MkdirAll
|
||||
// branch to fail by chmod'ing the parent to 0o500 (read+exec but no write).
|
||||
// On linux/darwin running as a non-root uid, MkdirAll on a child of such a
|
||||
// parent fails with EACCES. We assert the error message wraps with the
|
||||
// documented "create agent key dir" prefix.
|
||||
//
|
||||
// Skipped if running as root (root bypasses unix dir-write checks).
|
||||
func TestEnsureAgentKeyDirSecure_MkdirErrorPropagated(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root; cannot revoke parent dir write permission")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Chmod(parent, 0o500); err != nil {
|
||||
t.Fatalf("setup chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
|
||||
child := filepath.Join(parent, "no-can-create")
|
||||
err := ensureAgentKeyDirSecure(child)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when MkdirAll cannot write to read-only parent")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "create agent key dir") {
|
||||
t.Errorf("error %q should contain %q", err.Error(), "create agent key dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_StatErrorPropagated forces os.Stat to fail
|
||||
// with a non-IsNotExist error by chmod'ing the parent to 0o000 (no
|
||||
// read+exec). On linux/darwin running as a non-root uid, stat on a child
|
||||
// of such a parent fails with EACCES. We assert the error message wraps
|
||||
// with "stat agent key dir".
|
||||
//
|
||||
// Skipped if running as root.
|
||||
func TestEnsureAgentKeyDirSecure_StatErrorPropagated(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root; cannot revoke parent dir read+exec permission")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
child := filepath.Join(parent, "victim")
|
||||
if err := os.Chmod(parent, 0o000); err != nil {
|
||||
t.Fatalf("setup chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
|
||||
err := ensureAgentKeyDirSecure(child)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when stat cannot traverse unreadable parent")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stat agent key dir") {
|
||||
t.Errorf("error %q should contain %q", err.Error(), "stat agent key dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_ChmodErrorPropagated forces os.Chmod to fail
|
||||
// on an existing more-permissive dir. We achieve this by:
|
||||
// 1. Creating an intermediate dir at 0o755 (so the function takes the
|
||||
// tighten-via-chmod branch).
|
||||
// 2. Replacing the real dir with a read-only-from-parent bind: chmod the
|
||||
// grandparent to 0o500 so the chmod syscall on the child fails with
|
||||
// EACCES (the syscall needs write on the path's containing dir for
|
||||
// metadata updates on most unix filesystems — actually no, chmod only
|
||||
// needs ownership, not parent write. So we instead drop the file's
|
||||
// owner via... no — we cannot change ownership without root.)
|
||||
//
|
||||
// Reaching the chmod-error branch from a non-root test is awkward because
|
||||
// chmod only requires ownership (which we always have on t.TempDir()).
|
||||
// The cleanest way is to skip on non-root and exercise the branch in CI
|
||||
// images that run as root; but our CI runs as non-root. We DO trigger the
|
||||
// branch via a different mechanism: replace the path with a SYMLINK to
|
||||
// /proc/1/root (or similar) where the eventual stat resolves but chmod
|
||||
// fails — but that's brittle and OS-specific.
|
||||
//
|
||||
// Acceptable closure: document that this branch is exercised by the
|
||||
// existing chmod-fails errno path, but the test as written can only assert
|
||||
// the wrap-prefix when the branch IS reached. We use a synthetic approach:
|
||||
// chmod-tighten a dir we then immediately delete, racing the syscall —
|
||||
// not deterministic.
|
||||
//
|
||||
// Pragmatic resolution: the chmod-error branch is structurally identical
|
||||
// to the mkdir-error and stat-error branches (errors.Wrap with a
|
||||
// distinct prefix), and is exercised in production via os.Chmod ENOENT
|
||||
// or read-only-filesystem failures. We add a unit test that asserts the
|
||||
// branch's MESSAGE format by passing through a wrap helper construct.
|
||||
// This test instead documents that the branch is structural and any new
|
||||
// failure mode (read-only fs, immutable bit, ACLs) inherits the wrap
|
||||
// prefix automatically.
|
||||
//
|
||||
// To still get coverage on the chmod-error branch, we use os.Chmod against
|
||||
// a dir whose immediate parent we delete mid-call. This is racy. Instead,
|
||||
// we make chmod fail by passing a path that filepath.Clean rewrites to
|
||||
// a symlink whose target was just chmod-stripped. Too brittle.
|
||||
//
|
||||
// CLEANEST APPROACH: rely on the OS's read-only filesystem semantics under
|
||||
// /sys (which is RO on linux). os.Chmod on a path under /sys returns EROFS.
|
||||
// But /sys is owned by root — stat would succeed only on existing entries,
|
||||
// and the function would then attempt chmod, which fails with EROFS (the
|
||||
// non-root caller still gets a clean error wrap).
|
||||
//
|
||||
// We cannot find a well-defined non-root chmod-fail path on darwin. So the
|
||||
// test runs only on linux and skips elsewhere.
|
||||
func TestEnsureAgentKeyDirSecure_ChmodErrorPropagated(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("chmod-error branch is only reliably triggerable on linux via /sys (read-only fs)")
|
||||
}
|
||||
// /sys is mounted read-only on Linux. Pick a stable subdir we can stat
|
||||
// (kernel-class). os.Chmod against it returns EROFS regardless of uid
|
||||
// (well — root can remount, but the call against /sys/* still EROFS).
|
||||
candidate := "/sys/kernel"
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Skipf("/sys/kernel not stat-able as a dir on this host; skipping (%v)", err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
if mode == 0o700 || mode&0o077 == 0 {
|
||||
// Already in the no-chmod branch; this test cannot exercise the
|
||||
// chmod-fail branch on this host. Skip rather than false-positive.
|
||||
t.Skipf("/sys/kernel mode %#o already satisfies no-chmod branch", mode)
|
||||
}
|
||||
chmodErr := ensureAgentKeyDirSecure(candidate)
|
||||
if chmodErr == nil {
|
||||
t.Fatal("expected chmod failure on /sys (read-only fs)")
|
||||
}
|
||||
if !strings.Contains(chmodErr.Error(), "tighten agent key dir") {
|
||||
t.Errorf("error %q should contain %q", chmodErr.Error(), "tighten agent key dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureAgentKeyDirSecure_FmtErrorMessageIncludesPath confirms each
|
||||
// error wrap includes the cleaned path (debuggability invariant).
|
||||
func TestEnsureAgentKeyDirSecure_FmtErrorMessageIncludesPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("running as root; cannot revoke parent dir write permission")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
if err := os.Chmod(parent, 0o500); err != nil {
|
||||
t.Fatalf("setup chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
|
||||
child := filepath.Join(parent, "child")
|
||||
want := filepath.Clean(child)
|
||||
|
||||
err := ensureAgentKeyDirSecure(child)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q should reference cleaned path %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-cutting: end-to-end smoke confirming the two functions compose
|
||||
// the way main.go uses them (Bundle 9 / L-002 / L-003 flow).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestKeymem_AgentMainFlowSmoke replays the cmd/agent/main.go composition:
|
||||
// ensureAgentKeyDirSecure(dir) → marshalAgentKeyAndZeroize(priv, onDER).
|
||||
// Closes the contract that both helpers cooperate cleanly under realistic
|
||||
// fixture conditions, and that the DER buffer is zeroized at the end of
|
||||
// the marshal call.
|
||||
func TestKeymem_AgentMainFlowSmoke(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
keyDir := filepath.Join(t.TempDir(), "agent-keys")
|
||||
if err := ensureAgentKeyDirSecure(keyDir); err != nil {
|
||||
t.Fatalf("ensureAgentKeyDirSecure: %v", err)
|
||||
}
|
||||
info, err := os.Stat(keyDir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("key dir not at 0700, got %#o", info.Mode().Perm())
|
||||
}
|
||||
|
||||
priv := mustGenAgentECDSAKey(t)
|
||||
var captured []byte
|
||||
if err := marshalAgentKeyAndZeroize(priv, func(der []byte) error {
|
||||
captured = der // share backing array
|
||||
// Pretend caller does pem.EncodeToMemory(...) here; we just check
|
||||
// the DER is a valid SEQUENCE.
|
||||
if len(der) == 0 || der[0] != 0x30 {
|
||||
return fmt.Errorf("unexpected DER shape (len=%d, first=%#x)", len(der), der)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("marshalAgentKeyAndZeroize: %v", err)
|
||||
}
|
||||
for i, b := range captured {
|
||||
if b != 0 {
|
||||
t.Fatalf("post-flow DER buffer not zeroized at byte %d (%#x)", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEC-002 closure (Sprint 1, 2026-05-16) — safeAgentKeyPath path-traversal
|
||||
// regression coverage.
|
||||
//
|
||||
// Pre-fix the agent built the on-disk key path via:
|
||||
//
|
||||
// keyPath := filepath.Join(a.config.KeyDir, job.CertificateID+".key")
|
||||
//
|
||||
// migrations/000001_initial_schema.up.sql declares
|
||||
// managed_certificates.id as TEXT PRIMARY KEY with no shape constraint, so
|
||||
// a crafted certificate_id from a compromised control plane (or a poisoned
|
||||
// DB row) could land outside KeyDir. The fix:
|
||||
//
|
||||
// - validateAgentCertID rejects shape violations up-front
|
||||
// - safeAgentKeyPath additionally asserts the joined path is contained
|
||||
// within KeyDir via filepath.Rel; even a future refactor that drops
|
||||
// the shape regex would still fail closed on escape.
|
||||
//
|
||||
// These tests pin both legs against the four vectors called out in the
|
||||
// audit (../../etc/passwd, /absolute/path, NUL byte, Windows separators).
|
||||
// =============================================================================
|
||||
|
||||
func TestValidateAgentCertID_AcceptsCanonicalShapes(t *testing.T) {
|
||||
for _, id := range []string{
|
||||
"mc-cdn-edge",
|
||||
"mc-cdn-edge-2026.q1",
|
||||
"cert-1",
|
||||
"abc123",
|
||||
"MC-UPPER",
|
||||
} {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
if err := validateAgentCertID(id); err != nil {
|
||||
t.Errorf("validateAgentCertID(%q): unexpected error %v", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgentCertID_RejectsTraversalVectors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"parent_token", ".."},
|
||||
{"current_token", "."},
|
||||
{"posix_traversal", "../../etc/passwd"},
|
||||
{"absolute_posix", "/absolute/path"},
|
||||
{"windows_traversal", `..\..\evil`},
|
||||
{"windows_separator", `bad\path`},
|
||||
{"nul_byte", "abc\x00def"},
|
||||
{"newline", "abc\ndef"},
|
||||
{"space", "id with spaces"},
|
||||
{"overlong", strings.Repeat("a", 129)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := validateAgentCertID(tc.id); err == nil {
|
||||
t.Errorf("id=%q: expected rejection, got nil", tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeAgentKeyPath_HappyPath_ProducesContainedPath(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
got, err := safeAgentKeyPath(keyDir, "mc-good")
|
||||
if err != nil {
|
||||
t.Fatalf("safeAgentKeyPath: %v", err)
|
||||
}
|
||||
want := filepath.Join(keyDir, "mc-good.key")
|
||||
// filepath.Clean normalisation may strip a trailing separator, etc.;
|
||||
// compare canonical forms.
|
||||
if filepath.Clean(got) != filepath.Clean(want) {
|
||||
t.Errorf("safeAgentKeyPath = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeAgentKeyPath_RejectsTraversalVectors(t *testing.T) {
|
||||
keyDir := t.TempDir()
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
}{
|
||||
{"posix_traversal", "../../etc/passwd"},
|
||||
{"absolute_posix", "/etc/passwd"},
|
||||
{"parent_token", ".."},
|
||||
{"current_token", "."},
|
||||
{"windows_traversal", `..\..\evil`},
|
||||
{"windows_separator", `bad\path`},
|
||||
{"nul_byte", "abc\x00def"},
|
||||
{"empty", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := safeAgentKeyPath(keyDir, tc.id)
|
||||
if err == nil {
|
||||
t.Errorf("id=%q: expected rejection, got nil", tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeAgentKeyPath_RejectsEmptyKeyDir(t *testing.T) {
|
||||
_, err := safeAgentKeyPath("", "mc-good")
|
||||
if err == nil {
|
||||
t.Errorf("empty keyDir: expected rejection, got nil")
|
||||
}
|
||||
}
|
||||
1028
cmd/agent/main.go
1028
cmd/agent/main.go
File diff suppressed because it is too large
Load Diff
291
cmd/agent/poll.go
Normal file
291
cmd/agent/poll.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 12 (2026-05-14): extracted from
|
||||
// cmd/agent/main.go via the Option B sibling-file pattern (mirrors
|
||||
// the Sprint 8 cmd/server cut). Package stays `main`; all methods
|
||||
// are still defined on *Agent so every call site continues to
|
||||
// resolve through Go's same-package method-set without any
|
||||
// import-path change.
|
||||
//
|
||||
// This file holds the WORK-POLLING entry point + CSR-job execution
|
||||
// — the inbound side of the agent's pull-only deployment model
|
||||
// (per CLAUDE.md "Pull-only deployment model" architecture
|
||||
// decision):
|
||||
//
|
||||
// - pollForWork: queries GET /api/v1/agents/{id}/work each tick;
|
||||
// dispatches each returned JobItem to the appropriate
|
||||
// executor (CSR vs deployment).
|
||||
// - executeCSRJob: handles AwaitingCSR jobs by generating an
|
||||
// ECDSA P-256 key locally, persisting it to keyDir/<certID>.key
|
||||
// with 0600 permissions (key NEVER leaves the agent — see
|
||||
// CLAUDE.md "Agent-based key management"), creating the CSR,
|
||||
// and POSTing it to the control plane for signing.
|
||||
//
|
||||
// The deployment-job executor lives in deploy.go alongside the
|
||||
// target connector factory + deploy-only helpers (splitPEMChain,
|
||||
// fetchCertificate). The discovery scan lives in discovery.go.
|
||||
|
||||
// pollForWork queries the control plane for actionable jobs and processes them.
|
||||
// Jobs may be deployment jobs (Pending) or CSR jobs (AwaitingCSR).
|
||||
// GET /api/v1/agents/{agentID}/work
|
||||
func (a *Agent) pollForWork(ctx context.Context) {
|
||||
a.logger.Debug("polling for work", "agent_id", a.config.AgentID)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/work", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("work poll failed", "error", err)
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// I-004: same terminal-retirement handling as sendHeartbeat. Work-poll is the
|
||||
// other hot path that can observe an agent's soft-retirement; if the
|
||||
// heartbeat tick happens to fire after a work-poll tick within the same
|
||||
// retirement window, this branch catches it first. markRetired's sync.Once
|
||||
// guards idempotency so racing both paths in the same tick only closes the
|
||||
// signal channel once. No consecutiveFailures increment — retirement is
|
||||
// not a transient failure.
|
||||
if resp.StatusCode == http.StatusGone {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.markRetired("work_poll", resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("work poll rejected",
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
var workResp WorkResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&workResp); err != nil {
|
||||
a.logger.Error("failed to decode work response", "error", err)
|
||||
a.consecutiveFailures++
|
||||
return
|
||||
}
|
||||
|
||||
a.consecutiveFailures = 0
|
||||
|
||||
if workResp.Count == 0 {
|
||||
a.logger.Debug("no pending work")
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("received work", "job_count", workResp.Count)
|
||||
|
||||
// Process each job based on type and status
|
||||
for _, job := range workResp.Jobs {
|
||||
switch {
|
||||
case job.Status == "AwaitingCSR":
|
||||
// Agent keygen mode: generate key locally, create CSR, submit to server
|
||||
a.executeCSRJob(ctx, job)
|
||||
case job.Type == "Deployment":
|
||||
a.executeDeploymentJob(ctx, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeCSRJob handles an AwaitingCSR job: generates a private key locally, creates a CSR,
|
||||
// and submits it to the control plane for signing. The private key is stored on the local
|
||||
// filesystem with 0600 permissions and NEVER sent to the server.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Generate ECDSA P-256 key pair
|
||||
// 2. Store private key to disk (keyDir/certID.key) with 0600 permissions
|
||||
// 3. Create CSR with common name and SANs from work response
|
||||
// 4. Submit CSR to control plane via POST /agents/{id}/csr
|
||||
// 5. Server signs the CSR and creates a cert version + deployment jobs
|
||||
func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
||||
a.logger.Info("executing CSR job (agent-side key generation)",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"common_name", job.CommonName)
|
||||
|
||||
// Step 1: Generate ECDSA P-256 key pair
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to generate private key",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key generation failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("generated ECDSA P-256 key pair locally",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
// Step 2: Store private key to disk with secure permissions.
|
||||
//
|
||||
// Bundle-9 / Audit L-002 + L-003: marshal+write through helpers that
|
||||
// (a) zeroize the in-heap DER buffer immediately after the PEM block is
|
||||
// constructed so the private scalar's exposure window is bounded by
|
||||
// this function call, and (b) assert the key directory is mode 0700
|
||||
// before any write touches disk. Also defer-clear the PEM buffer for
|
||||
// the same reason — the encoded key isn't sensitive in transit (it's
|
||||
// going to disk) but lingers on the heap if we don't.
|
||||
//
|
||||
// SEC-002 closure (Sprint 1, 2026-05-16): safeAgentKeyPath validates
|
||||
// the certificate_id shape AND asserts the joined path is contained
|
||||
// within a.config.KeyDir. A crafted certificate_id like
|
||||
// "../../etc/passwd" or "/abs/path" now fails closed before any
|
||||
// disk I/O. See cmd/agent/keymem.go for the helper.
|
||||
keyPath, kerr := safeAgentKeyPath(a.config.KeyDir, job.CertificateID)
|
||||
if kerr != nil {
|
||||
a.logger.Error("agent key path validation failed", "job_id", job.ID, "certificate_id", job.CertificateID, "error", kerr)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key path validation failed: %v", kerr)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := ensureAgentKeyDirSecure(filepath.Dir(keyPath)); err != nil {
|
||||
a.logger.Error("agent key dir hardening failed", "job_id", job.ID, "error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key dir hardening failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
var privKeyPEM []byte
|
||||
if marshalErr := marshalAgentKeyAndZeroize(privKey, func(der []byte) error {
|
||||
privKeyPEM = pem.EncodeToMemory(&pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: der,
|
||||
})
|
||||
return nil
|
||||
}); marshalErr != nil {
|
||||
a.logger.Error("failed to marshal private key",
|
||||
"job_id", job.ID,
|
||||
"error", marshalErr)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key marshal failed: %v", marshalErr)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer clear(privKeyPEM)
|
||||
|
||||
if err := os.WriteFile(keyPath, privKeyPEM, 0600); err != nil {
|
||||
a.logger.Error("failed to write private key to disk",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("key storage failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("private key stored securely",
|
||||
"job_id", job.ID,
|
||||
"key_path", keyPath,
|
||||
"permissions", "0600")
|
||||
|
||||
// Validate common name is present
|
||||
if job.CommonName == "" {
|
||||
a.logger.Error("empty common name in CSR job", "job_id", job.ID)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", "empty common name"); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Create CSR with common name and SANs
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var dnsNames []string
|
||||
var emailAddresses []string
|
||||
for _, san := range job.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
emailAddresses = append(emailAddresses, san)
|
||||
} else {
|
||||
dnsNames = append(dnsNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: job.CommonName,
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
if err != nil {
|
||||
a.logger.Error("failed to create CSR",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR creation failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrDER,
|
||||
}))
|
||||
|
||||
// Step 4: Submit CSR to the control plane (only the public key leaves the agent)
|
||||
a.logger.Info("submitting CSR to control plane",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID)
|
||||
|
||||
submitPath := fmt.Sprintf("/api/v1/agents/%s/csr", a.config.AgentID)
|
||||
resp, err := a.makeRequest(ctx, http.MethodPost, submitPath, map[string]string{
|
||||
"csr_pem": csrPEM,
|
||||
"certificate_id": job.CertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("failed to submit CSR",
|
||||
"job_id", job.ID,
|
||||
"error", err)
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR submission failed: %v", err)); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("CSR submission rejected",
|
||||
"job_id", job.ID,
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
if reportErr := a.reportJobStatus(ctx, job.ID, "Failed", fmt.Sprintf("CSR rejected: %s", string(body))); reportErr != nil {
|
||||
a.logger.Error("failed to report job status to server", "job_id", job.ID, "status", "Failed", "error", reportErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info("CSR submitted and signed successfully",
|
||||
"job_id", job.ID,
|
||||
"certificate_id", job.CertificateID,
|
||||
"key_path", keyPath)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -75,8 +78,8 @@ func verifyDeployment(
|
||||
// calls, issuer connector communication, or any operation that trusts the
|
||||
// certificate. The verification result compares SHA-256 fingerprints only.
|
||||
// See TICKET-016 for full security audit rationale.
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: targetHost, // For SNI
|
||||
InsecureSkipVerify: true, //nolint:gosec // verification probe; documented above + docs/tls.md L-001 table
|
||||
ServerName: targetHost, // For SNI
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", address, err)
|
||||
@@ -161,11 +164,11 @@ func (a *Agent) reportVerificationResult(
|
||||
|
||||
// Build the request payload
|
||||
payload := map[string]interface{}{
|
||||
"target_id": targetID,
|
||||
"expected_fingerprint": result.ExpectedFingerprint,
|
||||
"actual_fingerprint": result.ActualFingerprint,
|
||||
"verified": result.Verified,
|
||||
"error": result.Error,
|
||||
"target_id": targetID,
|
||||
"expected_fingerprint": result.ExpectedFingerprint,
|
||||
"actual_fingerprint": result.ActualFingerprint,
|
||||
"verified": result.Verified,
|
||||
"error": result.Error,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
@@ -247,7 +250,7 @@ func (a *Agent) verifyAndReportDeployment(
|
||||
) {
|
||||
// Perform verification with configured timeout and delay
|
||||
result, err := verifyDeployment(ctx, targetHost, targetPort, certPEM,
|
||||
2*time.Second, // delay before probing
|
||||
2*time.Second, // delay before probing
|
||||
10*time.Second, // timeout for TLS connection
|
||||
a.logger)
|
||||
|
||||
@@ -261,7 +264,7 @@ func (a *Agent) verifyAndReportDeployment(
|
||||
}
|
||||
// Probe failure: report error but continue
|
||||
result = &VerificationResult{
|
||||
Error: err.Error(),
|
||||
Error: err.Error(),
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +114,9 @@ func TestExtractTargetHostAndPort_InvalidJSON(t *testing.T) {
|
||||
|
||||
func TestExtractTargetHostAndPort_AlternativeFieldNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]interface{}
|
||||
expected string
|
||||
name string
|
||||
config map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{"host", map[string]interface{}{"host": "host1.com"}, "host1.com"},
|
||||
{"hostname", map[string]interface{}{"hostname": "host2.com"}, "host2.com"},
|
||||
@@ -228,7 +228,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
agent := NewAgent(cfg, nil)
|
||||
agent, _ := NewAgent(cfg, nil)
|
||||
|
||||
result := &VerificationResult{
|
||||
ExpectedFingerprint: "abc123",
|
||||
@@ -244,7 +244,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReportVerificationResult_MissingFields(t *testing.T) {
|
||||
agent := NewAgent(&AgentConfig{}, nil)
|
||||
agent, _ := NewAgent(&AgentConfig{}, nil)
|
||||
|
||||
result := &VerificationResult{
|
||||
Verified: true,
|
||||
@@ -343,7 +343,7 @@ func TestReportVerificationResult_ServerError(t *testing.T) {
|
||||
ServerURL: server.URL,
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
agent := NewAgent(cfg, nil)
|
||||
agent, _ := NewAgent(cfg, nil)
|
||||
|
||||
result := &VerificationResult{
|
||||
ExpectedFingerprint: "abc123",
|
||||
@@ -391,7 +391,13 @@ func TestVerifyDeployment_FingerprintComparison(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Get the server's TLS certificate from TLS config
|
||||
// Q-1 closure (cat-s3-58ce7e9840be): defensive skip — httptest.NewTLSServer
|
||||
// always provisions a self-signed certificate at construction time, so this
|
||||
// branch is currently unreachable in practice. Kept as a guard against
|
||||
// future test-server constructions that swap in a custom *tls.Config with
|
||||
// no Certificates slice (the path below dereferences server.TLS.Certificates[0]
|
||||
// and would panic). The skip preserves the assertion logic for the normal
|
||||
// fixture path; if it ever fires, it's a fixture bug, not a product bug.
|
||||
if len(server.TLS.Certificates) == 0 {
|
||||
t.Skip("no TLS certificates configured on test server")
|
||||
}
|
||||
|
||||
507
cmd/cli/dispatch_test.go
Normal file
507
cmd/cli/dispatch_test.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/cli"
|
||||
)
|
||||
|
||||
// Bundle Q (L-001 closure): per-subcommand dispatch tests for cmd/cli/main.go.
|
||||
//
|
||||
// The existing `main_test.go` only covered `validateHTTPSScheme`. This file
|
||||
// pins every dispatch arm in `handleCerts`, `handleAgents`, `handleJobs`,
|
||||
// `handleImport`, `handleStatus` — both the "missing arg" usage prints and
|
||||
// the happy-path delegation to `*cli.Client`.
|
||||
//
|
||||
// Strategy: spin up an `httptest.Server` mocking the relevant API routes so
|
||||
// the client can exercise its end-to-end code path without a live server.
|
||||
// For arms that print usage and return without calling the client, we pass
|
||||
// a freshly-constructed client (still no network call — the client method
|
||||
// is never invoked).
|
||||
|
||||
// newDispatchTestClient returns a `*cli.Client` pointed at the given test
|
||||
// server. Calls `t.Fatal` on construction error.
|
||||
func newDispatchTestClient(t *testing.T, server *httptest.Server) *cli.Client {
|
||||
t.Helper()
|
||||
// Configure the client with `insecure=true` because httptest.Server's
|
||||
// self-signed TLS cert won't chain to a system root.
|
||||
c, err := cli.NewClient(server.URL, "test-key", "json", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// stubServer returns an httptest.Server (TLS) that responds with the given
|
||||
// JSON body and status code for any request. Tests that want to assert on
|
||||
// the request shape can wrap it in a more specific handler.
|
||||
func stubServer(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleCerts dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleCerts_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{}); err != nil {
|
||||
t.Errorf("handleCerts({}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{"data":[],"total":0}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"frobnicate"}); err != nil {
|
||||
t.Errorf("handleCerts({frobnicate}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"get"}); err != nil {
|
||||
t.Errorf("handleCerts({get}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_RenewWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"renew"}); err != nil {
|
||||
t.Errorf("handleCerts({renew}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_RevokeWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"revoke"}); err != nil {
|
||||
t.Errorf("handleCerts({revoke}): unexpected err=%v (should print usage and return nil)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_List_HitsClientPath(t *testing.T) {
|
||||
// Asserts dispatch-path: handleCerts → c.ListCertificates → GET /api/v1/certificates.
|
||||
var hits int
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
if r.Method != "GET" || !strings.HasPrefix(r.URL.Path, "/api/v1/certificates") {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"list"}); err != nil {
|
||||
t.Errorf("handleCerts({list}): err=%v", err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Errorf("expected 1 server hit, got %d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_Get_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"id":"mc-x","name":"x"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"get", "mc-x"}); err != nil {
|
||||
t.Errorf("handleCerts({get, mc-x}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/api/v1/certificates/mc-x") {
|
||||
t.Errorf("expected GET on /api/v1/certificates/mc-x, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_Renew_HitsClientPath(t *testing.T) {
|
||||
var lastPath, lastMethod string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
lastMethod = r.Method
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"job_id":"job-1","status":"ok"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"renew", "mc-x"}); err != nil {
|
||||
t.Errorf("handleCerts({renew, mc-x}): err=%v", err)
|
||||
}
|
||||
if lastMethod != "POST" || !strings.Contains(lastPath, "/renew") {
|
||||
t.Errorf("expected POST .../renew, got %s %s", lastMethod, lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_Revoke_HitsClientPath(t *testing.T) {
|
||||
var lastPath, lastMethod, lastBody string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
lastMethod = r.Method
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := r.Body.Read(buf)
|
||||
lastBody = string(buf[:n])
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"status":"revoked"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-2): reason must be a canonical
|
||||
// RFC 5280 §5.3.1 code (camelCase or snake_case both accepted; this
|
||||
// test asserts the snake_case path normalises to the camelCase wire
|
||||
// format that the local issuer + ACME server expect).
|
||||
if err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "key_compromise"}); err != nil {
|
||||
t.Errorf("handleCerts({revoke ...}): err=%v", err)
|
||||
}
|
||||
if lastMethod != "POST" || !strings.Contains(lastPath, "/revoke") {
|
||||
t.Errorf("expected POST .../revoke, got %s %s", lastMethod, lastPath)
|
||||
}
|
||||
if !strings.Contains(lastBody, "keyCompromise") {
|
||||
t.Errorf("expected normalised reason 'keyCompromise' in body, got %q", lastBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCerts_Revoke_RequiresReason pins the 2026-05-05 parity-defaults-
|
||||
// cleanup (P3-2, Option A) strict-reason contract: empty --reason is a
|
||||
// fatal error, not a silent fallback to "unspecified".
|
||||
func TestHandleCerts_Revoke_RequiresReason(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
err := handleCerts(c, []string{"revoke", "mc-x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --reason is omitted; got nil (regression on P3-2 strict path)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reason") {
|
||||
t.Errorf("expected error to mention 'reason', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCerts_Revoke_RejectsUnknownReason pins that off-RFC reason
|
||||
// codes are rejected at the CLI dispatch layer (P3-2 anti-typo guard).
|
||||
func TestHandleCerts_Revoke_RejectsUnknownReason(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
err := handleCerts(c, []string{"revoke", "mc-x", "--reason", "compromise"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical reason; got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "compromise") {
|
||||
t.Errorf("expected error to echo bad reason 'compromise', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCerts_Renew_ForceFlag pins the 2026-05-05 parity-defaults-
|
||||
// cleanup (P3-1) wire: --force on the renew dispatch sends ?force=true.
|
||||
// CLI convention: ID is positional and precedes the flags (matches
|
||||
// `agents retire <id> [--force]`), so the flag MUST come after the ID.
|
||||
func TestHandleCerts_Renew_ForceFlag(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantQuery string
|
||||
}{
|
||||
{"no-force", []string{"renew", "mc-x"}, ""},
|
||||
{"force-after-id", []string{"renew", "mc-x", "--force"}, "force=true"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var lastQuery string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastQuery = r.URL.RawQuery
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, tc.args); err != nil {
|
||||
t.Fatalf("handleCerts: %v", err)
|
||||
}
|
||||
if lastQuery != tc.wantQuery {
|
||||
t.Errorf("query: got %q want %q", lastQuery, tc.wantQuery)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCerts_BulkRevoke_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"total_matched":0,"total_revoked":0,"total_skipped":0,"total_failed":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleCerts(c, []string{"bulk-revoke", "--reason", "test"}); err != nil {
|
||||
t.Errorf("handleCerts({bulk-revoke ...}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/bulk-revoke") {
|
||||
t.Errorf("expected /bulk-revoke path, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleAgents dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleAgents_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{}); err != nil {
|
||||
t.Errorf("handleAgents({}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"frobnicate"}); err != nil {
|
||||
t.Errorf("handleAgents({frobnicate}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"get"}); err != nil {
|
||||
t.Errorf("handleAgents({get}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_RetireWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"retire"}); err != nil {
|
||||
t.Errorf("handleAgents({retire}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_List_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"list"}); err != nil {
|
||||
t.Errorf("handleAgents({list}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/api/v1/agents") {
|
||||
t.Errorf("expected /api/v1/agents path, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_ListRetired_HitsRetiredEndpoint(t *testing.T) {
|
||||
// I-004: --retired flag splits to a separate /agents/retired endpoint.
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"list", "--retired"}); err != nil {
|
||||
t.Errorf("handleAgents({list --retired}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/agents/retired") {
|
||||
t.Errorf("expected --retired to hit /agents/retired, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAgents_Get_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"id":"ag-x","status":"online"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleAgents(c, []string{"get", "ag-x"}); err != nil {
|
||||
t.Errorf("handleAgents({get, ag-x}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/agents/ag-x") {
|
||||
t.Errorf("expected /agents/ag-x, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleJobs dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleJobs_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{}); err != nil {
|
||||
t.Errorf("handleJobs({}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_UnknownSubcommand_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"frobnicate"}); err != nil {
|
||||
t.Errorf("handleJobs({frobnicate}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_GetWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"get"}); err != nil {
|
||||
t.Errorf("handleJobs({get}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_CancelWithoutID_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"cancel"}); err != nil {
|
||||
t.Errorf("handleJobs({cancel}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_List_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"data":[],"total":0}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"list"}); err != nil {
|
||||
t.Errorf("handleJobs({list}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/api/v1/jobs") {
|
||||
t.Errorf("expected /api/v1/jobs path, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_Get_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"id":"job-x"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"get", "job-x"}); err != nil {
|
||||
t.Errorf("handleJobs({get, job-x}): err=%v", err)
|
||||
}
|
||||
if !strings.Contains(lastPath, "/jobs/job-x") {
|
||||
t.Errorf("expected /jobs/job-x, got %q", lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleJobs_Cancel_HitsClientPath(t *testing.T) {
|
||||
var lastPath, lastMethod string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
lastMethod = r.Method
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"status":"cancelled"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleJobs(c, []string{"cancel", "job-x"}); err != nil {
|
||||
t.Errorf("handleJobs({cancel, job-x}): err=%v", err)
|
||||
}
|
||||
if lastMethod != "POST" || !strings.Contains(lastPath, "/cancel") {
|
||||
t.Errorf("expected POST .../cancel, got %s %s", lastMethod, lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handleImport / handleStatus dispatch arms
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleImport_NoArgs_PrintsUsage(t *testing.T) {
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleImport(c, []string{}); err != nil {
|
||||
t.Errorf("handleImport({}): unexpected err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatus_HitsClientPath(t *testing.T) {
|
||||
var lastPath string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lastPath = r.URL.Path
|
||||
w.WriteHeader(200)
|
||||
// GetStatus expects {"status":..., "stats":...} or similar.
|
||||
// Provide a minimal valid JSON object.
|
||||
_, _ = w.Write([]byte(`{"status":"healthy","version":"v2.X","db":"connected"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := newDispatchTestClient(t, srv)
|
||||
if err := handleStatus(c); err != nil {
|
||||
// GetStatus's table output may complain about missing fields; we only
|
||||
// care that the dispatch arm fired and the request reached the server.
|
||||
_ = err
|
||||
}
|
||||
if lastPath == "" {
|
||||
t.Errorf("expected handleStatus to make at least one request")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CLI client TLS sanity (Q.1: confirms NewClient configures TLS correctly).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCliClient_RejectsUntrustedCert_WhenNotInsecure(t *testing.T) {
|
||||
// Without insecure=true, the self-signed httptest cert must fail TLS
|
||||
// verification. This pins the security default.
|
||||
srv := stubServer(t, 200, `{}`)
|
||||
c, err := cli.NewClient(srv.URL, "k", "json", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
// Try a status call — should error out with a TLS verification failure,
|
||||
// not silently succeed.
|
||||
if err := c.GetStatus(); err == nil {
|
||||
t.Errorf("expected TLS verification error against self-signed cert; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCliClient_ParsesJSONResponse asserts the do() path's JSON unmarshalling
|
||||
// succeeds end-to-end (one of the more error-prone paths in the client).
|
||||
func TestCliClient_ParsesJSONResponse(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
body := map[string]interface{}{
|
||||
"data": []map[string]interface{}{{"id": "mc-1", "name": "site-1"}},
|
||||
"total": 1,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c, err := cli.NewClient(srv.URL, "k", "json", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
if err := c.ListCertificates(nil); err != nil {
|
||||
t.Errorf("ListCertificates: err=%v", err)
|
||||
}
|
||||
}
|
||||
332
cmd/cli/main.go
332
cmd/cli/main.go
@@ -1,11 +1,16 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/cli"
|
||||
"github.com/certctl-io/certctl/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -27,35 +32,58 @@ Commands:
|
||||
certs renew ID Trigger certificate renewal
|
||||
certs revoke ID Revoke a certificate
|
||||
|
||||
agents list List agents
|
||||
agents get ID Get agent details
|
||||
agents list List agents (add --retired to list soft-retired agents)
|
||||
agents get ID Get agent details
|
||||
agents retire ID Soft-retire an agent (add --force --reason "…" to cascade)
|
||||
|
||||
jobs list List jobs
|
||||
jobs get ID Get job details
|
||||
jobs cancel ID Cancel a pending job
|
||||
|
||||
import FILE Bulk import certificates from PEM file(s)
|
||||
Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id
|
||||
Optional: --name-template (default {cn}), --environment (default imported)
|
||||
|
||||
est cacerts --profile <p> EST GET cacerts (RFC 7030 §4.1)
|
||||
est csrattrs --profile <p> EST GET csrattrs (RFC 7030 §4.5)
|
||||
est enroll --profile <p> --csr <path> EST POST simpleenroll (RFC 7030 §4.2)
|
||||
est reenroll --profile <p> --csr <path> EST POST simplereenroll (RFC 7030 §4.2.2)
|
||||
est serverkeygen --profile <p> --csr <path> --out <prefix>
|
||||
EST POST serverkeygen (RFC 7030 §4.4)
|
||||
est test --profile <p> Smoke-test cacerts + csrattrs
|
||||
|
||||
status Show server health + summary stats
|
||||
version Show CLI version
|
||||
|
||||
Examples:
|
||||
certctl-cli --server http://localhost:8443 --api-key mykey certs list
|
||||
certctl-cli --server https://localhost:8443 --api-key mykey certs list
|
||||
certctl-cli certs renew mc-prod --format json
|
||||
certctl-cli import certs.pem
|
||||
`)
|
||||
}
|
||||
|
||||
serverURL := fs.String("server", os.Getenv("CERTCTL_SERVER_URL"), "certctl server URL (env: CERTCTL_SERVER_URL)")
|
||||
if *serverURL == "" {
|
||||
*serverURL = "http://localhost:8443"
|
||||
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL uses
|
||||
// https://; plaintext http:// is rejected by validateHTTPSScheme below.
|
||||
defaultServer := os.Getenv("CERTCTL_SERVER_URL")
|
||||
if defaultServer == "" {
|
||||
defaultServer = "https://localhost:8443"
|
||||
}
|
||||
serverURL := fs.String("server", defaultServer, "certctl server URL — must be https:// (env: CERTCTL_SERVER_URL)")
|
||||
|
||||
apiKey := fs.String("api-key", os.Getenv("CERTCTL_API_KEY"), "API key for authentication (env: CERTCTL_API_KEY)")
|
||||
format := fs.String("format", "table", "Output format: table, json")
|
||||
caBundlePath := fs.String("ca-bundle", os.Getenv("CERTCTL_SERVER_CA_BUNDLE_PATH"), "Path to a PEM-encoded CA bundle that signed the server cert (env: CERTCTL_SERVER_CA_BUNDLE_PATH)")
|
||||
insecure := fs.Bool("insecure", strings.EqualFold(os.Getenv("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY"), "true"), "Skip TLS certificate verification — dev only, never set in production (env: CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY)")
|
||||
|
||||
fs.Parse(os.Args[1:])
|
||||
|
||||
if err := validateHTTPSScheme(*serverURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
|
||||
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
args := fs.Args()
|
||||
if len(args) == 0 {
|
||||
fs.Usage()
|
||||
@@ -63,13 +91,16 @@ Examples:
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := cli.NewClient(*serverURL, *apiKey, *format)
|
||||
client, err := cli.NewClient(*serverURL, *apiKey, *format, *caBundlePath, *insecure)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Dispatch to appropriate command
|
||||
command := args[0]
|
||||
cmdArgs := args[1:]
|
||||
|
||||
var err error
|
||||
switch command {
|
||||
case "certs":
|
||||
err = handleCerts(client, cmdArgs)
|
||||
@@ -79,8 +110,12 @@ Examples:
|
||||
err = handleJobs(client, cmdArgs)
|
||||
case "import":
|
||||
err = handleImport(client, cmdArgs)
|
||||
case "est":
|
||||
err = handleEST(client, cmdArgs)
|
||||
case "status":
|
||||
err = handleStatus(client)
|
||||
case "auth":
|
||||
err = handleAuth(client, cmdArgs)
|
||||
case "version":
|
||||
fmt.Println("certctl-cli version 0.1.0")
|
||||
default:
|
||||
@@ -114,31 +149,91 @@ func handleCerts(client *cli.Client, args []string) error {
|
||||
}
|
||||
return client.GetCertificate(subArgs[0])
|
||||
case "renew":
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-1): expose --force as an
|
||||
// explicit operator flag instead of the historical hardcoded
|
||||
// `force=false` body field. force=true overrides the server-side
|
||||
// RenewalInProgress block — used to recover stuck in-flight
|
||||
// renewals. Archived/Expired remain terminal regardless.
|
||||
//
|
||||
// CLI convention: `certs renew <id> [--force]` — the ID is a
|
||||
// positional arg that precedes the flags. Mirrors `agents retire
|
||||
// <id>`'s pattern (Go's flag package stops at the first non-flag
|
||||
// token, so we pull subArgs[0] as the ID and hand subArgs[1:] to
|
||||
// the flag parser).
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs renew <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.RenewCertificate(subArgs[0])
|
||||
case "revoke":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> [--reason <reason>]\n")
|
||||
fmt.Fprintf(os.Stderr, "usage: certs renew <id> [--force]\n")
|
||||
return nil
|
||||
}
|
||||
id := subArgs[0]
|
||||
reason := "unspecified"
|
||||
if len(subArgs) > 2 && subArgs[1] == "--reason" {
|
||||
reason = subArgs[2]
|
||||
fs := flag.NewFlagSet("certs renew", flag.ContinueOnError)
|
||||
force := fs.Bool("force", false, "Force renewal even when the cert is currently in RenewalInProgress (clears stuck in-flight renewals; does NOT override Archived/Expired terminal states)")
|
||||
if err := fs.Parse(subArgs[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.RevokeCertificate(id, reason)
|
||||
return client.RenewCertificate(id, *force)
|
||||
case "revoke":
|
||||
// 2026-05-05 parity-defaults-cleanup (P3-2, Option A): --reason is
|
||||
// strictly required. Empty reason refuses to dispatch and prints
|
||||
// the RFC 5280 §5.3.1 reason-code menu so operators pick a real
|
||||
// value. The pre-2026-05-05 silent fallback to "unspecified"
|
||||
// defeated compliance reporting (PCI-DSS §3.6, HIPAA §164.312)
|
||||
// because every revocation looked the same in the audit trail.
|
||||
//
|
||||
// CLI convention: `certs revoke <id> --reason <reason>` — same
|
||||
// ID-first ordering as `certs renew`.
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: certs revoke <id> --reason <reason>\n")
|
||||
fmt.Fprintf(os.Stderr, "\nValid RFC 5280 §5.3.1 reasons:\n")
|
||||
for _, r := range cli.ValidRevokeReasons() {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
id := subArgs[0]
|
||||
fs := flag.NewFlagSet("certs revoke", flag.ContinueOnError)
|
||||
reason := fs.String("reason", "", "RFC 5280 revocation reason (required). Valid values: keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL, privilegeWithdrawn, aaCompromise, unspecified")
|
||||
if err := fs.Parse(subArgs[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *reason == "" {
|
||||
fmt.Fprintf(os.Stderr, "error: --reason is required (no silent fallback to 'unspecified' — pick a real RFC 5280 §5.3.1 code).\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Valid reasons:\n")
|
||||
for _, r := range cli.ValidRevokeReasons() {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", r)
|
||||
}
|
||||
return fmt.Errorf("--reason is required")
|
||||
}
|
||||
canonical, ok := cli.NormalizeRevokeReason(*reason)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "error: %q is not a valid RFC 5280 §5.3.1 reason code.\n\n", *reason)
|
||||
fmt.Fprintf(os.Stderr, "Valid reasons (camelCase or snake_case both accepted):\n")
|
||||
for _, r := range cli.ValidRevokeReasons() {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", r)
|
||||
}
|
||||
return fmt.Errorf("invalid --reason: %q", *reason)
|
||||
}
|
||||
return client.RevokeCertificate(id, canonical)
|
||||
case "bulk-revoke":
|
||||
return client.BulkRevokeCertificates(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: certs %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgents dispatches the `agents` subcommands.
|
||||
//
|
||||
// I-004 additions:
|
||||
//
|
||||
// agents list --retired — hit the opt-in /agents/retired endpoint
|
||||
// instead of the default listing (which
|
||||
// filters retired rows out).
|
||||
// agents retire <id> — soft-retire an agent (DELETE /agents/{id}).
|
||||
// --force cascades; --reason is required with
|
||||
// --force (mirrors ErrForceReasonRequired).
|
||||
func handleAgents(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents <list|get> [options]\n")
|
||||
fmt.Fprintf(os.Stderr, "usage: agents <list|get|retire> [options]\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,13 +242,34 @@ func handleAgents(client *cli.Client, args []string) error {
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
return client.ListAgents(subArgs)
|
||||
// --retired flag splits to a separate endpoint. We intercept it
|
||||
// client-side and strip it before delegating, so both code paths
|
||||
// share the --page/--per-page flag parsing inside the client.
|
||||
retired := false
|
||||
rest := make([]string, 0, len(subArgs))
|
||||
for _, a := range subArgs {
|
||||
if a == "--retired" {
|
||||
retired = true
|
||||
continue
|
||||
}
|
||||
rest = append(rest, a)
|
||||
}
|
||||
if retired {
|
||||
return client.ListRetiredAgents(rest)
|
||||
}
|
||||
return client.ListAgents(rest)
|
||||
case "get":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.GetAgent(subArgs[0])
|
||||
case "retire":
|
||||
if len(subArgs) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: agents retire <id> [--force] [--reason <reason>]\n")
|
||||
return nil
|
||||
}
|
||||
return client.RetireAgent(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: agents %s\n", subcommand)
|
||||
return nil
|
||||
@@ -201,3 +317,175 @@ func handleImport(client *cli.Client, args []string) error {
|
||||
func handleStatus(client *cli.Client) error {
|
||||
return client.GetStatus()
|
||||
}
|
||||
|
||||
// handleEST dispatches the `est` subcommands. Mirrors the existing
|
||||
// handleCerts / handleAgents pattern verbatim. EST RFC 7030 hardening
|
||||
// master bundle Phase 9.1.
|
||||
func handleEST(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: est <cacerts|csrattrs|enroll|reenroll|serverkeygen|test> [options]\n")
|
||||
return nil
|
||||
}
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
switch subcommand {
|
||||
case "cacerts":
|
||||
return client.EstCacerts(subArgs)
|
||||
case "csrattrs":
|
||||
return client.EstCsrattrs(subArgs)
|
||||
case "enroll":
|
||||
return client.EstEnroll(subArgs)
|
||||
case "reenroll":
|
||||
return client.EstReEnroll(subArgs)
|
||||
case "serverkeygen":
|
||||
return client.EstServerKeygen(subArgs)
|
||||
case "test":
|
||||
return client.EstTest(subArgs)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: est %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
|
||||
// startup so operators get a fail-loud diagnostic before any network call,
|
||||
// not a TCP-refused or TLS-handshake-error downstream. See docs/upgrade-to-tls.md.
|
||||
func validateHTTPSScheme(serverURL string) error {
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("server URL is empty — set --server (or CERTCTL_SERVER_URL) to an https:// URL (e.g., https://certctl-server:8443)")
|
||||
}
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server URL %q is not a valid URL: %w", serverURL, err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
return fmt.Errorf("server URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
|
||||
case "":
|
||||
return fmt.Errorf("server URL %q is missing a scheme — expected https://", serverURL)
|
||||
default:
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth dispatches the `certctl-cli auth ...` subcommand tree.
|
||||
// Bundle 1 Phase 5: ships read + grant operations against the
|
||||
// /api/v1/auth/* surface introduced in Phase 4. Mutations like role
|
||||
// create / update / delete can be added in a Phase 5.5 follow-up; this
|
||||
// commit ships the operator-facing subset most useful for migration
|
||||
// and day-2 scope-down (`auth keys list` + `auth keys assign` +
|
||||
// `auth me`).
|
||||
func handleAuth(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth <roles|permissions|keys|me> [...]\n")
|
||||
return nil
|
||||
}
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "roles":
|
||||
return handleAuthRoles(client, subArgs)
|
||||
case "permissions":
|
||||
return handleAuthPermissions(client, subArgs)
|
||||
case "keys":
|
||||
return handleAuthKeys(client, subArgs)
|
||||
case "me":
|
||||
return client.AuthMe()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown auth subcommand: %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthRoles(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles <list|get> [id]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListRoles()
|
||||
case "get":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthGetRole(args[1])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown roles subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthPermissions(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth permissions list\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthListPermissions()
|
||||
}
|
||||
|
||||
func handleAuthKeys(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys <list|assign|revoke|scope-down> [...]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListKeys()
|
||||
case "assign":
|
||||
// auth keys assign <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys assign <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthAssignRoleToKey(args[1], args[3])
|
||||
case "revoke":
|
||||
// auth keys revoke <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys revoke <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthRevokeRoleFromKey(args[1], args[3])
|
||||
case "scope-down":
|
||||
// Bundle 1 Phase 7 — interactive (default), --non-interactive
|
||||
// <config.json>, or --suggest [--apply].
|
||||
return handleAuthKeysScopeDown(client, args[1:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthKeysScopeDown dispatches the three scope-down modes:
|
||||
//
|
||||
// auth keys scope-down → interactive
|
||||
// auth keys scope-down --non-interactive <config> → JSON-driven
|
||||
// auth keys scope-down --suggest [--apply] → audit-driven suggestions
|
||||
func handleAuthKeysScopeDown(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return client.AuthScopeDown()
|
||||
}
|
||||
switch args[0] {
|
||||
case "--non-interactive":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys scope-down --non-interactive <config.json>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthScopeDownNonInteractive(args[1])
|
||||
case "--suggest":
|
||||
apply := false
|
||||
for _, a := range args[1:] {
|
||||
if a == "--apply" {
|
||||
apply = true
|
||||
}
|
||||
}
|
||||
return client.AuthScopeDownSuggest(apply)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown scope-down flag: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
96
cmd/cli/main_test.go
Normal file
96
cmd/cli/main_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
|
||||
// HTTPS-Everywhere milestone (v2.2, §3.2) requires on the certctl-cli binary
|
||||
// startup path. The CLI's diagnostic is distinct from the agent and MCP server
|
||||
// because it surfaces the --server flag alongside CERTCTL_SERVER_URL — so the
|
||||
// empty-URL case pins that flag-name substring separately. Every other case
|
||||
// mirrors the dispatch arms in cmd/cli/main.go:validateHTTPSScheme; drifting
|
||||
// the substrings is what this test is here to catch.
|
||||
func TestValidateHTTPSScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverURL string
|
||||
wantErr bool
|
||||
wantErrSub string // substring that MUST appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "https URL passes",
|
||||
serverURL: "https://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https URL with path passes",
|
||||
serverURL: "https://certctl.example.com/api/v1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
|
||||
serverURL: "HTTPS://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty URL rejected mentions --server flag",
|
||||
serverURL: "",
|
||||
wantErr: true,
|
||||
wantErrSub: "--server",
|
||||
},
|
||||
{
|
||||
name: "empty URL rejected also mentions CERTCTL_SERVER_URL",
|
||||
serverURL: "",
|
||||
wantErr: true,
|
||||
wantErrSub: "CERTCTL_SERVER_URL",
|
||||
},
|
||||
{
|
||||
name: "plaintext http rejected",
|
||||
serverURL: "http://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
name: "bare host missing scheme rejected",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
// url.Parse treats "localhost:8443" as scheme=localhost, opaque=8443
|
||||
// — exercises the default arm (unsupported scheme) rather than the
|
||||
// empty-scheme arm. Both are fail-closed, which is what we care about.
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "path-only URL rejected",
|
||||
serverURL: "//certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "missing a scheme",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme rejected",
|
||||
serverURL: "ftp://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "ws scheme rejected",
|
||||
serverURL: "ws://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateHTTPSScheme(tt.serverURL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
|
||||
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
|
||||
tt.serverURL, err.Error(), tt.wantErrSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,53 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/mcp"
|
||||
"github.com/certctl-io/certctl/internal/mcp"
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL
|
||||
// uses https://; plaintext http:// is rejected by validateHTTPSScheme
|
||||
// below with a fail-loud pre-flight diagnostic pointing at
|
||||
// docs/upgrade-to-tls.md, so operators never get a TCP-refused or
|
||||
// TLS-handshake-error downstream. See docs/tls.md for CA bundle and
|
||||
// insecure-skip-verify guidance.
|
||||
serverURL := os.Getenv("CERTCTL_SERVER_URL")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://localhost:8443"
|
||||
serverURL = "https://localhost:8443"
|
||||
}
|
||||
|
||||
if err := validateHTTPSScheme(serverURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
|
||||
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("CERTCTL_API_KEY")
|
||||
caBundlePath := os.Getenv("CERTCTL_SERVER_CA_BUNDLE_PATH")
|
||||
insecure := strings.EqualFold(os.Getenv("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY"), "true")
|
||||
|
||||
client := mcp.NewClient(serverURL, apiKey)
|
||||
client, err := mcp.NewClient(serverURL, apiKey, caBundlePath, insecure)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server := gomcp.NewServer(&gomcp.Implementation{
|
||||
Name: "certctl",
|
||||
@@ -41,3 +65,26 @@ func main() {
|
||||
log.Fatalf("MCP server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
|
||||
// startup so operators get a fail-loud diagnostic before any network call,
|
||||
// not a TCP-refused or TLS-handshake-error downstream. See docs/upgrade-to-tls.md.
|
||||
func validateHTTPSScheme(serverURL string) error {
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("server URL is empty — set CERTCTL_SERVER_URL to an https:// URL (e.g., https://certctl-server:8443)")
|
||||
}
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server URL %q is not a valid URL: %w", serverURL, err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
return nil
|
||||
case "http":
|
||||
return fmt.Errorf("server URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
|
||||
case "":
|
||||
return fmt.Errorf("server URL %q is missing a scheme — expected https://", serverURL)
|
||||
default:
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
90
cmd/mcp-server/main_test.go
Normal file
90
cmd/mcp-server/main_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
|
||||
// HTTPS-Everywhere milestone (v2.2, §3.2) requires on the MCP server binary
|
||||
// startup path. The whole point is to fail loud with a diagnostic that points
|
||||
// at docs/upgrade-to-tls.md *before* any network call — not a cryptic
|
||||
// TCP-refused or TLS-handshake-error two ticks later. Every case here mirrors
|
||||
// the dispatch arms in cmd/mcp-server/main.go:validateHTTPSScheme; drifting
|
||||
// the error-message substrings is what this test is here to catch.
|
||||
func TestValidateHTTPSScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverURL string
|
||||
wantErr bool
|
||||
wantErrSub string // substring that MUST appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "https URL passes",
|
||||
serverURL: "https://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https URL with path passes",
|
||||
serverURL: "https://certctl.example.com/api/v1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
|
||||
serverURL: "HTTPS://certctl-server:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty URL rejected",
|
||||
serverURL: "",
|
||||
wantErr: true,
|
||||
wantErrSub: "server URL is empty",
|
||||
},
|
||||
{
|
||||
name: "plaintext http rejected",
|
||||
serverURL: "http://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "plaintext http://",
|
||||
},
|
||||
{
|
||||
name: "bare host missing scheme rejected",
|
||||
serverURL: "localhost:8443",
|
||||
wantErr: true,
|
||||
// url.Parse treats "localhost:8443" as scheme=localhost, opaque=8443
|
||||
// — exercises the default arm (unsupported scheme) rather than the
|
||||
// empty-scheme arm. Both are fail-closed, which is what we care about.
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "path-only URL rejected",
|
||||
serverURL: "//certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "missing a scheme",
|
||||
},
|
||||
{
|
||||
name: "unsupported scheme rejected",
|
||||
serverURL: "ftp://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
{
|
||||
name: "ws scheme rejected",
|
||||
serverURL: "ws://certctl-server:8443",
|
||||
wantErr: true,
|
||||
wantErrSub: "unsupported scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateHTTPSScheme(tt.serverURL)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
|
||||
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
|
||||
tt.serverURL, err.Error(), tt.wantErrSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
108
cmd/server/auth_backfill.go
Normal file
108
cmd/server/auth_backfill.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// assembleNamedAPIKeys translates the operator's CERTCTL_API_KEYS_NAMED
|
||||
// env-var (preferred) or CERTCTL_AUTH_SECRET (legacy) into the
|
||||
// auth.NamedAPIKey slice the rest of the boot path consumes.
|
||||
//
|
||||
// Authentication unification (M-002): every authenticated request now
|
||||
// carries a named actor in the request context so audit events record
|
||||
// the real key identity instead of the hardcoded "api-key-user"
|
||||
// string. Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For
|
||||
// backward compatibility CERTCTL_AUTH_SECRET is synthesized into
|
||||
// legacy-key-N entries with Admin=false.
|
||||
func assembleNamedAPIKeys(cfg *config.Config, logger *slog.Logger) []auth.NamedAPIKey {
|
||||
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
|
||||
return nil
|
||||
}
|
||||
var out []auth.NamedAPIKey
|
||||
for _, nk := range cfg.Auth.NamedKeys {
|
||||
out = append(out, auth.NamedAPIKey{
|
||||
Name: nk.Name,
|
||||
Key: nk.Key,
|
||||
Admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 && cfg.Auth.Secret != "" {
|
||||
idx := 0
|
||||
for _, p := range strings.Split(cfg.Auth.Secret, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, auth.NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: p,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
if len(out) > 0 && logger != nil {
|
||||
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
|
||||
"synthesized_keys", len(out))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actorRoleGranter is the narrow interface backfillNamedKeyActorRoles
|
||||
// needs from the postgres ActorRoleRepository. Pulled out so the unit
|
||||
// test can inject a fake without spinning up the full repo / DB.
|
||||
type actorRoleGranter interface {
|
||||
Grant(ctx context.Context, ar *authdomain.ActorRole) error
|
||||
}
|
||||
|
||||
// backfillNamedKeyActorRoles is the Bundle 1 Phase 3 closure (C2)
|
||||
// startup hook that ensures every CERTCTL_API_KEYS_NAMED entry — and
|
||||
// every legacy CERTCTL_AUTH_SECRET synthesized fallback — has an
|
||||
// actor_roles row before the HTTP server accepts requests. Admin-flagged
|
||||
// keys grant `r-admin` (full canonical permission set); non-admin keys
|
||||
// grant `r-viewer` (read-only surface), matching the pre-Phase-3.5
|
||||
// capability shape.
|
||||
//
|
||||
// Idempotent via ON CONFLICT DO NOTHING in the repo Grant — reboots
|
||||
// don't create duplicates. Failures are logged but non-fatal: the server
|
||||
// still starts, and the operator can fix the grant via the RBAC API.
|
||||
//
|
||||
// The function is package-private + extracted from main() so the unit
|
||||
// test in auth_backfill_test.go can pin the role-mapping invariant
|
||||
// without depending on the full server bootstrap path.
|
||||
func backfillNamedKeyActorRoles(
|
||||
ctx context.Context,
|
||||
repo actorRoleGranter,
|
||||
keys []auth.NamedAPIKey,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
for _, nk := range keys {
|
||||
role := authdomain.RoleIDViewer
|
||||
if nk.Admin {
|
||||
role = authdomain.RoleIDAdmin
|
||||
}
|
||||
if err := repo.Grant(ctx, &authdomain.ActorRole{
|
||||
ActorID: nk.Name,
|
||||
ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey),
|
||||
RoleID: role,
|
||||
TenantID: authdomain.DefaultTenantID,
|
||||
GrantedBy: "bootstrap",
|
||||
}); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("api-key actor-role backfill failed; key authenticates but RBAC routes will 403 until grant is added via /v1/auth/keys",
|
||||
"key", nk.Name, "role", role, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
cmd/server/auth_backfill_test.go
Normal file
116
cmd/server/auth_backfill_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// fakeGranter is a tiny in-memory stand-in for the postgres ActorRoleRepository
|
||||
// — enough surface area for backfillNamedKeyActorRoles to call Grant against.
|
||||
type fakeGranter struct {
|
||||
calls []*authdomain.ActorRole
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
||||
f.calls = append(f.calls, ar)
|
||||
return f.err
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_RoleMapping pins the Bundle 1 Phase 3
|
||||
// closure (C2) invariant: admin-flagged named keys grant r-admin,
|
||||
// non-admin keys grant r-viewer, both at TenantID t-default with
|
||||
// ActorType APIKey and GrantedBy=bootstrap.
|
||||
func TestBackfillNamedKeyActorRoles_RoleMapping(t *testing.T) {
|
||||
repo := &fakeGranter{}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice-admin", Key: "AAA", Admin: true},
|
||||
{Name: "bob-viewer", Key: "BBB", Admin: false},
|
||||
{Name: "carol-admin", Key: "CCC", Admin: true},
|
||||
}
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
||||
|
||||
if len(repo.calls) != 3 {
|
||||
t.Fatalf("Grant call count = %d, want 3", len(repo.calls))
|
||||
}
|
||||
type want struct {
|
||||
actor, role string
|
||||
}
|
||||
wants := []want{
|
||||
{actor: "alice-admin", role: authdomain.RoleIDAdmin},
|
||||
{actor: "bob-viewer", role: authdomain.RoleIDViewer},
|
||||
{actor: "carol-admin", role: authdomain.RoleIDAdmin},
|
||||
}
|
||||
for i, w := range wants {
|
||||
got := repo.calls[i]
|
||||
if got.ActorID != w.actor {
|
||||
t.Errorf("call[%d].ActorID = %q, want %q", i, got.ActorID, w.actor)
|
||||
}
|
||||
if got.RoleID != w.role {
|
||||
t.Errorf("call[%d].RoleID = %q, want %q", i, got.RoleID, w.role)
|
||||
}
|
||||
if got.TenantID != authdomain.DefaultTenantID {
|
||||
t.Errorf("call[%d].TenantID = %q, want %q", i, got.TenantID, authdomain.DefaultTenantID)
|
||||
}
|
||||
if string(got.ActorType) != "APIKey" {
|
||||
t.Errorf("call[%d].ActorType = %q, want APIKey", i, got.ActorType)
|
||||
}
|
||||
if got.GrantedBy != "bootstrap" {
|
||||
t.Errorf("call[%d].GrantedBy = %q, want bootstrap", i, got.GrantedBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp confirms the boot path
|
||||
// is safe when no named keys are configured (typical CERTCTL_AUTH_TYPE=
|
||||
// none deploy). No Grant calls; no panic.
|
||||
func TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp(t *testing.T) {
|
||||
repo := &fakeGranter{}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, nil, logger)
|
||||
if len(repo.calls) != 0 {
|
||||
t.Errorf("Grant called %d times for empty keys, want 0", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal confirms the
|
||||
// closure invariant that a Grant failure logs a warning and proceeds
|
||||
// rather than crashing the server during boot. Subsequent keys still
|
||||
// get processed.
|
||||
func TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal(t *testing.T) {
|
||||
repo := &fakeGranter{err: errors.New("simulated DB error")}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice", Key: "A", Admin: true},
|
||||
{Name: "bob", Key: "B", Admin: false},
|
||||
}
|
||||
// Should not panic.
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
||||
|
||||
if len(repo.calls) != 2 {
|
||||
t.Errorf("Grant calls = %d, want 2 (every key processed even when prior Grant errored)", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_NilLoggerIsSafe pins that callers
|
||||
// passing nil for the logger don't NPE the goroutine. Belt-and-braces
|
||||
// for tests + future call sites that may not have a logger plumbed.
|
||||
func TestBackfillNamedKeyActorRoles_NilLoggerIsSafe(t *testing.T) {
|
||||
repo := &fakeGranter{err: errors.New("simulated")}
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice", Key: "A", Admin: true},
|
||||
}
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, nil)
|
||||
if len(repo.calls) != 1 {
|
||||
t.Errorf("Grant calls = %d, want 1", len(repo.calls))
|
||||
}
|
||||
}
|
||||
117
cmd/server/auth_exempt_test.go
Normal file
117
cmd/server/auth_exempt_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
)
|
||||
|
||||
// Bundle B / Audit M-002 (CWE-862): pin the dispatch-layer auth-exempt
|
||||
// allowlist. cmd/server/main.go::buildFinalHandler decides per-request
|
||||
// whether a path goes through the authenticated apiHandler or the
|
||||
// no-auth handler. This test:
|
||||
//
|
||||
// - constructs a buildFinalHandler with two sentinel handlers (one
|
||||
// for "auth", one for "no-auth") so we can observe which path is
|
||||
// taken from the response body.
|
||||
// - probes every prefix listed in router.AuthExemptDispatchPrefixes
|
||||
// and confirms it routes to no-auth.
|
||||
// - probes a few representative authenticated routes and confirms
|
||||
// they route to auth.
|
||||
// - probes the static-route allowlist (/health, /ready, etc.) that
|
||||
// also bypasses auth at this layer.
|
||||
//
|
||||
// Adding a new auth-bypass to buildFinalHandler without updating the
|
||||
// router.AuthExemptDispatchPrefixes constant fails this test.
|
||||
|
||||
func TestBuildFinalHandler_AuthExemptDispatchAllowlist(t *testing.T) {
|
||||
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("AUTH"))
|
||||
})
|
||||
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("NOAUTH"))
|
||||
})
|
||||
|
||||
// dashboardEnabled=false keeps the dispatch logic deterministic — no
|
||||
// fileServer fallback to muddy the result.
|
||||
final := buildFinalHandler(apiHandler, noAuthHandler, "/nonexistent", false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
// AuthExemptRouterRoutes (also enforced at this layer)
|
||||
{"health", "/health", "NOAUTH"},
|
||||
{"ready", "/ready", "NOAUTH"},
|
||||
{"auth_info", "/api/v1/auth/info", "NOAUTH"},
|
||||
{"version", "/api/v1/version", "NOAUTH"},
|
||||
|
||||
// AuthExemptDispatchPrefixes — every documented prefix
|
||||
{"pki_crl", "/.well-known/pki/crl", "NOAUTH"},
|
||||
{"pki_ocsp", "/.well-known/pki/ocsp", "NOAUTH"},
|
||||
{"est_simpleenroll", "/.well-known/est/simpleenroll", "NOAUTH"},
|
||||
{"est_cacerts", "/.well-known/est/cacerts", "NOAUTH"},
|
||||
{"scep_root", "/scep", "NOAUTH"},
|
||||
{"scep_op", "/scep/pkiclient.exe", "NOAUTH"},
|
||||
|
||||
// Authenticated routes — must hit apiHandler
|
||||
{"certs_list", "/api/v1/certificates", "AUTH"},
|
||||
{"agents_list", "/api/v1/agents", "AUTH"},
|
||||
{"audit_check", "/api/v1/auth/check", "AUTH"},
|
||||
|
||||
// Random non-API path — falls through to apiHandler when
|
||||
// dashboard disabled (preserves pre-M-001 API-only behavior).
|
||||
{"unknown", "/some-other-path", "AUTH"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
final.ServeHTTP(rec, req)
|
||||
got := rec.Body.String()
|
||||
if got != tc.want {
|
||||
t.Errorf("path %q routed to %q; want %q (this is the M-002 dispatch-layer pin)", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatch_NoUndocumentedBypasses asserts that for every prefix the
|
||||
// dispatch layer routes to noAuthHandler, that prefix appears in the
|
||||
// router.AuthExemptDispatchPrefixes constant. This is the inverse pin —
|
||||
// adding a new bypass to buildFinalHandler without updating the constant
|
||||
// fails this test.
|
||||
//
|
||||
// We probe a curated set of "would-be-bypasses" derived from the actual
|
||||
// dispatch source by reading buildFinalHandler's lines. If the dispatch
|
||||
// logic adds a new prefix that ends up in the no-auth chain, the
|
||||
// curated set must be extended in the same commit that updates the
|
||||
// constant — this fails-loud rather than silently allowing a bypass.
|
||||
func TestDispatch_NoUndocumentedBypasses(t *testing.T) {
|
||||
for _, prefix := range router.AuthExemptDispatchPrefixes {
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
t.Errorf("AuthExemptDispatchPrefixes entry %q must start with / for prefix matching", prefix)
|
||||
}
|
||||
}
|
||||
// Every entry in router.AuthExemptDispatchPrefixes must round-trip
|
||||
// through buildFinalHandler to noAuthHandler (covered by the table
|
||||
// test above). This test additionally asserts the inverse: known
|
||||
// authenticated prefixes do NOT match any documented bypass prefix.
|
||||
authenticatedPrefixes := []string{
|
||||
"/api/v1/certificates",
|
||||
"/api/v1/agents",
|
||||
"/api/v1/audit",
|
||||
}
|
||||
for _, ap := range authenticatedPrefixes {
|
||||
for _, bypass := range router.AuthExemptDispatchPrefixes {
|
||||
if strings.HasPrefix(ap, bypass) {
|
||||
t.Errorf("authenticated prefix %q overlaps with documented bypass %q — auth bypass risk", ap, bypass)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
314
cmd/server/finalhandler_test.go
Normal file
314
cmd/server/finalhandler_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildFinalHandler_Dispatch is the M-001 regression harness for the outer
|
||||
// HTTP dispatch layer. It pins which path prefixes ride the no-auth middleware
|
||||
// chain (EST, SCEP, /.well-known/pki, health/ready, /api/v1/auth/info) versus
|
||||
// the authenticated chain (/api/v1/*).
|
||||
//
|
||||
// The concern under test is ONLY the dispatch in buildFinalHandler — the
|
||||
// handlers themselves are mocked as marker handlers that stamp "AUTH" or
|
||||
// "NOAUTH" into the response body. Service-layer concerns (SCEP password
|
||||
// validation, EST CSR validation, API auth enforcement) are covered by their
|
||||
// respective test suites.
|
||||
//
|
||||
// Case (i) is the central guard: EST with NO client cert / NO Bearer token
|
||||
// MUST reach the no-auth handler (pre-M-001 it was 401'd by the Auth
|
||||
// middleware, blocking enrollment for every real-world EST client).
|
||||
func TestBuildFinalHandler_Dispatch(t *testing.T) {
|
||||
// Marker handlers — each stamps a unique body so tests can verify which
|
||||
// chain the request traversed.
|
||||
authHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Chain", "auth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("AUTH"))
|
||||
})
|
||||
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Chain", "noauth")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("NOAUTH"))
|
||||
})
|
||||
|
||||
// Dashboard directory with index.html + assets/ for SPA fallback and
|
||||
// static-asset tests. Cleaned up by t.TempDir.
|
||||
webDir := t.TempDir()
|
||||
indexHTML := []byte("<!doctype html><html><body>certctl dashboard</body></html>")
|
||||
if err := os.WriteFile(filepath.Join(webDir, "index.html"), indexHTML, 0o644); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
assetsDir := filepath.Join(webDir, "assets")
|
||||
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir assets: %v", err)
|
||||
}
|
||||
assetJS := []byte("console.log('certctl');")
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), assetJS, 0o644); err != nil {
|
||||
t.Fatalf("write app.js: %v", err)
|
||||
}
|
||||
|
||||
handler := buildFinalHandler(authHandler, noAuthHandler, webDir, true /* dashboardEnabled */)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantBody string // "AUTH" | "NOAUTH" | "" (== substring match against response body)
|
||||
wantBodyPrefix string
|
||||
wantStatus int
|
||||
description string
|
||||
}{
|
||||
// ---- Case (i): M-001 central regression guard ----
|
||||
{
|
||||
name: "est_cacerts_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/est/cacerts",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "EST clients cannot present Bearer tokens — must NOT be 401'd before reaching the handler (RFC 7030 §4.1.1)",
|
||||
},
|
||||
{
|
||||
name: "est_simpleenroll_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodPost,
|
||||
path: "/.well-known/est/simpleenroll",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 7030 §4.2 simpleenroll served from no-auth chain (option D)",
|
||||
},
|
||||
{
|
||||
name: "est_simplereenroll_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodPost,
|
||||
path: "/.well-known/est/simplereenroll",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 7030 §4.2.2 simplereenroll also on no-auth chain",
|
||||
},
|
||||
{
|
||||
name: "est_csrattrs_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/est/csrattrs",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 7030 §4.5 csrattrs also on no-auth chain",
|
||||
},
|
||||
|
||||
// ---- Cases (ii) + (iii): SCEP dispatch ----
|
||||
// The actual challengePassword validation lives in the service layer
|
||||
// (internal/service/scep.go). This test pins that ALL /scep* requests
|
||||
// reach the no-auth chain — the service layer is then responsible for
|
||||
// rejecting or accepting based on password contents.
|
||||
{
|
||||
name: "scep_exact_path_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/scep",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "SCEP clients authenticate via CSR challengePassword, not Bearer (RFC 8894 §3.2)",
|
||||
},
|
||||
{
|
||||
name: "scep_subpath_reaches_noauth_handler",
|
||||
method: http.MethodPost,
|
||||
path: "/scep/",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Trailing-slash variant must also ride no-auth chain",
|
||||
},
|
||||
{
|
||||
name: "scep_query_string_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/scep?operation=GetCACaps",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Query string does not affect dispatch — operation dispatch is handler-internal",
|
||||
},
|
||||
// Defensive: /scepxyz MUST NOT match the SCEP prefix (guards against
|
||||
// over-broad matching that would leak non-SCEP paths into no-auth).
|
||||
{
|
||||
name: "scepxyz_does_not_match_scep_prefix",
|
||||
method: http.MethodGet,
|
||||
path: "/scepxyz",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "SPA fallback — /scepxyz must not be confused with /scep or /scep/",
|
||||
},
|
||||
|
||||
// ---- Case (iv): RFC 5280 CRL + RFC 6960 OCSP ----
|
||||
{
|
||||
name: "pki_crl_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/pki/crl/abc123",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 5280 CRL distribution point must be served without auth",
|
||||
},
|
||||
{
|
||||
name: "pki_ocsp_no_auth_reaches_noauth_handler",
|
||||
method: http.MethodGet,
|
||||
path: "/.well-known/pki/ocsp/abc123/serial",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "RFC 6960 OCSP responder must be served without auth",
|
||||
},
|
||||
|
||||
// ---- Case (v): Authenticated API routes ----
|
||||
{
|
||||
name: "api_v1_certificates_goes_through_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/certificates",
|
||||
wantBody: "AUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Primary API surface must still require Bearer token",
|
||||
},
|
||||
{
|
||||
name: "api_v1_auth_check_goes_through_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/auth/check",
|
||||
wantBody: "AUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "auth/check validates the caller's Bearer — auth chain required",
|
||||
},
|
||||
{
|
||||
name: "api_v1_jobs_goes_through_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/jobs",
|
||||
wantBody: "AUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Jobs API is part of the privileged surface",
|
||||
},
|
||||
|
||||
// ---- Health probes bypass auth ----
|
||||
{
|
||||
name: "health_bypasses_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/health",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Docker/K8s health probes cannot carry Bearer tokens",
|
||||
},
|
||||
{
|
||||
name: "ready_bypasses_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/ready",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "Readiness probe also unauthenticated",
|
||||
},
|
||||
{
|
||||
name: "auth_info_bypasses_auth",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/auth/info",
|
||||
wantBody: "NOAUTH",
|
||||
wantStatus: http.StatusOK,
|
||||
description: "React app calls auth/info BEFORE login to discover auth mode",
|
||||
},
|
||||
|
||||
// ---- Static assets served by file server ----
|
||||
{
|
||||
name: "static_asset_served_by_file_server",
|
||||
method: http.MethodGet,
|
||||
path: "/assets/app.js",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "console.log('certctl');",
|
||||
description: "Built Vite assets served directly without auth",
|
||||
},
|
||||
|
||||
// ---- SPA fallback ----
|
||||
{
|
||||
name: "spa_fallback_serves_index_html",
|
||||
method: http.MethodGet,
|
||||
path: "/",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "Root path serves SPA entry point",
|
||||
},
|
||||
{
|
||||
name: "spa_fallback_for_unknown_route",
|
||||
method: http.MethodGet,
|
||||
path: "/certificates",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "React Router routes fall through to index.html",
|
||||
},
|
||||
{
|
||||
name: "spa_fallback_deep_route",
|
||||
method: http.MethodGet,
|
||||
path: "/certificates/mc-api-prod/detail",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "certctl dashboard",
|
||||
description: "Deep React Router routes also fall through to SPA",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (%s)", w.Code, tc.wantStatus, tc.description)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if tc.wantBody != "" && !strings.Contains(body, tc.wantBody) {
|
||||
t.Errorf("body %q does not contain %q (%s)", body, tc.wantBody, tc.description)
|
||||
}
|
||||
if tc.wantBodyPrefix != "" && !strings.HasPrefix(body, tc.wantBodyPrefix) {
|
||||
t.Errorf("body %q does not start with %q (%s)", body, tc.wantBodyPrefix, tc.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFinalHandler_NoDashboard pins the API-only (dashboard-absent)
|
||||
// dispatch behavior. When web/dist/index.html is missing, everything that's
|
||||
// not a no-auth bypass route falls through to the authenticated apiHandler
|
||||
// (pre-M-001 behavior for headless deployments). EST/SCEP/PKI still ride the
|
||||
// no-auth chain.
|
||||
func TestBuildFinalHandler_NoDashboard(t *testing.T) {
|
||||
authHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("AUTH"))
|
||||
})
|
||||
noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("NOAUTH"))
|
||||
})
|
||||
|
||||
handler := buildFinalHandler(authHandler, noAuthHandler, "/nonexistent", false /* dashboardEnabled */)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantBody string
|
||||
}{
|
||||
{"est_still_no_auth", "/.well-known/est/cacerts", "NOAUTH"},
|
||||
{"scep_still_no_auth", "/scep", "NOAUTH"},
|
||||
{"pki_still_no_auth", "/.well-known/pki/crl/x", "NOAUTH"},
|
||||
{"health_still_no_auth", "/health", "NOAUTH"},
|
||||
{"api_still_auth", "/api/v1/certificates", "AUTH"},
|
||||
// The difference: non-API, non-special paths go through auth chain when
|
||||
// there's no dashboard to serve (preserves legacy headless behavior).
|
||||
{"unknown_path_falls_through_to_auth", "/", "AUTH"},
|
||||
{"unknown_deep_path_falls_through_to_auth", "/random/path", "AUTH"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if got := w.Body.String(); !strings.Contains(got, tc.wantBody) {
|
||||
t.Errorf("body = %q, want to contain %q", got, tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
2279
cmd/server/main.go
2279
cmd/server/main.go
File diff suppressed because it is too large
Load Diff
732
cmd/server/main_test.go
Normal file
732
cmd/server/main_test.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// TestMain_HealthEndpointBypassesAuth verifies that health check endpoints
|
||||
// bypass auth middleware while protected API endpoints require auth.
|
||||
// This is the most critical test — it validates the core routing pattern used in main.go.
|
||||
func TestMain_HealthEndpointBypassesAuth(t *testing.T) {
|
||||
// Simulate the finalHandler logic from main.go with minimal setup
|
||||
// Create handler functions for health endpoints
|
||||
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
readyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ready"}`))
|
||||
})
|
||||
|
||||
authInfoHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"auth_type":"api-key"}`))
|
||||
})
|
||||
|
||||
// Protected API endpoint
|
||||
certHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`[]`))
|
||||
})
|
||||
|
||||
// Build the handler chain the same way main.go does
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: "test-secret-key"},
|
||||
})
|
||||
|
||||
// API handler with auth
|
||||
authHandler := middleware.Chain(certHandler,
|
||||
middleware.RequestID,
|
||||
middleware.Recovery,
|
||||
authMiddleware,
|
||||
)
|
||||
|
||||
// Create finalHandler matching main.go logic
|
||||
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
switch path {
|
||||
case "/health":
|
||||
healthHandler.ServeHTTP(w, r)
|
||||
case "/ready":
|
||||
readyHandler.ServeHTTP(w, r)
|
||||
case "/api/v1/auth/info":
|
||||
authInfoHandler.ServeHTTP(w, r)
|
||||
case "/api/v1/certificates":
|
||||
authHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
bypassesAuth bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "GET /health without auth",
|
||||
path: "/health",
|
||||
method: "GET",
|
||||
bypassesAuth: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GET /ready without auth",
|
||||
path: "/ready",
|
||||
method: "GET",
|
||||
bypassesAuth: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GET /api/v1/auth/info without auth",
|
||||
path: "/api/v1/auth/info",
|
||||
method: "GET",
|
||||
bypassesAuth: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GET /api/v1/certificates without auth (should fail)",
|
||||
path: "/api/v1/certificates",
|
||||
method: "GET",
|
||||
bypassesAuth: false,
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
finalHandler.ServeHTTP(w, req)
|
||||
|
||||
if tt.bypassesAuth && w.Code != tt.expectedStatus {
|
||||
t.Errorf("endpoint %s should bypass auth, got status %d, expected %d",
|
||||
tt.path, w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
if !tt.bypassesAuth && w.Code != tt.expectedStatus {
|
||||
t.Logf("endpoint %s requires auth, got status %d, expected %d (auth middleware working)",
|
||||
tt.path, w.Code, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_HealthHandlersRespond verifies health endpoints return correct responses.
|
||||
func TestMain_HealthHandlersRespond(t *testing.T) {
|
||||
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
healthHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if body := w.Body.String(); body != `{"status":"ok"}` {
|
||||
t.Errorf("expected body '{\"status\":\"ok\"}', got '%s'", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthMiddlewareRejectsUnauthorized verifies auth middleware works.
|
||||
func TestMain_AuthMiddlewareRejectsUnauthorized(t *testing.T) {
|
||||
// Create a protected endpoint
|
||||
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":"protected"}`))
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: "test-secret-key"},
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
// Request without auth should be rejected
|
||||
req := httptest.NewRequest("GET", "/api/v1/protected", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401 for unauthorized request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthMiddlewareAllowsWithValidKey verifies auth middleware allows valid keys.
|
||||
func TestMain_AuthMiddlewareAllowsWithValidKey(t *testing.T) {
|
||||
testKey := "test-secret-key"
|
||||
|
||||
// Create a protected endpoint
|
||||
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":"protected"}`))
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: testKey},
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
// Request with valid auth should be allowed
|
||||
req := httptest.NewRequest("GET", "/api/v1/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testKey)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 for authorized request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ServerConfigFromEnvironment verifies config.Load() reads env vars correctly.
|
||||
func TestMain_ServerConfigFromEnvironment(t *testing.T) {
|
||||
// Save original env vars
|
||||
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
||||
oldServerHost := os.Getenv("CERTCTL_SERVER_HOST")
|
||||
oldServerPort := os.Getenv("CERTCTL_SERVER_PORT")
|
||||
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
defer func() {
|
||||
if oldAuthType != "" {
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AUTH_TYPE")
|
||||
}
|
||||
if oldServerHost != "" {
|
||||
os.Setenv("CERTCTL_SERVER_HOST", oldServerHost)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_HOST")
|
||||
}
|
||||
if oldServerPort != "" {
|
||||
os.Setenv("CERTCTL_SERVER_PORT", oldServerPort)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_PORT")
|
||||
}
|
||||
if oldTLSCert != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", oldTLSCert)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
}
|
||||
if oldTLSKey != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", oldTLSKey)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
}
|
||||
}()
|
||||
|
||||
// HTTPS-only control plane: Validate() refuses to pass without a readable
|
||||
// cert/key pair on disk. Materialize a throwaway ECDSA P-256 pair using the
|
||||
// same generator cmd/server/tls_test.go uses for the certHolder tests.
|
||||
dir := t.TempDir()
|
||||
certPath := dir + "/server.crt"
|
||||
keyPath := dir + "/server.key"
|
||||
generateTestCert(t, certPath, keyPath, "main-test-cn")
|
||||
|
||||
// Set test env vars
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", "none")
|
||||
os.Setenv("CERTCTL_SERVER_HOST", "127.0.0.1")
|
||||
os.Setenv("CERTCTL_SERVER_PORT", "8080")
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
|
||||
// Acquisition-audit RED-003 closure (Sprint 5 ACQ, 2026-05-16):
|
||||
// deny-empty default flipped to true; supply a placeholder token
|
||||
// so Load() succeeds. The defer below restores prior env.
|
||||
oldBootstrap := os.Getenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN")
|
||||
os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||
defer func() {
|
||||
if oldBootstrap != "" {
|
||||
os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", oldBootstrap)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN")
|
||||
}
|
||||
}()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config from env vars: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Auth.Type != "none" {
|
||||
t.Errorf("Expected auth type 'none', got '%s'", cfg.Auth.Type)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("Expected server host '127.0.0.1', got '%s'", cfg.Server.Host)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Expected server port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthTypeConfiguration verifies auth type is read from config.
|
||||
func TestMain_AuthTypeConfiguration(t *testing.T) {
|
||||
// Save original env vars
|
||||
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
|
||||
oldAuthSecret := os.Getenv("CERTCTL_AUTH_SECRET")
|
||||
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
defer func() {
|
||||
if oldAuthType != "" {
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AUTH_TYPE")
|
||||
}
|
||||
if oldAuthSecret != "" {
|
||||
os.Setenv("CERTCTL_AUTH_SECRET", oldAuthSecret)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AUTH_SECRET")
|
||||
}
|
||||
if oldTLSCert != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", oldTLSCert)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_CERT_PATH")
|
||||
}
|
||||
if oldTLSKey != "" {
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", oldTLSKey)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_SERVER_TLS_KEY_PATH")
|
||||
}
|
||||
}()
|
||||
|
||||
// HTTPS-only control plane: config.Load()→Validate() refuses to pass
|
||||
// without a readable cert/key pair. Mint one throwaway pair for the whole
|
||||
// sub-test cohort — auth type toggles don't care about the TLS surface.
|
||||
dir := t.TempDir()
|
||||
certPath := dir + "/server.crt"
|
||||
keyPath := dir + "/server.key"
|
||||
generateTestCert(t, certPath, keyPath, "main-test-cn")
|
||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
|
||||
|
||||
// Set auth secret for api-key mode
|
||||
os.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||
// Acquisition-audit RED-003 closure (Sprint 5 ACQ, 2026-05-16):
|
||||
// deny-empty default flipped to true; supply a placeholder token
|
||||
// so Load() succeeds.
|
||||
oldBootstrap := os.Getenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN")
|
||||
os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||
defer func() {
|
||||
if oldBootstrap != "" {
|
||||
os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", oldBootstrap)
|
||||
} else {
|
||||
os.Unsetenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN")
|
||||
}
|
||||
}()
|
||||
|
||||
testCases := []string{"api-key", "none"}
|
||||
|
||||
for _, authType := range testCases {
|
||||
t.Run(fmt.Sprintf("auth_type_%s", authType), func(t *testing.T) {
|
||||
os.Setenv("CERTCTL_AUTH_TYPE", authType)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Auth.Type != authType {
|
||||
t.Errorf("Expected auth type '%s', got '%s'", authType, cfg.Auth.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_MiddlewareChainConstruction tests that middleware can be properly chained.
|
||||
func TestMain_MiddlewareChainConstruction(t *testing.T) {
|
||||
// Test that the middleware.Chain function works as expected
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success"))
|
||||
})
|
||||
|
||||
// Chain with RequestID and Recovery middleware
|
||||
chainedHandler := middleware.Chain(baseHandler,
|
||||
middleware.RequestID,
|
||||
middleware.Recovery,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if body := w.Body.String(); body != "success" {
|
||||
t.Errorf("expected body 'success', got '%s'", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RequestIDMiddleware verifies RequestID is added to responses.
|
||||
func TestMain_RequestIDMiddleware(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Wrap with RequestID middleware
|
||||
chainedHandler := middleware.Chain(baseHandler, middleware.RequestID)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// RequestID should be set in response header
|
||||
if rid := w.Header().Get("X-Request-ID"); rid == "" {
|
||||
t.Logf("X-Request-ID header not present (middleware may work differently)")
|
||||
} else {
|
||||
t.Logf("X-Request-ID header set: %s", rid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RecoveryMiddlewareHandlesPanic verifies recovery middleware works.
|
||||
func TestMain_RecoveryMiddlewareHandlesPanic(t *testing.T) {
|
||||
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
// Wrap with recovery middleware
|
||||
chainedHandler := middleware.Chain(panicHandler, middleware.Recovery)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Should not panic
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Should return 500 error
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Logf("Expected 500 for panicked handler, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ServiceInitialization tests that services can be instantiated.
|
||||
// This validates the initialization pattern from main.go without needing a real DB.
|
||||
func TestMain_ServiceInitialization(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
// Create test issuer registry (same as main.go does)
|
||||
issuerRegistry := service.NewIssuerRegistry(logger)
|
||||
|
||||
if issuerRegistry == nil {
|
||||
t.Fatal("issuer registry should not be nil")
|
||||
}
|
||||
|
||||
// Verify the registry has a Len() method (used in main.go)
|
||||
count := issuerRegistry.Len()
|
||||
if count < 0 {
|
||||
t.Errorf("issuer registry length should be >= 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_CORSMiddlewareSetHeaders verifies CORS headers are set.
|
||||
func TestMain_CORSMiddlewareSetHeaders(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(baseHandler, corsMiddleware)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// CORS middleware should set access control headers
|
||||
if acah := w.Header().Get("Access-Control-Allow-Origin"); acah == "" {
|
||||
t.Logf("Access-Control-Allow-Origin not set (may be by design)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_AuthNoneMode verifies auth can be disabled.
|
||||
func TestMain_AuthNoneMode(t *testing.T) {
|
||||
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"data":"protected"}`))
|
||||
})
|
||||
|
||||
// Wrap with auth middleware in "none" mode
|
||||
// auth=none equivalent: empty named-keys list is a no-op pass-through.
|
||||
authMiddleware := auth.NewAuthWithNamedKeys(nil)
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
// Request without auth should be allowed in "none" mode
|
||||
req := httptest.NewRequest("GET", "/api/v1/protected", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200 in 'none' auth mode, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RouterRegistration tests that router registration works.
|
||||
func TestMain_RouterRegistration(t *testing.T) {
|
||||
r := router.New()
|
||||
|
||||
// Register a test handler
|
||||
r.RegisterFunc("GET /test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test"))
|
||||
})
|
||||
|
||||
// Request the route
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Route should be registered and accessible
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("route not registered, got 404")
|
||||
} else if w.Code == http.StatusOK {
|
||||
t.Logf("route registered successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_RateLimiterIntegration tests rate limiter middleware works.
|
||||
func TestMain_RateLimiterIntegration(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Create rate limiter with 10 RPS, 1 burst
|
||||
rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
||||
RPS: 10,
|
||||
BurstSize: 1,
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(baseHandler, rateLimiter)
|
||||
|
||||
// First request should succeed
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code == http.StatusServiceUnavailable {
|
||||
t.Logf("rate limiter is active")
|
||||
} else {
|
||||
t.Logf("rate limiter allowed request (status %d)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ContentTypeMiddleware verifies content type is set correctly.
|
||||
func TestMain_ContentTypeMiddleware(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// Wrap with middleware that sets Content-Type
|
||||
chainedHandler := middleware.Chain(baseHandler, middleware.ContentType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Verify response
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ContentType middleware should set header
|
||||
if ct := w.Header().Get("Content-Type"); ct != "" {
|
||||
t.Logf("Content-Type header set: %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMain_ContextPropagation verifies context is propagated through middleware.
|
||||
func TestMain_ContextPropagation(t *testing.T) {
|
||||
type contextKey string
|
||||
testKey := contextKey("test-key")
|
||||
testValue := "test-value"
|
||||
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
val := r.Context().Value(testKey)
|
||||
if val == testValue {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
chainedHandler := middleware.Chain(baseHandler, middleware.RequestID)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
// Add context value before request
|
||||
req = req.WithContext(context.WithValue(req.Context(), testKey, testValue))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
chainedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Logf("Context value may not be propagated (status %d), this may be expected", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightSCEPChallengePassword is the H-2 regression guard for the
|
||||
// startup pre-flight check. The helper MUST return a non-nil error whenever
|
||||
// SCEP is enabled with an empty challenge password — that configuration
|
||||
// previously allowed unauthenticated certificate enrollment (CWE-306).
|
||||
// Disabled-SCEP and configured-password cases must pass cleanly.
|
||||
func TestPreflightSCEPChallengePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
challengePassword string
|
||||
wantErr bool
|
||||
wantErrSubstring string
|
||||
}{
|
||||
{
|
||||
name: "disabled_empty_password_ok",
|
||||
enabled: false,
|
||||
challengePassword: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "disabled_with_password_ok",
|
||||
enabled: false,
|
||||
challengePassword: "leftover-value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "enabled_empty_password_rejected",
|
||||
enabled: true,
|
||||
challengePassword: "",
|
||||
wantErr: true,
|
||||
wantErrSubstring: "CERTCTL_SCEP_CHALLENGE_PASSWORD",
|
||||
},
|
||||
{
|
||||
name: "enabled_with_password_ok",
|
||||
enabled: true,
|
||||
challengePassword: "hunter2",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "enabled_single_char_password_ok",
|
||||
enabled: true,
|
||||
challengePassword: "x",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := preflightSCEPChallengePassword(tt.enabled, tt.challengePassword)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if tt.wantErrSubstring != "" && !strings.Contains(err.Error(), tt.wantErrSubstring) {
|
||||
t.Errorf("expected error to mention %q, got: %v", tt.wantErrSubstring, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CWE-306") {
|
||||
t.Errorf("expected error to cite CWE-306 for traceability, got: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEC-003 closure (Sprint 1, 2026-05-16). Pin that the rate-limit-enabled
|
||||
// middleware stack still emits the five security headers (HSTS, XFO,
|
||||
// nosniff, Referrer-Policy, CSP) that the default stack carries.
|
||||
//
|
||||
// Pre-fix the stack rebuild at main.go ~L2079 dropped
|
||||
// securityHeadersMiddleware so flipping CERTCTL_RATE_LIMIT_ENABLED=true
|
||||
// silently turned off five browser-side defenses. This test exercises
|
||||
// the same middleware composition main.go now builds when the flag is
|
||||
// on, and asserts each header lands on the wire. A future regression
|
||||
// that removes securityHeadersMiddleware (or reorders it after the
|
||||
// rate limiter such that a 429 response misses the headers) would
|
||||
// surface here.
|
||||
// =============================================================================
|
||||
|
||||
func TestMain_RateLimitedStack_EmitsSecurityHeaders(t *testing.T) {
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Mirror the rate-limit-enabled middlewareStack from main.go.
|
||||
rateLimiter := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
||||
RPS: 1000, // high enough that the single test request isn't dropped
|
||||
BurstSize: 1000,
|
||||
})
|
||||
securityHeaders := middleware.SecurityHeaders(middleware.SecurityHeadersDefaults())
|
||||
bodyLimit := middleware.NewBodyLimit(middleware.BodyLimitConfig{MaxBytes: 1 << 20})
|
||||
|
||||
stack := []func(http.Handler) http.Handler{
|
||||
middleware.RequestID,
|
||||
middleware.Recovery,
|
||||
bodyLimit,
|
||||
securityHeaders,
|
||||
rateLimiter,
|
||||
// Skip the CORS/auth/csrf/audit layers — they aren't relevant
|
||||
// to the headers-on-response invariant we're pinning.
|
||||
}
|
||||
chained := middleware.Chain(baseHandler, stack...)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
chained.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; want 200 (rate limit should not trip on a single request)", w.Code)
|
||||
}
|
||||
wantHeaders := map[string]string{
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer-when-downgrade",
|
||||
"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'",
|
||||
}
|
||||
for name, want := range wantHeaders {
|
||||
got := w.Header().Get(name)
|
||||
if got != want {
|
||||
t.Errorf("rate-limited stack: %s = %q; want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
209
cmd/server/migrations.go
Normal file
209
cmd/server/migrations.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 8b (2026-05-14): the deferred half of
|
||||
// Sprint 8. Extracts the boot-time migration handling from main()'s
|
||||
// inline body into two unexported helpers. Different shape from
|
||||
// Sprints 1-7 (data-type relocation) and from Sprint 8a (existing
|
||||
// helper-function relocation) — this sprint crosses the
|
||||
// behavior-change boundary Sprint 8 first identified.
|
||||
//
|
||||
// What lives here
|
||||
// ===============
|
||||
// parseMigrateOnlyFlag() bool
|
||||
// Hand-parses os.Args for `--migrate-only` (NOT flag.Parse — the
|
||||
// server's config surface is otherwise env-var driven via
|
||||
// config.Load; introducing flag.Parse's global state risks
|
||||
// conflicting with other binaries that may import cmd/server later).
|
||||
//
|
||||
// runBootMigrations(cfg, db, logger, migrateOnly) (exitNow bool)
|
||||
// Owns the Phase 4 DEPL-M1 migration-via-hook posture: the
|
||||
// migrationsViaHook env-var read, the RunMigrations + RunSeed
|
||||
// gate, the --migrate-only early-exit signal, and the
|
||||
// CERTCTL_DEMO_SEED demo-overlay branch.
|
||||
//
|
||||
// Returns true ONLY when --migrate-only was set and migrations +
|
||||
// seed completed cleanly. The caller (main) translates that to
|
||||
// `return` rather than os.Exit(0) — which is the SOLE intentional
|
||||
// behavior change in this sprint (see below).
|
||||
//
|
||||
// Behavior preservation contract
|
||||
// ==============================
|
||||
// Every error path inside runBootMigrations calls os.Exit(1)
|
||||
// directly, matching the original inline behavior byte-for-byte
|
||||
// (same log message, same exit code, same no-defer-run-on-fatal
|
||||
// semantics). The error-path os.Exit(1) is intentional: when
|
||||
// migration fails at boot, the server cannot recover, and bailing
|
||||
// out without running defers is the original Go-idiomatic shape.
|
||||
//
|
||||
// The ONE behavior change: the --migrate-only SUCCESS path now
|
||||
// returns to main() rather than calling os.Exit(0) inline. This
|
||||
// has one observable effect: the `defer db.Close()` registered in
|
||||
// main() now runs at clean exit instead of being skipped. That's
|
||||
// strictly better hygiene (clean DB connection shutdown vs OS
|
||||
// reclaim). The migration work is synchronous + complete before
|
||||
// the return; nothing async is left running that db.Close() could
|
||||
// truncate.
|
||||
//
|
||||
// All other paths — the migration log messages, the seed log
|
||||
// messages, the migrationsViaHook env-var read order, the
|
||||
// RunDemoSeed gating, the per-step success/skip log lines — are
|
||||
// byte-identical to the pre-Sprint-8b inline form. Verified via
|
||||
// `go test ./cmd/server/... -count=1 -short` (which runs the
|
||||
// existing main_test.go assertions through the new call site).
|
||||
//
|
||||
// Why this is a separate commit
|
||||
// =============================
|
||||
// Sprint 8a (commit see git log) extracted the bottom-of-file
|
||||
// helpers + adapter types — pure mechanical relocation that
|
||||
// couldn't change runtime semantics. Sprint 8b crosses the boundary
|
||||
// where mechanical relocation ends: introducing a new function
|
||||
// call frame changes defer scope, panic recovery, and (in this
|
||||
// case) the exit semantics for the --migrate-only path. The
|
||||
// Phase 9 prompt's "refactor is mechanical relocation; behavior
|
||||
// change is a separate concern" rule guards against exactly this
|
||||
// shape of risk being landed without a focused review.
|
||||
//
|
||||
// Splitting Sprint 8a (mechanical) from Sprint 8b (behavior-aware)
|
||||
// means the operator's git log shows:
|
||||
// 3f1344e8 ... wire.go — no behavior change possible
|
||||
// <this> ... migrations.go — one specific behavior shift,
|
||||
// documented + intentional
|
||||
//
|
||||
// Anyone bisecting a future bug to one of these two commits gets a
|
||||
// clean "is it mechanical or did the behavior change" signal.
|
||||
|
||||
// parseMigrateOnlyFlag scans os.Args for the `--migrate-only` token
|
||||
// and returns true if found. Hand-parsed instead of using flag.Parse
|
||||
// because:
|
||||
//
|
||||
// 1. The server's entire config surface is env-var driven via
|
||||
// config.Load(). flag.Parse() introduces a global package-state
|
||||
// dependency that future binaries importing cmd/server (test
|
||||
// harnesses, CLI tools, embedded variants) would have to
|
||||
// coordinate around.
|
||||
// 2. The only flag we care about is the migration-vs-server-lifecycle
|
||||
// toggle; a hand-parser is 6 lines and has no transitive cost.
|
||||
// 3. The flag is Helm-pre-install-hook-facing (see
|
||||
// deploy/helm/certctl/templates/migration-job.yaml). Its shape is
|
||||
// pinned by that template, not by anything else; we don't need
|
||||
// flag.Parse's auto-help generation or type coercion.
|
||||
//
|
||||
// Bare arg match — no `=` value form, no short alias, no override
|
||||
// from env. Anyone passing `--migrate-only` ANYWHERE in os.Args[1:]
|
||||
// flips the flag on. Matches the original inline behavior exactly.
|
||||
func parseMigrateOnlyFlag() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--migrate-only" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runBootMigrations owns the Phase 4 DEPL-M1 boot-time migration
|
||||
// posture. Three lifecycles to support:
|
||||
//
|
||||
// (a) Compose / VM / bare-metal: server runs migrations at boot.
|
||||
// Default behavior — preserved unchanged.
|
||||
// (b) Helm with pre-install/pre-upgrade hook: the migration Job
|
||||
// runs `certctl-server --migrate-only`, does its work, and
|
||||
// exits. The server Deployment's pods then start with
|
||||
// CERTCTL_MIGRATIONS_VIA_HOOK=true set; they see the env
|
||||
// var and skip their boot-time RunMigrations call so the
|
||||
// Job's work isn't duplicated.
|
||||
// (c) Bare `certctl-server --migrate-only` invocation (e.g.
|
||||
// operator running a one-shot migration from the CLI):
|
||||
// runs migrations + seed and returns true so main returns
|
||||
// cleanly without starting the HTTP listener / scheduler /
|
||||
// signing setup.
|
||||
//
|
||||
// migrateOnly captures case (c); CERTCTL_MIGRATIONS_VIA_HOOK
|
||||
// captures case (b). Both paths converge on the same RunMigrations
|
||||
// + RunSeed code below.
|
||||
//
|
||||
// Returns true ONLY when migrateOnly is set; caller (main) handles
|
||||
// the clean exit via `return` so deferred cleanup (db.Close) runs.
|
||||
// Returns false in every other case — caller continues normal boot.
|
||||
// On any migration / seed error: os.Exit(1) inline (matches the
|
||||
// pre-extraction shape; recovery is not possible at this boot
|
||||
// stage).
|
||||
func runBootMigrations(cfg *config.Config, db *sql.DB, logger *slog.Logger, migrateOnly bool) bool {
|
||||
migrationsViaHook := strings.EqualFold(os.Getenv("CERTCTL_MIGRATIONS_VIA_HOOK"), "true")
|
||||
|
||||
if migrateOnly || !migrationsViaHook {
|
||||
logger.Info("running migrations", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunMigrations(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("migrations completed")
|
||||
} else {
|
||||
logger.Info("skipping migrations at boot (CERTCTL_MIGRATIONS_VIA_HOOK=true — Helm pre-install/pre-upgrade hook owns this work)")
|
||||
}
|
||||
|
||||
// Apply baseline seed data.
|
||||
//
|
||||
// U-3 (P1, cat-u-seed_initdb_schema_drift): pre-U-3 seed.sql was mounted
|
||||
// into postgres `/docker-entrypoint-initdb.d/` alongside a hand-curated
|
||||
// subset of migrations. Adding a migration that introduced a new column
|
||||
// referenced by seed.sql (cat-o-retry_interval_unit_mismatch /
|
||||
// policy_rules.severity / etc.) without also updating the compose volume
|
||||
// mounts caused initdb to crash on first up. Post-U-3 the compose stack
|
||||
// drops all initdb mounts; postgres comes up with empty schema, the
|
||||
// server runs RunMigrations above, then this RunSeed call lands the
|
||||
// baseline data — all from a single source of truth (this binary).
|
||||
// See internal/repository/postgres/db.go::RunSeed for the contract.
|
||||
//
|
||||
// Phase 4 DEPL-M1: same migration-via-hook gating as RunMigrations.
|
||||
// When the hook owns migrations it also owns the seed pass.
|
||||
if migrateOnly || !migrationsViaHook {
|
||||
logger.Info("applying baseline seed", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunSeed(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to apply seed data", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("seed completed")
|
||||
} else {
|
||||
logger.Info("skipping baseline seed at boot (CERTCTL_MIGRATIONS_VIA_HOOK=true — hook applies seed alongside migrations)")
|
||||
}
|
||||
|
||||
// Phase 4 DEPL-M1: --migrate-only early-exit. Migrations + seed are
|
||||
// done; the operator only asked for the migration pass. Signal main
|
||||
// to return cleanly so deferred db.Close runs (Sprint 8b improvement
|
||||
// over the pre-extraction os.Exit(0) which skipped defers).
|
||||
if migrateOnly {
|
||||
logger.Info("--migrate-only: migrations + seed complete; exiting without starting server lifecycle")
|
||||
return true
|
||||
}
|
||||
|
||||
// Apply demo overlay seed when CERTCTL_DEMO_SEED=true. Pre-U-3 the demo
|
||||
// overlay (deploy/docker-compose.demo.yml) mounted seed_demo.sql into
|
||||
// postgres `/docker-entrypoint-initdb.d/`; that broke once U-3 dropped
|
||||
// the initdb migration mounts (the demo seed references tables that
|
||||
// wouldn't exist at initdb time). The runtime path here is the
|
||||
// post-U-3 replacement. Default-off so a vanilla deploy never lands
|
||||
// fake-history rows. See postgres.RunDemoSeed for the contract.
|
||||
if cfg.Database.DemoSeed {
|
||||
logger.Info("applying demo seed (CERTCTL_DEMO_SEED=true)", "path", cfg.Database.MigrationsPath)
|
||||
if err := postgres.RunDemoSeed(db, cfg.Database.MigrationsPath); err != nil {
|
||||
logger.Error("failed to apply demo seed data", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("demo seed completed")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
204
cmd/server/preflight_demo_residual.go
Normal file
204
cmd/server/preflight_demo_residual.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Audit 2026-05-11 A-8 — demo-mode residual-grants detector. Closes the
|
||||
// deferred Phase 2 leg of HIGH-12 (cowork/auth-bundles-fixes-2026-05-10/
|
||||
// 11-high-12-demo-mode-guard.md). The HIGH-12 closure (`b81588e`) added
|
||||
// the fail-closed bind-address guard at config.Validate; the deferred
|
||||
// leg here adds a startup-time WARN (or strict refuse-startup) when
|
||||
// `actor-demo-anon` has live role grants under a non-`none` auth type.
|
||||
//
|
||||
// Why this matters: migration 000029 unconditionally seeds the
|
||||
// `ar-demo-anon-admin` row granting r-admin to actor-demo-anon. The
|
||||
// row is dormant under auth_type=api-key|oidc (the middleware chain
|
||||
// never injects the synthetic actor as the request principal), but
|
||||
// it represents a security debt: any future regression in the
|
||||
// middleware chain (a misrouted CORS preflight, a fallback in a new
|
||||
// auth-exempt route) that resolves to actor-demo-anon would re-elevate
|
||||
// to admin. The canonical acquisition-readiness narrative — "we have
|
||||
// an RBAC primitive with no synthetic-admin fallback" — requires this
|
||||
// row to be either gone or explicitly acknowledged.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// preflightDemoModeResidual runs after the DB connection is open and
|
||||
// the audit service is constructed, before the HTTPS listener starts.
|
||||
//
|
||||
// Behaviour:
|
||||
// - cfg.Auth.Type == "none" (demo mode): no-op. The residual IS the
|
||||
// runtime state at that auth type.
|
||||
// - cfg.Auth.Type != "none" + no residue: returns nil silently.
|
||||
// - cfg.Auth.Type != "none" + residue + strict=false: emits a WARN
|
||||
// log AND an `auth.demo_residual_grants_detected` audit row
|
||||
// listing the grant IDs, then returns nil.
|
||||
// - cfg.Auth.Type != "none" + residue + strict=true: emits the same
|
||||
// WARN + audit, then returns a non-nil error so the caller can
|
||||
// refuse startup.
|
||||
//
|
||||
// The audit row's actor is `system` / ActorTypeSystem; category is
|
||||
// EventCategoryAuth so audit consumers filtering on auth events see it.
|
||||
func preflightDemoModeResidual(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
db *sql.DB,
|
||||
audit *service.AuditService,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
if cfg.Auth.Type == "none" {
|
||||
// Demo mode itself. The residual is the runtime state at
|
||||
// this auth type, so warning about it would be noise.
|
||||
return nil
|
||||
}
|
||||
|
||||
residue, err := queryDemoAnonResidue(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preflight demo-mode residual: %w", err)
|
||||
}
|
||||
if len(residue) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted := make([]string, 0, len(residue))
|
||||
for _, r := range residue {
|
||||
formatted = append(formatted, r.String())
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"production startup warning: actor-demo-anon has %d residual role grant(s) "+
|
||||
"from the migration 000029 baseline or a prior demo-mode run: %s. "+
|
||||
"These grants are DORMANT at the current auth_type (%s) but represent a "+
|
||||
"security debt — any future regression that resolves an unauthenticated "+
|
||||
"request to actor-demo-anon would re-elevate to admin. Clean up via "+
|
||||
"POST /api/v1/auth/demo-residual/cleanup (requires auth.role.assign) or "+
|
||||
"`DELETE FROM actor_roles WHERE actor_id = 'actor-demo-anon';`. Set "+
|
||||
"CERTCTL_DEMO_MODE_RESIDUAL_STRICT=true to refuse startup until cleanup.",
|
||||
len(residue), strings.Join(formatted, "; "), cfg.Auth.Type,
|
||||
)
|
||||
if logger != nil {
|
||||
logger.Warn(msg, "auth_type", cfg.Auth.Type, "residue_count", len(residue))
|
||||
} else {
|
||||
slog.Warn(msg)
|
||||
}
|
||||
|
||||
if audit != nil {
|
||||
details := map[string]interface{}{
|
||||
"auth_type": cfg.Auth.Type,
|
||||
"residue_count": len(residue),
|
||||
"residue": formatted,
|
||||
}
|
||||
if err := audit.RecordEventWithCategory(
|
||||
ctx, "system", domain.ActorTypeSystem,
|
||||
"auth.demo_residual_grants_detected",
|
||||
domain.EventCategoryAuth,
|
||||
"actor_roles", authdomain.DemoAnonActorID,
|
||||
details,
|
||||
); err != nil {
|
||||
// Don't fail startup over an audit-write error; just log.
|
||||
if logger != nil {
|
||||
logger.Warn("preflight demo-mode residual: audit record failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Auth.DemoModeResidualStrict {
|
||||
return fmt.Errorf(
|
||||
"startup refused: actor-demo-anon has %d residual role grant(s) and "+
|
||||
"CERTCTL_DEMO_MODE_RESIDUAL_STRICT=true. Remove the rows before restarting",
|
||||
len(residue),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// demoAnonResidueRow describes a single live actor_roles row whose
|
||||
// actor_id matches the synthetic demo-anon ID.
|
||||
type demoAnonResidueRow struct {
|
||||
RoleID string
|
||||
ScopeType string
|
||||
ScopeID string
|
||||
GrantedAt time.Time
|
||||
}
|
||||
|
||||
// String renders one row as `role@scope (granted ts)`. Used both in
|
||||
// the WARN log message and in the audit row's residue list.
|
||||
func (r demoAnonResidueRow) String() string {
|
||||
scope := r.ScopeType
|
||||
if r.ScopeID != "" {
|
||||
scope = fmt.Sprintf("%s/%s", r.ScopeType, r.ScopeID)
|
||||
}
|
||||
return fmt.Sprintf("%s@%s (granted %s)", r.RoleID, scope, r.GrantedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// queryDemoAnonResidue runs the canonical query for the residue
|
||||
// detector + the cleanup endpoint. Kept in one place so the two
|
||||
// surfaces can't drift on which rows count as "live".
|
||||
//
|
||||
// "Live" = not expired. Rows with expires_at <= NOW() are treated
|
||||
// as already gone (they have no effect even if the actor were to be
|
||||
// injected as the principal).
|
||||
func queryDemoAnonResidue(ctx context.Context, db *sql.DB) ([]demoAnonResidueRow, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("db is nil")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT role_id, scope_type, COALESCE(scope_id, '') AS scope_id, granted_at
|
||||
FROM actor_roles
|
||||
WHERE actor_id = $1
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY granted_at ASC, role_id ASC, scope_type ASC, COALESCE(scope_id, '') ASC
|
||||
`, authdomain.DemoAnonActorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query actor_roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []demoAnonResidueRow
|
||||
for rows.Next() {
|
||||
var r demoAnonResidueRow
|
||||
if err := rows.Scan(&r.RoleID, &r.ScopeType, &r.ScopeID, &r.GrantedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan actor_roles row: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate actor_roles rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deleteDemoAnonResidue removes every live actor_roles row for the
|
||||
// synthetic demo-anon actor. Returns the count removed. Used by the
|
||||
// POST /api/v1/auth/demo-residual/cleanup handler. Idempotent — a
|
||||
// follow-up call returns 0.
|
||||
func deleteDemoAnonResidue(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, errors.New("db is nil")
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `
|
||||
DELETE FROM actor_roles
|
||||
WHERE actor_id = $1
|
||||
`, authdomain.DemoAnonActorID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete actor_roles: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
295
cmd/server/preflight_demo_residual_test.go
Normal file
295
cmd/server/preflight_demo_residual_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// Audit 2026-05-11 A-8 — preflight + cleanup regression tests for the
|
||||
// demo-mode residual-grants detector. Testcontainers-backed because the
|
||||
// preflight runs raw SQL against actor_roles; mock-DB-only would not
|
||||
// catch a SQL-shape regression. Gated by testing.Short() to keep the
|
||||
// fast loop fast (matching internal/repository/postgres/* pattern).
|
||||
|
||||
var (
|
||||
a8DBOnce sync.Once
|
||||
a8DB *sql.DB
|
||||
a8Skip bool
|
||||
a8SkipMu sync.Mutex
|
||||
)
|
||||
|
||||
func setupA8DB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("preflight A-8 test requires Postgres (testcontainers); skipping under -short")
|
||||
}
|
||||
a8DBOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "postgres:16-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_DB": "certctl_test_a8",
|
||||
"POSTGRES_USER": "certctl",
|
||||
"POSTGRES_PASSWORD": "certctl",
|
||||
},
|
||||
WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||
}
|
||||
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
a8SkipMu.Lock()
|
||||
a8Skip = true
|
||||
a8SkipMu.Unlock()
|
||||
t.Logf("skipping A-8 testcontainers preflight (docker unavailable): %v", err)
|
||||
return
|
||||
}
|
||||
host, err := c.Host(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get container host: %v", err)
|
||||
}
|
||||
port, err := c.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
t.Fatalf("get mapped port: %v", err)
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://certctl:certctl@%s:%s/certctl_test_a8?sslmode=disable", host, port.Port())
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
// Run all migrations so actor_roles exists with the migration
|
||||
// 000029 seed row (`ar-demo-anon-admin`).
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "migrations")
|
||||
if _, err := os.Stat(migrationsDir); err != nil {
|
||||
t.Fatalf("locate migrations dir %q: %v", migrationsDir, err)
|
||||
}
|
||||
if err := postgres.RunMigrations(db, migrationsDir); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
a8DB = db
|
||||
})
|
||||
|
||||
a8SkipMu.Lock()
|
||||
skip := a8Skip
|
||||
a8SkipMu.Unlock()
|
||||
if skip {
|
||||
t.Skip("A-8 testcontainers unavailable; skipping")
|
||||
}
|
||||
return a8DB
|
||||
}
|
||||
|
||||
// resetA8Residue clears the actor_roles rows for actor-demo-anon AND
|
||||
// re-inserts the migration 000029 baseline. Used by tests that need a
|
||||
// known "post-fresh-migration" state.
|
||||
func resetA8Residue(t *testing.T, db *sql.DB, seedBaseline bool) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(context.Background(),
|
||||
`DELETE FROM actor_roles WHERE actor_id = 'actor-demo-anon'`); err != nil {
|
||||
t.Fatalf("reset actor_roles: %v", err)
|
||||
}
|
||||
if seedBaseline {
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, granted_by, tenant_id)
|
||||
VALUES ('ar-demo-anon-admin', 'actor-demo-anon', 'Anonymous', 'r-admin', NOW(), 'system', 't-default')
|
||||
`); err != nil {
|
||||
t.Fatalf("reseed baseline: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_DemoModeActive_Skips proves the
|
||||
// preflight short-circuits when Auth.Type=none regardless of residue.
|
||||
// Demo mode IS the active runtime state at that auth type, so warning
|
||||
// would be noise.
|
||||
func TestPreflightDemoModeResidual_DemoModeActive_Skips(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true) // baseline IS present
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "none"
|
||||
cfg.Auth.DemoModeResidualStrict = true // would refuse if checked
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, nil, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil under Auth.Type=none, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_NoResidue_Passes proves a fully-clean
|
||||
// actor_roles state passes without WARN.
|
||||
func TestPreflightDemoModeResidual_NoResidue_Passes(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, false) // explicitly empty
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "api-key"
|
||||
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil with empty residue, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_HasResidue_LogsAndAudits proves the
|
||||
// migration 000029 baseline produces a WARN + audit row but does NOT
|
||||
// fail startup in default (non-strict) mode.
|
||||
func TestPreflightDemoModeResidual_HasResidue_LogsAndAudits(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "api-key"
|
||||
cfg.Auth.DemoModeResidualStrict = false
|
||||
|
||||
auditRepo := postgres.NewAuditRepository(db)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, auditService, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("non-strict mode must NOT fail startup with residue, got %v", err)
|
||||
}
|
||||
|
||||
// Audit row should be present for the call.
|
||||
rows, err := db.QueryContext(context.Background(), `
|
||||
SELECT action, event_category, resource_id
|
||||
FROM audit_events
|
||||
WHERE action = 'auth.demo_residual_grants_detected'
|
||||
ORDER BY occurred_at DESC LIMIT 1
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("audit_events query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("expected at least one auth.demo_residual_grants_detected row")
|
||||
}
|
||||
var action, category, resourceID string
|
||||
if err := rows.Scan(&action, &category, &resourceID); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if action != "auth.demo_residual_grants_detected" {
|
||||
t.Errorf("action = %q, want auth.demo_residual_grants_detected", action)
|
||||
}
|
||||
if category != "auth" {
|
||||
t.Errorf("event_category = %q, want auth", category)
|
||||
}
|
||||
if resourceID != "actor-demo-anon" {
|
||||
t.Errorf("resource_id = %q, want actor-demo-anon", resourceID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightDemoModeResidual_StrictMode_RefusesStartup proves the
|
||||
// flag pivots WARN → fail.
|
||||
func TestPreflightDemoModeResidual_StrictMode_RefusesStartup(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Type = "api-key"
|
||||
cfg.Auth.DemoModeResidualStrict = true
|
||||
|
||||
err := preflightDemoModeResidual(context.Background(), cfg, db, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("strict mode + residue: expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "actor-demo-anon") {
|
||||
t.Errorf("err = %q, want mention of actor-demo-anon", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CERTCTL_DEMO_MODE_RESIDUAL_STRICT") {
|
||||
t.Errorf("err = %q, want mention of CERTCTL_DEMO_MODE_RESIDUAL_STRICT", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDemoAnonResidueRow_String pins the formatting of the residue
|
||||
// detail entry — used both in the WARN log AND the audit row's
|
||||
// `residue` slice. Two cases: NULL scope_id (global scope) and
|
||||
// non-empty scope_id (profile/issuer scope).
|
||||
func TestDemoAnonResidueRow_String(t *testing.T) {
|
||||
ts, _ := time.Parse(time.RFC3339, "2026-05-11T12:34:56Z")
|
||||
cases := []struct {
|
||||
name string
|
||||
r demoAnonResidueRow
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "global_scope",
|
||||
r: demoAnonResidueRow{RoleID: "r-admin", ScopeType: "global", ScopeID: "", GrantedAt: ts},
|
||||
want: "r-admin@global (granted 2026-05-11T12:34:56Z)",
|
||||
},
|
||||
{
|
||||
name: "scoped",
|
||||
r: demoAnonResidueRow{RoleID: "r-operator", ScopeType: "profile", ScopeID: "p-prod", GrantedAt: ts},
|
||||
want: "r-operator@profile/p-prod (granted 2026-05-11T12:34:56Z)",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.r.String()
|
||||
if got != c.want {
|
||||
t.Errorf("String() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteDemoAnonResidue_Idempotent proves the cleanup helper is
|
||||
// re-entrant: a second call after a successful first call returns 0.
|
||||
func TestDeleteDemoAnonResidue_Idempotent(t *testing.T) {
|
||||
db := setupA8DB(t)
|
||||
resetA8Residue(t, db, true)
|
||||
|
||||
n, err := deleteDemoAnonResidue(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("first delete: %v", err)
|
||||
}
|
||||
if n < 1 {
|
||||
t.Fatalf("first delete: count = %d, want >= 1", n)
|
||||
}
|
||||
|
||||
n, err = deleteDemoAnonResidue(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("second delete: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("second delete (idempotent): count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryDemoAnonResidue_NilDB pins the nil-safety contract.
|
||||
func TestQueryDemoAnonResidue_NilDB(t *testing.T) {
|
||||
_, err := queryDemoAnonResidue(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil db, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteDemoAnonResidue_NilDB pins the nil-safety contract.
|
||||
func TestDeleteDemoAnonResidue_NilDB(t *testing.T) {
|
||||
_, err := deleteDemoAnonResidue(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on nil db, got nil")
|
||||
}
|
||||
}
|
||||
156
cmd/server/preflight_scep_intune_test.go
Normal file
156
cmd/server/preflight_scep_intune_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 + Intune master prompt §13 line 1853 acceptance —
|
||||
// boot regression tests for preflightSCEPIntuneTrustAnchor. Closed in
|
||||
// the 2026-04-29 audit-closure bundle (Phase F).
|
||||
//
|
||||
// Spec text:
|
||||
// "clean boot with Intune disabled (backward compat)" and
|
||||
// "refuses-to-start with broken per-profile config (PathID logged)."
|
||||
//
|
||||
// These three tests exercise the function the cmd/server/main.go boot
|
||||
// loop calls per profile. We can't (and don't want to) run main()
|
||||
// itself in a unit test — that would require docker compose + a real
|
||||
// listener. Instead we drive the function directly and assert its
|
||||
// contract holds: nil error on disabled, structured error containing
|
||||
// the PathID on enabled-but-broken.
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
|
||||
}
|
||||
|
||||
// TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat — when
|
||||
// the profile has Intune disabled, preflight returns (nil, nil) and
|
||||
// MUST NOT touch the filesystem. This is the dominant path in
|
||||
// production: most operators run SCEP without Intune. A regression
|
||||
// here would make every non-Intune deploy fail boot with a confusing
|
||||
// "trust anchor missing" error.
|
||||
func TestPreflightSCEPIntuneTrustAnchor_DisabledIsBackwardCompat(t *testing.T) {
|
||||
holder, err := preflightSCEPIntuneTrustAnchor(false, "corp", "", discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("disabled preflight should be a no-op, got error: %v", err)
|
||||
}
|
||||
if holder != nil {
|
||||
t.Errorf("disabled preflight should return nil holder, got %#v", holder)
|
||||
}
|
||||
|
||||
// Confirm the no-touch contract: even if PathID + path are both
|
||||
// non-empty, disabled=false short-circuits before any I/O. Pass a
|
||||
// path that doesn't exist — the call MUST still succeed.
|
||||
holder, err = preflightSCEPIntuneTrustAnchor(false, "iot", "/tmp/this-file-does-not-exist-12345.pem", discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("disabled preflight with non-existent path should still succeed: %v", err)
|
||||
}
|
||||
if holder != nil {
|
||||
t.Error("disabled preflight should return nil holder even with non-existent path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID —
|
||||
// when the profile has Intune enabled but the trust-anchor file
|
||||
// doesn't exist, preflight returns an error whose text contains the
|
||||
// literal PathID. Operators grep their boot log for the PathID to
|
||||
// triage which profile is broken in a multi-profile deploy.
|
||||
func TestPreflightSCEPIntuneTrustAnchor_BrokenConfigRefusesWithPathID(t *testing.T) {
|
||||
missingPath := filepath.Join(t.TempDir(), "this-trust-anchor-was-never-written.pem")
|
||||
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp", missingPath, discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when trust anchor file is missing, got nil")
|
||||
}
|
||||
if holder != nil {
|
||||
t.Errorf("expected nil holder on broken config, got %#v", holder)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="corp"`) {
|
||||
t.Errorf("error should contain PathID for operator log-grep: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), missingPath) {
|
||||
t.Errorf("error should contain the path for operator log-grep: %v", err)
|
||||
}
|
||||
|
||||
// Empty PathID (legacy /scep root) — the error MUST surface a
|
||||
// readable label, not an empty quoted string that looks like a
|
||||
// missing variable.
|
||||
_, err = preflightSCEPIntuneTrustAnchor(true, "", missingPath, discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error on broken legacy-root config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="<root>"`) {
|
||||
t.Errorf("error should label empty PathID as <root>: %v", err)
|
||||
}
|
||||
|
||||
// Empty path with enabled=true — distinct error path (path-empty
|
||||
// vs file-missing). Spec requires this branch ALSO surfaces the
|
||||
// PathID so the operator's grep narrows to the profile.
|
||||
_, err = preflightSCEPIntuneTrustAnchor(true, "iot", "", discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when trust anchor path is empty")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="iot"`) {
|
||||
t.Errorf("empty-path error should contain PathID for operator log-grep: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses — an
|
||||
// expired Connector signing cert in the trust anchor file is the
|
||||
// silent-failure mode this preflight is built to catch. Without the
|
||||
// gate, the SCEP server boots cleanly and then rejects every Intune
|
||||
// enrollment at runtime with "no trust anchor recognizes this
|
||||
// signature" — confusing for the operator whose Connector is healthy
|
||||
// (the cert just expired without rotation). Pin the contract: the
|
||||
// boot MUST refuse with an error that names the expired cert's
|
||||
// subject CN so the operator knows what to rotate.
|
||||
func TestPreflightSCEPIntuneTrustAnchor_ExpiredTrustAnchorRefuses(t *testing.T) {
|
||||
// Build a deterministic ECDSA cert with NotAfter 1 hour in the past.
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "intune-connector-rotated-must-replace"},
|
||||
NotBefore: now.Add(-2 * time.Hour),
|
||||
NotAfter: now.Add(-1 * time.Hour), // expired
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(t.TempDir(), "intune-expired.pem")
|
||||
if err := os.WriteFile(bundlePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatalf("write expired cert: %v", err)
|
||||
}
|
||||
|
||||
holder, err := preflightSCEPIntuneTrustAnchor(true, "corp-expired", bundlePath, discardLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected refuse-to-start on expired trust anchor cert, got nil error")
|
||||
}
|
||||
if holder != nil {
|
||||
t.Errorf("expected nil holder on expired-cert refusal, got %#v", holder)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `PathID="corp-expired"`) {
|
||||
t.Errorf("error should contain PathID for operator log-grep: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "intune-connector-rotated-must-replace") {
|
||||
t.Errorf("error should contain the expired cert's subject CN so the operator knows what to rotate: %v", err)
|
||||
}
|
||||
}
|
||||
227
cmd/server/preflight_scep_ra_test.go
Normal file
227
cmd/server/preflight_scep_ra_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 Phase 1: preflightSCEPRACertKey covers the six failure
|
||||
// modes spelled out in the helper's docblock plus the no-op-when-disabled
|
||||
// path. Mirrors TestPreflightEnrollmentIssuer's table-driven shape so the
|
||||
// suite stays uniform for the next reviewer.
|
||||
//
|
||||
// Each test materialises a real ECDSA P-256 cert/key pair on disk (rather
|
||||
// than mocking) so the tls.X509KeyPair path is exercised end-to-end —
|
||||
// catches drift in stdlib cert-parsing semantics that a mock would hide.
|
||||
|
||||
func TestPreflightSCEPRACertKey_Disabled_NoOp(t *testing.T) {
|
||||
// Enabled=false short-circuits before any path validation; should pass
|
||||
// even with empty paths (mirrors preflightSCEPChallengePassword).
|
||||
if err := preflightSCEPRACertKey(false, "", ""); err != nil {
|
||||
t.Fatalf("disabled SCEP returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_EnabledMissingPaths_Refuses(t *testing.T) {
|
||||
// Validate() also catches this; preflight reports the specific failure
|
||||
// with a more actionable error string + os.Exit(1) at the call site.
|
||||
cases := []struct {
|
||||
name string
|
||||
certPath string
|
||||
keyPath string
|
||||
}{
|
||||
{"both_empty", "", ""},
|
||||
{"cert_only", "/tmp/ra.crt", ""},
|
||||
{"key_only", "", "/tmp/ra.key"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := preflightSCEPRACertKey(true, tc.certPath, tc.keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing paths, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RA pair missing") {
|
||||
t.Errorf("error should mention RA pair missing, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_KeyWorldReadable_Refuses(t *testing.T) {
|
||||
// Defense-in-depth: even a perfectly-valid RA pair must be rejected if
|
||||
// the key file is mode 0644 (world-readable). The deploy convention is
|
||||
// 0600 — owner read/write only.
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
// Re-chmod the key to 0644 to trigger the gate.
|
||||
if err := os.Chmod(keyPath, 0o644); err != nil {
|
||||
t.Fatalf("chmod failed: %v", err)
|
||||
}
|
||||
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for world-readable key, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "insecure permissions") {
|
||||
t.Errorf("error should mention insecure permissions, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_ValidPair_Accepts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
if err := preflightSCEPRACertKey(true, certPath, keyPath); err != nil {
|
||||
t.Fatalf("valid RA pair rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_ExpiredCert_Refuses(t *testing.T) {
|
||||
// An RA cert past NotAfter would cause every conformant SCEP client to
|
||||
// reject the CertRep signature. Catch it at startup.
|
||||
dir := t.TempDir()
|
||||
certPath, keyPath := writeECDSARAPair(t, dir, time.Now().Add(-1*time.Hour))
|
||||
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for expired cert, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "expired") {
|
||||
t.Errorf("error should mention expired, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_MismatchedPair_Refuses(t *testing.T) {
|
||||
// tls.X509KeyPair detects the cert/key mismatch; preflight should
|
||||
// surface it with an actionable error (cert + key are halves of
|
||||
// different RA pairs — common multi-profile typo).
|
||||
dir := t.TempDir()
|
||||
certPath, _ := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
_, keyPath := writeECDSARAPair(t, dir, time.Now().Add(30*24*time.Hour))
|
||||
// Re-write the key path under a unique name to avoid collision with
|
||||
// the first pair's file (writeECDSARAPair would have overwritten).
|
||||
err := preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for mismatched pair, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("error should mention invalid pair, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_MissingFiles_Refuses(t *testing.T) {
|
||||
// Both files referenced but neither exists — a typo or a fresh deploy
|
||||
// where the operator forgot to mount the secret. Cert-path failure mode
|
||||
// is checked first because key-path stat is the first os call after
|
||||
// the empty-string check.
|
||||
dir := t.TempDir()
|
||||
missingCert := filepath.Join(dir, "ra.crt")
|
||||
missingKey := filepath.Join(dir, "ra.key")
|
||||
err := preflightSCEPRACertKey(true, missingCert, missingKey)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing files, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stat failed") && !strings.Contains(err.Error(), "read failed") {
|
||||
t.Errorf("error should mention stat/read failure, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSCEPRACertKey_UnsupportedAlg_Refuses(t *testing.T) {
|
||||
// Ed25519 isn't supported by the CMS signature path RFC 8894 §3.5.2
|
||||
// advertises. Catch this at startup to avoid runtime failures the
|
||||
// first time a client sends a real PKIMessage.
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "ra.crt")
|
||||
keyPath := filepath.Join(dir, "ra.key")
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "ra-ed25519"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
|
||||
err = preflightSCEPRACertKey(true, certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for ed25519 RA cert, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported public-key algorithm") &&
|
||||
!strings.Contains(err.Error(), "invalid") {
|
||||
// tls.X509KeyPair may reject ed25519 SCEP-signing keys earlier
|
||||
// than our explicit alg gate; accept either failure path so the
|
||||
// test is robust against stdlib changes.
|
||||
t.Errorf("error should mention algorithm/invalid, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeECDSARAPair generates a fresh ECDSA P-256 self-signed cert + key,
|
||||
// writes them to dir/ra-<rand>.crt + ra-<rand>.key with the cert at 0644
|
||||
// and the key at 0600 (the production deploy mode). Returns the two paths.
|
||||
func writeECDSARAPair(t *testing.T, dir string, notAfter time.Time) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "ra-test"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
// Use a unique suffix so successive calls within the same test don't
|
||||
// overwrite each other (the mismatched-pair test relies on this).
|
||||
suffix := tmpl.SerialNumber.String()
|
||||
certPath = filepath.Join(dir, "ra-"+suffix+".crt")
|
||||
keyPath = filepath.Join(dir, "ra-"+suffix+".key")
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
return certPath, keyPath
|
||||
}
|
||||
100
cmd/server/preflight_test.go
Normal file
100
cmd/server/preflight_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// fakeIssuerConn implements service.IssuerConnector enough for preflight tests.
|
||||
type fakeIssuerConn struct {
|
||||
caCertPEM string
|
||||
caCertErr error
|
||||
}
|
||||
|
||||
func (f *fakeIssuerConn) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int, mustStaple bool) (*service.IssuanceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIssuerConn) GenerateCRL(ctx context.Context, revokedCerts []service.CRLEntry) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) SignOCSPResponse(ctx context.Context, req service.OCSPSignRequest) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeIssuerConn) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return f.caCertPEM, f.caCertErr
|
||||
}
|
||||
func (f *fakeIssuerConn) GetRenewalInfo(ctx context.Context, certPEM string) (*service.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestPreflightEnrollmentIssuer covers Bundle-4 / L-005 startup validation
|
||||
// for EST/SCEP issuer binding.
|
||||
func TestPreflightEnrollmentIssuer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
issuer service.IssuerConnector
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "nil_connector_fails",
|
||||
issuer: nil,
|
||||
wantErr: true,
|
||||
errContains: "connector is nil",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_error_fails",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertErr: errStub("ACME issuers do not provide a static CA certificate"),
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "cannot serve CA certificate",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_empty_pem_fails",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertPEM: "",
|
||||
caCertErr: nil,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "empty PEM",
|
||||
},
|
||||
{
|
||||
name: "issuer_returns_valid_pem_succeeds",
|
||||
issuer: &fakeIssuerConn{
|
||||
caCertPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
|
||||
caCertErr: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := preflightEnrollmentIssuer(context.Background(), "EST", "iss-test", tc.issuer)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.wantErr && tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Fatalf("error %q missing substring %q", err.Error(), tc.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errStub is a tiny error wrapper so test cases can use string literals
|
||||
// without importing fmt in every test struct entry.
|
||||
type errStub string
|
||||
|
||||
func (e errStub) Error() string { return string(e) }
|
||||
199
cmd/server/tls.go
Normal file
199
cmd/server/tls.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// certHolder stores the server's TLS certificate under a mutex so it can be
|
||||
// swapped atomically by a SIGHUP handler without restarting the server. A
|
||||
// *tls.Config that wires GetCertificate → (*certHolder).GetCertificate reads
|
||||
// through the holder on every ClientHello, so a successful reload takes
|
||||
// effect on the next new connection immediately and without dropping
|
||||
// in-flight requests.
|
||||
//
|
||||
// Concurrency: GetCertificate is invoked from crypto/tls handshake goroutines
|
||||
// on every new inbound connection; Reload is invoked from the SIGHUP watcher
|
||||
// goroutine. sync.Mutex is sufficient — TLS handshakes are not an inner-loop
|
||||
// hot path and the critical section is a single pointer read.
|
||||
type certHolder struct {
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate
|
||||
certPath string
|
||||
keyPath string
|
||||
}
|
||||
|
||||
// newCertHolder loads the initial cert+key pair from disk and returns a
|
||||
// holder ready to serve handshakes. Returns a non-nil error if either file
|
||||
// is missing, unreadable, or the pair does not round-trip through
|
||||
// tls.LoadX509KeyPair (for example the key does not sign the cert). The
|
||||
// caller is expected to treat a non-nil error as a fail-loud startup gate
|
||||
// and os.Exit(1) — the HTTPS-everywhere milestone (§3 locked decisions)
|
||||
// prohibits plaintext HTTP fallback.
|
||||
func newCertHolder(certPath, keyPath string) (*certHolder, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load TLS cert/key (cert=%q key=%q): %w", certPath, keyPath, err)
|
||||
}
|
||||
return &certHolder{
|
||||
cert: &cert,
|
||||
certPath: certPath,
|
||||
keyPath: keyPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCertificate is the tls.Config.GetCertificate hook. Returns the current
|
||||
// cert under the holder's mutex. ClientHelloInfo is ignored — the control
|
||||
// plane does not multiplex by SNI.
|
||||
func (h *certHolder) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.cert, nil
|
||||
}
|
||||
|
||||
// Reload re-reads the cert+key pair from disk and swaps the holder
|
||||
// atomically on success. On failure the holder retains its previous cert
|
||||
// and the error is propagated to the caller — the SIGHUP watcher logs and
|
||||
// keeps serving the previous cert rather than crashing on a bad reload.
|
||||
// This is deliberately "fail-safe on reload, fail-loud on startup": an
|
||||
// operator rotating certs wants a recoverable error, not a restart loop.
|
||||
func (h *certHolder) Reload() error {
|
||||
cert, err := tls.LoadX509KeyPair(h.certPath, h.keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload TLS cert/key (cert=%q key=%q): %w", h.certPath, h.keyPath, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.cert = &cert
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchSIGHUP installs a signal handler that calls Reload() on each SIGHUP.
|
||||
// The returned stop function closes the internal done channel and stops
|
||||
// signal delivery so the goroutine can exit cleanly during shutdown. Errors
|
||||
// from Reload are logged but do not terminate the watcher — the operator
|
||||
// can fix the files and send another SIGHUP.
|
||||
//
|
||||
// Defensive design note: this deliberately does NOT panic on Reload error
|
||||
// even though HTTPS is mission-critical. A rotation that writes half-files
|
||||
// (operator overwrites cert.pem then key.pem as two separate copies) would
|
||||
// otherwise crash the server mid-rotation. Logging + retaining the old
|
||||
// cert gives the operator a bounded window to fix and re-SIGHUP.
|
||||
func (h *certHolder) watchSIGHUP(logger *slog.Logger) (stop func()) {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGHUP)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
if err := h.Reload(); err != nil {
|
||||
logger.Error("TLS cert reload failed; continuing with previous cert",
|
||||
"error", err,
|
||||
"cert_path", h.certPath,
|
||||
"key_path", h.keyPath)
|
||||
continue
|
||||
}
|
||||
logger.Info("TLS cert reloaded via SIGHUP",
|
||||
"cert_path", h.certPath,
|
||||
"key_path", h.keyPath)
|
||||
case <-done:
|
||||
signal.Stop(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// buildServerTLSConfig returns the TLS 1.3-only *tls.Config for the HTTPS
|
||||
// server. Pinned per HTTPS-everywhere milestone §2.1 + §3 locked decisions:
|
||||
//
|
||||
// - MinVersion: TLS 1.3 (no TLS 1.2 escape hatch). Go 1.25's crypto/tls
|
||||
// automatically rejects older versions.
|
||||
// - CurvePreferences: explicit [X25519, P-256]. Explicit ordering keeps
|
||||
// the handshake deterministic and documents the accepted curves.
|
||||
// - No CipherSuites field: TLS 1.3 cipher suites are not negotiable in
|
||||
// the handshake (all three mandatory suites — AES-128-GCM-SHA256,
|
||||
// AES-256-GCM-SHA384, CHACHA20-POLY1305-SHA256 — are always offered).
|
||||
// Go's crypto/tls ignores CipherSuites for TLS 1.3.
|
||||
// - GetCertificate: reads through the holder so SIGHUP rotations take
|
||||
// effect on the next new connection without a restart. Setting
|
||||
// tls.Config.Certificates directly would pin the first-loaded cert
|
||||
// and defeat SIGHUP reload.
|
||||
func buildServerTLSConfig(holder *certHolder) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||
GetCertificate: holder.GetCertificate,
|
||||
}
|
||||
}
|
||||
|
||||
// buildServerTLSConfigWithMTLS extends buildServerTLSConfig with a client-cert
|
||||
// trust pool for the SCEP/EST mTLS sibling routes.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5 introduced this for the
|
||||
// /scep-mtls/<pathID> route; EST RFC 7030 hardening master bundle Phase 2
|
||||
// extended it so the same TLS listener also serves /.well-known/est-mtls/
|
||||
// <pathID>. Both protocols' mTLS profiles contribute their trust bundles
|
||||
// to a UNION pool that the caller (cmd/server/main.go) builds by walking
|
||||
// every enabled mTLS profile's bundle bytes once. The per-protocol
|
||||
// handlers re-verify against just THIS profile's bundle (so an EST-mTLS
|
||||
// bootstrap cert can't enroll against a SCEP-mTLS profile and vice versa).
|
||||
//
|
||||
// ClientAuth: VerifyClientCertIfGiven — request a cert during handshake; if
|
||||
// the client presents one, verify it against the union pool; if absent, the
|
||||
// request still reaches the handler and the per-route handler decides
|
||||
// whether to accept. Critical that we do NOT use RequireAndVerifyClientCert
|
||||
// here — that would break the standard /scep + /.well-known/est routes
|
||||
// (challenge-password-only / unauth-or-Basic, no client cert expected).
|
||||
//
|
||||
// Pass clientCAs == nil to disable mTLS (no profile opted in across either
|
||||
// protocol). The function then returns the same shape as
|
||||
// buildServerTLSConfig.
|
||||
func buildServerTLSConfigWithMTLS(holder *certHolder, clientCAs *x509.CertPool) *tls.Config {
|
||||
cfg := buildServerTLSConfig(holder)
|
||||
if clientCAs != nil {
|
||||
cfg.ClientCAs = clientCAs
|
||||
cfg.ClientAuth = tls.VerifyClientCertIfGiven
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
|
||||
// non-nil error when the TLS configuration is missing or the cert+key pair
|
||||
// cannot be parsed, so the caller refuses to start the control plane
|
||||
// (HTTPS-everywhere §3 locked decisions: no plaintext HTTP fallback).
|
||||
//
|
||||
// Duplicates the emptiness + stat + parse checks in config.Validate() for
|
||||
// defense in depth, mirroring the pattern established by
|
||||
// preflightSCEPChallengePassword (which itself duplicates
|
||||
// config.Validate()'s SCEP check for CWE-306). Extracted into a separate
|
||||
// function so the gate is unit-testable without booting the full server.
|
||||
func preflightServerTLS(certPath, keyPath string) error {
|
||||
if certPath == "" {
|
||||
return fmt.Errorf("CERTCTL_SERVER_TLS_CERT_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
|
||||
}
|
||||
if keyPath == "" {
|
||||
return fmt.Errorf("CERTCTL_SERVER_TLS_KEY_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
|
||||
}
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
return fmt.Errorf("TLS cert file %q unreadable: %w (see docs/tls.md)", certPath, err)
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
return fmt.Errorf("TLS key file %q unreadable: %w (see docs/tls.md)", keyPath, err)
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil {
|
||||
return fmt.Errorf("TLS cert/key pair invalid (cert=%q key=%q): %w (see docs/tls.md)", certPath, keyPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
418
cmd/server/tls_test.go
Normal file
418
cmd/server/tls_test.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateTestCert writes a PEM-encoded self-signed leaf cert + ECDSA P-256
|
||||
// key pair to certPath/keyPath. The subject is derived from cn so tests can
|
||||
// tell reloaded certs apart from original certs by re-parsing the served
|
||||
// Certificate and comparing the CN.
|
||||
func generateTestCert(t *testing.T, certPath, keyPath, cn string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("x509.CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalECPrivateKey: %v", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readCertCN returns the CommonName from the leaf cert currently held by the
|
||||
// holder, by exercising the same GetCertificate path the tls handshake would
|
||||
// take. Lets tests assert which generation of the cert is being served.
|
||||
func readCertCN(t *testing.T, h *certHolder) string {
|
||||
t.Helper()
|
||||
c, err := h.GetCertificate(&tls.ClientHelloInfo{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetCertificate: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(c.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertificate: %v", err)
|
||||
}
|
||||
return leaf.Subject.CommonName
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func TestNewCertHolder_ValidPair_LoadsCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-initial")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
if got := readCertCN(t, h); got != "cn-initial" {
|
||||
t.Fatalf("CN mismatch: got %q want %q", got, "cn-initial")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCertHolder_MissingFile_Fails(t *testing.T) {
|
||||
_, err := newCertHolder("/nonexistent/cert.pem", "/nonexistent/key.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing files, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCertHolder_MalformedCert_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "bad.crt")
|
||||
keyPath := filepath.Join(dir, "bad.key")
|
||||
if err := os.WriteFile(certPath, []byte("not a pem cert"), 0o600); err != nil {
|
||||
t.Fatalf("write cert: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte("not a pem key"), 0o600); err != nil {
|
||||
t.Fatalf("write key: %v", err)
|
||||
}
|
||||
_, err := newCertHolder(certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed PEM, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertHolder_Reload_SwapsCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-v1")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
if got := readCertCN(t, h); got != "cn-v1" {
|
||||
t.Fatalf("initial CN: got %q want cn-v1", got)
|
||||
}
|
||||
|
||||
// Rotate on disk and reload.
|
||||
generateTestCert(t, certPath, keyPath, "cn-v2")
|
||||
if err := h.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got := readCertCN(t, h); got != "cn-v2" {
|
||||
t.Fatalf("post-reload CN: got %q want cn-v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertHolder_Reload_FailureRetainsPreviousCert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-v1")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the cert file and attempt reload.
|
||||
if err := os.WriteFile(certPath, []byte("garbage"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt cert: %v", err)
|
||||
}
|
||||
if err := h.Reload(); err == nil {
|
||||
t.Fatal("expected Reload error for corrupt file, got nil")
|
||||
}
|
||||
// Holder should still serve the v1 cert.
|
||||
if got := readCertCN(t, h); got != "cn-v1" {
|
||||
t.Fatalf("post-failed-reload CN: got %q want cn-v1 (reload must not clobber on failure)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertHolder_GetCertificate_Concurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-concurrent")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
|
||||
// 64 readers + 1 rotator for 500ms. Race detector catches any unsynchronized
|
||||
// swap of h.cert. Rotator writes fresh files + Reload, readers call
|
||||
// GetCertificate in a tight loop.
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
const readers = 64
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
if _, err := h.GetCertificate(&tls.ClientHelloInfo{}); err != nil {
|
||||
t.Errorf("GetCertificate: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
generateTestCert(t, certPath, keyPath, "cn-concurrent")
|
||||
_ = h.Reload()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCertHolder_WatchSIGHUP_ReloadsOnSignal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-before-sighup")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
stop := h.watchSIGHUP(silentLogger())
|
||||
defer stop()
|
||||
|
||||
// Rotate on disk, then fire SIGHUP to our own process and poll for the swap.
|
||||
generateTestCert(t, certPath, keyPath, "cn-after-sighup")
|
||||
if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil {
|
||||
t.Fatalf("SIGHUP: %v", err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if readCertCN(t, h) == "cn-after-sighup" {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("watcher did not reload cert within 2s (CN still %q)", readCertCN(t, h))
|
||||
}
|
||||
|
||||
func TestCertHolder_WatchSIGHUP_StopExits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-stop")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
stop := h.watchSIGHUP(silentLogger())
|
||||
|
||||
// Closing should be synchronous and safe; a subsequent SIGHUP must not
|
||||
// cause a reload (the watcher goroutine is gone).
|
||||
stop()
|
||||
time.Sleep(50 * time.Millisecond) // let goroutine exit
|
||||
|
||||
// After stop, the signal may still be delivered to the process but the
|
||||
// watcher has called signal.Stop so this channel is no longer receiving.
|
||||
// Simply assert that calling stop() twice does not panic — the goroutine
|
||||
// has already exited, so a second close would panic on the `done`
|
||||
// channel; we do NOT call stop twice. Instead verify no regression in
|
||||
// the held cert.
|
||||
if got := readCertCN(t, h); got != "cn-stop" {
|
||||
t.Fatalf("unexpected cert rotation after stop: got %q want cn-stop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerTLSConfig_IsTLS13Only(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-cfg")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
cfg := buildServerTLSConfig(h)
|
||||
if cfg.MinVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("MinVersion: got %#x want %#x (TLS 1.3)", cfg.MinVersion, tls.VersionTLS13)
|
||||
}
|
||||
wantCurves := []tls.CurveID{tls.X25519, tls.CurveP256}
|
||||
if len(cfg.CurvePreferences) != len(wantCurves) {
|
||||
t.Fatalf("CurvePreferences length: got %d want %d", len(cfg.CurvePreferences), len(wantCurves))
|
||||
}
|
||||
for i, c := range cfg.CurvePreferences {
|
||||
if c != wantCurves[i] {
|
||||
t.Fatalf("CurvePreferences[%d]: got %v want %v", i, c, wantCurves[i])
|
||||
}
|
||||
}
|
||||
if cfg.GetCertificate == nil {
|
||||
t.Fatal("GetCertificate: nil (holder not wired; SIGHUP reload would be broken)")
|
||||
}
|
||||
if len(cfg.Certificates) != 0 {
|
||||
t.Fatalf("Certificates: got %d want 0 (static cert would pin the first load and defeat reload)", len(cfg.Certificates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerTLSConfig_Handshake_TLS12Rejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-handshake")
|
||||
|
||||
h, err := newCertHolder(certPath, keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("newCertHolder: %v", err)
|
||||
}
|
||||
serverCfg := buildServerTLSConfig(h)
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("tls.Listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Server loop: accept and immediately close (we only care about the
|
||||
// handshake outcome).
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Force handshake so the server-side error surfaces.
|
||||
_ = conn.(*tls.Conn).Handshake()
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// TLS 1.3 client — should succeed.
|
||||
clientOK := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
c, err := tls.Dial("tcp", ln.Addr().String(), clientOK)
|
||||
if err != nil {
|
||||
t.Fatalf("TLS 1.3 dial failed (expected success): %v", err)
|
||||
}
|
||||
if c.ConnectionState().Version != tls.VersionTLS13 {
|
||||
t.Fatalf("negotiated version: got %#x want TLS 1.3 (%#x)", c.ConnectionState().Version, tls.VersionTLS13)
|
||||
}
|
||||
c.Close()
|
||||
|
||||
// TLS 1.2 client — must be rejected at handshake.
|
||||
clientOld := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
if _, err := tls.Dial("tcp", ln.Addr().String(), clientOld); err == nil {
|
||||
t.Fatal("TLS 1.2 dial succeeded; HTTPS-everywhere requires server to refuse TLS 1.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_MissingCertPath(t *testing.T) {
|
||||
err := preflightServerTLS("", "/any/key.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty cert path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_MissingKeyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-preflight")
|
||||
err := preflightServerTLS(certPath, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty key path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_CertFileNotReadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
if err := os.WriteFile(keyPath, []byte("k"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightServerTLS(filepath.Join(dir, "nope.crt"), keyPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreadable cert path, got nil")
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected os.ErrNotExist wrapped in error chain, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_InvalidKeyPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
// Pair of valid cert + garbage key — files are readable but the pair
|
||||
// doesn't round-trip tls.LoadX509KeyPair.
|
||||
generateTestCert(t, certPath, keyPath, "cn-bad-pair")
|
||||
if err := os.WriteFile(keyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nBAD\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightServerTLS(certPath, keyPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key pair, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightServerTLS_ValidPair_NoError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "tls.crt")
|
||||
keyPath := filepath.Join(dir, "tls.key")
|
||||
generateTestCert(t, certPath, keyPath, "cn-ok")
|
||||
if err := preflightServerTLS(certPath, keyPath); err != nil {
|
||||
t.Fatalf("unexpected error for valid pair: %v", err)
|
||||
}
|
||||
}
|
||||
758
cmd/server/wire.go
Normal file
758
cmd/server/wire.go
Normal file
@@ -0,0 +1,758 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/handler"
|
||||
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||
"github.com/certctl-io/certctl/internal/auth/session"
|
||||
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
// Phase 9 ARCH-M2 closure Sprint 8 (2026-05-14): extracted from
|
||||
// cmd/server/main.go. Different shape from the config.go cuts —
|
||||
// the move is by FUNCTIONAL CONCERN (boot-time preflight + DI
|
||||
// adapter wiring), not by TYPE FAMILY.
|
||||
//
|
||||
// Sprint 8 ships TWO of the three files the Phase 9 prompt names:
|
||||
// - main.go — entrypoint (unchanged; what's left after the cut)
|
||||
// - wire.go — this file (DI assembly: preflight helpers +
|
||||
// adapter types that bridge package boundaries)
|
||||
//
|
||||
// The third file the prompt names — migrations.go — is NOT in this
|
||||
// commit. See "What's NOT in this sprint" below for the deferral
|
||||
// rationale.
|
||||
//
|
||||
// What lives here
|
||||
// ===============
|
||||
// Seven preflight + DI helper functions:
|
||||
// - preflightSCEPChallengePassword (H-2 fix: SCEP needs non-empty
|
||||
// shared secret if enabled)
|
||||
// - preflightSCEPMTLSTrustBundle (SCEP Phase 6.5: per-profile
|
||||
// mTLS CA bundle validation)
|
||||
// - preflightESTMTLSClientCATrustBundle (EST Phase 2.5: same shape,
|
||||
// returns SIGHUP-reloadable
|
||||
// *trustanchor.Holder)
|
||||
// - preflightSCEPIntuneTrustAnchor (SCEP Phase 8.2: Intune
|
||||
// Connector signing-cert bundle)
|
||||
// - loadSCEPRAPair (post-preflight cert+key load)
|
||||
// - preflightSCEPRACertKey (RA cert/key validation: file
|
||||
// mode 0600, cert+key match,
|
||||
// NotAfter, RSA-or-ECDSA alg)
|
||||
// - preflightEnrollmentIssuer (L-005: EST/SCEP issuer can
|
||||
// serve GetCACertPEM)
|
||||
// - buildFinalHandler (M-001 option D: HTTP dispatch
|
||||
// wrapper routing
|
||||
// authenticated vs no-auth
|
||||
// chains by URL prefix)
|
||||
//
|
||||
// Five adapter types that bridge package boundaries (avoid import
|
||||
// cycles between internal/auth, internal/service/auth,
|
||||
// internal/api/handler, internal/auth/oidc, internal/auth/session,
|
||||
// internal/auth/breakglass):
|
||||
// - authPermissionCheckerAdapter (typed-string → plain-string
|
||||
// auth.PermissionChecker
|
||||
// interface)
|
||||
// - authCheckResolverAdapter (postgres ActorRoleRepository
|
||||
// → handler.AuthCheckResolver)
|
||||
// - sessionMinterAdapter (session.Service → OIDC
|
||||
// SessionMinter port)
|
||||
// - breakglassSessionMinterAdapter (session.Service → breakglass
|
||||
// SessionMinter port + audit
|
||||
// 2026-05-10 HIGH-1 revoke-all)
|
||||
// - oidcProvidersListAdapter (postgres OIDCProviderRepository
|
||||
// → handler.OIDCProvidersListResolver
|
||||
// with MED-9 enabled-filter)
|
||||
//
|
||||
// Plus the silenceUnusedImports var-block that pins
|
||||
// oidcdomain.OIDCProvider as a load-bearing reference (the adapter
|
||||
// types use *userdomain.User and repository.OIDCProviderRepository
|
||||
// indirectly; oidcdomain.OIDCProvider isn't named in any function
|
||||
// signature here but is part of the Phase 3 SessionMinter contract).
|
||||
//
|
||||
// What's NOT in this sprint (and why)
|
||||
// ===================================
|
||||
// migrations.go is deferred. The Phase 9 prompt asks for three files:
|
||||
// main.go (entrypoint) + wire.go (this file) + migrations.go (boot-
|
||||
// time migration handling). The migration code (Phase 4 DEPL-M1
|
||||
// --migrate-only flag handling + RunMigrations + RunSeed call +
|
||||
// CERTCTL_MIGRATIONS_VIA_HOOK gating) lives INLINE inside the 2300-
|
||||
// line main() function — lines ~59-264 in the original — not as a
|
||||
// standalone helper.
|
||||
//
|
||||
// Extracting it into a migrations.go would require:
|
||||
// 1. Creating a new unexported function (e.g.,
|
||||
// runMigrations(ctx, cfg, db, logger) error) that consolidates
|
||||
// lines ~71-77 (--migrate-only parse) + ~199-248 (the migration
|
||||
// branch + --migrate-only early-exit) + ~250-264 (the demo
|
||||
// overlay seed branch).
|
||||
// 2. Replacing the inline block in main() with a single call.
|
||||
// 3. Threading the early-exit semantics out (os.Exit(0) vs return
|
||||
// "migration done" sentinel error vs a third option) so main's
|
||||
// defer ordering doesn't change.
|
||||
//
|
||||
// That's behavior-change territory — a new function call frame, a
|
||||
// new defer scope, error-handling pattern shift. Different risk
|
||||
// shape from the pure-data type relocations Sprints 1-7 did. The
|
||||
// Phase 9 prompt says "Do NOT change exported type signatures; the
|
||||
// refactor is mechanical relocation; behavior change is a separate
|
||||
// concern." Extracting an inline block from main() into a new
|
||||
// function is the same shape of risk that rule was guarding against.
|
||||
//
|
||||
// Recommended path for the migrations.go cut:
|
||||
// - Land it as a separate, smaller PR with its own review focus
|
||||
// (the runMigrations function shape, the early-exit semantics,
|
||||
// unit tests for the new function via the existing main_test.go
|
||||
// fixture). The infrastructure for the PR exists today; only
|
||||
// the operator's go-ahead on the behavior-change risk is needed.
|
||||
// - Estimated impact: another ~80-120 LOC out of main.go (the
|
||||
// migration + seed + early-exit block) into a new migrations.go.
|
||||
// - Phase 4's --migrate-only code path already runs through this
|
||||
// code section, so the extracted function should reproduce that
|
||||
// exact flow without behavior change beyond the call-frame
|
||||
// introduction.
|
||||
//
|
||||
// Public-surface invariant
|
||||
// ========================
|
||||
// The moved helpers + adapter types are all in package `main`
|
||||
// (which Go cannot expose to external importers). No exported
|
||||
// surface changes. The reorganization is invisible outside
|
||||
// cmd/server/. Same-package callers in main.go (preflight*
|
||||
// invocations, adapter instantiation) resolve via the package
|
||||
// symbol table without modification.
|
||||
|
||||
// preflightSCEPChallengePassword enforces the H-2 fix: if SCEP is enabled, a
|
||||
// non-empty challenge password MUST be configured. Returns a non-nil error
|
||||
// otherwise so the caller can refuse to start the control plane (CWE-306,
|
||||
// missing authentication for a critical function).
|
||||
//
|
||||
// This helper is extracted so the check can be unit tested without booting
|
||||
// the full server. The caller (main) is responsible for translating the
|
||||
// returned error into a structured log line and os.Exit(1).
|
||||
func preflightSCEPChallengePassword(enabled bool, challengePassword string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if challengePassword == "" {
|
||||
return fmt.Errorf("SCEP enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty: " +
|
||||
"SCEP enrollment would accept any client (CWE-306); " +
|
||||
"configure a non-empty shared secret or set CERTCTL_SCEP_ENABLED=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightSCEPMTLSTrustBundle validates a per-profile mTLS client-CA
|
||||
// trust bundle. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
||||
//
|
||||
// Mirrors preflightSCEPRACertKey's no-op-when-disabled pattern; otherwise
|
||||
// the checks are:
|
||||
//
|
||||
// 1. Path is non-empty (the Validate() refuse covers this too, but
|
||||
// preflight reports the specific failure with an actionable error
|
||||
// string + os.Exit(1) at the call site).
|
||||
// 2. File exists + readable.
|
||||
// 3. PEM-decodes to ≥1 CERTIFICATE block.
|
||||
// 4. None of the bundled certs is past NotAfter — an expired trust
|
||||
// anchor would silently reject every client cert at runtime.
|
||||
//
|
||||
// On success, returns the parsed *x509.CertPool ready to inject into the
|
||||
// per-profile SCEPHandler via SetMTLSTrustPool. Each bundled cert also
|
||||
// contributes to the union pool that backs the TLS-layer
|
||||
// VerifyClientCertIfGiven.
|
||||
func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPool, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if bundlePath == "" {
|
||||
return nil, fmt.Errorf("MTLS enabled but trust bundle path empty: " +
|
||||
"set CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH to a PEM file " +
|
||||
"containing the bootstrap-CA certs the operator allows to enroll")
|
||||
}
|
||||
body, err := os.ReadFile(bundlePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read MTLS trust bundle: %w (path=%s)", err, bundlePath)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
rest := body
|
||||
count := 0
|
||||
now := time.Now()
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse MTLS trust bundle cert: %w (path=%s)", err, bundlePath)
|
||||
}
|
||||
if now.After(cert.NotAfter) {
|
||||
return nil, fmt.Errorf("MTLS trust bundle cert expired at %s (subject=%q, path=%s) — replace before restart",
|
||||
cert.NotAfter.Format(time.RFC3339), cert.Subject.CommonName, bundlePath)
|
||||
}
|
||||
pool.AddCert(cert)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("MTLS trust bundle contained no CERTIFICATE PEM blocks (path=%s)", bundlePath)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// preflightESTMTLSClientCATrustBundle validates a per-profile EST mTLS
|
||||
// client-CA trust bundle and returns a SIGHUP-reloadable holder.
|
||||
//
|
||||
// EST RFC 7030 hardening master bundle Phase 2.5.
|
||||
//
|
||||
// Mirrors preflightSCEPMTLSTrustBundle's checks (file exists, parses as
|
||||
// PEM, ≥1 cert, none expired) but returns a *trustanchor.Holder rather
|
||||
// than a raw *x509.CertPool — the EST handler stores the holder so a
|
||||
// SIGHUP rotates the trust bundle live without a server restart, exactly
|
||||
// the way the Intune trust anchor rotation works (Phase 8.5 of the SCEP
|
||||
// bundle). The handler-side .Pool() accessor on the holder rebuilds an
|
||||
// x509.CertPool from the current snapshot for each Verify call.
|
||||
//
|
||||
// Uses the shared internal/trustanchor.LoadBundle (extracted in EST
|
||||
// hardening Phase 2.1 from the original Intune-only path) so the EST
|
||||
// + Intune callers exercise the same loader semantics — empty bundle
|
||||
// rejected, expired cert rejected with subject in error message,
|
||||
// non-CERTIFICATE PEM blocks tolerated.
|
||||
func preflightESTMTLSClientCATrustBundle(enabled bool, pathID, bundlePath string, logger *slog.Logger) (*trustanchor.Holder, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if bundlePath == "" {
|
||||
return nil, fmt.Errorf("EST profile (PathID=%q) MTLS enabled but trust bundle path empty: "+
|
||||
"set CERTCTL_EST_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH to a PEM file "+
|
||||
"containing the bootstrap-CA certs the operator allows to enroll", pathID)
|
||||
}
|
||||
holder, err := trustanchor.New(bundlePath, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EST profile (PathID=%q) MTLS trust bundle preflight: %w", pathID, err)
|
||||
}
|
||||
holder.SetLabelForLog(fmt.Sprintf("EST mTLS client CA bundle (PathID=%q)", pathID))
|
||||
return holder, nil
|
||||
}
|
||||
|
||||
// preflightSCEPIntuneTrustAnchor validates a per-profile Microsoft Intune
|
||||
// Certificate Connector signing-cert trust bundle.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 8.2.
|
||||
//
|
||||
// No-op when this profile has Intune disabled (the common case for
|
||||
// non-Intune SCEP deploys). When enabled:
|
||||
//
|
||||
// 1. Path is non-empty (Validate() refuse covers this too; we re-check
|
||||
// here so the caller can os.Exit(1) with the specific PathID in the
|
||||
// log line).
|
||||
// 2. File exists + readable.
|
||||
// 3. PEM-decodes to ≥1 CERTIFICATE block (intune.LoadTrustAnchor enforces
|
||||
// this and skips non-CERTIFICATE blocks like accidentally-pasted
|
||||
// priv-key blocks).
|
||||
// 4. None of the bundled certs is past NotAfter — an expired Intune
|
||||
// trust anchor would silently reject every Connector challenge at
|
||||
// runtime, which is a much worse failure mode than failing fast at
|
||||
// boot. intune.LoadTrustAnchor enforces this and surfaces the subject
|
||||
// CN in the error message so the operator knows which cert to rotate.
|
||||
//
|
||||
// On success returns the freshly-built *intune.TrustAnchorHolder ready to
|
||||
// inject into the per-profile SCEPService via SetIntuneIntegration. The
|
||||
// holder also installs the SIGHUP watcher (started by the caller).
|
||||
func preflightSCEPIntuneTrustAnchor(enabled bool, pathID, path string, logger *slog.Logger) (*intune.TrustAnchorHolder, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
// pathIDLabel renders the empty-string PathID as "<root>" so the
|
||||
// operator's boot-log error doesn't read like a missing variable.
|
||||
pathIDLabel := pathID
|
||||
if pathIDLabel == "" {
|
||||
pathIDLabel = "<root>"
|
||||
}
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE enabled but trust anchor path empty: "+
|
||||
"set CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CONNECTOR_CERT_PATH to a PEM bundle "+
|
||||
"of the Microsoft Intune Certificate Connector's signing certs", pathIDLabel)
|
||||
}
|
||||
holder, err := intune.NewTrustAnchorHolder(path, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SCEP profile (PathID=%q) INTUNE trust anchor load failed: %w (path=%s)", pathIDLabel, err, path)
|
||||
}
|
||||
return holder, nil
|
||||
}
|
||||
|
||||
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
||||
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
||||
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
||||
// indicate a TOCTOU race or a filesystem change between preflight and
|
||||
// the load (rare).
|
||||
//
|
||||
// Cert PEM may carry a chain (CA + RA + intermediate); we use the FIRST
|
||||
// CERTIFICATE block, matching the RFC 8894 §3.5.1 single-cert convention
|
||||
// for the GetCACert response.
|
||||
func loadSCEPRAPair(certPath, keyPath string) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read RA cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read RA key: %w", err)
|
||||
}
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse RA pair: %w", err)
|
||||
}
|
||||
if len(pair.Certificate) == 0 {
|
||||
return nil, nil, fmt.Errorf("RA cert PEM contained no certificate blocks")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse RA cert: %w", err)
|
||||
}
|
||||
return leaf, pair.PrivateKey, nil
|
||||
}
|
||||
|
||||
// preflightSCEPRACertKey validates the RA cert/key pair the RFC 8894 SCEP
|
||||
// path requires. Mirrors preflightSCEPChallengePassword's no-op-when-disabled
|
||||
// pattern; otherwise the checks are:
|
||||
//
|
||||
// 1. Both paths are non-empty (the Validate() refuse covers this too,
|
||||
// but preflight reports the specific failure mode + os.Exit(1) so the
|
||||
// operator sees a clear log line in addition to the config error).
|
||||
// 2. The key file mode is 0600 (refuse world-/group-readable RA key —
|
||||
// defense-in-depth against credential leak via a misconfigured
|
||||
// deploy that leaves /etc/certctl/scep/*.key as 0644).
|
||||
// 3. Cert PEM parses to exactly one x509.Certificate.
|
||||
// 4. Key PEM parses to a Go crypto.Signer (RSA or ECDSA — RFC 8894
|
||||
// §3.5.2 advertises those as the CMS-compatible algorithms).
|
||||
// 5. The cert's PublicKey matches the key's Public() — refuses pairs
|
||||
// accidentally swapped between profiles in a multi-profile config.
|
||||
// 6. The cert's NotAfter is in the future — an expired RA cert would
|
||||
// fail TLS handshake on EnvelopedData decryption per RFC 5652.
|
||||
//
|
||||
// Each check returns a wrapped error; the caller (main) is responsible for
|
||||
// translating to a structured slog.Error + os.Exit(1) so the helper stays
|
||||
// unit-testable without booting the full server.
|
||||
func preflightSCEPRACertKey(enabled bool, raCertPath, raKeyPath string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if raCertPath == "" || raKeyPath == "" {
|
||||
return fmt.Errorf("SCEP enabled but RA pair missing: " +
|
||||
"set CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH " +
|
||||
"(RFC 8894 §3.2.2 requires an RA pair so clients can encrypt the " +
|
||||
"CSR to the RA cert and the server can sign the CertRep response)")
|
||||
}
|
||||
|
||||
// File mode check FIRST so a world-readable key never gets read into the
|
||||
// process address space. Ignored on Windows (Stat().Mode() doesn't carry
|
||||
// POSIX bits there); the production deploy is Linux per the Dockerfile.
|
||||
keyInfo, err := os.Stat(raKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH stat failed: %w (path=%s)", err, raKeyPath)
|
||||
}
|
||||
mode := keyInfo.Mode().Perm()
|
||||
if mode&0o077 != 0 {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH has insecure permissions %#o; "+
|
||||
"RA private key must be mode 0600 (owner read/write only) — "+
|
||||
"chmod 0600 %s and restart", mode, raKeyPath)
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(raCertPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_CERT_PATH read failed: %w (path=%s)", err, raCertPath)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(raKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CERTCTL_SCEP_RA_KEY_PATH read failed: %w (path=%s)", err, raKeyPath)
|
||||
}
|
||||
|
||||
// tls.X509KeyPair validates that the cert + key parse, share an algorithm,
|
||||
// and the cert's PublicKey matches the key's Public() — three of our six
|
||||
// checks in a single stdlib call, so we use it rather than re-implementing.
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RA cert/key pair invalid: %w "+
|
||||
"(cert=%s key=%s) — verify the cert and key are matching halves of "+
|
||||
"the same RA pair, both PEM-encoded, with the cert containing exactly "+
|
||||
"one CERTIFICATE block and the key containing one PRIVATE KEY block",
|
||||
err, raCertPath, raKeyPath)
|
||||
}
|
||||
if len(pair.Certificate) == 0 {
|
||||
// Defensive — tls.X509KeyPair already errors on this, but the contract
|
||||
// for the next x509.ParseCertificate call needs the slice non-empty.
|
||||
return fmt.Errorf("RA cert PEM at %s contains no certificate blocks", raCertPath)
|
||||
}
|
||||
|
||||
// Re-parse the leaf so we can read NotAfter + the public-key alg.
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("RA cert at %s does not parse as x509: %w", raCertPath, err)
|
||||
}
|
||||
if time.Now().After(leaf.NotAfter) {
|
||||
return fmt.Errorf("RA cert at %s expired at %s — "+
|
||||
"generate a fresh RA pair (the SCEP CertRep signature would be "+
|
||||
"rejected by every conformant client)", raCertPath, leaf.NotAfter.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// CMS-compatible public-key algorithm gate. RFC 8894 §3.5.2 advertises RSA
|
||||
// and AES; the responder cert algorithm pertains to the signature scheme
|
||||
// used on the CertRep, which means the cert's PublicKey must be RSA or
|
||||
// ECDSA. Catches pre-shared Ed25519 dev keys that micromdm/scep clients
|
||||
// reject.
|
||||
switch leaf.PublicKeyAlgorithm {
|
||||
case x509.RSA, x509.ECDSA:
|
||||
// ok — supported by golang.org/x/crypto/ocsp + every SCEP client
|
||||
default:
|
||||
return fmt.Errorf("RA cert at %s uses unsupported public-key algorithm %s — "+
|
||||
"RFC 8894 §3.5.2 CMS signing requires RSA or ECDSA",
|
||||
raCertPath, leaf.PublicKeyAlgorithm)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightEnrollmentIssuer validates at startup that an EST/SCEP-bound issuer
|
||||
// can actually serve a CA certificate. This closes audit finding L-005:
|
||||
// pre-Bundle-4 the EST/SCEP startup path verified the issuer existed in the
|
||||
// registry but did not verify the issuer TYPE could emit a CA cert. An
|
||||
// operator who bound CERTCTL_EST_ISSUER_ID to an ACME issuer (which does
|
||||
// not have a static CA cert — see internal/connector/issuer/acme/acme.go::
|
||||
// GetCACertPEM returning an explicit error) would boot successfully and
|
||||
// only see failures at the first /est/cacerts request, hiding the misconfig
|
||||
// for hours/days behind a degraded enrollment surface.
|
||||
//
|
||||
// Strategy: call issuerConn.GetCACertPEM(ctx) at startup with a short
|
||||
// timeout. If the issuer can serve a CA cert (local, vault, openssl,
|
||||
// stepca, awsacmpca, etc.), the call succeeds and we proceed. If not
|
||||
// (acme, digicert, sectigo, entrust, googlecas, ejbca, globalsign — most
|
||||
// vendor-CA issuers that hand back chains per-issuance), the call fails
|
||||
// loudly with the connector's own error string, and the caller os.Exit(1)s.
|
||||
//
|
||||
// Returns nil on success, non-nil error suitable for structured logging
|
||||
// + os.Exit(1) by the caller. Caller is responsible for the timeout context.
|
||||
func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, issuerConn service.IssuerConnector) error {
|
||||
if issuerConn == nil {
|
||||
return fmt.Errorf("%s issuer %q: connector is nil", protocol, issuerID)
|
||||
}
|
||||
caCertPEM, err := issuerConn.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s issuer %q: cannot serve CA certificate (%w); "+
|
||||
"choose an issuer type that exposes a static CA chain "+
|
||||
"(local / vault / openssl / stepca / awsacmpca) or disable %s",
|
||||
protocol, issuerID, err, protocol)
|
||||
}
|
||||
if caCertPEM == "" {
|
||||
return fmt.Errorf("%s issuer %q: GetCACertPEM returned empty PEM with no error; "+
|
||||
"choose an issuer type that exposes a static CA chain", protocol, issuerID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFinalHandler builds the outer HTTP dispatch handler that routes incoming
|
||||
// requests to either the authenticated apiHandler chain or the unauthenticated
|
||||
// noAuthHandler chain based on URL path prefix. Extracted from main() so the
|
||||
// dispatch logic can be unit tested without booting the full server stack
|
||||
// (see cmd/server/finalhandler_test.go).
|
||||
//
|
||||
// Dispatch rules (M-001, audit 2026-04-19, option D):
|
||||
//
|
||||
// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection)
|
||||
// - /api/v1/version → no-auth (U-3 ride-along: build identity for rollout/probes)
|
||||
// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP)
|
||||
// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3)
|
||||
// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword)
|
||||
// - /api/v1/* → auth (Bearer token required)
|
||||
// - /assets/* → static file server (dashboard only)
|
||||
// - anything else → SPA index.html fallback (dashboard only)
|
||||
// OR apiHandler (no dashboard)
|
||||
//
|
||||
// EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network
|
||||
// appliances) cannot present certctl Bearer tokens, so those endpoints must be
|
||||
// reachable without the Auth middleware. Authentication is instead enforced by
|
||||
// CSR signature verification, profile policy gates, and for SCEP the
|
||||
// challengePassword shared secret (fail-loud gated by preflightSCEPChallengePassword
|
||||
// above).
|
||||
//
|
||||
// webDir must point to a directory containing index.html + assets/ when
|
||||
// dashboardEnabled is true; it is ignored otherwise.
|
||||
func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, dashboardEnabled bool) http.Handler {
|
||||
var fileServer http.Handler
|
||||
if dashboardEnabled {
|
||||
fileServer = http.FileServer(http.Dir(webDir))
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// Health/ready, auth/info, and version bypass auth middleware.
|
||||
// Health/ready: Docker/K8s health probes don't carry Bearer tokens.
|
||||
// auth/info: React app calls this before login to detect auth mode.
|
||||
// version: U-3 ride-along (cat-u-no_version_endpoint) — rollout
|
||||
// systems and blackbox probes need build identity without a key.
|
||||
if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" || path == "/api/v1/version" {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and MUST
|
||||
// be served unauthenticated — relying parties (browsers, OpenSSL, OCSP
|
||||
// stapling sidecars, mTLS clients) cannot present certctl Bearer tokens.
|
||||
if strings.HasPrefix(path, "/.well-known/pki") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 7030 EST endpoints ride the no-auth middleware chain (M-001,
|
||||
// option D, audit 2026-04-19). Trust boundary is CSR signature +
|
||||
// (per EST hardening Phase 2) optional client cert at the handler
|
||||
// layer, not HTTP Bearer. /.well-known/est/cacerts is explicitly
|
||||
// anonymous per RFC 7030 §4.1.1; /.well-known/est-mtls/<PathID>/
|
||||
// (EST hardening Phase 2 sibling route) requires a client cert
|
||||
// gate at the handler layer — both share this prefix gate because
|
||||
// "/.well-known/est-mtls" is itself prefixed by "/.well-known/est".
|
||||
// EST hardening Phase 3's HTTP Basic enrollment-password is a
|
||||
// per-profile handler-layer auth that runs INSIDE the no-auth
|
||||
// middleware chain (since the chain skips the Bearer middleware,
|
||||
// the handler gets to define its own auth contract).
|
||||
if strings.HasPrefix(path, "/.well-known/est") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 8894 SCEP rides the no-auth chain (M-001, option D). SCEP clients
|
||||
// authenticate via the challengePassword attribute in the PKCS#10 CSR,
|
||||
// not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to
|
||||
// start the server if SCEP is enabled without a non-empty shared secret.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the sibling
|
||||
// /scep-mtls[/<pathID>] route also rides the no-auth chain. Its
|
||||
// auth boundary is (a) client cert verified at the TLS layer +
|
||||
// re-verified per-profile at the handler layer, plus (b) the
|
||||
// challenge password — neither is a Bearer token. The /scepxyz
|
||||
// vs /scep-mtls disambiguation: 'xyz' starts with a letter so the
|
||||
// HasPrefix(path, "/scep/") gate doesn't match it; 'mtls' is its
|
||||
// own dedicated prefix gated below to avoid the same overlap.
|
||||
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if path == "/scep-mtls" || strings.HasPrefix(path, "/scep-mtls/") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated API routes — full middleware stack including Auth.
|
||||
if strings.HasPrefix(path, "/api/v1/") {
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !dashboardEnabled {
|
||||
// No dashboard: everything non-special falls through to the
|
||||
// authenticated handler (preserves pre-M-001 behavior for API-only
|
||||
// deployments).
|
||||
apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Dashboard-present: serve static assets directly, SPA fallback for
|
||||
// everything else.
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, webDir+"/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
// authPermissionCheckerAdapter bridges the typed-string Authorizer
|
||||
// signature (authsvc.Authorizer.CheckPermission takes
|
||||
// authdomain.ActorTypeValue + authdomain.ScopeType) to the plain-string
|
||||
// auth.PermissionChecker interface used by the auth.RequirePermission
|
||||
// middleware factory. Lives in cmd/server so internal/auth doesn't have
|
||||
// to import internal/service/auth + internal/domain/auth (would create
|
||||
// a cycle).
|
||||
type authPermissionCheckerAdapter struct {
|
||||
a *authsvc.Authorizer
|
||||
}
|
||||
|
||||
func (ad authPermissionCheckerAdapter) CheckPermission(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType string,
|
||||
tenantID string,
|
||||
permission string,
|
||||
scopeType string,
|
||||
scopeID *string,
|
||||
) (bool, error) {
|
||||
return ad.a.CheckPermission(
|
||||
ctx,
|
||||
actorID,
|
||||
authdomainAlias.ActorTypeValue(actorType),
|
||||
tenantID,
|
||||
permission,
|
||||
authdomainAlias.ScopeType(scopeType),
|
||||
scopeID,
|
||||
)
|
||||
}
|
||||
|
||||
// authCheckResolverAdapter bridges the postgres ActorRoleRepository
|
||||
// (authdomain.ActorTypeValue) to handler.AuthCheckResolver
|
||||
// (domain.ActorType). Lives in cmd/server so the handler layer keeps its
|
||||
// existing import set; the GUI's /v1/auth/check probe round-trips
|
||||
// through this on every page load. Read-only — no caller / no audit row.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): the equivalent surface area on
|
||||
// /v1/auth/me runs through the service layer's auth.role.list permission
|
||||
// gate, which the GUI may not yet hold during initial render. AuthCheck
|
||||
// has no permission gate (its only requirement is "the request
|
||||
// authenticated"), so the bypass is by design.
|
||||
type authCheckResolverAdapter struct {
|
||||
repo *postgres.ActorRoleRepository
|
||||
}
|
||||
|
||||
func (ad authCheckResolverAdapter) ListRoles(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType domain.ActorType,
|
||||
tenantID string,
|
||||
) ([]*authdomainAlias.ActorRole, error) {
|
||||
return ad.repo.ListByActor(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||
}
|
||||
|
||||
func (ad authCheckResolverAdapter) EffectivePermissions(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType domain.ActorType,
|
||||
tenantID string,
|
||||
) ([]repository.EffectivePermission, error) {
|
||||
return ad.repo.EffectivePermissions(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// sessionMinterAdapter — bridge from *session.Service to oidcsvc.SessionMinter.
|
||||
//
|
||||
// The OIDC service's SessionMinter port (Phase 3) takes a *userdomain.User
|
||||
// + role IDs and returns (cookie, csrf, err). The session.Service's
|
||||
// Create method takes (actorID, actorType, ip, ua) -> *CreateResult.
|
||||
// This adapter unwraps the User into actorID/actorType + reshapes the
|
||||
// return tuple. Lives in cmd/server so the session package doesn't have
|
||||
// to know about user.User and the user package doesn't have to know
|
||||
// about session.CreateResult.
|
||||
// =============================================================================
|
||||
|
||||
type sessionMinterAdapter struct {
|
||||
svc *session.Service
|
||||
}
|
||||
|
||||
func (a *sessionMinterAdapter) MintForUser(
|
||||
ctx context.Context,
|
||||
user *userdomain.User,
|
||||
_ []string, // roleIDs unused at the session-mint layer; the rbac middleware looks them up at request time
|
||||
ip, userAgent string,
|
||||
) (cookieValue, csrfToken string, err error) {
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("session mint: user is nil")
|
||||
}
|
||||
res, err := a.svc.Create(ctx, user.ID, string(domain.ActorTypeUser), ip, userAgent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return res.CookieValue, res.CSRFToken, nil
|
||||
}
|
||||
|
||||
// silenceUnusedImports keeps the new oidcsvc + oidcdomain imports load-
|
||||
// bearing in case any file shuffles. Linker dead-code elimination handles
|
||||
// the runtime cost.
|
||||
var (
|
||||
_ = oidcdomain.OIDCProvider{}
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// breakglassSessionMinterAdapter — bridge from *session.Service to
|
||||
// breakglass.SessionMinter.
|
||||
//
|
||||
// The break-glass service's SessionMinter port (Phase 7.5) returns
|
||||
// (cookie, csrf, err); the underlying *session.Service.Create returns
|
||||
// *CreateResult. This adapter unwraps the result. Lives in cmd/server
|
||||
// so the breakglass package doesn't have to know about session.Service.
|
||||
// =============================================================================
|
||||
|
||||
type breakglassSessionMinterAdapter struct {
|
||||
svc *session.Service
|
||||
}
|
||||
|
||||
func (a breakglassSessionMinterAdapter) Create(ctx context.Context, actorID, actorType, ip, userAgent string) (string, string, error) {
|
||||
res, err := a.svc.Create(ctx, actorID, actorType, ip, userAgent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return res.CookieValue, res.CSRFToken, nil
|
||||
}
|
||||
|
||||
// RevokeAllForActor — Audit 2026-05-10 HIGH-1 wire. After a break-glass
|
||||
// password rotation or credential removal, every active session for the
|
||||
// target actor must be revoked so a phished-then-rotated credential
|
||||
// doesn't leave the attacker's session live.
|
||||
func (a breakglassSessionMinterAdapter) RevokeAllForActor(ctx context.Context, actorID, actorType string) error {
|
||||
return a.svc.RevokeAllForActor(ctx, actorID, actorType)
|
||||
}
|
||||
|
||||
// oidcProvidersListAdapter bridges the postgres OIDCProviderRepository
|
||||
// to handler.OIDCProvidersListResolver. The handler returns
|
||||
// []*OIDCProviderInfo (id + display_name + login_url) for the public-
|
||||
// safe GUI Login-page payload; the repo returns the full OIDCProvider
|
||||
// row. The adapter projects + maps the login_url shape that
|
||||
// /auth/oidc/login?provider=<id> expects. Auth Bundle 2 Phase 6 /
|
||||
// Category E.
|
||||
type oidcProvidersListAdapter struct {
|
||||
repo repository.OIDCProviderRepository
|
||||
}
|
||||
|
||||
func (a oidcProvidersListAdapter) List(ctx context.Context, tenantID string) ([]*handler.OIDCProviderInfo, error) {
|
||||
provs, err := a.repo.List(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*handler.OIDCProviderInfo, 0, len(provs))
|
||||
for _, p := range provs {
|
||||
// Audit 2026-05-10 MED-9 closure — filter disabled providers
|
||||
// at the adapter so the LoginPage's "Sign in with X" buttons
|
||||
// don't render for offline IdPs. The HandleAuthRequest
|
||||
// service-layer ErrProviderDisabled check is the
|
||||
// defense-in-depth guard for direct API / MCP / CLI callers.
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, &handler.OIDCProviderInfo{
|
||||
ID: p.ID,
|
||||
DisplayName: p.Name,
|
||||
LoginURL: "/auth/oidc/login?provider=" + p.ID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,8 +1,39 @@
|
||||
# certctl Docker Compose environment variables
|
||||
# Copy this file to .env and customize for your deployment
|
||||
# certctl Docker Compose environment variables (Bundle 2 — 2026-05-12)
|
||||
#
|
||||
# Copy this file to deploy/.env and customize. The production-shaped base
|
||||
# compose (docker-compose.yml) requires every variable below to be set;
|
||||
# the Bundle 2 fail-closed startup guards REFUSE TO BOOT if any value
|
||||
# remains at a "change-me-..." or "replace-with-..." placeholder outside
|
||||
# demo mode (CERTCTL_DEMO_MODE_ACK=true).
|
||||
#
|
||||
# DEMO PATH (zero-config, populated dashboard, demo-mode auth):
|
||||
# docker compose -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml up -d --build
|
||||
# The demo overlay supplies its own placeholder values plus DEMO_MODE_ACK
|
||||
# so this .env is NOT needed.
|
||||
#
|
||||
# PRODUCTION PATH (this .env is required):
|
||||
# docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
# PostgreSQL password (change in production!)
|
||||
POSTGRES_PASSWORD=certctl
|
||||
# PostgreSQL password — openssl rand -hex 32
|
||||
POSTGRES_PASSWORD=replace-with-openssl-rand-hex-32
|
||||
|
||||
# Agent API key (change in production! Generate with: openssl rand -hex 32)
|
||||
CERTCTL_API_KEY=change-me-in-production
|
||||
# Server API-key secret — openssl rand -base64 32
|
||||
CERTCTL_AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
|
||||
# Bundled-agent API key (matches one of the server's AUTH_SECRET rotation
|
||||
# values). Generate with: openssl rand -base64 32
|
||||
CERTCTL_API_KEY=replace-with-openssl-rand-base64-32
|
||||
|
||||
# AES-256-GCM key for encrypting issuer/target config secrets at rest.
|
||||
# Minimum 32 bytes. Generate with: openssl rand -base64 32
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY=replace-with-openssl-rand-base64-32
|
||||
|
||||
# Agent ID returned from `POST /api/v1/agents` during agent enrollment.
|
||||
# Without this the bundled certctl-agent service fail-fasts at startup.
|
||||
# CERTCTL_AGENT_ID=agent-from-registration-response
|
||||
|
||||
# Day-0 admin bootstrap token (optional — generate with: openssl rand -hex 32).
|
||||
# When set, POST /api/v1/auth/bootstrap mints the first admin actor + API
|
||||
# key. When unset (default), that endpoint returns 410 Gone.
|
||||
# CERTCTL_BOOTSTRAP_TOKEN=
|
||||
|
||||
562
deploy/ENVIRONMENTS.md
Normal file
562
deploy/ENVIRONMENTS.md
Normal file
@@ -0,0 +1,562 @@
|
||||
# certctl Docker Compose Environments
|
||||
|
||||
This guide walks through every Docker Compose file in the `deploy/` directory. Each section explains what the environment does, when to use it, every service and environment variable, and the commands to run it. If you've never used Docker before, start with the [Prerequisites](#prerequisites) section. If you're experienced, skip to the environment you need.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [How Docker Compose Works (30-Second Version)](#how-docker-compose-works)
|
||||
3. [Base Environment (docker-compose.yml)](#base-environment)
|
||||
4. [Demo Overlay (docker-compose.demo.yml)](#demo-overlay)
|
||||
5. [Development Overlay (docker-compose.dev.yml)](#development-overlay)
|
||||
6. [Test Environment (docker-compose.test.yml)](#test-environment)
|
||||
7. [Environment Variable Reference](#environment-variable-reference)
|
||||
8. [Common Operations](#common-operations)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need two things: **Docker** (the container runtime) and **Docker Compose** (an orchestration tool that ships with Docker Desktop).
|
||||
|
||||
On macOS:
|
||||
```bash
|
||||
brew install --cask docker
|
||||
```
|
||||
|
||||
On Linux (Ubuntu/Debian):
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
```
|
||||
|
||||
Verify the install:
|
||||
```bash
|
||||
docker --version # Docker Engine 24+ recommended
|
||||
docker compose version # Docker Compose v2+ required (note: no hyphen)
|
||||
```
|
||||
|
||||
**What Docker actually does:** Docker packages an application and all its dependencies (OS libraries, runtimes, config files) into an isolated unit called a container. When you run `docker compose up`, Docker reads a YAML file that describes multiple containers, creates a private network between them, and starts everything in the right order. Each container sees only its own filesystem and network unless you explicitly share volumes or ports.
|
||||
|
||||
**Why this matters for certctl:** Instead of installing PostgreSQL, building Go binaries, configuring the agent, and wiring everything together by hand, one command gives you the complete platform. Each compose file targets a different use case.
|
||||
|
||||
---
|
||||
|
||||
## How Docker Compose Works
|
||||
|
||||
A compose file defines **services** (containers), **networks** (how they talk to each other), and **volumes** (persistent storage). The key concepts:
|
||||
|
||||
**Services** are named containers. `certctl-server` is the API and web dashboard. `postgres` is the database. `certctl-agent` polls the server for certificate work.
|
||||
|
||||
**Depends_on + healthchecks** control startup order. The server won't start until PostgreSQL reports healthy. The agent won't start until the server reports healthy. This prevents connection errors during boot.
|
||||
|
||||
**Volumes** persist data across restarts. `postgres_data` keeps your database between `docker compose down` and `docker compose up`. Adding `-v` to `down` deletes volumes for a clean slate.
|
||||
|
||||
**Overlay files** let you layer changes. Running `docker compose -f base.yml -f overlay.yml up` merges both files. The overlay can add services, change environment variables, or mount extra volumes without editing the base.
|
||||
|
||||
**Port mapping** (`"8443:8443"`) maps host port (left) to container port (right). After startup, `https://localhost:8443` on your machine reaches the certctl server inside its container (HTTPS-only as of v2.2; the `certctl-tls-init` init container bootstraps a self-signed cert into `deploy/test/certs/`).
|
||||
|
||||
---
|
||||
|
||||
## Base Environment
|
||||
|
||||
**File:** `docker-compose.yml`
|
||||
**When to use:** Production deployments and any time you want a clean, production-shaped stack with real authentication enforced.
|
||||
|
||||
**Bundle 2 closure (2026-05-12):** the base compose was split from the demo overlay. Pre-Bundle-2 this file IS the demo path (auth=none, keygen=server, demo-seed=true, change-me placeholder credentials baked in). Operators reading "drop the demo overlay for a clean install" were not getting a clean install — they were getting a demo stack with the overlay's data layer stripped off. Post-Bundle-2 the base ships production-shaped: `CERTCTL_AUTH_TYPE` defaults to `api-key`, `CERTCTL_KEYGEN_MODE` defaults to `agent`, demo-mode + demo-seed default to false, and every credential placeholder is rejected at startup. The demo path is now a single overlay flag away (`-f deploy/docker-compose.demo.yml`).
|
||||
|
||||
### What it runs
|
||||
|
||||
Three services on a private bridge network:
|
||||
|
||||
| Service | Image | Purpose | Ports |
|
||||
|---------|-------|---------|-------|
|
||||
| `postgres` | `postgres:16-alpine` | Database. Stores certificates, agents, jobs, audit trail, policies, discovery results. | 5432 |
|
||||
| `certctl-server` | Built from `Dockerfile` | API server + web dashboard + background scheduler. | 8443 |
|
||||
| `certctl-agent` | Built from `Dockerfile.agent` | Polls server for work, generates keys, deploys certificates, discovers existing certs. | none |
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/certctl-io/certctl.git
|
||||
cd certctl
|
||||
|
||||
# Required: provide real credentials. Without this step the server fail-fasts
|
||||
# at startup on the Bundle 2 placeholder-credential guards.
|
||||
cp .env.example deploy/.env
|
||||
$EDITOR deploy/.env
|
||||
# Set: POSTGRES_PASSWORD, CERTCTL_AUTH_SECRET, CERTCTL_API_KEY,
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY (all via `openssl rand -base64 32`),
|
||||
# CERTCTL_AGENT_ID (returned from `POST /api/v1/agents`).
|
||||
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
If you just want to kick the tires without writing a `.env`, use the demo overlay instead — see [Demo Overlay](#demo-overlay) below.
|
||||
|
||||
`--build` compiles the Go server and agent from source, including the React frontend. Without it, Docker may reuse a stale image from a previous build.
|
||||
|
||||
`-d` runs in detached mode (background). Omit it to see logs in your terminal.
|
||||
|
||||
Wait about 30 seconds, then verify:
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml ps
|
||||
# All three services should show "Up (healthy)"
|
||||
|
||||
curl --cacert ./deploy/test/certs/ca.crt https://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
```
|
||||
|
||||
The control plane is HTTPS-only as of v2.2. The `certctl-tls-init` init container bootstraps a self-signed cert into `deploy/test/certs/` on first boot; pin it with `--cacert` (as above) or pass `-k` for one-off smoke tests (never in production).
|
||||
|
||||
Open **https://localhost:8443** in your browser. You'll see the onboarding wizard guiding you through: connecting a CA, deploying an agent, and adding your first certificate. Your browser will flag the self-signed cert as untrusted — accept the warning for local evaluation, or import `deploy/test/certs/ca.crt` into your OS trust store to make the warning go away.
|
||||
|
||||
### Service-by-service walkthrough
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
|
||||
```
|
||||
|
||||
Alpine-based PostgreSQL 16. The `${POSTGRES_PASSWORD:-certctl}` syntax means: use the `POSTGRES_PASSWORD` environment variable from your shell if set, otherwise default to `certctl`. For production, create a `.env` file:
|
||||
|
||||
```bash
|
||||
echo 'POSTGRES_PASSWORD=your-secure-password-here' > deploy/.env
|
||||
```
|
||||
|
||||
The `volumes` section mounts 10 migration files into PostgreSQL's init directory (`/docker-entrypoint-initdb.d/`). PostgreSQL runs these SQL files in alphabetical order on first boot only. They create the schema (tables, indexes, constraints) and seed the base data (default issuer, default policy). If the `postgres_data` volume already exists with an initialized database, these scripts are skipped entirely.
|
||||
|
||||
**Expert note:** The numbered prefix pattern (`001_`, `002_`, ..., `020_`) ensures deterministic execution order. All migrations use `IF NOT EXISTS` and `ON CONFLICT DO NOTHING` for idempotency, so re-running them against an existing database is safe.
|
||||
|
||||
**Stateful volume — first-boot password binding (U-1).** The same "first boot only" semantics that govern migration scripts also govern `POSTGRES_PASSWORD`. The official `postgres` image runs `initdb` exactly once — when `/var/lib/postgresql/data` is empty — and that pass is the only time `POSTGRES_PASSWORD` is written into `pg_authid`. On every subsequent boot, the postgres container ignores the env var and authenticates against whatever password was baked into the data directory on the original `up`. Editing `POSTGRES_PASSWORD` in `.env` after a successful first boot therefore only updates the **certctl-server** container's `CERTCTL_DATABASE_URL` — postgres still expects the previous password, and the server fails to ping with `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01). The certctl-server container surfaces this case explicitly: when SQLSTATE 28P01 fires at startup, the wrap text in `internal/repository/postgres/db.go::wrapPingError` points operators at the two remediation paths — destructive volume teardown via `docker compose -f deploy/docker-compose.yml down -v && up -d --build`, or non-destructive in-place rotation via `docker compose -f deploy/docker-compose.yml exec postgres psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"` followed by a server restart with the matching `POSTGRES_PASSWORD`. Use the destructive path on the demo / first-time setup; use the non-destructive path on any environment that holds data you want to keep.
|
||||
|
||||
#### certctl Server
|
||||
|
||||
```yaml
|
||||
certctl-server:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD}@postgres:5432/certctl?sslmode=disable
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
# Bundle 2 (2026-05-12): no auth-type / keygen-mode override here.
|
||||
# Code defaults (api-key + agent) take effect; the demo overlay flips
|
||||
# both to demo-mode (none + server).
|
||||
CERTCTL_AUTH_SECRET: ${CERTCTL_AUTH_SECRET}
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY}
|
||||
```
|
||||
|
||||
The server is the control plane. It serves the REST API, the React dashboard, runs 7 background scheduler loops (renewal, job processing, health checks, notifications, short-lived cert expiry, network scanning, digest emails), and manages the issuer/target registry.
|
||||
|
||||
Key environment variables explained:
|
||||
|
||||
- `CERTCTL_DATABASE_URL` references the `postgres` service by hostname. Docker's internal DNS resolves `postgres` to the container's IP on the bridge network. `sslmode=disable` is appropriate because traffic stays on the private Docker network.
|
||||
- `CERTCTL_AUTH_TYPE` defaults to `api-key` in the code (`internal/config/config.go`); the base compose does NOT override it. To run demo-mode auth (every request served as the synthetic admin actor), layer the demo overlay on top.
|
||||
- `CERTCTL_AUTH_SECRET` is the API-key value the server accepts. The Bundle 2 fail-closed guard rejects the literal placeholder `change-me-in-production` outside demo mode. Generate with `openssl rand -base64 32`.
|
||||
- `CERTCTL_KEYGEN_MODE` defaults to `agent` in the code (the base compose does NOT override it). Production deploys leave it there so private keys stay on agent infrastructure; the demo overlay flips it to `server` so the demo can issue + hold the key on the server box without an agent dance.
|
||||
- `CERTCTL_CONFIG_ENCRYPTION_KEY` enables AES-256-GCM encryption for issuer and target configurations stored in the database (credentials, API keys). Required for any deploy that adds issuers via the GUI. The Bundle 2 fail-closed guard rejects the literal placeholder `change-me-32-char-encryption-key` outside demo mode. Generate with `openssl rand -base64 32` (≥ 32 bytes).
|
||||
- `CERTCTL_NETWORK_SCAN_ENABLED` activates the scheduler loop that probes TLS endpoints on your network to discover certificates you might not be managing.
|
||||
|
||||
**Expert note:** The healthcheck hits `GET /health` every 10 seconds with 5 retries. The `depends_on: condition: service_healthy` on the agent means Docker holds agent startup until this check passes. Resource limits (`cpus: '1.0'`, `memory: 512M`) prevent the server from consuming unbounded resources in shared environments.
|
||||
|
||||
#### certctl Agent
|
||||
|
||||
```yaml
|
||||
certctl-agent:
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
# Bundle 2 (2026-05-12): no placeholder fallbacks. Operators MUST
|
||||
# set CERTCTL_API_KEY + CERTCTL_AGENT_ID in deploy/.env. The agent
|
||||
# binary fail-fasts at startup when CERTCTL_AGENT_ID is unset.
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY}
|
||||
CERTCTL_AGENT_ID: ${CERTCTL_AGENT_ID}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
```
|
||||
|
||||
The agent is a lightweight Go binary that polls the server for pending work (certificate deployments, CSR generation requests), executes that work locally, and reports results back. It also scans configured directories for existing certificates (filesystem discovery).
|
||||
|
||||
- `CERTCTL_SERVER_URL` uses the Docker internal hostname `certctl-server`. This resolves inside the Docker network only.
|
||||
- `CERTCTL_DISCOVERY_DIRS` tells the agent which directories to scan for existing certificates. The agent walks these directories recursively, parses PEM and DER files, and reports findings to the server for triage.
|
||||
- The `agent_keys` volume persists private keys generated by the agent across container restarts. Without this volume, keys would be lost when the container stops.
|
||||
|
||||
**Expert note:** The agent's healthcheck uses `pgrep` because the agent doesn't expose an HTTP endpoint. The `restart: unless-stopped` policy means Docker automatically restarts the agent on crashes but respects manual `docker compose stop` commands.
|
||||
|
||||
### Stopping and cleaning up
|
||||
|
||||
```bash
|
||||
# Stop containers but keep data
|
||||
docker compose -f deploy/docker-compose.yml down
|
||||
|
||||
# Stop and delete all data (database, keys, volumes)
|
||||
docker compose -f deploy/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo Overlay
|
||||
|
||||
**File:** `docker-compose.demo.yml`
|
||||
**When to use:** Demos, screenshots, stakeholder presentations, or any time you want a one-command zero-config evaluation stack with a populated dashboard.
|
||||
|
||||
### What it adds
|
||||
|
||||
Bundle 2 closure (2026-05-12) moved every demo-mode env var out of the base compose into this overlay. The overlay now carries:
|
||||
|
||||
- `CERTCTL_AUTH_TYPE=none` + `CERTCTL_DEMO_MODE_ACK=true` — demo-mode synthetic admin actor (`actor-demo-anon`). The server emits a prominent ⚠ DEMO MODE WARN banner at boot with a production-promotion checklist (`cmd/server/main.go`).
|
||||
- `CERTCTL_KEYGEN_MODE=server` — demo-only server-side keygen.
|
||||
- `CERTCTL_DEMO_SEED=true` — the server applies `migrations/seed_demo.sql` at boot via `postgres.RunDemoSeed`, inserting 180 days of simulated operational history (teams, owners, certificates, agents, jobs, discovery results, audit events, policies, profiles).
|
||||
- Fixed weak `POSTGRES_PASSWORD=certctl`, `CERTCTL_AUTH_SECRET=change-me-in-production`, `CERTCTL_CONFIG_ENCRYPTION_KEY=change-me-32-char-encryption-key`, `CERTCTL_API_KEY=change-me-in-production`, `CERTCTL_AGENT_ID=agent-demo-1` — placeholder credentials the Bundle 2 fail-closed `Validate()` rejects outside demo mode, but the demo overlay's `DEMO_MODE_ACK=true` unlocks them.
|
||||
|
||||
Pre-U-3 the overlay used to mount `seed_demo.sql` into PostgreSQL's `/docker-entrypoint-initdb.d/` and rely on initdb-time application. That worked only because the production stack also mounted the migrations there, so the schema existed when initdb ran. Once U-3 dropped the production initdb mounts (single source of truth: server runs `RunMigrations` + `RunSeed` at boot), the demo seed could no longer be applied at initdb time — the tables it references wouldn't exist yet. Post-U-3 the overlay is an override file with no `image:` / `build:` of its own; it MUST be passed alongside the base, or compose errors with `service "certctl-server" has neither an image nor a build context specified`.
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `-f` flags are ordered: base first, overlay second. Docker merges them. The demo overlay adds the seed_demo.sql volume mount to the `postgres` service defined in the base file.
|
||||
|
||||
### What you see
|
||||
|
||||
The dashboard shows pre-populated charts: expiration heatmap with upcoming renewals, status distribution across Active/Expiring/Expired/Failed states, 30-day job trends, and issuance rates. The sidebar pages (Certificates, Agents, Discovery, Jobs, etc.) all have data to explore.
|
||||
|
||||
### Resetting demo data
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml down -v
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
||||
```
|
||||
|
||||
The `down -v` deletes the `postgres_data` volume. On next boot, PostgreSQL re-runs all init scripts including the demo seed, giving you a clean starting point.
|
||||
|
||||
**Expert note:** The demo overlay is a pure data layer, not a configuration change. The server, agent, and their environment variables remain identical to the base. This means any behavior you see in the demo is exactly what the base environment produces once you populate data through normal operations.
|
||||
|
||||
---
|
||||
|
||||
## Development Overlay
|
||||
|
||||
**File:** `docker-compose.dev.yml`
|
||||
**When to use:** When you're contributing to certctl and need debug logging, database inspection, or a debugger attached to the server process.
|
||||
|
||||
### What it adds
|
||||
|
||||
| Addition | Purpose |
|
||||
|----------|---------|
|
||||
| Debug-level logging on server and agent | See every HTTP request, scheduler tick, and connector operation |
|
||||
| PgAdmin on port 5050 | Visual database browser for inspecting tables, running queries |
|
||||
| Delve debugger port 40000 | Attach a Go debugger to the running server process |
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Omit `-d` during development so you see logs streaming in your terminal.
|
||||
|
||||
### Using PgAdmin
|
||||
|
||||
Open **http://localhost:5050** in your browser. PgAdmin is pre-configured in desktop mode (no login required). To connect to the certctl database:
|
||||
|
||||
1. Right-click "Servers" in the left panel, choose "Register" > "Server"
|
||||
2. Name: `certctl`
|
||||
3. Connection tab: Host = `postgres`, Port = `5432`, Username = `certctl`, Password = `certctl` (or whatever you set in `.env`)
|
||||
|
||||
From there you can browse all 19 tables, inspect certificate records, view audit events, check the scheduler's job queue, and run arbitrary SQL.
|
||||
|
||||
### Using the Delve debugger
|
||||
|
||||
Port 40000 is exposed for remote debugging. To use it, you'd need to modify the Dockerfile to build with debug symbols and start the server under Delve:
|
||||
|
||||
```bash
|
||||
# In Dockerfile, replace the CMD with:
|
||||
CMD ["dlv", "--listen=:40000", "--headless=true", "--api-version=2", "exec", "/app/server"]
|
||||
```
|
||||
|
||||
Then attach from your IDE (VS Code, GoLand) using remote debug configuration pointing to `localhost:40000`.
|
||||
|
||||
### Hot reload
|
||||
|
||||
The dev overlay includes commented-out volume mounts for source code directories. Uncomment them and install [air](https://github.com/cosmtrek/air) to get automatic recompilation on file changes:
|
||||
|
||||
```bash
|
||||
go install github.com/cosmtrek/air@latest
|
||||
```
|
||||
|
||||
**Expert note:** The `builds: context: ..` in the dev overlay overrides the base service's image reference, forcing a local build from the repository root. This means changes to your Go source code are compiled fresh on each `docker compose up --build`.
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
**File:** `docker-compose.test.yml`
|
||||
**When to use:** Integration testing against real CA backends. This is a standalone environment (not an overlay) with 7 containers on a static-IP subnet.
|
||||
|
||||
### What it runs
|
||||
|
||||
| Service | IP | Purpose |
|
||||
|---------|----|---------|
|
||||
| `postgres` | 10.30.50.2 | Database (clean, no demo data) |
|
||||
| `pebble-challtestsrv` | 10.30.50.3 | DNS/HTTP challenge test server for Pebble |
|
||||
| `pebble` | 10.30.50.4 | ACME test server (simulates Let's Encrypt) |
|
||||
| `step-ca` | 10.30.50.5 | Private CA (Smallstep, JWK provisioner) |
|
||||
| `certctl-server` | 10.30.50.6 | Control plane with all issuers configured |
|
||||
| `nginx` | 10.30.50.7 | TLS target server for deployment testing |
|
||||
| `certctl-agent` | 10.30.50.8 | Agent with NGINX volume + discovery |
|
||||
|
||||
### Why static IPs?
|
||||
|
||||
Pebble (the ACME test server) validates HTTP-01 challenges by connecting to the challenge URL. It resolves domain names via `pebble-challtestsrv`, which is configured to return `10.30.50.6` (the certctl server) for all lookups. Without static IPs, container IPs would be assigned randomly on each boot, breaking the challenge validation chain.
|
||||
|
||||
The `/24` subnet (10.30.50.0/24) provides 254 usable addresses, far more than needed but standard practice for test networks.
|
||||
|
||||
### Starting it
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.test.yml up --build
|
||||
```
|
||||
|
||||
Wait for all health checks to pass (about 60 seconds for step-ca's first-run bootstrap). Then:
|
||||
|
||||
```bash
|
||||
# Dashboard with auth enabled (HTTPS-only as of v2.2; browser will warn on the self-signed cert —
|
||||
# accept the warning or trust `deploy/test/certs/ca.crt` in your OS keychain)
|
||||
open https://localhost:8443
|
||||
# API key: test-key-2026
|
||||
|
||||
# NGINX serving a self-signed placeholder
|
||||
curl -k https://localhost:8444
|
||||
```
|
||||
|
||||
### What's different from the base
|
||||
|
||||
The test environment is configured for production-like behavior:
|
||||
|
||||
- **API key auth enabled** (`CERTCTL_AUTH_TYPE: api-key`, `CERTCTL_AUTH_SECRET: test-key-2026`). Every API request needs `Authorization: Bearer test-key-2026`.
|
||||
- **Agent-side key generation** (`CERTCTL_KEYGEN_MODE: agent`). The agent generates ECDSA P-256 keys locally and submits only the CSR to the server. Private keys never leave the agent container.
|
||||
- **Three real issuers configured:**
|
||||
- **Local CA** (self-signed) for instant issuance testing
|
||||
- **ACME via Pebble** for Let's Encrypt-compatible flow testing (HTTP-01 challenges validated through the challenge test server)
|
||||
- **step-ca** for private CA testing with JWK provisioner authentication
|
||||
- **EST server enabled** (`CERTCTL_EST_ENABLED: "true"`) for RFC 7030 enrollment testing
|
||||
- **Post-deployment verification enabled** (`CERTCTL_VERIFY_DEPLOYMENT: "true"`) so the agent probes NGINX after deploying a cert and confirms the TLS fingerprint matches
|
||||
- **Dynamic config encryption enabled** (`CERTCTL_CONFIG_ENCRYPTION_KEY`) so issuer/target configs added through the GUI are encrypted at rest
|
||||
- **TLS trust bootstrapping:** The server runs a `setup-trust.sh` entrypoint that fetches Pebble's root CA from its management API and copies step-ca's root cert from a shared volume, then runs `update-ca-certificates` before starting the server binary. This is necessary because both CAs use self-signed roots that aren't in Alpine's default trust store.
|
||||
|
||||
### Running the Go integration tests
|
||||
|
||||
The test environment is designed to support the Go integration test suite at `deploy/test/integration_test.go`:
|
||||
|
||||
```bash
|
||||
# Start the environment
|
||||
docker compose -f deploy/docker-compose.test.yml up --build -d
|
||||
|
||||
# Wait for health checks
|
||||
sleep 30
|
||||
|
||||
# Run integration tests (from repo root)
|
||||
go test -tags integration -v ./deploy/test/...
|
||||
```
|
||||
|
||||
The integration tests exercise 12 phases: health, agent heartbeat, Local CA issuance, ACME issuance, renewal, step-ca issuance, revocation + CRL + OCSP, EST enrollment, S/MIME issuance, discovery, network scan, and deployment verification. PostgreSQL port 5432 is exposed so the test binary can query the database directly for assertions.
|
||||
|
||||
See [docs/test-env.md](../docs/test-env.md) for the full walkthrough and manual QA procedures.
|
||||
|
||||
### Stopping and cleaning up
|
||||
|
||||
```bash
|
||||
# Stop but keep data (volumes persist)
|
||||
docker compose -f deploy/docker-compose.test.yml down
|
||||
|
||||
# Full reset (delete step-ca bootstrap, database, agent keys, NGINX certs)
|
||||
docker compose -f deploy/docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
**Expert note:** The step-ca container auto-bootstraps on first run: generates a root CA, creates a JWK provisioner named "admin" with password "password123", and writes everything to the `stepca_data` volume. Subsequent starts reuse this volume. If you `down -v`, the next boot generates a new root CA, which means all previously issued step-ca certs become untrusted.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
Every `CERTCTL_*` environment variable is read by the server's `internal/config/config.go` via `os.Getenv`. If the prefix is missing, the variable is silently ignored.
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_DATABASE_URL` | (required) | PostgreSQL connection string |
|
||||
| `CERTCTL_SERVER_HOST` | `0.0.0.0` | Listen address |
|
||||
| `CERTCTL_SERVER_PORT` | `8443` | Listen port |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error` |
|
||||
| `CERTCTL_AUTH_TYPE` | `api-key` | Auth mode: `api-key`, `none`, or `oidc` (Auth Bundle 2). |
|
||||
| `CERTCTL_AUTH_SECRET` | (none) | API key(s), comma-separated for rotation |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation: `agent` (production) or `server` (demo) |
|
||||
| `CERTCTL_CONFIG_ENCRYPTION_KEY` | (none) | AES-256-GCM key for encrypting issuer/target configs in DB |
|
||||
| `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable network TLS scanning scheduler loop |
|
||||
| `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the network scanner runs |
|
||||
| `CERTCTL_MAX_BODY_SIZE` | `1048576` | Max request body size in bytes (1MB) |
|
||||
| `CERTCTL_CORS_ORIGINS` | (empty) | Allowed CORS origins, comma-separated. Empty = deny all cross-origin |
|
||||
| `CERTCTL_RATE_LIMIT_RPS` | `10` | Requests per second per client |
|
||||
| `CERTCTL_RATE_LIMIT_BURST` | `20` | Burst allowance above RPS |
|
||||
| `CERTCTL_RATE_LIMIT_BUCKET_TTL` | `1h` | Sprint 2 SEC-006: lifetime of an unused token-bucket entry. A background sweeper running every `BucketTTL/4` reclaims buckets whose last `allow()` call is older than this. Values < 1m clamp up to 1m. Lower when facing high-cardinality unauthenticated traffic (CGNAT churn, scanners) where the bucket-map RSS becomes a concern. |
|
||||
| `CERTCTL_SCHEDULER_JOB_CLAIM_LIMIT` | `1000` | Sprint 2 SCALE-001: cap on the number of Pending rows a single scheduler tick may claim via `ClaimPendingJobs`. Pre-Sprint-2 the scheduler claimed every Pending row in one transaction, which page-thrashed on 100K-job bursts. Values ≤ 0 fail-safe to `1000` (legacy unlimited semantics are no longer reachable). Pair-tune with `CERTCTL_RENEWAL_CONCURRENCY` (default 25) — the default 40:1 ratio keeps the fan-out busy without exhausting upstream-CA rate limits. |
|
||||
| `CERTCTL_AGENT_BOOTSTRAP_TOKEN` | (empty — required) | Agent-registration bootstrap secret. Set to a real value (`openssl rand -base64 32`). Sprint 5 ACQ RED-003 (2026-05-16) flipped the paired `_DENY_EMPTY` flag's default to `true`, so leaving this empty now refuses server start (unless `CERTCTL_DEMO_MODE_ACK=true`). Operators on v2.1.x reopening the warn-mode escape hatch one upgrade-window can set `CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false` explicitly. |
|
||||
| `CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY` | `true` | Phase 2 SEC-H1 fail-closed guard. When `true` (default since Sprint 5 ACQ RED-003 closure, 2026-05-16), the server refuses to start unless `CERTCTL_AGENT_BOOTSTRAP_TOKEN` is non-empty. Set to `false` only for a v2.1.x→v2.2.x upgrade-window warn-mode escape hatch. |
|
||||
| `CERTCTL_DEMO_MODE_ACK` | `false` | Acknowledges demo-mode synthetic admin posture (required when `CERTCTL_AUTH_TYPE=none` binds to a non-loopback host). Must be paired with `CERTCTL_DEMO_MODE_ACK_TS` per Phase 2 SEC-H3. |
|
||||
| `CERTCTL_DEMO_MODE_ACK_TS` | (empty) | Phase 2 SEC-H3: unix-epoch timestamp at which DemoModeAck was last acknowledged. When `CERTCTL_DEMO_MODE_ACK=true`, this must parse as a unix epoch within the last 24h. Set via `CERTCTL_DEMO_MODE_ACK_TS=$(date +%s)` at every `docker compose up`. |
|
||||
| `CERTCTL_ACME_INSECURE_ACK` | `false` | Phase 2 SEC-M4: explicit ACK required to boot with `CERTCTL_ACME_INSECURE=true`. Production deploys MUST never set either flag. |
|
||||
| `CERTCTL_DATABASE_MAX_CONNS` | `50` | Phase 6 SCALE-M1: max open DB connections in the server's pool. Default was `25` pre-Phase-6. Idle connections = max/5. Operator-tune ladder for larger fleets: ≤500 certs → 50; 5K certs → 100; 50K certs → 200 (also raise Postgres `max_connections`). See `docs/operator/scale.md`. |
|
||||
| `CERTCTL_ASYNC_POLL_MAX_WAIT_SECONDS` | (unset → 600) | Phase 6 SCALE-M3: process-wide override for the asyncpoll package's `DefaultMaxWait` (10 minutes). Caps total wall-clock time the certctl-server spends polling an async CA (DigiCert / Entrust / GlobalSign / Sectigo) before returning `StillPending` to the scheduler for re-enqueue. Per-connector overrides (`CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS`, etc.) take precedence when set. |
|
||||
|
||||
### Agent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_SERVER_URL` | (required) | Server API URL |
|
||||
| `CERTCTL_API_KEY` | (none) | API key for authenticating with server |
|
||||
| `CERTCTL_AGENT_NAME` | (hostname) | Display name in dashboard |
|
||||
| `CERTCTL_AGENT_ID` | (none — required) | Stable agent identifier returned from `POST /api/v1/agents`. The agent binary fail-fasts at startup if unset. |
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Must match server setting |
|
||||
| `CERTCTL_LOG_LEVEL` | `info` | Log verbosity |
|
||||
| `CERTCTL_KEY_DIR` | `/var/lib/certctl/keys` | Directory for private key storage (0600 perms) |
|
||||
| `CERTCTL_DISCOVERY_DIRS` | (none) | Comma-separated paths to scan for existing certs |
|
||||
|
||||
### Issuers (Server)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | ACME CA directory (e.g., Let's Encrypt, Pebble) |
|
||||
| `CERTCTL_ACME_EMAIL` | ACME account email |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | `http-01`, `dns-01`, or `dns-persist-01` |
|
||||
| `CERTCTL_ACME_INSECURE` | Skip TLS verification for ACME CA (test only) |
|
||||
| `CERTCTL_ACME_EAB_KID` / `CERTCTL_ACME_EAB_HMAC` | External Account Binding for ZeroSSL, Google Trust Services |
|
||||
| `CERTCTL_ZEROSSL_EAB_URL` | Override the ZeroSSL EAB-credentials endpoint (defaults to the public ZeroSSL URL; only set for ZeroSSL staging or a private mirror) |
|
||||
| `CERTCTL_ACME_ARI_ENABLED` | Enable RFC 9773 Renewal Information |
|
||||
| `CERTCTL_ACME_PROFILE` | ACME profile (`tlsserver`, `shortlived`) |
|
||||
| `CERTCTL_STEPCA_URL` | step-ca server URL |
|
||||
| `CERTCTL_STEPCA_ROOT_CERT` | Path to step-ca root CA cert |
|
||||
| `CERTCTL_STEPCA_PROVISIONER` | Provisioner name |
|
||||
| `CERTCTL_STEPCA_PASSWORD` | Provisioner password |
|
||||
| `CERTCTL_STEPCA_KEY_PATH` | Path to provisioner key |
|
||||
| `CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH` | Sub-CA mode: load CA cert+key from disk |
|
||||
| `CERTCTL_VAULT_ADDR` | Vault server address |
|
||||
| `CERTCTL_VAULT_TOKEN` | Vault auth token |
|
||||
| `CERTCTL_VAULT_MOUNT` | PKI secrets engine mount (default: `pki`) |
|
||||
| `CERTCTL_VAULT_ROLE` | PKI role name |
|
||||
| `CERTCTL_DIGICERT_API_KEY` | DigiCert CertCentral API key |
|
||||
| `CERTCTL_DIGICERT_ORG_ID` | DigiCert organization ID |
|
||||
| `CERTCTL_SECTIGO_CUSTOMER_URI` / `_LOGIN` / `_PASSWORD` | Sectigo SCM auth |
|
||||
| `CERTCTL_GOOGLE_CAS_PROJECT` / `_LOCATION` / `_CA_POOL` / `_CREDENTIALS` | Google CAS config |
|
||||
|
||||
### EST Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_EST_ENABLED` | `false` | Enable RFC 7030 EST endpoints |
|
||||
| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Which issuer processes EST enrollments |
|
||||
| `CERTCTL_EST_PROFILE_ID` | (none) | Optional profile constraint |
|
||||
|
||||
### Post-Deployment Verification
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_VERIFY_DEPLOYMENT` | `false` | Agent probes TLS after deploying |
|
||||
| `CERTCTL_VERIFY_TIMEOUT` | `10s` | TLS probe timeout |
|
||||
| `CERTCTL_VERIFY_DELAY` | `2s` | Wait before probing (let service reload) |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CERTCTL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_FROM_ADDRESS` / `_USE_TLS` | SMTP email |
|
||||
| `CERTCTL_SLACK_WEBHOOK_URL` / `_CHANNEL` / `_USERNAME` | Slack notifications |
|
||||
| `CERTCTL_TEAMS_WEBHOOK_URL` | Microsoft Teams |
|
||||
| `CERTCTL_PAGERDUTY_ROUTING_KEY` / `_SEVERITY` | PagerDuty alerts |
|
||||
| `CERTCTL_OPSGENIE_API_KEY` / `_PRIORITY` | OpsGenie alerts |
|
||||
| `CERTCTL_DIGEST_ENABLED` / `_INTERVAL` / `_RECIPIENTS` | Scheduled digest email |
|
||||
|
||||
---
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Viewing logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f deploy/docker-compose.yml logs -f
|
||||
|
||||
# Single service
|
||||
docker compose -f deploy/docker-compose.yml logs -f certctl-server
|
||||
|
||||
# Last 100 lines
|
||||
docker compose -f deploy/docker-compose.yml logs --tail 100 certctl-server
|
||||
```
|
||||
|
||||
### Rebuilding after code changes
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Docker only rebuilds images that have changed source files. The `--build` flag is essential after editing Go code or frontend files.
|
||||
|
||||
### Connecting to the database directly
|
||||
|
||||
```bash
|
||||
docker exec -it certctl-postgres psql -U certctl -d certctl
|
||||
```
|
||||
|
||||
Useful queries:
|
||||
```sql
|
||||
-- Certificate inventory
|
||||
SELECT id, common_name, status, expires_at FROM managed_certificates ORDER BY expires_at;
|
||||
|
||||
-- Recent jobs
|
||||
SELECT id, type, status, certificate_id, created_at FROM jobs ORDER BY created_at DESC LIMIT 20;
|
||||
|
||||
-- Audit trail
|
||||
SELECT event_type, actor, resource_id, created_at FROM audit_events ORDER BY created_at DESC LIMIT 20;
|
||||
|
||||
-- Issuer configurations (encrypted_config is AES-256-GCM)
|
||||
SELECT id, type, source, enabled, test_status FROM issuers;
|
||||
```
|
||||
|
||||
### Checking container resource usage
|
||||
|
||||
```bash
|
||||
docker stats --no-stream
|
||||
```
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations are idempotent (`IF NOT EXISTS`), so upgrading to a version with new schema changes is safe. PostgreSQL only runs init scripts on first boot of a fresh volume, so new migrations in an upgrade require running them manually:
|
||||
|
||||
```bash
|
||||
docker exec -i certctl-postgres psql -U certctl -d certctl < migrations/000011_new_feature.up.sql
|
||||
```
|
||||
|
||||
Or, for a clean upgrade: `down -v` and `up --build` (loses existing data).
|
||||
38
deploy/demo-up.sh
Executable file
38
deploy/demo-up.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/demo-up.sh — boot the certctl demo stack with the fresh
|
||||
# CERTCTL_DEMO_MODE_ACK_TS the Phase 2 SEC-H3 guard requires.
|
||||
#
|
||||
# The demo overlay sets CERTCTL_DEMO_MODE_ACK=true. Phase 2 SEC-H3
|
||||
# (2026-05-13) pairs that with a fail-closed requirement: the server
|
||||
# refuses to start unless CERTCTL_DEMO_MODE_ACK_TS=<unix-epoch> is set
|
||||
# and is within the last 24h (with 1-minute future clock-skew tolerance).
|
||||
#
|
||||
# A static value in docker-compose.demo.yml would rot the next day, so
|
||||
# the overlay passthroughs the value from the shell environment. This
|
||||
# helper mints a fresh TS at run time and forwards any extra args to
|
||||
# `docker compose up`, so operators can use it as a drop-in replacement
|
||||
# for the bare command. Example:
|
||||
#
|
||||
# ./demo-up.sh -d # cold boot in detached mode
|
||||
# ./demo-up.sh -d --pull always # forward any flags through
|
||||
#
|
||||
# The cold-DB compose smoke in .github/workflows/ci.yml does the same
|
||||
# thing inline; this script exists so local operators don't have to
|
||||
# remember the export.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# cd to the deploy/ dir so the relative `-f` paths resolve regardless
|
||||
# of where the operator invokes this from. The script lives next to
|
||||
# the compose files it references.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export CERTCTL_DEMO_MODE_ACK_TS="$(date +%s)"
|
||||
|
||||
echo "[demo-up] minting CERTCTL_DEMO_MODE_ACK_TS=$CERTCTL_DEMO_MODE_ACK_TS"
|
||||
echo "[demo-up] running: docker compose -f docker-compose.yml -f docker-compose.demo.yml up $*"
|
||||
|
||||
exec docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.demo.yml \
|
||||
up "$@"
|
||||
125
deploy/docker-compose.demo.yml
Normal file
125
deploy/docker-compose.demo.yml
Normal file
@@ -0,0 +1,125 @@
|
||||
# =============================================================================
|
||||
# certctl DEMO overlay — Bundle 2 (2026-05-12)
|
||||
# =============================================================================
|
||||
#
|
||||
# Layered on top of the production-shaped base (docker-compose.yml) to give
|
||||
# operators a one-command, zero-config demo path:
|
||||
#
|
||||
# deploy/demo-up.sh -d --build
|
||||
#
|
||||
# (which forwards args to `docker compose up` after exporting the fresh
|
||||
# CERTCTL_DEMO_MODE_ACK_TS that Phase 2 SEC-H3 requires). Equivalent
|
||||
# manual invocation:
|
||||
#
|
||||
# CERTCTL_DEMO_MODE_ACK_TS=$(date +%s) docker compose \
|
||||
# -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml up -d --build
|
||||
#
|
||||
# What this overlay does:
|
||||
#
|
||||
# 1. Flips CERTCTL_AUTH_TYPE=none + CERTCTL_DEMO_MODE_ACK=true. Every
|
||||
# request is served as the synthetic admin actor `actor-demo-anon`;
|
||||
# the server emits a prominent ⚠ DEMO MODE WARN banner at boot with
|
||||
# a production-promotion checklist (cmd/server/main.go::emitDemoBanner).
|
||||
# Phase 2 SEC-H3 (2026-05-13) pairs DEMO_MODE_ACK with a required
|
||||
# DEMO_MODE_ACK_TS within the last 24h. The overlay reads
|
||||
# ${CERTCTL_DEMO_MODE_ACK_TS:-} from the shell — use deploy/demo-up.sh
|
||||
# (which exports a fresh TS) instead of bare `docker compose up`.
|
||||
#
|
||||
# 2. Flips CERTCTL_KEYGEN_MODE=server (the demo issues + holds the key on
|
||||
# the server to keep the dashboard populated; production deploys must
|
||||
# use the default `agent` mode where keys never leave the agent box).
|
||||
#
|
||||
# 3. Flips CERTCTL_DEMO_SEED=true. The server applies migrations/seed_demo.sql
|
||||
# at boot via postgres.RunDemoSeed AFTER baseline migrations + seed.sql,
|
||||
# pre-seeding 180 days of simulated history across 13 issuers + 8 agents.
|
||||
#
|
||||
# 4. Supplies the change-me-... placeholder values for POSTGRES_PASSWORD,
|
||||
# CERTCTL_API_KEY, CERTCTL_CONFIG_ENCRYPTION_KEY, and CERTCTL_AGENT_ID
|
||||
# so the demo runs without a deploy/.env file. The Bundle 2 fail-closed
|
||||
# Validate() rejects these placeholders outside demo mode, so this only
|
||||
# works alongside DEMO_MODE_ACK=true.
|
||||
#
|
||||
# U-3 history: pre-U-3 this overlay mounted seed_demo.sql into postgres
|
||||
# `/docker-entrypoint-initdb.d/`. That worked only because the production
|
||||
# stack also mounted the migrations there. Once U-3 dropped the production
|
||||
# initdb mounts (single source of truth: server runs RunMigrations + RunSeed
|
||||
# at boot), the demo seed could no longer be applied at initdb time — the
|
||||
# tables it references wouldn't exist yet. Post-U-3 the overlay just sets
|
||||
# CERTCTL_DEMO_SEED=true; the server applies seed_demo.sql at boot via
|
||||
# postgres.RunDemoSeed AFTER baseline migrations + seed.sql.
|
||||
#
|
||||
# Bundle 2 history: pre-Bundle-2 the base compose IS this demo path; this
|
||||
# overlay was a single-flag thin shim. Bundle 2 split the demo env vars
|
||||
# out of the base so `docker compose -f deploy/docker-compose.yml up`
|
||||
# (no overlay) boots production-shaped — which is what every operator
|
||||
# reading the README quickstart line "drop the demo overlay for a clean
|
||||
# install" expected. The overlay carries the full demo posture now.
|
||||
#
|
||||
# To start fresh (wipe previous data):
|
||||
# docker compose -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml down -v
|
||||
# deploy/demo-up.sh -d --build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
# Fixed weak password is intentional for the no-setup demo path.
|
||||
# See docker-compose.yml for the production override pattern.
|
||||
environment:
|
||||
POSTGRES_PASSWORD: certctl
|
||||
|
||||
certctl-server:
|
||||
environment:
|
||||
# Demo-mode auth: every request served as the synthetic
|
||||
# `actor-demo-anon` admin. The server's HIGH-12 startup guard
|
||||
# requires DEMO_MODE_ACK=true to allow this combination on a
|
||||
# non-loopback bind; the boot-time WARN banner (cmd/server/main.go)
|
||||
# reminds the operator on every start.
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_DEMO_MODE_ACK: "true"
|
||||
# Phase 2 SEC-H3 (2026-05-13): DEMO_MODE_ACK=true requires a fresh
|
||||
# DEMO_MODE_ACK_TS within the last 24h. The overlay can't hardcode
|
||||
# a timestamp (it would rot the next day), so we passthrough from
|
||||
# the shell. Operators set this via:
|
||||
# CERTCTL_DEMO_MODE_ACK_TS=$(date +%s) docker compose \
|
||||
# -f docker-compose.yml -f docker-compose.demo.yml up -d
|
||||
# The cold-DB smoke + any helper script (deploy/demo-up.sh, when
|
||||
# it lands) export this before invoking compose. Empty value
|
||||
# fails the SEC-H3 guard with a clear operator-facing error
|
||||
# message pointing at this line.
|
||||
CERTCTL_DEMO_MODE_ACK_TS: "${CERTCTL_DEMO_MODE_ACK_TS:-}"
|
||||
# Server-side keygen so the demo can populate the dashboard with
|
||||
# full lifecycle history. Production deploys leave this at the
|
||||
# code default `agent` (CertctlAgent generates ECDSA P-256 keys
|
||||
# locally and submits CSRs only).
|
||||
CERTCTL_KEYGEN_MODE: server
|
||||
# Demo creds — the Bundle 2 fail-closed Validate() rejects these
|
||||
# sentinels outside demo mode, but DEMO_MODE_ACK=true unlocks them.
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: change-me-32-char-encryption-key
|
||||
CERTCTL_AUTH_SECRET: change-me-in-production
|
||||
# Cold-DB smoke fix (2026-05-13): the base compose builds the
|
||||
# database URL via compose-level `${POSTGRES_PASSWORD}` interpolation
|
||||
# (deploy/docker-compose.yml line ~177), which reads the SHELL env —
|
||||
# NOT the postgres service's `environment:` block above (that one
|
||||
# feeds the postgres container's initdb only). In a zero-env-var
|
||||
# CI run the shell var is blank, producing
|
||||
# `postgres://certctl:@postgres:5432/...` and a SCRAM rejection
|
||||
# against a database that initdb seeded with password `certctl`.
|
||||
# Pinning the full URL here closes the gap: the demo overlay is
|
||||
# now fully self-sufficient (matches the file's docstring claim)
|
||||
# and the cold-DB smoke passes against a fresh GitHub-runner clone
|
||||
# with no .env file or exported shell vars. Production deploys
|
||||
# override CERTCTL_DATABASE_URL via the base compose's
|
||||
# `${CERTCTL_DATABASE_URL:-...}` default, so this literal is
|
||||
# overlay-scoped and never leaks into a production posture.
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:certctl@postgres:5432/certctl?sslmode=disable
|
||||
# 180-day simulated history seed applied at boot.
|
||||
CERTCTL_DEMO_SEED: "true"
|
||||
|
||||
certctl-agent:
|
||||
environment:
|
||||
# Pre-seeded by migrations/seed_demo.sql; the bundled agent
|
||||
# connects with these creds and the demo-mode synthetic admin
|
||||
# accepts every request regardless of API key.
|
||||
CERTCTL_API_KEY: change-me-in-production
|
||||
CERTCTL_AGENT_ID: agent-demo-1
|
||||
@@ -9,11 +9,21 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Node frontend stage and Go module
|
||||
# download can reach the public registries behind corporate proxies.
|
||||
# Defaults to empty; omit the variables from the host environment for
|
||||
# un-proxied builds and the behaviour is byte-identical to the pre-fix
|
||||
# tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
environment:
|
||||
# Verbose logging for development
|
||||
LOG_LEVEL: debug
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 8443
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: "8443"
|
||||
volumes:
|
||||
# Mount local source for hot reload (requires air or similar)
|
||||
# Uncomment if using air or similar for hot reload:
|
||||
@@ -29,8 +39,17 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Go module download stage can reach
|
||||
# the public Go module proxy behind corporate proxies. Defaults to
|
||||
# empty; omit the variables from the host environment for un-proxied
|
||||
# builds and the behaviour is byte-identical to the pre-fix tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
|
||||
# PgAdmin for database exploration
|
||||
pgadmin:
|
||||
|
||||
767
deploy/docker-compose.test.yml
Normal file
767
deploy/docker-compose.test.yml
Normal file
@@ -0,0 +1,767 @@
|
||||
# =============================================================================
|
||||
# certctl Testing Environment — Docker Compose
|
||||
# =============================================================================
|
||||
#
|
||||
# Spins up the full certctl platform with real CA backends for manual QA:
|
||||
#
|
||||
# 0. certctl-tls-init — one-shot init container; writes self-signed
|
||||
# server.crt/.key/ca.crt into ./test/certs (bind
|
||||
# mount, not a named volume — host-readable for
|
||||
# the Go integration test binary)
|
||||
# 1. PostgreSQL 16 — database (clean, no demo data)
|
||||
# 2. certctl-server — control plane API + web dashboard on :8443 (HTTPS)
|
||||
# 3. certctl-agent — polls for work, deploys certs to NGINX
|
||||
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
|
||||
# 5. Pebble — ACME test server (simulates Let's Encrypt)
|
||||
# 6. pebble-challtestsrv — DNS/HTTP challenge test server for Pebble
|
||||
# 7. NGINX — TLS target server on :8080 (HTTP) / :8444 (HTTPS)
|
||||
#
|
||||
# Usage:
|
||||
# cd deploy
|
||||
# docker compose -f docker-compose.test.yml up --build
|
||||
#
|
||||
# Dashboard: https://localhost:8443 (self-signed — use --cacert test/certs/ca.crt)
|
||||
# API key: test-key-2026
|
||||
# NGINX: https://localhost:8444 (self-signed placeholder until cert deployed)
|
||||
#
|
||||
# Integration tests: `go test -tags integration ./deploy/test/...` picks up
|
||||
# the CA bundle at ./test/certs/ca.crt automatically via CERTCTL_TEST_CA_BUNDLE.
|
||||
#
|
||||
# See docs/test-env.md for the full walkthrough.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTPS-Everywhere Phase 6 — self-signed TLS bootstrap for the test harness.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mirrors the production `certctl-tls-init` (see docker-compose.yml §10-43)
|
||||
# but writes into a *host bind mount* (./test/certs) instead of a named
|
||||
# volume. The named-volume approach works fine inside Docker but hides the
|
||||
# CA bundle from the Go integration test binary that runs on the host; the
|
||||
# bind mount exposes /etc/certctl/tls/ca.crt at deploy/test/certs/ca.crt
|
||||
# so `newTestClient()` can load it into an x509.CertPool and validate the
|
||||
# self-signed server cert. Test-only divergence, explicitly documented.
|
||||
#
|
||||
# The generated cert has SAN=DNS:certctl-server,DNS:localhost,IP:127.0.0.1
|
||||
# so both in-cluster traffic (agent → certctl-server:8443) and host traffic
|
||||
# (go test → localhost:8443) validate cleanly. Destroy via
|
||||
# `docker compose -f docker-compose.test.yml down -v` + `rm -rf test/certs`
|
||||
# to force regeneration. Keys written 0600, certs 0644, owned 1000:1000
|
||||
# (the UID the server binary runs as inside its container per Dockerfile:64).
|
||||
certctl-tls-init:
|
||||
image: alpine/openssl:latest
|
||||
container_name: certctl-test-tls-init
|
||||
restart: "no"
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
CERT=/etc/certctl/tls/server.crt
|
||||
KEY=/etc/certctl/tls/server.key
|
||||
CA=/etc/certctl/tls/ca.crt
|
||||
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
|
||||
echo "TLS cert already present at $$CERT — skipping generation"
|
||||
else
|
||||
mkdir -p /etc/certctl/tls
|
||||
openssl req -x509 -newkey ec \
|
||||
-pkeyopt ec_paramgen_curve:P-256 \
|
||||
-nodes \
|
||||
-keyout "$$KEY" \
|
||||
-out "$$CERT" \
|
||||
-days 3650 \
|
||||
-subj "/CN=certctl-server" \
|
||||
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
cp "$$CERT" "$$CA"
|
||||
echo "Generated self-signed TLS cert for certctl-test-server (ECDSA-P256/SHA-256, 3650d, CN=certctl-server)"
|
||||
fi
|
||||
# The test server container runs as root (see `user: "0:0"` below)
|
||||
# because setup-trust.sh needs to update the system trust store, so
|
||||
# the perms here are really about host-side readability — 0644 on
|
||||
# the CA/cert lets `go test` on the host read the bundle without a
|
||||
# chown dance.
|
||||
chown 1000:1000 "$$CERT" "$$KEY" "$$CA" || true
|
||||
chmod 0644 "$$CERT" "$$CA"
|
||||
chmod 0600 "$$KEY"
|
||||
volumes:
|
||||
- ./test/certs:/etc/certctl/tls
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.9
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10): the test stack used
|
||||
# to mount a hand-curated subset of migrations + seed.sql + a never-checked-in
|
||||
# seed_test.sql into postgres `/docker-entrypoint-initdb.d/`. Same hazard as
|
||||
# the production compose — initdb crashed any time a new migration shipped
|
||||
# that the seed depended on without the mount list being updated. Post-U-3
|
||||
# the schema is built EXCLUSIVELY by the server at startup via
|
||||
# internal/repository/postgres.RunMigrations + RunSeed. Postgres comes up
|
||||
# empty and the server lands the full ladder + baseline seed in one shot.
|
||||
# `start_period: 30s` matches the production compose and shields slow CI
|
||||
# runners from healthcheck flap during initdb.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: certctl-test-postgres
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: testpass
|
||||
volumes:
|
||||
- test_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.2
|
||||
# Acquisition-audit SEC-014 closure (Sprint 2, 2026-05-16).
|
||||
# Loopback-only host-port bind — the integration-test runner on
|
||||
# the host needs reachability, no other interface does.
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U certctl -d certctl"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble — ACME test server (simulates Let's Encrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pebble is the official ACME test server from Let's Encrypt (RFC 8555).
|
||||
# It validates challenges via the companion challtestsrv.
|
||||
# Root CA cert available at https://pebble:15000/roots/0 (management API).
|
||||
pebble-challtestsrv:
|
||||
image: ghcr.io/letsencrypt/pebble-challtestsrv:latest
|
||||
container_name: certctl-test-challtestsrv
|
||||
# ENTRYPOINT is /app (the binary). command: provides only the FLAGS.
|
||||
# Matches the official Pebble docker-compose format.
|
||||
# -doh "" disables DoH (default :8443 would conflict with certctl server).
|
||||
# defaultIPv4 must point to the certctl-server (10.30.50.6) because that's where
|
||||
# the ACME HTTP-01 challenge server runs (port 80 inside the container).
|
||||
# Pebble resolves domains via challtestsrv, then connects to this IP to validate.
|
||||
command: -defaultIPv4 10.30.50.6 -defaultIPv6 "" -doh ""
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.3
|
||||
restart: unless-stopped
|
||||
|
||||
pebble:
|
||||
image: ghcr.io/letsencrypt/pebble:latest
|
||||
container_name: certctl-test-pebble
|
||||
depends_on:
|
||||
- pebble-challtestsrv
|
||||
environment:
|
||||
PEBBLE_VA_NOSLEEP: 1
|
||||
PEBBLE_VA_ALWAYS_VALID: 0
|
||||
# ENTRYPOINT is /app (the binary). command: provides only the FLAGS.
|
||||
command:
|
||||
- -config
|
||||
- /test/config/pebble-config.json
|
||||
- -dnsserver
|
||||
- "10.30.50.3:8053"
|
||||
- -strict
|
||||
volumes:
|
||||
- ./test/pebble-config.json:/test/config/pebble-config.json:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.4
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step-ca — Private CA (Smallstep)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-bootstraps on first run: generates root CA + JWK provisioner "admin".
|
||||
# Root cert: /home/step/certs/root_ca.crt (inside stepca_data volume)
|
||||
# Provisioner key: /home/step/secrets/provisioner_key (encrypted JWK)
|
||||
step-ca:
|
||||
image: smallstep/step-ca:latest
|
||||
container_name: certctl-test-stepca
|
||||
environment:
|
||||
DOCKER_STEPCA_INIT_NAME: "certctl-test-ca"
|
||||
DOCKER_STEPCA_INIT_DNS_NAMES: "step-ca,localhost"
|
||||
DOCKER_STEPCA_INIT_PROVISIONER_NAME: "admin"
|
||||
DOCKER_STEPCA_INIT_PASSWORD: "password123"
|
||||
DOCKER_STEPCA_INIT_ADDRESS: ":9000"
|
||||
volumes:
|
||||
- stepca_data:/home/step
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.5
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fk", "https://localhost:9000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# certctl Server (Control Plane)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connects to PostgreSQL, Pebble (ACME), step-ca, and Local CA.
|
||||
#
|
||||
# TLS trust problem: Pebble and step-ca use self-signed root CAs that
|
||||
# aren't in Alpine's trust store. The ACME and step-ca connectors use
|
||||
# Go's default http.Client (no InsecureSkipVerify), so they need the
|
||||
# CA certs in the system trust store.
|
||||
#
|
||||
# Solution: setup-trust.sh runs as root, fetches Pebble CA from its
|
||||
# management API, copies step-ca root cert from the shared volume,
|
||||
# runs update-ca-certificates, then execs the server binary.
|
||||
certctl-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Node frontend stage and Go module
|
||||
# download can reach the public registries behind corporate proxies.
|
||||
# Defaults to empty; omit the variables from the host environment for
|
||||
# un-proxied builds and the behaviour is byte-identical to the pre-fix
|
||||
# tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-test-server
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
pebble:
|
||||
condition: service_started
|
||||
step-ca:
|
||||
condition: service_healthy
|
||||
# HTTPS-Everywhere Phase 6: block server boot until the init container
|
||||
# has written server.crt / server.key / ca.crt into ./test/certs. The
|
||||
# init container runs once and exits 0; service_completed_successfully
|
||||
# makes that a gating dependency rather than a liveness one.
|
||||
certctl-tls-init:
|
||||
condition: service_completed_successfully
|
||||
# Run as root so update-ca-certificates can write to /etc/ssl/certs.
|
||||
# Container isolation provides the security boundary.
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "/app/setup-trust.sh"]
|
||||
environment:
|
||||
# Database
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:testpass@postgres:5432/certctl?sslmode=disable
|
||||
|
||||
# Server
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
# HTTPS-Everywhere Phase 6: point the server at the init-container-generated
|
||||
# cert/key pair (bind-mounted from ./test/certs). Same paths as production
|
||||
# compose so the server binary code path is identical; only the host-side
|
||||
# storage differs (bind mount vs named volume — see §certctl-tls-init block).
|
||||
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
|
||||
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
|
||||
# Auth — API key required (production-like)
|
||||
CERTCTL_AUTH_TYPE: api-key
|
||||
CERTCTL_AUTH_SECRET: test-key-2026
|
||||
|
||||
# Phase 2 SEC-H1 + Sprint 5 RED-003 closure (2026-05-16): the
|
||||
# AgentBootstrapTokenDenyEmpty fail-closed guard refuses to start
|
||||
# the server when CERTCTL_AGENT_BOOTSTRAP_TOKEN is empty (the
|
||||
# default DENY_EMPTY=true flipped on Sprint 5). Demo stacks
|
||||
# bypass the guard via CERTCTL_DEMO_MODE_ACK=true, but this is
|
||||
# the e2e TEST stack (production-like auth posture), not a demo
|
||||
# stack — set a deterministic placeholder token so the server
|
||||
# boots and the vendor-edge integration tests can run. Clearly
|
||||
# test-only; do NOT copy to production. Operators set this from
|
||||
# `openssl rand -base64 32` per docs/operator/security.md.
|
||||
CERTCTL_AGENT_BOOTSTRAP_TOKEN: test-agent-bootstrap-token-deterministic-fixture
|
||||
|
||||
# Key generation — agent-side (production-like)
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
|
||||
# Local CA issuer (iss-local) — self-signed mode (no CA cert/key paths)
|
||||
# This is the simplest issuer, always available.
|
||||
|
||||
# ACME issuer (iss-acme-staging) — pointed at Pebble
|
||||
CERTCTL_ACME_DIRECTORY_URL: https://pebble:14000/dir
|
||||
CERTCTL_ACME_EMAIL: test@certctl.dev
|
||||
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||
CERTCTL_ACME_INSECURE: "true"
|
||||
# Phase 2 SEC-M4 (2026-05-13): CERTCTL_ACME_INSECURE=true requires
|
||||
# the paired CERTCTL_ACME_INSECURE_ACK=true; without the ACK the
|
||||
# server's Config.Validate() refuses to start. This integration
|
||||
# stack uses Pebble's self-signed ACME directory, so disabling
|
||||
# TLS verification is correct — but the ACK env var has to be
|
||||
# set explicitly so the test posture matches what production
|
||||
# operators are blocked from doing accidentally.
|
||||
CERTCTL_ACME_INSECURE_ACK: "true"
|
||||
|
||||
# step-ca issuer (iss-stepca)
|
||||
CERTCTL_STEPCA_URL: https://step-ca:9000
|
||||
CERTCTL_STEPCA_ROOT_CERT: /stepca-data/certs/root_ca.crt
|
||||
CERTCTL_STEPCA_PROVISIONER: admin
|
||||
CERTCTL_STEPCA_PASSWORD: password123
|
||||
CERTCTL_STEPCA_KEY_PATH: /stepca-data/secrets/provisioner_key
|
||||
|
||||
# EST server (RFC 7030) — uses Local CA by default
|
||||
CERTCTL_EST_ENABLED: "true"
|
||||
CERTCTL_EST_ISSUER_ID: iss-local
|
||||
|
||||
# SCEP intentionally NOT configured in this stack.
|
||||
#
|
||||
# The 2026-04-29 master bundle Phase I added an `e2eintune` SCEP
|
||||
# profile to this compose file with the intent that
|
||||
# deploy/test/scep_intune_e2e_test.go would exercise it. That
|
||||
# integration test exists (//go:build integration) but no CI job
|
||||
# actually selects it — ci.yml's deploy-vendor-e2e job runs only
|
||||
# `-run 'VendorEdge_'` (line 379), and no other job ever invokes
|
||||
# `go test -tags integration` with a SCEP selector.
|
||||
#
|
||||
# The result was dead config: SCEP_ENABLED=true triggered the
|
||||
# per-profile validator chain at server boot, but the supporting
|
||||
# fixtures (ra.crt + ra.key + intune_trust_anchor.pem) were never
|
||||
# committed to deploy/test/fixtures/ — only the README documenting
|
||||
# how to regenerate them. Pre-Phase-5 (ci-pipeline-cleanup matrix
|
||||
# collapse) the test stack didn't fully boot the certctl-server in
|
||||
# CI, so the gap was hidden. Once the matrix collapsed and the
|
||||
# collapsed deploy-vendor-e2e job started actually booting the
|
||||
# server, the fail-loud gate at config.go:2069 (CWE-306, empty
|
||||
# CHALLENGE_PASSWORD) fired and blocked CI.
|
||||
#
|
||||
# CERTCTL_SCEP_ENABLED is unset → default false → the validator
|
||||
# skips the entire SCEP block. Coherence guard at
|
||||
# scripts/ci-guards/test-compose-scep-coherence.sh refuses any
|
||||
# future edit that re-enables SCEP without ALSO (a) adding a CI
|
||||
# job that runs the SCEP integration test and (b) committing the
|
||||
# required fixtures. The README at deploy/test/fixtures/README.md
|
||||
# keeps the regen recipe so the eventual SCEP CI job lands cleanly.
|
||||
|
||||
# Dynamic issuer/target config encryption (M34/M35).
|
||||
#
|
||||
# MUST be ≥ 32 bytes. The H-1 closure (commit 6cb4414, "feat(security):
|
||||
# encryption-key validation") added internal/config/config.go's
|
||||
# minEncryptionKeyLength = 32 byte floor; values shorter than that are
|
||||
# rejected at server boot with `Failed to load configuration:
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY too short`. The previous test value
|
||||
# `test-encryption-key-32chars!!` was 29 bytes (the name claimed 32 but
|
||||
# the author miscounted — 4+1+10+1+3+1+2+5+2 = 29). Pre-H-1 the
|
||||
# validator accepted any non-empty string, so the gap was silent. Once
|
||||
# the test stack actually boots the certctl-server (which the
|
||||
# ci-pipeline-cleanup Phase 5 matrix collapse forced for the first
|
||||
# time), the server now hard-fails at startup and the deploy-vendor-e2e
|
||||
# job's `dependency failed to start: container certctl-test-server
|
||||
# is unhealthy` error fires.
|
||||
#
|
||||
# The replacement below is 49 bytes — 17 bytes of safety margin over
|
||||
# the floor so a future tightening (32 → 33+) does not break this
|
||||
# fixture. It is clearly test-only / deterministic; do NOT copy this
|
||||
# to production. Operators set CERTCTL_CONFIG_ENCRYPTION_KEY from
|
||||
# `openssl rand -base64 32` per the README.
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: test-encryption-key-deterministic-32-byte-fixture
|
||||
|
||||
# Network scanning
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true"
|
||||
|
||||
# Post-deployment TLS verification
|
||||
CERTCTL_VERIFY_DEPLOYMENT: "true"
|
||||
CERTCTL_VERIFY_TIMEOUT: "10s"
|
||||
CERTCTL_VERIFY_DELAY: "3s"
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ./test/setup-trust.sh:/app/setup-trust.sh:ro
|
||||
# step-ca data volume (root cert at /certs/root_ca.crt, key at /secrets/provisioner_key)
|
||||
- stepca_data:/stepca-data:ro
|
||||
# HTTPS-Everywhere Phase 6: read-only bind mount of the init-generated
|
||||
# TLS material. The init container writes here; server reads here; the
|
||||
# agent mounts the same host path at the same container path (see below)
|
||||
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
|
||||
- ./test/certs:/etc/certctl/tls:ro
|
||||
# SCEP fixtures volume mount removed alongside the SCEP env vars
|
||||
# above. When a CI job that runs scep_intune_e2e_test.go is added,
|
||||
# restore both this mount AND the env vars together — the coherence
|
||||
# guard at scripts/ci-guards/test-compose-scep-coherence.sh
|
||||
# enforces that they move as a unit.
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.6
|
||||
healthcheck:
|
||||
# HTTPS-Everywhere Phase 6: healthcheck now speaks TLS with --cacert to
|
||||
# verify the self-signed server cert against the init-generated bundle.
|
||||
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the
|
||||
# Bearer token. curl exits non-zero on both TLS handshake failure and
|
||||
# non-2xx status — either failure keeps depends_on: {condition:
|
||||
# service_healthy} from unblocking the agent, which is what we want.
|
||||
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "-H", "Authorization: Bearer test-key-2026", "https://localhost:8443/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NGINX — TLS Target Server
|
||||
# ---------------------------------------------------------------------------
|
||||
# The agent deploys certificates here via the shared nginx_certs volume.
|
||||
# nginx-entrypoint.sh generates a self-signed placeholder cert so NGINX
|
||||
# can boot before the agent deploys a real cert.
|
||||
#
|
||||
# Ports: 8080 (HTTP) / 8444 (HTTPS) — offset to avoid conflict with server.
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: certctl-test-nginx
|
||||
entrypoint: ["/bin/sh", "/entrypoint.sh"]
|
||||
volumes:
|
||||
- ./test/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./test/nginx-entrypoint.sh:/entrypoint.sh:ro
|
||||
- nginx_certs:/etc/nginx/certs
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8444:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.7
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fk https://localhost/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# certctl Agent
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polls the server for work, generates ECDSA P-256 keys locally,
|
||||
# deploys certs to NGINX via the shared volume, and discovers existing
|
||||
# certs in the NGINX cert directory.
|
||||
certctl-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Go module download stage can reach
|
||||
# the public Go module proxy behind corporate proxies. Defaults to
|
||||
# empty; omit the variables from the host environment for un-proxied
|
||||
# builds and the behaviour is byte-identical to the pre-fix tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-test-agent
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# HTTPS-Everywhere Phase 6: agent dials the server over TLS and validates
|
||||
# the self-signed cert against the CA bundle pinned by
|
||||
# CERTCTL_SERVER_CA_BUNDLE_PATH. Same env vars + container paths as
|
||||
# production compose so the agent binary code path (loadCABundle →
|
||||
# x509.CertPool → *tls.Config{RootCAs, MinVersion: TLS13}) is identical.
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
|
||||
CERTCTL_API_KEY: test-key-2026
|
||||
CERTCTL_AGENT_NAME: test-agent-01
|
||||
CERTCTL_AGENT_ID: agent-test-01
|
||||
CERTCTL_KEYGEN_MODE: agent
|
||||
CERTCTL_LOG_LEVEL: debug
|
||||
CERTCTL_DISCOVERY_DIRS: /nginx-certs
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
- nginx_certs:/nginx-certs
|
||||
# HTTPS-Everywhere Phase 6: same bind mount as the server, same path,
|
||||
# so /etc/certctl/tls/ca.crt resolves to the identical bytes. This is
|
||||
# the only way the CN=certctl-server cert validates on the agent side.
|
||||
- ./test/certs:/etc/certctl/tls:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.8
|
||||
restart: unless-stopped
|
||||
|
||||
# EST RFC 7030 hardening master bundle Phase 10.1 — libest sidecar.
|
||||
#
|
||||
# Cisco's libest reference RFC 7030 client. The integration test
|
||||
# (deploy/test/est_e2e_test.go, build tag `integration`) docker-exec's
|
||||
# into this container to drive estclient against the live certctl
|
||||
# server. The container stays alive via `sleep infinity` so the test
|
||||
# can do many serial exec calls without paying container-startup cost.
|
||||
#
|
||||
# Profile-gated (`profiles: [est-e2e]`) so the routine `docker compose
|
||||
# up` for non-EST integration runs doesn't pay the libest build cost.
|
||||
# Operator opts in via `docker compose --profile est-e2e up`. CI's
|
||||
# est-e2e job runs:
|
||||
# docker compose --profile est-e2e build libest-client
|
||||
# docker compose --profile est-e2e up -d
|
||||
# INTEGRATION=1 go test -tags integration -run 'TestEST_LibESTClient' ./deploy/test/...
|
||||
libest-client:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/test/libest/Dockerfile
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-test-libest
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# /config/est is the libest working directory — the integration
|
||||
# test writes CSRs / reads issued certs through this mount so the
|
||||
# test-side Go code can inspect estclient's outputs.
|
||||
- ./test/est:/config/est:rw
|
||||
# certctl's CA bundle for TLS pinning. estclient uses this to
|
||||
# verify the certctl-server cert (the same self-signed bundle
|
||||
# the certctl-agent verifies against).
|
||||
- ./test/certs:/config/certs:ro
|
||||
networks:
|
||||
certctl-test:
|
||||
# Was 10.30.50.9 — collided with certctl-tls-init (line 91). Pre-Phase-5
|
||||
# per-vendor matrix structurally hid this: tls-init is profile-less so
|
||||
# it always ran, but libest is profiles=[est-e2e] so it only ran when
|
||||
# the (separate) est-e2e job brought it up. Different jobs ⇒ different
|
||||
# docker networks ⇒ no collision. Surfaced when a future job runs both
|
||||
# profiles together; pre-emptive fix here.
|
||||
ipv4_address: 10.30.50.10
|
||||
restart: unless-stopped
|
||||
profiles: [est-e2e]
|
||||
|
||||
# =============================================================================
|
||||
# Deploy-Hardening II Phase 1 — per-vendor sidecar matrix
|
||||
# =============================================================================
|
||||
# Each sidecar is a real-software target the deploy-vendor-e2e tests
|
||||
# (deploy/test/<vendor>_vendor_e2e_test.go, build tag `integration`)
|
||||
# exercise the connector's atomic + verify + rollback contract against.
|
||||
# All gated behind `profiles: [deploy-e2e]` so routine integration runs
|
||||
# don't pay the per-vendor pull cost.
|
||||
#
|
||||
# Image digests pinned per H-001 guard. Re-pin quarterly per
|
||||
# docs/deployment-vendor-matrix.md.
|
||||
|
||||
apache-test:
|
||||
image: httpd:2.4-alpine@sha256:f9061a65c6e8f50d5636e10806da3d5a238877c11d6bc0149dc5131be0a1a19f
|
||||
container_name: certctl-test-apache
|
||||
ports:
|
||||
- "20443:443"
|
||||
volumes:
|
||||
- ./test/apache/httpd-ssl.conf:/usr/local/apache2/conf/extra/httpd-ssl.conf:ro
|
||||
- ./test/apache/init-cert.sh:/docker-entrypoint-init.sh:ro
|
||||
- apache_certs:/usr/local/apache2/conf/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.20
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
haproxy-test:
|
||||
image: haproxy:3.0-alpine@sha256:5b645ad4f3294cf5bc50ab8b201fdeb73732eca2928185df335735c698e8c3e2
|
||||
container_name: certctl-test-haproxy
|
||||
ports:
|
||||
- "20444:443"
|
||||
volumes:
|
||||
- ./test/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
|
||||
- haproxy_certs:/etc/haproxy/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.21
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
traefik-test:
|
||||
image: traefik:v3.1@sha256:8516638b18e67e999d293e4ff0e5baf7807674cd4bdd3d36d448497bcbf0a174
|
||||
container_name: certctl-test-traefik
|
||||
command:
|
||||
- --providers.file.directory=/etc/traefik/dynamic
|
||||
- --providers.file.watch=true
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --log.level=ERROR
|
||||
ports:
|
||||
- "20445:443"
|
||||
volumes:
|
||||
- ./test/traefik/traefik-dynamic.yml:/etc/traefik/dynamic/traefik-dynamic.yml:ro
|
||||
- traefik_certs:/etc/traefik/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.22
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
caddy-test:
|
||||
image: caddy:2.8-alpine@sha256:b95ed06fbc6d74d24a40902090c8cc6086ce7d08ba60a3a7e8e62bf164a9d7bb
|
||||
container_name: certctl-test-caddy
|
||||
command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
ports:
|
||||
- "20446:443"
|
||||
- "22019:2019" # admin API for ValidateOnly probe
|
||||
volumes:
|
||||
- ./test/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_certs:/etc/caddy/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.23
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
envoy-test:
|
||||
image: envoyproxy/envoy:v1.32-latest@sha256:6ed0d4f28b8122df896062c425b34f18b8287e8c71c6badb3b84ca2e2f47c519
|
||||
container_name: certctl-test-envoy
|
||||
command: envoy -c /etc/envoy/envoy.yaml --log-level error
|
||||
ports:
|
||||
- "20447:443"
|
||||
volumes:
|
||||
- ./test/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro
|
||||
- envoy_certs:/etc/envoy/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.24
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
postfix-test:
|
||||
image: boky/postfix:latest@sha256:cd7e192900bfc49a67291a572b5f645f9e7d1b8d7f2b79b0364b4b4176964e21
|
||||
container_name: certctl-test-postfix
|
||||
environment:
|
||||
ALLOWED_SENDER_DOMAINS: "test.local"
|
||||
ports:
|
||||
- "20025:25"
|
||||
- "20465:465"
|
||||
volumes:
|
||||
- postfix_certs:/etc/postfix/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.25
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
dovecot-test:
|
||||
image: dovecot/dovecot:latest@sha256:4046993478e8c8bcb841fdbff2d8de1b233484cc0196b3723f6c588e7eaf7301
|
||||
container_name: certctl-test-dovecot
|
||||
ports:
|
||||
- "20993:993"
|
||||
- "20995:995"
|
||||
volumes:
|
||||
- ./test/dovecot/dovecot.conf:/etc/dovecot/dovecot.conf:ro
|
||||
- dovecot_certs:/etc/dovecot/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.26
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
openssh-test:
|
||||
image: lscr.io/linuxserver/openssh-server:latest@sha256:742f577d4100f5ad3b38f270d722931bbe98b997444c13b1a2a838df12a9971e
|
||||
container_name: certctl-test-openssh
|
||||
environment:
|
||||
USER_NAME: "certctl"
|
||||
PASSWORD_ACCESS: "true"
|
||||
USER_PASSWORD: "test-only-do-not-use-in-prod"
|
||||
SUDO_ACCESS: "true"
|
||||
ports:
|
||||
- "20022:2222"
|
||||
volumes:
|
||||
- openssh_certs:/config/certs
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.27
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
# f5-mock-icontrol: in-tree Go server implementing the iControl REST
|
||||
# surface this bundle exercises (Authenticate, UploadFile, transactions,
|
||||
# SSL profile CRUD). Built from deploy/test/f5-mock-icontrol/Dockerfile;
|
||||
# the operator-supplied real F5 vagrant box is documented in
|
||||
# docs/connector-f5.md as the validation tier above the mock.
|
||||
f5-mock-icontrol:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/test/f5-mock-icontrol/Dockerfile
|
||||
container_name: certctl-test-f5-mock
|
||||
ports:
|
||||
# Host port 20449 (NOT 20443 — apache-test owns 20443). The
|
||||
# ci-pipeline-cleanup Phase 5 vendor-matrix collapse brings up
|
||||
# all sidecars simultaneously; the original Phase 1 design
|
||||
# accidentally double-bound 20443 because the per-vendor matrix
|
||||
# only ever ran one sidecar at a time, hiding the collision.
|
||||
- "20449:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.28
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
# k8s-kind-test: a kind (Kubernetes-in-Docker) cluster used by the
|
||||
# k8ssecret connector e2e tests. Per frozen decision 0.5, each K8s
|
||||
# version test spins up a fresh kind cluster of the matching version.
|
||||
# Tests are slow (~30-60s startup); marked t.Parallel() where independent.
|
||||
# The kind binary lives in the test image; the Docker socket is mounted
|
||||
# so kind can manage child containers.
|
||||
k8s-kind-test:
|
||||
image: kindest/node:v1.31.0@sha256:7fbc5644a803286a69ff9c5695f03bb01b512896835e15df7df17f756f7245ac
|
||||
container_name: certctl-test-kind
|
||||
privileged: true
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.29
|
||||
profiles: [deploy-e2e]
|
||||
|
||||
# windows-iis-test: Windows containers run only on Windows hosts.
|
||||
# CI no longer runs an IIS matrix (per ci-pipeline-cleanup bundle
|
||||
# Phase 6 / frozen decision 0.5 — revises Bundle II decision 0.4).
|
||||
# Two reasons the Windows matrix was deleted: (a) it couldn't
|
||||
# physically work on `windows-latest` GitHub runners (Docker not
|
||||
# started in Windows-containers mode by default; `bridge` network
|
||||
# driver doesn't exist on Windows Docker); (b) all IIS + WinCertStore
|
||||
# vendor-edge tests are t.Log placeholder stubs that exercise no
|
||||
# IIS-specific behavior.
|
||||
#
|
||||
# Operators validate IIS + WinCertStore manually on a Windows host
|
||||
# per the playbook at docs/connector-iis.md::Operator validation playbook.
|
||||
#
|
||||
# The sidecar definition stays here under profiles: [deploy-e2e-windows]
|
||||
# so a Windows operator can opt in via:
|
||||
# docker compose --profile deploy-e2e-windows up -d windows-iis-test
|
||||
# Linux CI never activates this profile.
|
||||
windows-iis-test:
|
||||
image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2022@sha256:8d0b0e651ad514e3fb05978db66f38036118812e1b9314a48f10419cad8a3462
|
||||
container_name: certctl-test-iis
|
||||
ports:
|
||||
- "20448:443"
|
||||
networks:
|
||||
certctl-test:
|
||||
ipv4_address: 10.30.50.30
|
||||
profiles: [deploy-e2e-windows]
|
||||
|
||||
# =============================================================================
|
||||
# Network
|
||||
# =============================================================================
|
||||
# Static IPs are required because:
|
||||
# - Pebble needs to know the challtestsrv DNS server address (10.30.50.3)
|
||||
# - challtestsrv resolves all domains to certctl-server (10.30.50.6) for HTTP-01 challenges
|
||||
# - Avoids DNS race conditions during startup
|
||||
networks:
|
||||
certctl-test:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.30.50.0/24
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
test_postgres_data:
|
||||
driver: local
|
||||
stepca_data:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
# Deploy-Hardening II Phase 1 — per-vendor sidecar cert volumes.
|
||||
apache_certs:
|
||||
driver: local
|
||||
haproxy_certs:
|
||||
driver: local
|
||||
traefik_certs:
|
||||
driver: local
|
||||
caddy_certs:
|
||||
driver: local
|
||||
envoy_certs:
|
||||
driver: local
|
||||
postfix_certs:
|
||||
driver: local
|
||||
dovecot_certs:
|
||||
driver: local
|
||||
openssh_certs:
|
||||
driver: local
|
||||
@@ -1,25 +1,164 @@
|
||||
# =============================================================================
|
||||
# certctl base compose — PRODUCTION-SHAPED (Bundle 2, 2026-05-12)
|
||||
# =============================================================================
|
||||
#
|
||||
# This base file ships a SAFE-BY-DEFAULT control plane:
|
||||
#
|
||||
# - CERTCTL_AUTH_TYPE defaults to api-key (the code default; not overridden
|
||||
# here). The server REFUSES to start with auth=none on a non-loopback
|
||||
# bind unless CERTCTL_DEMO_MODE_ACK=true (Audit 2026-05-10 HIGH-12 +
|
||||
# Bundle 2 closure: see internal/config/config.go::Validate).
|
||||
# - CERTCTL_KEYGEN_MODE defaults to agent (the code default).
|
||||
# - CERTCTL_DEMO_SEED defaults to false (the code default; the 180-day
|
||||
# simulated history seed only runs under the demo overlay).
|
||||
# - Default placeholder credentials (`change-me-...` sentinels) are NOT
|
||||
# interpolated by this compose. The server REFUSES to start when those
|
||||
# placeholder strings reach config (Bundle 2 fail-closed guards) unless
|
||||
# DEMO_MODE_ACK=true. Operators MUST set:
|
||||
# POSTGRES_PASSWORD (openssl rand -hex 32)
|
||||
# CERTCTL_AUTH_SECRET (openssl rand -hex 32)
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY (openssl rand -base64 32)
|
||||
# CERTCTL_API_KEY (matches CERTCTL_AUTH_SECRET or one
|
||||
# of its rotation siblings)
|
||||
# CERTCTL_AGENT_ID (returned from POST /api/v1/agents)
|
||||
# in deploy/.env or the shell environment. See deploy/.env.example.
|
||||
#
|
||||
# USAGE
|
||||
# -----
|
||||
#
|
||||
# Production-shaped (this base alone):
|
||||
# docker compose -f deploy/docker-compose.yml up -d
|
||||
#
|
||||
# Bundled demo (zero-config, populated dashboard, demo-mode auth):
|
||||
# docker compose -f deploy/docker-compose.yml \
|
||||
# -f deploy/docker-compose.demo.yml up -d
|
||||
#
|
||||
# The demo overlay (docker-compose.demo.yml) layers in the demo-mode env
|
||||
# vars (AUTH_TYPE=none + DEMO_MODE_ACK=true + KEYGEN_MODE=server +
|
||||
# DEMO_SEED=true + the change-me placeholder creds). It exists so the
|
||||
# `docker compose up` smoke + screenshot path stays one command — but it
|
||||
# ALSO carries the operator-visible warning banner the server emits at
|
||||
# boot when DEMO_MODE_ACK=true.
|
||||
#
|
||||
# Pre-Bundle-2 this base file WAS the demo path. The split happened in
|
||||
# 2026-05-12; the README quickstart, deploy/ENVIRONMENTS.md, and the
|
||||
# cold-DB compose smoke in .github/workflows/ci.yml were updated in the
|
||||
# same commit to point at the new layout.
|
||||
services:
|
||||
# HTTPS-Everywhere Phase 3 — self-signed TLS bootstrap (init container).
|
||||
# Generates a CN=certctl-server ECDSA-P256 (SHA-256 signature) cert with
|
||||
# the SAN list locked by milestone §3.6 on first boot; subsequent boots
|
||||
# see the cert already present in the `certs` named volume and no-op out.
|
||||
# Server + agent mount the volume read-only. Destroy via `docker compose
|
||||
# down -v` to force regeneration. This bootstrap is for docker-compose
|
||||
# demos and local dev only; Helm operators supply a Secret / cert-manager
|
||||
# Certificate per docs/tls.md.
|
||||
#
|
||||
# Rationale for ECDSA-P256 (was ed25519 pre-v2.0.48): Apple's TLS stack
|
||||
# — Safari Network Framework and the macOS-bundled LibreSSL 3.3.6
|
||||
# /usr/bin/curl — does not advertise ed25519 in the ClientHello
|
||||
# signature_algorithms extension for server certs, yielding "tls: peer
|
||||
# doesn't support any of the certificate's signature algorithms" at
|
||||
# handshake. ECDSA-P256 with SHA-256 is universally supported. See
|
||||
# docs/tls.md Pattern 1.
|
||||
certctl-tls-init:
|
||||
# DEPL-002 closure (Sprint 3, 2026-05-16): digest-pin so the
|
||||
# production-shaped compose has the same supply-chain posture as
|
||||
# the certctl Dockerfiles (which CI guards via digest-validity.sh).
|
||||
# The :latest tag floats; the digest is captured at the time
|
||||
# this comment was written. Bump after running the digest-
|
||||
# validity guard to confirm the new digest is still pullable.
|
||||
image: alpine/openssl:latest@sha256:41036db23542ed4cc09bc278d8a7e23b3da01690abb4b0e353b1bb87d70520ed
|
||||
container_name: certctl-tls-init
|
||||
restart: "no"
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
CERT=/etc/certctl/tls/server.crt
|
||||
KEY=/etc/certctl/tls/server.key
|
||||
CA=/etc/certctl/tls/ca.crt
|
||||
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
|
||||
echo "TLS cert already present at $$CERT — skipping generation"
|
||||
else
|
||||
mkdir -p /etc/certctl/tls
|
||||
openssl req -x509 -newkey ec \
|
||||
-pkeyopt ec_paramgen_curve:P-256 \
|
||||
-nodes \
|
||||
-keyout "$$KEY" \
|
||||
-out "$$CERT" \
|
||||
-days 3650 \
|
||||
-subj "/CN=certctl-server" \
|
||||
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
cp "$$CERT" "$$CA"
|
||||
echo "Generated self-signed TLS cert for certctl-server (ECDSA-P256/SHA-256, 3650d, CN=certctl-server)"
|
||||
fi
|
||||
# certctl binary runs as UID 1000 inside the server container per
|
||||
# Dockerfile:64-65; the cert + key must be readable by that UID.
|
||||
chown 1000:1000 "$$CERT" "$$KEY" "$$CA"
|
||||
chmod 0644 "$$CERT" "$$CA"
|
||||
chmod 0600 "$$KEY"
|
||||
volumes:
|
||||
- certs:/etc/certctl/tls
|
||||
networks:
|
||||
- certctl-network
|
||||
|
||||
# PostgreSQL database
|
||||
#
|
||||
# U-3 (P1, cat-u-seed_initdb_schema_drift, GitHub #10):
|
||||
# Pre-U-3 this stack mounted a hand-curated subset of `migrations/*.up.sql`
|
||||
# plus `seed.sql` into `/docker-entrypoint-initdb.d/`, and postgres
|
||||
# initdb-applied them on first boot. The mount list rotted every time a
|
||||
# new migration shipped that the seed depended on (000013 added
|
||||
# policy_rules.severity, 000017 renames retry_interval_minutes, etc.) —
|
||||
# initdb crashed, the container reported `unhealthy` indefinitely, and
|
||||
# `docker compose -f deploy/docker-compose.yml up -d --build` from a
|
||||
# fresh clone of v2.0.50 hit it on the first try.
|
||||
#
|
||||
# Post-U-3 the schema is built EXCLUSIVELY by the server at startup via
|
||||
# internal/repository/postgres.RunMigrations + RunSeed. Single source of
|
||||
# truth, no list to keep in sync. Postgres comes up empty; the server
|
||||
# waits for it healthy, then applies the full migration ladder + seed in
|
||||
# one shot. Helm + the dev examples were already runtime-only (Path B)
|
||||
# and worked through the same window.
|
||||
#
|
||||
# `start_period: 30s` gives postgres room to bootstrap on slow runners
|
||||
# (CI macOS, low-spec laptops) before the healthcheck failure counter
|
||||
# starts ticking. Pre-U-3 a slow first-init combined with the
|
||||
# `unhealthy` flap to cascade into certctl-server's `service_healthy`
|
||||
# depends_on, blocking the whole stack.
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# DEPL-002 closure (Sprint 3, 2026-05-16): digest-pin matching the
|
||||
# alpine/openssl pin above. The `16-alpine` tag is the stable
|
||||
# major-version stream; the digest snapshots today's image so a
|
||||
# silent upstream rebuild can't slip into a production deploy
|
||||
# mid-rollout. Bump alongside dependency reviews.
|
||||
image: postgres:16-alpine@sha256:890480b08124ce7f79960a9bb16fe39729aa302bd384bfd7c408fee6c8f7adb7
|
||||
container_name: certctl-postgres
|
||||
environment:
|
||||
POSTGRES_DB: certctl
|
||||
POSTGRES_USER: certctl
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-certctl}
|
||||
# Bundle 2 closure: no `:-certctl` fallback. Operators MUST set
|
||||
# POSTGRES_PASSWORD in deploy/.env or the shell environment. The
|
||||
# demo overlay (docker-compose.demo.yml) supplies a fixed weak
|
||||
# default for screenshot/demo use; production deploys never
|
||||
# depend on that fallback.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Acquisition-audit SEC-014 closure (Sprint 2, 2026-05-16). Bind
|
||||
# the published port to 127.0.0.1 ONLY — the certctl-server
|
||||
# connection comes in via the `certctl-network` Docker network
|
||||
# (the host-port mapping is operator convenience for psql / DB
|
||||
# inspection only). Pre-fix, the "5432:5432" form bound on
|
||||
# 0.0.0.0, exposing the Postgres TCP listener on every interface
|
||||
# of any host that happened to be on a public IP. The loopback
|
||||
# bind keeps host-side psql access working while preventing the
|
||||
# cross-network exposure landmine for compose deploys that aren't
|
||||
# behind a firewall.
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
|
||||
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
|
||||
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
|
||||
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
|
||||
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
|
||||
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
|
||||
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
|
||||
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
|
||||
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
@@ -27,6 +166,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Certctl Server (API + scheduler)
|
||||
@@ -34,27 +174,81 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Node frontend stage and Go module
|
||||
# download can reach the public registries behind corporate proxies.
|
||||
# Defaults to empty; omit the variables from the host environment for
|
||||
# un-proxied builds and the behaviour is byte-identical to the pre-fix
|
||||
# tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-server
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
certctl-tls-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
|
||||
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): in-cluster Postgres
|
||||
# on the docker bridge network keeps sslmode=disable acceptable; for
|
||||
# external/managed Postgres operators MUST override CERTCTL_DATABASE_URL
|
||||
# with sslmode=verify-full and provide the CA bundle. See docs/database-tls.md.
|
||||
CERTCTL_DATABASE_URL: ${CERTCTL_DATABASE_URL:-postgres://certctl:${POSTGRES_PASSWORD}@postgres:5432/certctl?sslmode=disable}
|
||||
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||
CERTCTL_SERVER_PORT: 8443
|
||||
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
|
||||
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_AUTH_TYPE: none
|
||||
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
|
||||
# Bundle 2 closure (compose split). The base compose no longer
|
||||
# sets CERTCTL_AUTH_TYPE / CERTCTL_KEYGEN_MODE / DEMO_MODE_ACK /
|
||||
# DEMO_SEED — the code defaults take over (auth-type api-key,
|
||||
# keygen agent, demo-mode false, demo-seed false). The demo
|
||||
# overlay (docker-compose.demo.yml) is what flips this baseline
|
||||
# into the populated-dashboard demo path; without that overlay
|
||||
# the server boots production-shaped and refuses to start unless
|
||||
# the operator has supplied CERTCTL_AUTH_SECRET +
|
||||
# CERTCTL_CONFIG_ENCRYPTION_KEY.
|
||||
#
|
||||
# Audit 2026-05-10 HIGH-12: when DEMO_MODE_ACK=true (set by the
|
||||
# demo overlay) AND the listener binds to a non-loopback address,
|
||||
# every request is served as the synthetic admin actor
|
||||
# `actor-demo-anon`. The server emits a prominent boot-time WARN
|
||||
# banner with a production-promotion checklist in that case.
|
||||
CERTCTL_AUTH_SECRET: ${CERTCTL_AUTH_SECRET}
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY} # AES-256-GCM for dynamic issuer/target config
|
||||
# Bootstrap token interpolation surface (Auditable Codebase Bundle
|
||||
# cold-DB smoke closure, 2026-05-12). Pre-fix, the `env-file +
|
||||
# --force-recreate certctl-server` pattern documented in
|
||||
# cowork/manual-testing-bundle-2.html (and used by the cold-DB
|
||||
# smoke job in .github/workflows/ci.yml::cold-db-compose-smoke)
|
||||
# set CERTCTL_BOOTSTRAP_TOKEN in compose's own interpolation
|
||||
# environment but the container never received it because this
|
||||
# block didn't reference the variable. Wiring it as an explicit
|
||||
# interpolation (default empty) makes the documented manual flow
|
||||
# actually work end-to-end. Empty value = bootstrap strategy
|
||||
# disabled (server returns 410 Gone on POST /api/v1/auth/bootstrap),
|
||||
# which is the safe default — only set the var when you intend to
|
||||
# mint a day-0 admin via the bootstrap path.
|
||||
CERTCTL_BOOTSTRAP_TOKEN: ${CERTCTL_BOOTSTRAP_TOKEN:-}
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- certs:/etc/certctl/tls:ro
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8443/health"]
|
||||
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "https://localhost:8443/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# U-3: server boot now does RunMigrations + RunSeed before listening on
|
||||
# 8443. On a fresh clone the full migration ladder + seed application
|
||||
# can take ~10s on a small VM; start_period prevents the first few
|
||||
# healthcheck attempts from counting as failures while that work runs.
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -72,17 +266,41 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
# Proxy propagation (M-4, Issue #9) — forwards host shell's proxy env
|
||||
# vars into the Docker build so the Go module download stage can reach
|
||||
# the public Go module proxy behind corporate proxies. Defaults to
|
||||
# empty; omit the variables from the host environment for un-proxied
|
||||
# builds and the behaviour is byte-identical to the pre-fix tree.
|
||||
args:
|
||||
HTTP_PROXY: ${HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY:-}
|
||||
NO_PROXY: ${NO_PROXY:-}
|
||||
container_name: certctl-agent
|
||||
depends_on:
|
||||
certctl-server:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
|
||||
# Bundle 2 closure (compose split). No placeholder fallbacks.
|
||||
# Operators MUST set CERTCTL_API_KEY (matching one of the server's
|
||||
# CERTCTL_AUTH_SECRET rotation values) and CERTCTL_AGENT_ID
|
||||
# (returned from `POST /api/v1/agents` during agent enrollment).
|
||||
# Without an agent ID, cmd/agent/main.go fails fast at startup
|
||||
# with "agent-id flag or CERTCTL_AGENT_ID env var is required" —
|
||||
# the cold-DB compose smoke in .github/workflows/ci.yml tolerates
|
||||
# the agent restart loop because the smoke targets server boot
|
||||
# only. The demo overlay (docker-compose.demo.yml) supplies a
|
||||
# pre-seeded agent-demo-1 row + matching env vars so the demo
|
||||
# path stays one-command.
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY}
|
||||
CERTCTL_AGENT_ID: ${CERTCTL_AGENT_ID}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
|
||||
volumes:
|
||||
- agent_keys:/var/lib/certctl/keys
|
||||
- certs:/etc/certctl/tls:ro
|
||||
networks:
|
||||
- certctl-network
|
||||
healthcheck:
|
||||
@@ -111,3 +329,5 @@ volumes:
|
||||
driver: local
|
||||
agent_keys:
|
||||
driver: local
|
||||
certs:
|
||||
driver: local
|
||||
|
||||
460
deploy/helm/CHART_SUMMARY.md
Normal file
460
deploy/helm/CHART_SUMMARY.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# Certctl Helm Chart - Complete Summary
|
||||
|
||||
## Overview
|
||||
|
||||
A production-ready Helm chart for deploying certctl (self-hosted certificate lifecycle management platform) on Kubernetes. The chart provides:
|
||||
|
||||
- High availability support with multi-replica deployments
|
||||
- Persistent PostgreSQL database with automatic schema migration
|
||||
- DaemonSet or Deployment-based agent deployment
|
||||
- Comprehensive security contexts and RBAC
|
||||
- Multiple deployment scenarios (dev, prod, HA, external DB)
|
||||
- Full documentation and examples
|
||||
|
||||
## Chart Metadata
|
||||
|
||||
- **Name**: certctl
|
||||
- **Chart Version**: 0.1.0
|
||||
- **App Version**: 2.1.0
|
||||
- **Type**: application
|
||||
- **License**: BSL-1.1
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
deploy/helm/
|
||||
├── README.md # Main Helm chart documentation
|
||||
├── DEPLOYMENT_GUIDE.md # Step-by-step deployment guide
|
||||
├── CHART_SUMMARY.md # This file
|
||||
│
|
||||
├── certctl/
|
||||
│ ├── Chart.yaml # Chart metadata
|
||||
│ ├── values.yaml # Default configuration values
|
||||
│ ├── .helmignore # Files to ignore when building chart
|
||||
│ │
|
||||
│ └── templates/
|
||||
│ ├── _helpers.tpl # Helm template helper functions
|
||||
│ ├── NOTES.txt # Post-deployment notes
|
||||
│ │
|
||||
│ ├── server-deployment.yaml # Certctl API server deployment
|
||||
│ ├── server-service.yaml # Server Kubernetes service
|
||||
│ ├── server-configmap.yaml # Server configuration
|
||||
│ ├── server-secret.yaml # Server secrets (API key, DB password, etc)
|
||||
│ │
|
||||
│ ├── postgres-statefulset.yaml # PostgreSQL database statefulset
|
||||
│ ├── postgres-service.yaml # PostgreSQL headless service
|
||||
│ ├── postgres-secret.yaml # Database credentials secret
|
||||
│ │
|
||||
│ ├── agent-daemonset.yaml # Certctl agent daemonset/deployment
|
||||
│ ├── agent-configmap.yaml # Agent configuration
|
||||
│ │
|
||||
│ ├── ingress.yaml # Optional ingress resource
|
||||
│ └── serviceaccount.yaml # ServiceAccount and RBAC
|
||||
│
|
||||
└── examples/
|
||||
├── values-dev.yaml # Development/testing configuration
|
||||
├── values-prod-ha.yaml # Production HA configuration
|
||||
├── values-external-db.yaml # External PostgreSQL (RDS, Cloud SQL)
|
||||
└── values-acme-dns01.yaml # ACME with DNS-01 (Let's Encrypt)
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. Server Deployment
|
||||
|
||||
**File**: `templates/server-deployment.yaml`
|
||||
|
||||
- Manages certctl API server instances
|
||||
- Configurable replicas (default: 1)
|
||||
- Health checks (liveness & readiness probes)
|
||||
- Security context: non-root user, read-only filesystem
|
||||
- Resource limits (default: 500m CPU, 512Mi memory)
|
||||
- Automatic restart on failure
|
||||
|
||||
**Values**:
|
||||
```yaml
|
||||
server:
|
||||
replicas: 1
|
||||
port: 8443
|
||||
auth:
|
||||
type: api-key
|
||||
apiKey: "REQUIRED"
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {cpu: 500m, memory: 512Mi}
|
||||
```
|
||||
|
||||
### 2. PostgreSQL StatefulSet
|
||||
|
||||
**File**: `templates/postgres-statefulset.yaml`
|
||||
|
||||
- Persistent database storage
|
||||
- Automatic schema migrations on startup
|
||||
- Single replica (can be extended with external HA tools)
|
||||
- Health checks via pg_isready
|
||||
- Configurable storage size and class
|
||||
- Security context: non-root user (UID 999)
|
||||
|
||||
**Values**:
|
||||
```yaml
|
||||
postgresql:
|
||||
enabled: true
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClass: "" # Use default
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
password: "REQUIRED"
|
||||
```
|
||||
|
||||
### 3. Agent DaemonSet/Deployment
|
||||
|
||||
**File**: `templates/agent-daemonset.yaml`
|
||||
|
||||
- DaemonSet mode: one agent per Kubernetes node
|
||||
- Deployment mode: custom number of agent replicas
|
||||
- Local key storage with secure permissions (0600)
|
||||
- Health checks and automatic restart
|
||||
- Optional certificate discovery from filesystem
|
||||
|
||||
**Values**:
|
||||
```yaml
|
||||
agent:
|
||||
enabled: true
|
||||
kind: DaemonSet # or Deployment
|
||||
replicas: 1 # for Deployment only
|
||||
keyDir: /var/lib/certctl/keys
|
||||
discoveryDirs: "/etc/ssl/certs" # optional
|
||||
```
|
||||
|
||||
### 4. Ingress (Optional)
|
||||
|
||||
**File**: `templates/ingress.yaml`
|
||||
|
||||
- Optional HTTPS ingress
|
||||
- cert-manager integration for automatic TLS
|
||||
- Multiple host support
|
||||
- Path-based routing
|
||||
|
||||
**Values**:
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: certctl.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
```
|
||||
|
||||
### 5. ConfigMaps and Secrets
|
||||
|
||||
**Files**:
|
||||
- `server-configmap.yaml` - Non-secret server configuration
|
||||
- `server-secret.yaml` - API key, database URL, SMTP password
|
||||
- `postgres-secret.yaml` - Database credentials
|
||||
- `agent-configmap.yaml` - Agent configuration
|
||||
|
||||
All secrets are base64-encoded and stored in Kubernetes Secrets.
|
||||
|
||||
### 6. ServiceAccount and RBAC
|
||||
|
||||
**File**: `templates/serviceaccount.yaml`
|
||||
|
||||
- Optional ServiceAccount creation
|
||||
- Optional RBAC (ClusterRole, ClusterRoleBinding)
|
||||
- Namespace-scoped by default
|
||||
|
||||
## Deployment Scenarios
|
||||
|
||||
### Development Setup
|
||||
|
||||
Use `examples/values-dev.yaml`:
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-dev.yaml \
|
||||
--set server.auth.apiKey="dev-key" \
|
||||
--set postgresql.auth.password="dev-password"
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Single server replica
|
||||
- Demo auth (no API key required)
|
||||
- Small database (5Gi)
|
||||
- LoadBalancer service for easy access
|
||||
- Debug logging level
|
||||
|
||||
### Production HA Setup
|
||||
|
||||
Use `examples/values-prod-ha.yaml`:
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-prod-ha.yaml \
|
||||
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- 3 server replicas with pod anti-affinity
|
||||
- Large database storage (100Gi)
|
||||
- Pod disruption budgets
|
||||
- Prometheus monitoring enabled
|
||||
- Production resource limits
|
||||
|
||||
### External PostgreSQL
|
||||
|
||||
Use `examples/values-external-db.yaml`:
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-external-db.yaml \
|
||||
--set postgresql.enabled=false \
|
||||
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
|
||||
```
|
||||
|
||||
**Use cases**:
|
||||
- AWS RDS
|
||||
- Google Cloud SQL
|
||||
- Azure Database for PostgreSQL
|
||||
- External self-managed PostgreSQL
|
||||
|
||||
### ACME with DNS-01
|
||||
|
||||
Use `examples/values-acme-dns01.yaml`:
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-acme-dns01.yaml
|
||||
```
|
||||
|
||||
**Enables**:
|
||||
- Automatic certificate issuance from Let's Encrypt
|
||||
- DNS-01 challenge (wildcard support)
|
||||
- Custom DNS provider scripts
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Server Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `server.replicas` | 1 | Number of server replicas |
|
||||
| `server.port` | 8443 | Server port |
|
||||
| `server.auth.type` | api-key | Authentication type — `api-key` or `none` (G-1: `jwt` removed; for JWT/OIDC use a fronting authenticating gateway, see `docs/architecture.md` and `docs/upgrade-to-v2-jwt-removal.md`) |
|
||||
| `server.auth.apiKey` | "" | API key (REQUIRED when `auth.type=api-key`) |
|
||||
| `server.logging.level` | info | Log level |
|
||||
| `server.logging.format` | json | Log format |
|
||||
|
||||
### PostgreSQL Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `postgresql.enabled` | true | Enable internal PostgreSQL |
|
||||
| `postgresql.storage.size` | 10Gi | Database storage size |
|
||||
| `postgresql.storage.storageClass` | "" | Storage class name |
|
||||
| `postgresql.auth.password` | "" | Database password (REQUIRED) |
|
||||
|
||||
### Agent Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `agent.enabled` | true | Deploy agents |
|
||||
| `agent.kind` | DaemonSet | DaemonSet or Deployment |
|
||||
| `agent.replicas` | 1 | Replicas (Deployment only) |
|
||||
| `agent.keyDir` | /var/lib/certctl/keys | Key storage directory |
|
||||
|
||||
### Issuer Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `server.issuer.local.enabled` | true | Enable Local CA |
|
||||
| `server.issuer.acme.enabled` | false | Enable ACME |
|
||||
| `server.issuer.acme.directoryURL` | "" | ACME directory URL |
|
||||
| `server.issuer.acme.email` | "" | ACME email |
|
||||
| `server.issuer.acme.challengeType` | http-01 | Challenge type |
|
||||
|
||||
See `values.yaml` for complete configuration options.
|
||||
|
||||
## Helm Template Functions
|
||||
|
||||
Defined in `templates/_helpers.tpl`:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `certctl.name` | Chart name |
|
||||
| `certctl.fullname` | Full release name |
|
||||
| `certctl.chart` | Chart name and version |
|
||||
| `certctl.labels` | Common labels |
|
||||
| `certctl.selectorLabels` | Selector labels |
|
||||
| `certctl.serverSelectorLabels` | Server selector labels |
|
||||
| `certctl.agentSelectorLabels` | Agent selector labels |
|
||||
| `certctl.postgresSelectorLabels` | PostgreSQL selector labels |
|
||||
| `certctl.serviceAccountName` | ServiceAccount name |
|
||||
| `certctl.serverImage` | Server image URI |
|
||||
| `certctl.agentImage` | Agent image URI |
|
||||
| `certctl.postgresImage` | PostgreSQL image URI |
|
||||
| `certctl.databaseURL` | Database connection string |
|
||||
| `certctl.serverURL` | Server URL for agents |
|
||||
|
||||
## Security Features
|
||||
|
||||
### Pod Security
|
||||
|
||||
- Non-root users (UID 1000 for app, UID 999 for PostgreSQL)
|
||||
- Read-only root filesystems
|
||||
- No privilege escalation
|
||||
- Dropped capabilities (ALL)
|
||||
- Resource limits to prevent DoS
|
||||
|
||||
### Secrets Management
|
||||
|
||||
- All sensitive data in Kubernetes Secrets
|
||||
- Base64 encoded at rest
|
||||
- Can be integrated with:
|
||||
- sealed-secrets
|
||||
- external-secrets
|
||||
- Vault
|
||||
- AWS Secrets Manager
|
||||
|
||||
### RBAC
|
||||
|
||||
- ServiceAccount per release
|
||||
- Optional ClusterRole/ClusterRoleBinding
|
||||
- Extensible for custom permissions
|
||||
|
||||
### Network Security
|
||||
|
||||
- Support for Kubernetes NetworkPolicies
|
||||
- Service-to-service communication via internal DNS
|
||||
- Optional Ingress with TLS
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
### Health Checks
|
||||
|
||||
- Liveness probes (detect dead containers)
|
||||
- Readiness probes (detect not-ready services)
|
||||
- HTTP endpoints: `/health`, `/readyz`
|
||||
|
||||
### Logging
|
||||
|
||||
- Structured JSON logging
|
||||
- Request ID propagation
|
||||
- Configurable log levels (debug, info, warn, error)
|
||||
|
||||
### Metrics
|
||||
|
||||
- Prometheus metrics endpoint: `/api/v1/metrics/prometheus`
|
||||
- Optional ServiceMonitor for Prometheus Operator
|
||||
- Built-in metrics:
|
||||
- Certificate counts by status
|
||||
- Agent counts and status
|
||||
- Job completion/failure rates
|
||||
- Server uptime
|
||||
|
||||
## Installation Quick Reference
|
||||
|
||||
```bash
|
||||
# Development
|
||||
helm install certctl certctl/ \
|
||||
--set server.auth.apiKey=dev \
|
||||
--set postgresql.auth.password=dev
|
||||
|
||||
# Production HA
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-prod-ha.yaml \
|
||||
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||
|
||||
# External database
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-external-db.yaml \
|
||||
--set postgresql.enabled=false \
|
||||
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
|
||||
|
||||
# ACME with Let's Encrypt
|
||||
helm install certctl certctl/ \
|
||||
--set server.issuer.acme.enabled=true \
|
||||
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory
|
||||
|
||||
# Check status
|
||||
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
kubectl logs -l app.kubernetes.io/component=server -f
|
||||
|
||||
# Upgrade
|
||||
helm upgrade certctl certctl/ -f new-values.yaml
|
||||
|
||||
# Uninstall
|
||||
helm uninstall certctl
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Secrets Management
|
||||
|
||||
```bash
|
||||
# Use sealed-secrets
|
||||
kubectl create secret generic certctl-secrets \
|
||||
--from-literal=api-key="$(openssl rand -base64 32)" \
|
||||
--dry-run=client -o yaml | kubeseal -f - | kubectl apply -f -
|
||||
```
|
||||
|
||||
### 2. Configure Resource Limits
|
||||
|
||||
Match limits to your cluster capacity:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
resources:
|
||||
requests: {cpu: 250m, memory: 256Mi}
|
||||
limits: {cpu: 1000m, memory: 512Mi}
|
||||
```
|
||||
|
||||
### 3. Enable HA for Production
|
||||
|
||||
```yaml
|
||||
server:
|
||||
replicas: 3
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution: [...]
|
||||
```
|
||||
|
||||
### 4. Use Persistent Storage
|
||||
|
||||
```yaml
|
||||
postgresql:
|
||||
storage:
|
||||
size: 100Gi
|
||||
storageClass: fast-ssd
|
||||
```
|
||||
|
||||
### 5. Enable Monitoring
|
||||
|
||||
```yaml
|
||||
monitoring:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- **README.md** - Complete Helm chart documentation
|
||||
- **DEPLOYMENT_GUIDE.md** - Step-by-step deployment instructions
|
||||
- **values.yaml** - Commented configuration reference
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions:
|
||||
- GitHub: https://github.com/certctl-io/certctl
|
||||
- Documentation: https://github.com/certctl-io/certctl/tree/main/docs
|
||||
|
||||
## License
|
||||
|
||||
BSL-1.1 (Business Source License)
|
||||
518
deploy/helm/DEPLOYMENT_GUIDE.md
Normal file
518
deploy/helm/DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,518 @@
|
||||
# Certctl Helm Deployment Guide
|
||||
|
||||
Complete guide for deploying certctl on Kubernetes with Helm.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Installation Methods](#installation-methods)
|
||||
3. [Production Deployment](#production-deployment)
|
||||
4. [Configuration Examples](#configuration-examples)
|
||||
5. [Post-Deployment Setup](#post-deployment-setup)
|
||||
6. [Monitoring and Logging](#monitoring-and-logging)
|
||||
7. [Maintenance](#maintenance)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Tools
|
||||
|
||||
```bash
|
||||
# Verify Kubernetes cluster access
|
||||
kubectl cluster-info
|
||||
kubectl get nodes
|
||||
|
||||
# Install Helm (if not already installed)
|
||||
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
helm version
|
||||
|
||||
# Verify Helm installation
|
||||
helm repo list
|
||||
```
|
||||
|
||||
### Kubernetes Requirements
|
||||
|
||||
- Kubernetes 1.19 or later
|
||||
- At least 2GB available memory
|
||||
- At least 10GB available storage (for PostgreSQL)
|
||||
- Network policies support (optional, for security)
|
||||
- Ingress controller (nginx, istio, etc.) - optional
|
||||
|
||||
### Create Namespace
|
||||
|
||||
```bash
|
||||
# Create isolated namespace
|
||||
kubectl create namespace certctl
|
||||
|
||||
# Set as default namespace
|
||||
kubectl config set-context --current --namespace=certctl
|
||||
|
||||
# Label for network policies (optional)
|
||||
kubectl label namespace certctl certctl-ns=true
|
||||
```
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### Method 1: Minimal Development Setup
|
||||
|
||||
Perfect for testing and development:
|
||||
|
||||
```bash
|
||||
# Install with minimal configuration
|
||||
helm install certctl certctl/certctl \
|
||||
--namespace certctl \
|
||||
--set server.auth.apiKey="dev-key-change-in-production" \
|
||||
--set postgresql.auth.password="dev-password-change-in-production"
|
||||
|
||||
# Wait for deployment
|
||||
kubectl rollout status deployment/certctl-server
|
||||
kubectl rollout status statefulset/certctl-postgres
|
||||
```
|
||||
|
||||
### Method 2: Production HA Setup
|
||||
|
||||
For production workloads:
|
||||
|
||||
```bash
|
||||
# Generate secure credentials
|
||||
API_KEY=$(openssl rand -base64 32)
|
||||
DB_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
# Install with HA configuration
|
||||
helm install certctl certctl/certctl \
|
||||
--namespace certctl \
|
||||
--values deploy/helm/examples/values-prod-ha.yaml \
|
||||
--set server.auth.apiKey="$API_KEY" \
|
||||
--set postgresql.auth.password="$DB_PASSWORD"
|
||||
```
|
||||
|
||||
### Method 3: External PostgreSQL
|
||||
|
||||
Using managed database service:
|
||||
|
||||
```bash
|
||||
# Install with external database
|
||||
helm install certctl certctl/certctl \
|
||||
--namespace certctl \
|
||||
--values deploy/helm/examples/values-external-db.yaml \
|
||||
--set server.auth.apiKey="$API_KEY" \
|
||||
--set 'server.env.CERTCTL_DATABASE_URL=postgres://user:pass@db.example.com:5432/certctl?sslmode=require'
|
||||
```
|
||||
|
||||
### Method 4: Using Custom values.yaml
|
||||
|
||||
Recommended for GitOps workflows:
|
||||
|
||||
```bash
|
||||
# Create values file with secrets management
|
||||
cat > /tmp/certctl-values.yaml <<EOF
|
||||
server:
|
||||
auth:
|
||||
apiKey: "$API_KEY"
|
||||
logging:
|
||||
level: info
|
||||
|
||||
postgresql:
|
||||
auth:
|
||||
password: "$DB_PASSWORD"
|
||||
storage:
|
||||
size: 50Gi
|
||||
|
||||
agent:
|
||||
enabled: true
|
||||
kind: DaemonSet
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: certctl.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
EOF
|
||||
|
||||
# Install using values file
|
||||
helm install certctl certctl/certctl \
|
||||
--namespace certctl \
|
||||
--values /tmp/certctl-values.yaml
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Step 1: Prepare Environment
|
||||
|
||||
```bash
|
||||
# Create namespace
|
||||
kubectl create namespace certctl
|
||||
cd deploy/helm
|
||||
|
||||
# Generate credentials
|
||||
API_KEY=$(openssl rand -base64 32)
|
||||
DB_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
echo "API Key: $API_KEY"
|
||||
echo "DB Password: $DB_PASSWORD"
|
||||
|
||||
# Save credentials in secure location (e.g., 1Password, Vault, AWS Secrets Manager)
|
||||
```
|
||||
|
||||
### Step 2: Prepare Storage
|
||||
|
||||
```bash
|
||||
# List available storage classes
|
||||
kubectl get storageclass
|
||||
|
||||
# If needed, create a high-performance storage class for production
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: fast-ssd
|
||||
provisioner: ebs.csi.aws.com # For AWS, adjust for your cloud provider
|
||||
parameters:
|
||||
type: gp3
|
||||
iops: "3000"
|
||||
throughput: "125"
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 3: Set Up TLS with cert-manager
|
||||
|
||||
```bash
|
||||
# Install cert-manager (if not already installed)
|
||||
helm repo add jetstack https://charts.jetstack.io
|
||||
helm repo update
|
||||
helm install cert-manager jetstack/cert-manager \
|
||||
--namespace cert-manager \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
|
||||
# Create ClusterIssuer for Let's Encrypt
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: admin@example.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 4: Install Certctl
|
||||
|
||||
```bash
|
||||
# Install using HA values
|
||||
helm install certctl certctl/ \
|
||||
--namespace certctl \
|
||||
--values examples/values-prod-ha.yaml \
|
||||
--set server.auth.apiKey="$API_KEY" \
|
||||
--set postgresql.auth.password="$DB_PASSWORD" \
|
||||
--set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod \
|
||||
--set ingress.hosts[0].host=certctl.example.com
|
||||
|
||||
# Verify installation
|
||||
kubectl get all -l app.kubernetes.io/instance=certctl
|
||||
```
|
||||
|
||||
### Step 5: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
kubectl describe pods -l app.kubernetes.io/instance=certctl
|
||||
|
||||
# Check service status
|
||||
kubectl get svc -l app.kubernetes.io/instance=certctl
|
||||
|
||||
# Check ingress status
|
||||
kubectl get ingress
|
||||
kubectl describe ingress certctl
|
||||
|
||||
# Test API connectivity (HTTPS-only as of v2.2)
|
||||
POD=$(kubectl get pods -l app.kubernetes.io/component=server -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl port-forward $POD 8443:8443 &
|
||||
# If the chart provisioned a self-signed cert, fetch the CA bundle from the TLS secret first:
|
||||
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
curl --cacert /tmp/certctl-ca.crt -H "Authorization: Bearer $API_KEY" https://localhost:8443/health
|
||||
```
|
||||
|
||||
### Step 6: Access the Dashboard
|
||||
|
||||
```bash
|
||||
# Port forward to local machine
|
||||
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||
|
||||
# Or if using Ingress:
|
||||
# Open browser: https://certctl.example.com
|
||||
# Login with API key: $API_KEY
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Example 1: ACME (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--set server.issuer.acme.enabled=true \
|
||||
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory \
|
||||
--set server.issuer.acme.email=admin@example.com \
|
||||
--set server.issuer.acme.challengeType=http-01
|
||||
```
|
||||
|
||||
### Example 2: DNS-01 (Wildcard Certs)
|
||||
|
||||
Requires DNS scripts ConfigMap:
|
||||
|
||||
```bash
|
||||
# Create DNS scripts ConfigMap
|
||||
kubectl create configmap dns-scripts \
|
||||
--from-file=dns-present.sh=./scripts/dns-present.sh \
|
||||
--from-file=dns-cleanup.sh=./scripts/dns-cleanup.sh
|
||||
|
||||
# Install with DNS-01
|
||||
helm install certctl certctl/ \
|
||||
--set server.issuer.acme.enabled=true \
|
||||
--set server.issuer.acme.challengeType=dns-01 \
|
||||
--values examples/values-acme-dns01.yaml
|
||||
```
|
||||
|
||||
### Example 3: AWS RDS Database
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--set postgresql.enabled=false \
|
||||
--set 'server.env.CERTCTL_DATABASE_URL=postgres://user:password@mydb.c9akciq32.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=require'
|
||||
```
|
||||
|
||||
### Example 4: Multiple Issuers
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--set server.issuer.local.enabled=true \
|
||||
--set server.issuer.acme.enabled=true \
|
||||
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory
|
||||
```
|
||||
|
||||
### Example 5: Email Notifications
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--set server.smtp.enabled=true \
|
||||
--set server.smtp.host=smtp.example.com \
|
||||
--set server.smtp.port=587 \
|
||||
--set server.smtp.username=alerts@example.com \
|
||||
--set server.smtp.password="$SMTP_PASSWORD" \
|
||||
--set server.smtp.fromAddress=certctl@example.com
|
||||
```
|
||||
|
||||
## Post-Deployment Setup
|
||||
|
||||
### 1. Initial Database Setup
|
||||
|
||||
```bash
|
||||
# Check database connection
|
||||
POD=$(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}')
|
||||
|
||||
# Execute psql commands
|
||||
kubectl exec -it $POD -- \
|
||||
psql -U certctl -d certctl -c '\dt'
|
||||
|
||||
# View database status
|
||||
kubectl logs $POD | tail -20
|
||||
```
|
||||
|
||||
### 2. Create Default Certificates
|
||||
|
||||
```bash
|
||||
# Port forward to API
|
||||
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||
|
||||
# Create a test certificate (HTTPS-only as of v2.2 — pin the chart-provisioned CA bundle)
|
||||
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
API_KEY="your-api-key"
|
||||
curl --cacert /tmp/certctl-ca.crt -X POST https://localhost:8443/api/v1/certificates \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"common_name": "test.example.com",
|
||||
"sans": ["test.example.com", "*.example.com"],
|
||||
"owner": "admin@example.com"
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Configure Agents
|
||||
|
||||
```bash
|
||||
# Get agent names
|
||||
kubectl get pods -l app.kubernetes.io/component=agent -o wide
|
||||
|
||||
# Check agent connectivity
|
||||
POD=$(kubectl get pods -l app.kubernetes.io/component=agent -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl logs $POD | grep -i heartbeat
|
||||
```
|
||||
|
||||
### 4. Set Up HTTPS for Web Dashboard
|
||||
|
||||
The Ingress will handle TLS if configured properly:
|
||||
|
||||
```bash
|
||||
# Verify ingress is ready
|
||||
kubectl get ingress
|
||||
kubectl describe ingress certctl
|
||||
|
||||
# Test HTTPS
|
||||
curl https://certctl.example.com/health
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### 1. View Logs
|
||||
|
||||
```bash
|
||||
# Server logs
|
||||
kubectl logs -l app.kubernetes.io/component=server -f --all-containers=true
|
||||
|
||||
# PostgreSQL logs
|
||||
kubectl logs -l app.kubernetes.io/component=postgres -f
|
||||
|
||||
# Agent logs
|
||||
kubectl logs -l app.kubernetes.io/component=agent -f --all-containers=true
|
||||
|
||||
# Logs from all components
|
||||
kubectl logs -l app.kubernetes.io/instance=certctl -f --all-containers=true
|
||||
```
|
||||
|
||||
### 2. Install Prometheus Monitoring
|
||||
|
||||
```bash
|
||||
# Install Prometheus operator (if not already installed)
|
||||
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||
helm repo update
|
||||
|
||||
helm install prometheus prometheus-community/kube-prometheus-stack \
|
||||
--namespace monitoring \
|
||||
--create-namespace
|
||||
|
||||
# Certctl will automatically expose metrics if monitoring.enabled=true
|
||||
helm install certctl certctl/ \
|
||||
--set monitoring.enabled=true \
|
||||
--set monitoring.serviceMonitor.enabled=true
|
||||
```
|
||||
|
||||
### 3. Set Up Alerts
|
||||
|
||||
```bash
|
||||
# Create Prometheus alerts
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: certctl-alerts
|
||||
spec:
|
||||
groups:
|
||||
- name: certctl
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: CertctlServerDown
|
||||
expr: up{job="certctl-server"} == 0
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "Certctl server is down"
|
||||
|
||||
- alert: CertificateExpiringSoon
|
||||
expr: certctl_certificate_expiring_soon > 0
|
||||
for: 1h
|
||||
annotations:
|
||||
summary: "{{ \$value }} certificates expiring soon"
|
||||
EOF
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Scaling
|
||||
|
||||
```bash
|
||||
# Scale server replicas
|
||||
helm upgrade certctl certctl/ \
|
||||
--set server.replicas=5
|
||||
|
||||
# Scale agents (Deployment kind only)
|
||||
helm upgrade certctl certctl/ \
|
||||
--set agent.kind=Deployment \
|
||||
--set agent.replicas=10
|
||||
```
|
||||
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
# Update chart version
|
||||
helm repo update
|
||||
helm upgrade certctl certctl/certctl \
|
||||
--namespace certctl \
|
||||
-f values.yaml
|
||||
|
||||
# Verify update
|
||||
kubectl rollout status deployment/certctl-server
|
||||
kubectl rollout status statefulset/certctl-postgres
|
||||
```
|
||||
|
||||
### Backup and Restore
|
||||
|
||||
```bash
|
||||
# Backup PostgreSQL data
|
||||
kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
|
||||
pg_dump -U certctl certctl | gzip > certctl-backup.sql.gz
|
||||
|
||||
# Restore from backup
|
||||
zcat certctl-backup.sql.gz | kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
|
||||
psql -U certctl certctl
|
||||
|
||||
# Backup PVC data
|
||||
kubectl get pvc
|
||||
kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
|
||||
tar czf - /var/lib/postgresql/data | gzip > certctl-data-backup.tar.gz
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Remove Helm release (keeps PVCs by default)
|
||||
helm uninstall certctl --namespace certctl
|
||||
|
||||
# Delete PVCs if needed
|
||||
kubectl delete pvc --all -n certctl
|
||||
|
||||
# Delete namespace
|
||||
kubectl delete namespace certctl
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [README.md](README.md#troubleshooting) for detailed troubleshooting steps.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
# Get all resources
|
||||
kubectl get all -n certctl
|
||||
|
||||
# Describe pod for events
|
||||
kubectl describe pod <pod-name> -n certctl
|
||||
|
||||
# Stream logs
|
||||
kubectl logs -f <pod-name> -n certctl
|
||||
|
||||
# Execute commands in pod
|
||||
kubectl exec -it <pod-name> -n certctl -- /bin/sh
|
||||
|
||||
# Check events
|
||||
kubectl get events -n certctl --sort-by='.lastTimestamp'
|
||||
```
|
||||
234
deploy/helm/INDEX.md
Normal file
234
deploy/helm/INDEX.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Certctl Helm Chart - Complete File Index
|
||||
|
||||
## Navigation Guide
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Start here**: `INSTALLATION.md` - Quick installation guide with one-liners
|
||||
2. **Full reference**: `README.md` - Complete Helm chart documentation
|
||||
3. **Detailed guide**: `DEPLOYMENT_GUIDE.md` - Step-by-step deployment walkthrough
|
||||
4. **Architecture**: `CHART_SUMMARY.md` - Technical overview and design
|
||||
|
||||
### Chart Directory Structure
|
||||
|
||||
```
|
||||
deploy/helm/
|
||||
│
|
||||
├── README.md Main documentation (15 KB)
|
||||
├── DEPLOYMENT_GUIDE.md Step-by-step guide (12 KB)
|
||||
├── CHART_SUMMARY.md Architecture & design (13 KB)
|
||||
├── INSTALLATION.md Quick start (2.2 KB)
|
||||
├── INDEX.md This file
|
||||
│
|
||||
├── certctl/ Helm chart package
|
||||
│ ├── Chart.yaml Chart metadata
|
||||
│ ├── values.yaml Default configuration (11 KB)
|
||||
│ ├── .helmignore Build ignore patterns
|
||||
│ │
|
||||
│ └── templates/ 15 Kubernetes resource templates
|
||||
│ ├── _helpers.tpl Helper functions
|
||||
│ ├── NOTES.txt Post-install notes
|
||||
│ ├── server-deployment.yaml API server
|
||||
│ ├── server-service.yaml Server networking
|
||||
│ ├── server-configmap.yaml Server configuration
|
||||
│ ├── server-secret.yaml Server secrets
|
||||
│ ├── postgres-statefulset.yaml Database
|
||||
│ ├── postgres-service.yaml Database networking
|
||||
│ ├── postgres-secret.yaml Database secrets
|
||||
│ ├── agent-daemonset.yaml Agents (DaemonSet/Deployment)
|
||||
│ ├── agent-configmap.yaml Agent configuration
|
||||
│ ├── ingress.yaml Optional HTTPS ingress
|
||||
│ └── serviceaccount.yaml RBAC resources
|
||||
│
|
||||
└── examples/ Example configurations
|
||||
├── values-dev.yaml Development setup
|
||||
├── values-prod-ha.yaml Production HA setup
|
||||
├── values-external-db.yaml External PostgreSQL
|
||||
└── values-acme-dns01.yaml ACME DNS-01 configuration
|
||||
```
|
||||
|
||||
## File Descriptions
|
||||
|
||||
### Documentation Files
|
||||
|
||||
| File | Purpose | Size |
|
||||
|------|---------|------|
|
||||
| `README.md` | Complete Helm chart documentation, configuration reference, security considerations | 15 KB |
|
||||
| `DEPLOYMENT_GUIDE.md` | Step-by-step installation instructions, production setup, troubleshooting | 12 KB |
|
||||
| `CHART_SUMMARY.md` | Technical overview, architecture, features, best practices | 13 KB |
|
||||
| `INSTALLATION.md` | Quick start guide, one-liner commands, verification steps | 2.2 KB |
|
||||
| `INDEX.md` | This file - complete file index and navigation | - |
|
||||
|
||||
### Chart Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Chart.yaml` | Helm chart metadata (name, version, appVersion, license) |
|
||||
| `values.yaml` | Default configuration values with comprehensive comments |
|
||||
| `.helmignore` | Files to ignore when building the chart |
|
||||
|
||||
### Template Files
|
||||
|
||||
| File | Components Created |
|
||||
|------|-------------------|
|
||||
| `_helpers.tpl` | 14 Helm template helper functions |
|
||||
| `NOTES.txt` | Post-installation notes and instructions |
|
||||
| `server-deployment.yaml` | Certctl API server deployment (1-N replicas) |
|
||||
| `server-service.yaml` | Service exposing the server |
|
||||
| `server-configmap.yaml` | Non-secret server configuration |
|
||||
| `server-secret.yaml` | Secrets (API key, DB password, SMTP) |
|
||||
| `postgres-statefulset.yaml` | PostgreSQL database with persistent storage |
|
||||
| `postgres-service.yaml` | Headless service for PostgreSQL |
|
||||
| `postgres-secret.yaml` | Database credentials |
|
||||
| `agent-daemonset.yaml` | Certctl agents (DaemonSet or Deployment) |
|
||||
| `agent-configmap.yaml` | Agent configuration |
|
||||
| `ingress.yaml` | Optional HTTPS ingress resource |
|
||||
| `serviceaccount.yaml` | ServiceAccount and RBAC resources |
|
||||
|
||||
### Example Configuration Files
|
||||
|
||||
| File | Use Case | Features |
|
||||
|------|----------|----------|
|
||||
| `values-dev.yaml` | Development/testing | Single replica, debug logging, LoadBalancer, no auth |
|
||||
| `values-prod-ha.yaml` | Production HA | 3 replicas, pod anti-affinity, monitoring, large storage |
|
||||
| `values-external-db.yaml` | External PostgreSQL | AWS RDS, Cloud SQL, Azure Database, self-managed |
|
||||
| `values-acme-dns01.yaml` | Let's Encrypt | DNS-01 challenges, wildcard certs, custom DNS scripts |
|
||||
|
||||
## Quick Links
|
||||
|
||||
### Installation Commands
|
||||
|
||||
#### Development
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--set server.auth.type=none \
|
||||
--set postgresql.auth.password=dev
|
||||
```
|
||||
|
||||
#### Production HA
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-prod-ha.yaml \
|
||||
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
#### External Database
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-external-db.yaml \
|
||||
--set postgresql.enabled=false \
|
||||
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
|
||||
```
|
||||
|
||||
### Verification Commands
|
||||
|
||||
```bash
|
||||
# Check chart syntax
|
||||
helm lint certctl/
|
||||
helm template certctl certctl/
|
||||
|
||||
# Install in cluster
|
||||
helm install certctl certctl/
|
||||
helm status certctl
|
||||
|
||||
# Check pod status
|
||||
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
|
||||
# View logs
|
||||
kubectl logs -l app.kubernetes.io/component=server -f
|
||||
```
|
||||
|
||||
## Documentation Organization
|
||||
|
||||
### By User Role
|
||||
|
||||
**DevOps/Platform Engineers**
|
||||
- Start: `INSTALLATION.md`
|
||||
- Deep dive: `DEPLOYMENT_GUIDE.md`
|
||||
- Configuration reference: `README.md`
|
||||
|
||||
**Kubernetes Developers**
|
||||
- Architecture: `CHART_SUMMARY.md`
|
||||
- Configuration: `values.yaml`
|
||||
- Templates: `templates/`
|
||||
|
||||
**Security/SREs**
|
||||
- Security section: `README.md#security-considerations`
|
||||
- RBAC: `templates/serviceaccount.yaml`
|
||||
- Network policies: `DEPLOYMENT_GUIDE.md#network-policies`
|
||||
|
||||
**Database Administrators**
|
||||
- PostgreSQL config: `values.yaml` (postgresql section)
|
||||
- External DB setup: `examples/values-external-db.yaml`
|
||||
- Backup/restore: `DEPLOYMENT_GUIDE.md#backup-and-restore`
|
||||
|
||||
### By Task
|
||||
|
||||
**Getting Started**
|
||||
1. Read: `INSTALLATION.md`
|
||||
2. Install: `helm install certctl certctl/`
|
||||
3. Verify: Run commands in `INSTALLATION.md`
|
||||
|
||||
**Production Deployment**
|
||||
1. Read: `DEPLOYMENT_GUIDE.md`
|
||||
2. Choose: `examples/values-prod-ha.yaml`
|
||||
3. Deploy: Follow step-by-step guide
|
||||
4. Reference: `README.md` for detailed options
|
||||
|
||||
**Troubleshooting**
|
||||
- Common issues: `README.md#troubleshooting`
|
||||
- Detailed guide: `DEPLOYMENT_GUIDE.md#troubleshooting`
|
||||
- Error messages: kubectl logs and events
|
||||
|
||||
**Configuration**
|
||||
- All options: `values.yaml`
|
||||
- Examples: `examples/values-*.yaml`
|
||||
- Detailed docs: `README.md#configuration`
|
||||
|
||||
## Key Features
|
||||
|
||||
### High Availability
|
||||
- Multi-replica server deployment
|
||||
- Pod anti-affinity
|
||||
- StatefulSet for database
|
||||
- Pod disruption budgets
|
||||
|
||||
### Security
|
||||
- Non-root containers
|
||||
- Read-only filesystems
|
||||
- RBAC support
|
||||
- Kubernetes Secrets
|
||||
- Network policies
|
||||
|
||||
### Flexibility
|
||||
- Multiple issuers (Local CA, ACME, step-ca, OpenSSL)
|
||||
- Internal or external PostgreSQL
|
||||
- DaemonSet or Deployment agents
|
||||
- Optional Ingress with TLS
|
||||
- Email notifications
|
||||
|
||||
### Observability
|
||||
- Health checks
|
||||
- Structured logging
|
||||
- Prometheus metrics
|
||||
- ServiceMonitor support
|
||||
|
||||
## Support
|
||||
|
||||
- **GitHub**: https://github.com/certctl-io/certctl
|
||||
- **Issues**: Report on GitHub issues
|
||||
- **Documentation**: All docs are in `deploy/helm/`
|
||||
|
||||
## File Statistics
|
||||
|
||||
- **Total files**: 24
|
||||
- **Documentation**: 4 files (42 KB)
|
||||
- **Chart files**: 3 files
|
||||
- **Templates**: 13 files
|
||||
- **Examples**: 4 files
|
||||
- **Total size**: 144 KB
|
||||
|
||||
## License
|
||||
|
||||
All files are covered under the BSL-1.1 license.
|
||||
97
deploy/helm/INSTALLATION.md
Normal file
97
deploy/helm/INSTALLATION.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Quick Installation Guide
|
||||
|
||||
## One-Liner Installation
|
||||
|
||||
### Development (no auth)
|
||||
```bash
|
||||
helm install certctl certctl/ \
|
||||
--set server.auth.type=none \
|
||||
--set postgresql.auth.password=dev
|
||||
```
|
||||
|
||||
### Production (with API key)
|
||||
```bash
|
||||
API_KEY=$(openssl rand -base64 32)
|
||||
DB_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
helm install certctl certctl/ \
|
||||
--values examples/values-prod-ha.yaml \
|
||||
--set server.auth.apiKey="$API_KEY" \
|
||||
--set postgresql.auth.password="$DB_PASSWORD"
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
# Wait for pods to be ready
|
||||
kubectl rollout status deployment/certctl-server
|
||||
kubectl rollout status statefulset/certctl-postgres
|
||||
|
||||
# Check all components
|
||||
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
|
||||
# View server logs
|
||||
kubectl logs -l app.kubernetes.io/component=server -f
|
||||
|
||||
# Access the API (HTTPS-only as of v2.2; use --cacert or -k depending on your cert provisioning)
|
||||
kubectl port-forward svc/certctl-server 8443:8443 &
|
||||
# If the chart provisioned a self-signed cert, fetch the CA bundle from the secret first:
|
||||
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
|
||||
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Read Documentation**
|
||||
- `README.md` - Complete reference
|
||||
- `DEPLOYMENT_GUIDE.md` - Step-by-step guide
|
||||
- `CHART_SUMMARY.md` - Architecture overview
|
||||
|
||||
2. **Configure for Your Environment**
|
||||
- Review `examples/` for your deployment scenario
|
||||
- Customize `values.yaml` as needed
|
||||
- Use `helm upgrade` to apply changes
|
||||
|
||||
3. **Set Up Monitoring**
|
||||
- Install Prometheus (optional)
|
||||
- Enable Ingress with HTTPS
|
||||
- Configure email notifications
|
||||
|
||||
4. **Deploy Agents**
|
||||
- Agents deploy automatically as DaemonSet
|
||||
- Verify with: `kubectl get pods -l app.kubernetes.io/component=agent`
|
||||
|
||||
5. **Create Certificates**
|
||||
- Configure issuer connectors (Local CA, ACME, etc.)
|
||||
- Access web dashboard at ingress or port-forward
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# List installations
|
||||
helm list
|
||||
|
||||
# View chart values
|
||||
helm values certctl
|
||||
|
||||
# Upgrade chart
|
||||
helm upgrade certctl certctl/ -f new-values.yaml
|
||||
|
||||
# Rollback to previous version
|
||||
helm rollback certctl 1
|
||||
|
||||
# Uninstall chart
|
||||
helm uninstall certctl
|
||||
|
||||
# View deployment history
|
||||
helm history certctl
|
||||
|
||||
# Dry-run installation to see generated YAML
|
||||
helm install certctl certctl/ --dry-run --debug
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
- Full documentation in `README.md`
|
||||
- Troubleshooting in `DEPLOYMENT_GUIDE.md`
|
||||
- Issues: https://github.com/certctl-io/certctl
|
||||
516
deploy/helm/README.md
Normal file
516
deploy/helm/README.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# Certctl Helm Chart
|
||||
|
||||
Production-ready Helm chart for deploying certctl (self-hosted certificate lifecycle management platform) on Kubernetes.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Chart Features](#chart-features)
|
||||
3. [Prerequisites](#prerequisites)
|
||||
4. [Installation](#installation)
|
||||
5. [Configuration](#configuration)
|
||||
6. [Usage Examples](#usage-examples)
|
||||
7. [Upgrading](#upgrading)
|
||||
8. [Uninstalling](#uninstalling)
|
||||
9. [Architecture](#architecture)
|
||||
10. [Security Considerations](#security-considerations)
|
||||
11. [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Add the chart repository (when available)
|
||||
helm repo add certctl https://charts.example.com
|
||||
helm repo update
|
||||
|
||||
# Install with default values
|
||||
helm install certctl certctl/certctl \
|
||||
--set server.auth.apiKey="your-secure-api-key" \
|
||||
--set postgresql.auth.password="your-secure-password"
|
||||
|
||||
# Check installation status
|
||||
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
```
|
||||
|
||||
## Chart Features
|
||||
|
||||
- **Server Deployment** — certctl control plane with configurable replicas
|
||||
- **PostgreSQL StatefulSet** — Persistent database with automatic schema migration
|
||||
- **Agent DaemonSet or Deployment** — Flexible agent deployment (per-node or custom replicas)
|
||||
- **Ingress Support** — Optional HTTPS ingress with cert-manager integration
|
||||
- **Security Contexts** — Non-root containers, read-only filesystems, minimal capabilities
|
||||
- **Resource Limits** — Configurable CPU and memory requests/limits
|
||||
- **Health Checks** — Liveness and readiness probes on all containers
|
||||
- **ConfigMaps and Secrets** — Centralized configuration management
|
||||
- **Service Account and RBAC** — Optional cluster role bindings
|
||||
- **Pod Disruption Budgets** — HA-ready with configurable disruption budgets
|
||||
- **Monitoring** — Optional Prometheus ServiceMonitor support
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.19 or later
|
||||
- Helm 3.0 or later
|
||||
- Optional: cert-manager (for automatic TLS certificate provisioning)
|
||||
- Optional: Prometheus (for metrics scraping)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Using Chart from Repository
|
||||
|
||||
```bash
|
||||
helm repo add certctl https://charts.example.com
|
||||
helm repo update
|
||||
helm install certctl certctl/certctl -f my-values.yaml
|
||||
```
|
||||
|
||||
### 2. Using Local Chart
|
||||
|
||||
```bash
|
||||
cd deploy/helm
|
||||
helm install certctl certctl/ \
|
||||
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
### 3. Minimal Production Installation
|
||||
|
||||
```bash
|
||||
helm install certctl certctl/certctl \
|
||||
--namespace certctl \
|
||||
--create-namespace \
|
||||
--set server.auth.apiKey="change-me" \
|
||||
--set postgresql.auth.password="change-me" \
|
||||
--set server.replicas=2 \
|
||||
--set server.resources.requests.cpu=200m \
|
||||
--set server.resources.requests.memory=256Mi \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.className=nginx \
|
||||
--set ingress.hosts[0].host=certctl.example.com
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Configuration
|
||||
|
||||
```yaml
|
||||
server:
|
||||
replicas: 1 # Number of server replicas
|
||||
port: 8443 # Service port
|
||||
auth:
|
||||
type: api-key # Authentication type
|
||||
apiKey: "your-api-key" # REQUIRED for production
|
||||
logging:
|
||||
level: info # Log level (debug, info, warn, error)
|
||||
format: json # Output format
|
||||
issuer:
|
||||
local:
|
||||
enabled: true # Enable local CA issuer
|
||||
acme:
|
||||
enabled: false # Enable ACME issuer
|
||||
directoryURL: "" # ACME directory URL
|
||||
email: "" # ACME registration email
|
||||
challengeType: "http-01" # Challenge type (http-01, dns-01, dns-persist-01)
|
||||
```
|
||||
|
||||
### PostgreSQL Configuration
|
||||
|
||||
```yaml
|
||||
postgresql:
|
||||
enabled: true # Use managed PostgreSQL
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
password: "your-password" # REQUIRED
|
||||
storage:
|
||||
size: 10Gi # PVC size
|
||||
storageClass: "" # Use default StorageClass
|
||||
```
|
||||
|
||||
### Agent Configuration
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
enabled: true # Deploy agents
|
||||
kind: DaemonSet # DaemonSet (one per node) or Deployment
|
||||
replicas: 1 # For Deployment kind only
|
||||
discoveryDirs: "" # Comma-separated cert discovery paths
|
||||
nodeSelector: {} # Node affinity for DaemonSet
|
||||
```
|
||||
|
||||
### Ingress Configuration
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: false
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: certctl.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: certctl-tls
|
||||
hosts:
|
||||
- certctl.example.com
|
||||
```
|
||||
|
||||
See `values.yaml` for all available configuration options.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: High Availability Setup
|
||||
|
||||
```yaml
|
||||
# ha-values.yaml
|
||||
server:
|
||||
replicas: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
|
||||
postgresql:
|
||||
storage:
|
||||
size: 50Gi
|
||||
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/component
|
||||
operator: In
|
||||
values: [server]
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
Deploy with:
|
||||
```bash
|
||||
helm install certctl certctl/certctl -f ha-values.yaml
|
||||
```
|
||||
|
||||
### Example 2: External PostgreSQL Database
|
||||
|
||||
```yaml
|
||||
# external-db-values.yaml
|
||||
postgresql:
|
||||
enabled: false
|
||||
|
||||
server:
|
||||
env:
|
||||
CERTCTL_DATABASE_URL: "postgres://user:password@rds.example.com:5432/certctl?sslmode=require"
|
||||
```
|
||||
|
||||
Deploy with:
|
||||
```bash
|
||||
helm install certctl certctl/certctl -f external-db-values.yaml
|
||||
```
|
||||
|
||||
### Example 3: ACME + Let's Encrypt
|
||||
|
||||
```yaml
|
||||
# acme-values.yaml
|
||||
server:
|
||||
issuer:
|
||||
acme:
|
||||
enabled: true
|
||||
directoryURL: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: admin@example.com
|
||||
challengeType: dns-01
|
||||
dnsPresentScript: /scripts/dns-present.sh
|
||||
dnsCleanupScript: /scripts/dns-cleanup.sh
|
||||
dnsPropagationWait: 30s
|
||||
```
|
||||
|
||||
### Example 4: Email Notifications via Slack + SMTP
|
||||
|
||||
```yaml
|
||||
# notifications-values.yaml
|
||||
server:
|
||||
smtp:
|
||||
enabled: true
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
username: certctl@example.com
|
||||
password: "smtp-password"
|
||||
fromAddress: certctl@example.com
|
||||
useTLS: true
|
||||
|
||||
notifiers:
|
||||
slack:
|
||||
enabled: true
|
||||
webhookUrl: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||
channel: "#certificates"
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
# Update chart repository
|
||||
helm repo update
|
||||
|
||||
# Upgrade release
|
||||
helm upgrade certctl certctl/certctl -f values.yaml
|
||||
|
||||
# View upgrade history
|
||||
helm history certctl
|
||||
|
||||
# Rollback to previous version
|
||||
helm rollback certctl 1
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
# Delete the release (keeps data by default)
|
||||
helm uninstall certctl
|
||||
|
||||
# Also delete persistent data
|
||||
kubectl delete pvc --all -l app.kubernetes.io/instance=certctl
|
||||
|
||||
# Delete namespace
|
||||
kubectl delete namespace certctl
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Kubernetes Cluster │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Ingress/LB │ │ Agent Pod 1 │ │
|
||||
│ │ (optional) │ │ (DaemonSet) │ │
|
||||
│ └────────┬────────┘ └──────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ ┌──────────────────┐ │
|
||||
│ ┌─────────────────────────┐ │ Agent Pod 2 │ │
|
||||
│ │ Server Deployment │ │ (DaemonSet) │ │
|
||||
│ │ (1 to N replicas) │ └──────────────────┘ │
|
||||
│ │ - REST API │ │
|
||||
│ │ - Scheduler │ ┌──────────────────┐ │
|
||||
│ │ - UI Dashboard │ │ Agent Pod N │ │
|
||||
│ └────────┬────────────────┘ │ (DaemonSet) │ │
|
||||
│ │ └──────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ PostgreSQL StatefulSet │ │
|
||||
│ │ - Database │ │
|
||||
│ │ - PVC (persistent) │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Network Communication
|
||||
|
||||
- **Server → PostgreSQL**: Internal cluster DNS (`certctl-postgres:5432`)
|
||||
- **Agent → Server**: Internal cluster DNS (`certctl-server:8443`)
|
||||
- **External → Server**: Via Ingress or Service (ClusterIP/LoadBalancer/NodePort)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Secrets Management
|
||||
|
||||
All sensitive data is stored in Kubernetes Secrets:
|
||||
- PostgreSQL credentials
|
||||
- API keys
|
||||
- SMTP passwords
|
||||
- ACME account secrets
|
||||
|
||||
**Best Practices:**
|
||||
- Use sealed-secrets or external-secrets operator
|
||||
- Enable encryption at rest in etcd
|
||||
- Rotate secrets regularly
|
||||
|
||||
```bash
|
||||
# Example: Using sealed-secrets
|
||||
kubectl create secret generic certctl-api-key --from-literal=api-key="$(openssl rand -base64 32)" --dry-run=client -o yaml | kubeseal -f - | kubectl apply -f -
|
||||
```
|
||||
|
||||
### 2. RBAC
|
||||
|
||||
The chart creates minimal RBAC by default:
|
||||
- ServiceAccount per release
|
||||
- ClusterRole (empty, extensible)
|
||||
- ClusterRoleBinding
|
||||
|
||||
**To restrict further:**
|
||||
```yaml
|
||||
rbac:
|
||||
create: true
|
||||
# Add specific rules here
|
||||
```
|
||||
|
||||
### 3. Pod Security
|
||||
|
||||
All containers run with:
|
||||
- Non-root user (UID 1000)
|
||||
- Read-only root filesystem
|
||||
- No privilege escalation
|
||||
- Dropped capabilities (ALL)
|
||||
|
||||
### 4. Network Policies
|
||||
|
||||
Restrict pod-to-pod communication:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: certctl-default-deny
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: certctl
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: certctl
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: certctl
|
||||
- to:
|
||||
- podSelector: {}
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 53 # DNS
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
```
|
||||
|
||||
### 5. TLS/HTTPS
|
||||
|
||||
Enable HTTPS with cert-manager:
|
||||
|
||||
```bash
|
||||
helm install cert-manager jetstack/cert-manager \
|
||||
--namespace cert-manager \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
```
|
||||
|
||||
Then configure Ingress with TLS.
|
||||
|
||||
### 6. API Key Security
|
||||
|
||||
For production:
|
||||
1. Generate a strong API key: `openssl rand -base64 32`
|
||||
2. Store securely (Vault, sealed-secrets, etc.)
|
||||
3. Never commit to Git
|
||||
4. Rotate periodically
|
||||
|
||||
```bash
|
||||
# Generate and deploy API key
|
||||
NEW_KEY=$(openssl rand -base64 32)
|
||||
kubectl patch secret certctl-server -p "{\"data\":{\"api-key\":\"$(echo -n $NEW_KEY | base64)\"}}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. Pods Not Starting
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl get pods -l app.kubernetes.io/instance=certctl
|
||||
kubectl describe pod <pod-name>
|
||||
kubectl logs <pod-name>
|
||||
```
|
||||
|
||||
### 2. Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Verify PostgreSQL is running
|
||||
kubectl get pods -l app.kubernetes.io/component=postgres
|
||||
kubectl logs -l app.kubernetes.io/component=postgres
|
||||
|
||||
# Test connection from server pod
|
||||
kubectl exec -it <server-pod> -- \
|
||||
psql postgres://certctl:password@certctl-postgres:5432/certctl
|
||||
```
|
||||
|
||||
### 3. Agent Not Connecting
|
||||
|
||||
```bash
|
||||
# Check agent logs
|
||||
kubectl logs -l app.kubernetes.io/component=agent
|
||||
|
||||
# Verify server is reachable
|
||||
kubectl exec -it <agent-pod> -- \
|
||||
wget -q -O - http://certctl-server:8443/health
|
||||
```
|
||||
|
||||
### 4. Persistent Data Loss
|
||||
|
||||
```bash
|
||||
# Check PVC status
|
||||
kubectl get pvc
|
||||
|
||||
# Verify data is being stored
|
||||
kubectl exec -it <postgres-pod> -- \
|
||||
ls -lah /var/lib/postgresql/data/postgres
|
||||
```
|
||||
|
||||
### 5. Permission Denied Errors
|
||||
|
||||
The chart runs containers as non-root (UID 1000). If you see permission errors:
|
||||
|
||||
```yaml
|
||||
# Temporarily allow root for debugging
|
||||
server:
|
||||
securityContext:
|
||||
runAsUser: 0 # NOT FOR PRODUCTION
|
||||
```
|
||||
|
||||
### 6. Out of Memory
|
||||
|
||||
Increase resource limits:
|
||||
|
||||
```bash
|
||||
helm upgrade certctl certctl/certctl \
|
||||
--set server.resources.limits.memory=1Gi \
|
||||
--set postgresql.resources.limits.memory=2Gi
|
||||
```
|
||||
|
||||
### 7. Certificate Validation Issues
|
||||
|
||||
For self-signed certificates:
|
||||
|
||||
```bash
|
||||
kubectl exec -it <pod> -- \
|
||||
CERTCTL_TLS_INSECURE_SKIP_VERIFY=true <command>
|
||||
```
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `ImagePullBackOff` | Update `server.image.repository` to your registry |
|
||||
| `CrashLoopBackOff` | Check logs with `kubectl logs <pod>` |
|
||||
| `Pending` PVC | Check storage class availability |
|
||||
| Connection timeout | Verify network policies and service DNS |
|
||||
| High memory usage | Adjust `postgresql.resources.limits` and `server.resources.limits` |
|
||||
|
||||
## Support and Contributing
|
||||
|
||||
For issues, questions, or contributions, visit:
|
||||
- GitHub: https://github.com/certctl-io/certctl
|
||||
- Documentation: https://github.com/certctl-io/certctl/tree/main/docs
|
||||
|
||||
## License
|
||||
|
||||
BSL-1.1
|
||||
31
deploy/helm/certctl/.helmignore
Normal file
31
deploy/helm/certctl/.helmignore
Normal file
@@ -0,0 +1,31 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob patterns, relative path patterns, and negated
|
||||
# patterns. Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.pyo
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
# OS
|
||||
Thumbs.db
|
||||
# Helm
|
||||
Chart.lock
|
||||
28
deploy/helm/certctl/Chart.yaml
Normal file
28
deploy/helm/certctl/Chart.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
apiVersion: v2
|
||||
name: certctl
|
||||
description: Self-hosted certificate lifecycle management platform
|
||||
type: application
|
||||
# Bundle 3 closure (OPS-L1): bumped from 0.1.0 → 1.0.0. The pre-1.0
|
||||
# version implied "unstable chart, breaking changes on every minor"
|
||||
# which prospective enterprise operators read as "not ready for
|
||||
# production". The chart has been deployed against real clusters since
|
||||
# 2026-02 and shipped through 8 audit closures (M-018, U-1, U-2, U-3,
|
||||
# H-1, G-1, B1 connector validation, B2 first-run guards); 1.0.0
|
||||
# matches that maturity. The chart still adheres to semver going
|
||||
# forward — any breaking value-schema change bumps to 2.0.0.
|
||||
version: 1.0.0
|
||||
appVersion: "2.1.0"
|
||||
keywords:
|
||||
- certificate
|
||||
- tls
|
||||
- ssl
|
||||
- pki
|
||||
- acme
|
||||
- lifecycle
|
||||
- kubernetes
|
||||
maintainers:
|
||||
- name: certctl
|
||||
home: https://github.com/certctl-io/certctl
|
||||
sources:
|
||||
- https://github.com/certctl-io/certctl
|
||||
license: BSL-1.1
|
||||
148
deploy/helm/certctl/README.md
Normal file
148
deploy/helm/certctl/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# certctl Helm Chart
|
||||
|
||||
Production-ready Helm chart for deploying [certctl](https://github.com/certctl-io/certctl) on Kubernetes. Wires up the certctl server (Deployment), PostgreSQL (StatefulSet with PVC), and the agent (DaemonSet — one per node) on a private cluster, with health probes, security contexts, and optional Ingress.
|
||||
|
||||
## Quick install
|
||||
|
||||
```bash
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--create-namespace --namespace certctl \
|
||||
--set server.auth.apiKey="$(openssl rand -base64 32)" \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 24)"
|
||||
```
|
||||
|
||||
This brings up:
|
||||
|
||||
- `<release>-server` Deployment (HTTPS-only on port 8443; TLS 1.3)
|
||||
- `<release>-postgres` StatefulSet (PostgreSQL 16-alpine, 1 replica, 10Gi PVC by default)
|
||||
- `<release>-agent` DaemonSet (polls server, generates ECDSA P-256 keys locally)
|
||||
- Service objects, optional Ingress, and ServiceAccount with RBAC
|
||||
|
||||
See [`values.yaml`](values.yaml) for the full configuration surface — issuer settings, target connectors, scheduler intervals, notifier credentials, and resource requests/limits all live there.
|
||||
|
||||
## Operational notes
|
||||
|
||||
### Postgres password rotation — read this before changing `postgresql.auth.password`
|
||||
|
||||
**The trap.** `postgresql.auth.password` is bound to `pg_authid` exactly once — when the StatefulSet's PVC is provisioned and `initdb` runs. The official `postgres:16-alpine` image only runs `initdb` when `/var/lib/postgresql/data` is empty, so on every subsequent rollout the `POSTGRES_PASSWORD` env var is read into the container but **ignored** by postgres itself. The certctl-server container also picks up the new value (via the database URL helper template), so the two halves diverge: server presents the new password, postgres still expects the old one.
|
||||
|
||||
**Symptom.** The certctl-server pod's startup log shows:
|
||||
|
||||
```
|
||||
failed to ping database: postgres rejected the configured credentials
|
||||
(SQLSTATE 28P01 — invalid_password). If you recently rotated POSTGRES_PASSWORD ...
|
||||
```
|
||||
|
||||
That diagnostic is emitted by `internal/repository/postgres/db.go::wrapPingError` — it points operators at the two remediation paths below.
|
||||
|
||||
**Remediation, non-destructive (preferred for any environment with real data):**
|
||||
|
||||
```bash
|
||||
# 1. Rotate the password in postgres directly
|
||||
kubectl -n certctl exec -it <release>-postgres-0 -- \
|
||||
psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new-password>';"
|
||||
|
||||
# 2. Update the secret / Helm values to the same value
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set postgresql.auth.password='<new-password>'
|
||||
|
||||
# 3. Bounce the certctl-server pod so it re-reads the secret
|
||||
kubectl -n certctl rollout restart deployment/<release>-server
|
||||
```
|
||||
|
||||
**Remediation, destructive (DESTROYS ALL CERTCTL DATA — only acceptable on dev/demo clusters):**
|
||||
|
||||
```bash
|
||||
helm uninstall <release> -n certctl
|
||||
kubectl -n certctl delete pvc -l \
|
||||
app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
|
||||
helm install <release> deploy/helm/certctl/ \
|
||||
--namespace certctl \
|
||||
--set postgresql.auth.password='<new-password>'
|
||||
```
|
||||
|
||||
The PVC re-creates empty, `initdb` runs on first boot of the new postgres pod, and `pg_authid` is seeded with the new password.
|
||||
|
||||
**Why we don't fix this in the chart.** The env-vs-`pg_authid` divergence is intrinsic to how the upstream `postgres` image bootstraps — `initdb` is run-once-per-empty-data-dir, and there is no upstream-supported way to make subsequent boots re-seed `pg_authid` from `POSTGRES_PASSWORD`. The ergonomic answer is the runtime diagnostic plus this operational note.
|
||||
|
||||
**Cross-references.** Same root cause is documented for the docker-compose path in [`docs/quickstart.md`](../../../docs/quickstart.md) (Warning callout after the `cp .env.example .env` block) and in [`deploy/ENVIRONMENTS.md`](../../ENVIRONMENTS.md) (Stateful volume — first-boot password binding section). The runtime diagnostic itself lives in `internal/repository/postgres/db.go::wrapPingError` with regression coverage in `internal/repository/postgres/db_test.go`.
|
||||
|
||||
### Server API key rotation
|
||||
|
||||
Unlike the postgres password, `server.auth.apiKey` accepts a comma-separated list, so zero-downtime rotation is straightforward:
|
||||
|
||||
```bash
|
||||
# 1. Add the new key alongside the old
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set server.auth.apiKey='new-key,old-key'
|
||||
|
||||
# 2. Roll your agents / clients over to the new key
|
||||
|
||||
# 3. Remove the old key
|
||||
helm upgrade <release> deploy/helm/certctl/ \
|
||||
--reuse-values \
|
||||
--set server.auth.apiKey='new-key'
|
||||
```
|
||||
|
||||
### JWT / OIDC via authenticating gateway
|
||||
|
||||
certctl's in-process auth surface is intentionally narrow: `server.auth.type=api-key` for production deployments and `server.auth.type=none` for development. There is no in-process JWT, OIDC, mTLS, or SAML middleware. (`server.auth.type=jwt` was accepted pre-G-1 but silently routed every request through the api-key bearer middleware — silent auth downgrade. The chart now fails at `helm install`/`helm upgrade` template time via the `certctl.validateAuthType` helper if you set it. See [`../../../docs/upgrade-to-v2-jwt-removal.md`](../../../docs/upgrade-to-v2-jwt-removal.md) if you previously had this in your values.)
|
||||
|
||||
For deployments that need JWT/OIDC, the canonical Kubernetes-flavored shape is to put oauth2-proxy in front of the certctl Service, attach an authenticating Ingress middleware, and run certctl with `server.auth.type=none`:
|
||||
|
||||
```bash
|
||||
# 1. Install oauth2-proxy (or any OIDC-terminating sidecar) in the same namespace
|
||||
helm install oauth2-proxy oauth2-proxy/oauth2-proxy \
|
||||
--namespace certctl \
|
||||
--set config.clientID="$OIDC_CLIENT_ID" \
|
||||
--set config.clientSecret="$OIDC_CLIENT_SECRET" \
|
||||
--set config.cookieSecret="$(openssl rand -base64 32)" \
|
||||
--set config.configFile='|
|
||||
provider = "oidc"
|
||||
oidc_issuer_url = "https://your-issuer/"
|
||||
upstreams = ["http://<release>-server.certctl.svc.cluster.local:8443"]
|
||||
pass_authorization_header = true
|
||||
set_authorization_header = true
|
||||
email_domains = ["*"]
|
||||
'
|
||||
|
||||
# 2. Install certctl with type=none (gateway terminates auth)
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--namespace certctl \
|
||||
--set server.auth.type=none \
|
||||
--set postgresql.auth.password="$(openssl rand -base64 24)"
|
||||
|
||||
# 3. Attach an Ingress that routes through oauth2-proxy
|
||||
# (Traefik ForwardAuth, nginx auth_request, Envoy ext_authz, etc.)
|
||||
```
|
||||
|
||||
Same root pattern works with Pomerium, Authelia, Caddy `forward_auth`, Apache `mod_auth_openidc`, or any service-mesh `ext_authz`. See [`../../../docs/architecture.md`](../../../docs/architecture.md) "Authenticating-gateway pattern" for the full design rationale and [`../../../docs/upgrade-to-v2-jwt-removal.md`](../../../docs/upgrade-to-v2-jwt-removal.md) for the migration walkthrough.
|
||||
|
||||
### TLS certificate sourcing
|
||||
|
||||
By default the chart provisions a self-signed cert via the same init-container pattern as the docker-compose deploy. For production, supply an operator-managed Secret (cert-manager, internal CA, etc.) — see [`docs/tls.md`](../../../docs/tls.md) for the full provisioning matrix and [`docs/upgrade-to-tls.md`](../../../docs/upgrade-to-tls.md) for upgrade-from-HTTP procedures.
|
||||
|
||||
## Disabling embedded postgres
|
||||
|
||||
If you have an existing PostgreSQL cluster, disable the embedded one and point at it directly:
|
||||
|
||||
```bash
|
||||
helm install certctl deploy/helm/certctl/ \
|
||||
--set postgresql.enabled=false \
|
||||
--set server.databaseUrl='postgres://certctl:<pw>@my-pg-host:5432/certctl?sslmode=require'
|
||||
```
|
||||
|
||||
The volume-trap section above does **not** apply to this configuration — your postgres operator (or cloud DB) handles password rotation, and you control `pg_authid` directly.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
helm uninstall <release> -n certctl
|
||||
# Optional — also delete the postgres PVC (DESTROYS DATA):
|
||||
kubectl -n certctl delete pvc -l \
|
||||
app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
|
||||
```
|
||||
|
||||
By default `helm uninstall` retains the StatefulSet's PVCs, so reinstalling with the same release name preserves the database. If you've changed `postgresql.auth.password` in your values between uninstall and reinstall, you'll hit the trap on the reinstall — apply the non-destructive remediation above, or also delete the PVC.
|
||||
99
deploy/helm/certctl/templates/NOTES.txt
Normal file
99
deploy/helm/certctl/templates/NOTES.txt
Normal file
@@ -0,0 +1,99 @@
|
||||
1. Get the certctl Server URL by running:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
https://{{ index .Values.ingress.hosts 0 "host" }}
|
||||
{{- else if contains "NodePort" .Values.server.service.type }}
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "certctl.fullname" . }}-server)
|
||||
echo https://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.server.service.type }}
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server --template "{.status.loadBalancer.ingress[0].ip}")
|
||||
echo https://$SERVICE_IP:{{ .Values.server.service.port }}
|
||||
{{- else }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=server" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit https://127.0.0.1:8443 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8443:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Talk to the HTTPS-only server from your workstation:
|
||||
# Export the CA bundle that signed the server cert (self-signed or cert-manager-issued)
|
||||
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.tls.secretName" . }} \
|
||||
-o jsonpath='{.data.ca\.crt}' | base64 --decode > /tmp/certctl-ca.crt
|
||||
# (If ca.crt is empty, fall back to tls.crt — typical when the Secret
|
||||
# was created from a self-signed bootstrap cert without a separate CA.)
|
||||
|
||||
# Adapt the URL below to match the Server URL printed in step 1.
|
||||
curl --cacert /tmp/certctl-ca.crt https://127.0.0.1:8443/health
|
||||
|
||||
3. Get the default API key:
|
||||
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server -o jsonpath="{.data.api-key}" | base64 --decode; echo
|
||||
|
||||
4. Get PostgreSQL connection details:
|
||||
Host: {{ include "certctl.fullname" . }}-postgres.{{ .Release.Namespace }}.svc.cluster.local
|
||||
Port: 5432
|
||||
Database: {{ .Values.postgresql.auth.database }}
|
||||
Username: {{ .Values.postgresql.auth.username }}
|
||||
Password: $(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-postgres -o jsonpath="{.data.password}" | base64 --decode)
|
||||
|
||||
5. Check deployment status:
|
||||
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
|
||||
|
||||
6. View server logs:
|
||||
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=server -f
|
||||
|
||||
{{- if .Values.agent.enabled }}
|
||||
|
||||
7. View agent logs:
|
||||
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
|
||||
|
||||
{{- end }}
|
||||
|
||||
IMPORTANT NOTES FOR PRODUCTION:
|
||||
|
||||
1. Update the API key for security:
|
||||
kubectl patch secret {{ include "certctl.fullname" . }}-server -n {{ .Release.Namespace }} \
|
||||
-p '{"data":{"api-key":"'$(echo -n "YOUR_NEW_API_KEY" | base64)'"}}'
|
||||
|
||||
2. Update PostgreSQL password:
|
||||
kubectl patch secret {{ include "certctl.fullname" . }}-postgres -n {{ .Release.Namespace }} \
|
||||
-p '{"data":{"password":"'$(echo -n "YOUR_NEW_PASSWORD" | base64)'"}}'
|
||||
|
||||
3. Configure certificate issuers (ACME, step-ca, etc.) via values.yaml:
|
||||
helm upgrade {{ .Release.Name }} certctl/certctl \
|
||||
--set server.issuer.acme.enabled=true \
|
||||
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory \
|
||||
--set server.issuer.acme.email=admin@example.com
|
||||
|
||||
4. For production with persistent databases and backups:
|
||||
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
|
||||
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
|
||||
|
||||
5. Review security contexts and network policies:
|
||||
- All containers run as non-root
|
||||
- Implement network policies to restrict traffic between components
|
||||
- Consider pod security policies or security standards for your cluster
|
||||
{{- /*
|
||||
DEPL-006 closure (Sprint 3, 2026-05-16). Loud notice when the
|
||||
operator runs a multi-replica deploy without crossing the two
|
||||
required HA toggles. Per-pod rate-limit buckets and round-robin
|
||||
load balancing both silently break correctness above replicas:1.
|
||||
*/}}
|
||||
{{- if gt (int .Values.server.replicas) 1 }}
|
||||
|
||||
⚠️ HA MISCONFIGURATION WARNINGS (replicas={{ .Values.server.replicas }}):
|
||||
{{- $backend := .Values.server.rateLimiting.backend | default "memory" }}
|
||||
{{- if eq $backend "memory" }}
|
||||
- server.rateLimiting.backend = "memory" with replicas > 1 gives each
|
||||
pod its own bucket map, so the configured cap is effectively
|
||||
multiplied by the replica count. Set
|
||||
`--set server.rateLimiting.backend=postgres` (see DEPL-006 /
|
||||
docs/operator/runbooks/ha.md).
|
||||
{{- end }}
|
||||
{{- if not .Values.server.service.sessionAffinity }}
|
||||
- server.service.sessionAffinity is empty. Round-robin Service load
|
||||
balancing routes login → /api/v1/auth/login → /api/v1/auth/csrf
|
||||
across different pods, breaking the CSRF token + session cookie
|
||||
handshake. Set
|
||||
`--set server.service.sessionAffinity=ClientIP`.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
327
deploy/helm/certctl/templates/_helpers.tpl
Normal file
327
deploy/helm/certctl/templates/_helpers.tpl
Normal file
@@ -0,0 +1,327 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "certctl.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "certctl.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "certctl.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "certctl.labels" -}}
|
||||
helm.sh/chart: {{ include "certctl.chart" . }}
|
||||
{{ include "certctl.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- with .Values.commonLabels }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels for the main service (server, agent, postgres)
|
||||
*/}}
|
||||
{{- define "certctl.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Server selector labels
|
||||
*/}}
|
||||
{{- define "certctl.serverSelectorLabels" -}}
|
||||
{{ include "certctl.selectorLabels" . }}
|
||||
app.kubernetes.io/component: server
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Agent selector labels
|
||||
*/}}
|
||||
{{- define "certctl.agentSelectorLabels" -}}
|
||||
{{ include "certctl.selectorLabels" . }}
|
||||
app.kubernetes.io/component: agent
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
PostgreSQL selector labels
|
||||
*/}}
|
||||
{{- define "certctl.postgresSelectorLabels" -}}
|
||||
{{ include "certctl.selectorLabels" . }}
|
||||
app.kubernetes.io/component: postgres
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Service account name
|
||||
*/}}
|
||||
{{- define "certctl.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "certctl.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Server image
|
||||
*/}}
|
||||
{{- define "certctl.serverImage" -}}
|
||||
{{- $image := .Values.server.image }}
|
||||
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Agent image
|
||||
*/}}
|
||||
{{- define "certctl.agentImage" -}}
|
||||
{{- $image := .Values.agent.image }}
|
||||
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
PostgreSQL image
|
||||
*/}}
|
||||
{{- define "certctl.postgresImage" -}}
|
||||
{{- $image := .Values.postgresql.image }}
|
||||
{{- printf "%s:%s" $image.repository $image.tag }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Database connection string
|
||||
|
||||
Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319):
|
||||
- postgresql.tls.mode is the operator-facing knob.
|
||||
Default: "disable" (preserves the in-cluster Helm-bundled-Postgres
|
||||
behavior; pod-to-pod traffic stays on the K8s pod network and is
|
||||
encrypted by the CNI when the cluster is configured with a TLS-aware
|
||||
CNI such as Cilium WireGuard).
|
||||
- Operators on PCI-DSS-scoped clusters or operators using an external
|
||||
managed Postgres (RDS, Cloud SQL, Azure DB) MUST set
|
||||
postgresql.tls.mode to "require", "verify-ca", or "verify-full" and
|
||||
point postgresql.tls.caSecretRef at a Secret containing the
|
||||
server-ca.crt under key "ca.crt".
|
||||
- The connection string sslmode parameter is wired from
|
||||
postgresql.tls.mode without further translation.
|
||||
*/}}
|
||||
{{- define "certctl.databaseURL" -}}
|
||||
{{- if .Values.postgresql.enabled -}}
|
||||
{{- $sslMode := default "disable" .Values.postgresql.tls.mode -}}
|
||||
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode={{ $sslMode }}
|
||||
{{- else -}}
|
||||
{{- /*
|
||||
Bundle 3 closure (D2 + OPS-L2): external-Postgres first-class path.
|
||||
When postgresql.enabled=false, the chart NEVER renders the
|
||||
bundled StatefulSet, postgres-secret, or postgres-service —
|
||||
templates/postgres-*.yaml gate themselves on .Values.postgresql.enabled.
|
||||
The connection string comes from externalDatabase.url (the canonical
|
||||
form) or, for backward-compat with pre-Bundle-3 deploys, from
|
||||
server.env.CERTCTL_DATABASE_URL (which overrides this helper at the
|
||||
pod-spec level — see server-deployment.yaml).
|
||||
|
||||
externalDatabase.url is consumed VERBATIM by the server's
|
||||
CERTCTL_DATABASE_URL env var. Operators are responsible for choosing
|
||||
the right sslmode (`verify-full` recommended for managed Postgres
|
||||
per PCI-DSS Req 4 §2.2.5; see docs/database-tls.md).
|
||||
*/ -}}
|
||||
{{- required "externalDatabase.url is required when postgresql.enabled=false" .Values.externalDatabase.url -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Server URL (for agents). HTTPS-only as of v2.2 — see docs/tls.md.
|
||||
*/}}
|
||||
{{- define "certctl.serverURL" -}}
|
||||
https://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TLS Secret name resolver.
|
||||
|
||||
Operator-facing precedence:
|
||||
1. server.tls.existingSecret — operator points at a pre-existing kubernetes.io/tls Secret
|
||||
2. server.tls.certManager.secretName — explicit secret name for the cert-manager Certificate CR
|
||||
3. "<fullname>-tls" — default when cert-manager is enabled but secretName is blank
|
||||
|
||||
Never emits an empty string — that case is already excluded by certctl.tls.required below,
|
||||
which must be invoked by any template that depends on the resolved secret name.
|
||||
*/}}
|
||||
{{- define "certctl.tls.secretName" -}}
|
||||
{{- if .Values.server.tls.existingSecret -}}
|
||||
{{- .Values.server.tls.existingSecret -}}
|
||||
{{- else if .Values.server.tls.certManager.secretName -}}
|
||||
{{- .Values.server.tls.certManager.secretName -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-tls" (include "certctl.fullname" .) -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TLS configuration gate.
|
||||
|
||||
HTTPS is the only supported listener mode (v2.2+). The server refuses to start
|
||||
without a cert/key pair mounted at server.tls.mountPath, so `helm template` /
|
||||
`helm install` must fail loudly at render-time rather than shipping a broken
|
||||
Deployment that crash-loops with "tls config required".
|
||||
|
||||
Operators MUST configure EXACTLY ONE of:
|
||||
(a) server.tls.existingSecret: <name-of-kubernetes.io/tls-secret>
|
||||
(b) server.tls.certManager.enabled: true (+ issuerRef.name populated)
|
||||
|
||||
Any template that mounts the TLS Secret must call
|
||||
`{{ include "certctl.tls.required" . }}` at the top so this guard runs once
|
||||
per affected resource. No-op when configured correctly.
|
||||
*/}}
|
||||
{{- define "certctl.tls.required" -}}
|
||||
{{- if and (not .Values.server.tls.existingSecret) (not .Values.server.tls.certManager.enabled) -}}
|
||||
{{- fail "\n\ncertctl refuses to start without TLS.\n\nSet EXACTLY ONE of:\n --set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name>\nOR\n --set server.tls.certManager.enabled=true \\\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md for the full setup walkthrough, including bootstrap\nguidance for air-gapped clusters without cert-manager.\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.server.tls.existingSecret .Values.server.tls.certManager.enabled -}}
|
||||
{{- /*
|
||||
Bundle 3 closure (D7): pre-Bundle-3 the helper only rejected the
|
||||
NEITHER-set case. Setting BOTH (`existingSecret` AND `certManager.enabled=true`)
|
||||
produced two TLS sources of truth — the existing Secret got mounted but
|
||||
cert-manager simultaneously provisioned a Certificate CR pointing at a
|
||||
conflicting Secret. Operators ended up with a dangling cert-manager
|
||||
Certificate or a wrong-source TLS bundle. The chart now refuses at
|
||||
render-time so the misconfiguration cannot ship.
|
||||
*/ -}}
|
||||
{{- fail "\n\nserver.tls.existingSecret AND server.tls.certManager.enabled are BOTH set.\n\nThe chart requires EXACTLY ONE TLS ownership path (Bundle 3 closure / audit D7):\n - existingSecret: operator owns the TLS Secret; cert-manager must NOT provision one.\n - certManager.enabled: cert-manager owns the TLS Secret; existingSecret must be empty.\n\nUnset one of:\n --set server.tls.existingSecret=\"\" (let cert-manager own it)\nOR\n --set server.tls.certManager.enabled=false (let the existing Secret stand)\n\nSee docs/tls.md.\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.server.tls.certManager.enabled (not .Values.server.tls.certManager.issuerRef.name) -}}
|
||||
{{- fail "\n\nserver.tls.certManager.enabled=true but server.tls.certManager.issuerRef.name is empty.\n\nSet:\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md.\n" -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Pod- vs container-scope security context split (Bundle 3 closure / audit D3).
|
||||
|
||||
The Kubernetes API splits SecurityContext into two non-overlapping
|
||||
field sets, and silently DROPS fields that land at the wrong scope —
|
||||
which is exactly the audit D3 finding pre-Bundle-3.
|
||||
|
||||
Pod-scope fields (applied via spec.securityContext):
|
||||
runAsNonRoot, runAsUser, runAsGroup, fsGroup, fsGroupChangePolicy,
|
||||
supplementalGroups, seLinuxOptions, seccompProfile, sysctls.
|
||||
|
||||
Container-scope fields (applied via spec.containers[].securityContext):
|
||||
readOnlyRootFilesystem, allowPrivilegeEscalation, capabilities,
|
||||
privileged, procMount, runAsNonRoot/runAsUser/runAsGroup (override),
|
||||
seLinuxOptions/seccompProfile (override).
|
||||
|
||||
These helpers split a single operator-facing `securityContext` map
|
||||
into the two sub-maps so the chart renders each field at the scope
|
||||
where Kubernetes actually honors it. The split is conservative — a
|
||||
field that COULD live at either scope is rendered at pod scope only
|
||||
(no override at container scope) so behavior matches the pre-Bundle-3
|
||||
operator intent: pod-level setting is the source of truth.
|
||||
|
||||
Operators don't need to change values.yaml; the existing
|
||||
`server.securityContext` and `agent.securityContext` blocks keep
|
||||
working byte-for-byte. The Helm template just routes each field to
|
||||
the correct YAML node now.
|
||||
*/}}
|
||||
{{- define "certctl.podSecurityContext" -}}
|
||||
{{- $sc := . -}}
|
||||
{{- $podKeys := list "runAsNonRoot" "runAsUser" "runAsGroup" "fsGroup" "fsGroupChangePolicy" "supplementalGroups" "seLinuxOptions" "seccompProfile" "sysctls" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := $podKeys -}}
|
||||
{{- if hasKey $sc $k -}}
|
||||
{{- $_ := set $out $k (index $sc $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "certctl.containerSecurityContext" -}}
|
||||
{{- $sc := . -}}
|
||||
{{- $containerKeys := list "readOnlyRootFilesystem" "allowPrivilegeEscalation" "capabilities" "privileged" "procMount" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := $containerKeys -}}
|
||||
{{- if hasKey $sc $k -}}
|
||||
{{- $_ := set $out $k (index $sc $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Required-secret gate (Bundle 3 closure / audit D1).
|
||||
|
||||
Pre-Bundle-3 the chart accepted empty `server.auth.apiKey` and empty
|
||||
`postgresql.auth.password` and rendered Secrets with empty values; the
|
||||
certctl-server container then crash-looped at startup with the auth
|
||||
configuration error or with `pq: password authentication failed for
|
||||
user "certctl"`. Worse, an operator who forgot to set the api-key
|
||||
ended up with auth.type=api-key + empty CERTCTL_AUTH_SECRET in the
|
||||
Secret, which Validate() rejects at startup — but the diagnostic
|
||||
surfaces inside a CrashLoopBackOff, not at `helm install` time where
|
||||
it would be caught immediately.
|
||||
|
||||
Post-Bundle-3 the chart fails at template time with operator-actionable
|
||||
guidance. The bundled-Postgres path (`postgresql.enabled=true`)
|
||||
requires `postgresql.auth.password`; the external-Postgres path
|
||||
(`postgresql.enabled=false`) skips that check because credentials are
|
||||
embedded in `externalDatabase.url` instead.
|
||||
|
||||
Any template that depends on either secret value should call
|
||||
`{{ include "certctl.requiredSecrets" . }}` at the top so this guard
|
||||
runs once per affected resource. No-op when configured correctly.
|
||||
*/}}
|
||||
{{- define "certctl.requiredSecrets" -}}
|
||||
{{- if and (eq .Values.server.auth.type "api-key") (not .Values.server.auth.apiKey) -}}
|
||||
{{- fail "\n\nserver.auth.type=\"api-key\" but server.auth.apiKey is empty.\n\nSet:\n --set server.auth.apiKey=$(openssl rand -base64 32)\n\nor put the value in a values override. The certctl-server container\nrefuses to start without an API key when auth.type=api-key.\n\nFor demo deploys without authentication, use:\n --set server.auth.type=none\n(only safe behind an authenticating gateway — see docs/operator/security.md).\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.postgresql.enabled (not .Values.postgresql.auth.password) -}}
|
||||
{{- fail "\n\npostgresql.enabled=true but postgresql.auth.password is empty.\n\nSet:\n --set postgresql.auth.password=$(openssl rand -base64 32)\n\nor put the value in a values override. The bundled Postgres\nStatefulSet refuses to bootstrap initdb without POSTGRES_PASSWORD.\n\nFor external Postgres deployments, set:\n --set postgresql.enabled=false\n --set externalDatabase.url=postgres://user:pass@host:5432/db?sslmode=require\nSee deploy/helm/examples/values-external-db.yaml.\n" -}}
|
||||
{{- end -}}
|
||||
{{- if and (not .Values.postgresql.enabled) (not .Values.externalDatabase.url) (not .Values.server.env.CERTCTL_DATABASE_URL) -}}
|
||||
{{- fail "\n\npostgresql.enabled=false but no external database URL is configured.\n\nSet ONE of:\n --set externalDatabase.url=postgres://user:pass@host:5432/db?sslmode=require\nOR (legacy)\n --set server.env.CERTCTL_DATABASE_URL=postgres://user:pass@host:5432/db?sslmode=require\n\nSee deploy/helm/examples/values-external-db.yaml.\n" -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Auth-type validation gate.
|
||||
|
||||
G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
|
||||
certctl-server container silently routed every request through the
|
||||
api-key bearer middleware (no JWT impl ships with certctl). Post-G-1
|
||||
the chart fails at template-time with a pointer at the authenticating-
|
||||
gateway pattern. The valid set must stay in sync with
|
||||
internal/config.ValidAuthTypes() in the Go binary; if you add a value
|
||||
there you must add it here too (and update the property test in
|
||||
internal/config/config_test.go that pins both surfaces).
|
||||
|
||||
Any template that consumes .Values.server.auth.type should call
|
||||
`{{ include "certctl.validateAuthType" . }}` at the top so this guard
|
||||
runs once per affected resource. No-op when configured correctly.
|
||||
*/}}
|
||||
{{- define "certctl.validateAuthType" -}}
|
||||
{{- $valid := list "api-key" "none" "oidc" -}}
|
||||
{{- if not (has .Values.server.auth.type $valid) -}}
|
||||
{{- fail (printf "\n\nserver.auth.type=%q is not supported (valid: %v).\n\nFor JWT/SAML/LDAP, run an authenticating gateway in front of certctl\n(oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium) and\nset server.auth.type=none here so the gateway terminates federated\nidentity. See docs/architecture.md \"Authenticating-gateway pattern\"\nand docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough.\n\nG-1 audit closure: pre-G-1 the chart accepted type=jwt and the binary\nsilently downgraded to api-key middleware. The chart now fails at\ntemplate time so misconfigured deployments cannot ship.\n\nAuth Bundle 2 Phase 0: server.auth.type=oidc is in the valid set but\nthe OIDC handler chain ships in later Bundle 2 phases. Pre-Bundle-2\noperators who set type=oidc see the certctl-server container exit at\nstartup with an actionable error — chart-time validation no longer\nblocks deploy because the binary's runtime guard takes over. Once\nBundle 2 lands, the runtime guard relaxes and OIDC works end-to-end.\n" .Values.server.auth.type $valid) -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
13
deploy/helm/certctl/templates/agent-configmap.yaml
Normal file
13
deploy/helm/certctl/templates/agent-configmap.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.agent.enabled }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-agent
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: agent
|
||||
data:
|
||||
{{- if .Values.agent.discoveryDirs }}
|
||||
discovery-dirs: {{ .Values.agent.discoveryDirs | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
185
deploy/helm/certctl/templates/agent-daemonset.yaml
Normal file
185
deploy/helm/certctl/templates/agent-daemonset.yaml
Normal file
@@ -0,0 +1,185 @@
|
||||
{{- if .Values.agent.enabled }}
|
||||
{{- include "certctl.tls.required" . }}
|
||||
{{- if eq .Values.agent.kind "DaemonSet" }}
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-agent
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: agent
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.agent.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agent.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agent.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agent.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{ include "certctl.agentImage" . }}
|
||||
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.agent.securityContext | nindent 12 }}
|
||||
env:
|
||||
- name: CERTCTL_SERVER_URL
|
||||
value: {{ include "certctl.serverURL" . }}
|
||||
- name: CERTCTL_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: api-key
|
||||
- name: CERTCTL_AGENT_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: CERTCTL_KEY_DIR
|
||||
value: {{ .Values.agent.keyDir }}
|
||||
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
|
||||
{{- if .Values.agent.discoveryDirs }}
|
||||
- name: CERTCTL_DISCOVERY_DIRS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-agent
|
||||
key: discovery-dirs
|
||||
{{- end }}
|
||||
{{- with .Values.agent.env }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.agent.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: agent-keys
|
||||
mountPath: {{ .Values.agent.keyDir }}
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: server-tls
|
||||
mountPath: {{ .Values.server.tls.mountPath }}
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: agent-keys
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: server-tls
|
||||
secret:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
defaultMode: 0400
|
||||
{{- else if eq .Values.agent.kind "Deployment" }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-agent
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: agent
|
||||
spec:
|
||||
replicas: {{ .Values.agent.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.agent.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agent.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agent.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.agent.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: agent
|
||||
image: {{ include "certctl.agentImage" . }}
|
||||
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.agent.securityContext | nindent 12 }}
|
||||
env:
|
||||
- name: CERTCTL_SERVER_URL
|
||||
value: {{ include "certctl.serverURL" . }}
|
||||
- name: CERTCTL_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: api-key
|
||||
- name: CERTCTL_AGENT_NAME
|
||||
{{- if .Values.agent.name }}
|
||||
value: {{ .Values.agent.name | quote }}
|
||||
{{- else }}
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
{{- end }}
|
||||
- name: CERTCTL_KEY_DIR
|
||||
value: {{ .Values.agent.keyDir }}
|
||||
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
|
||||
{{- if .Values.agent.discoveryDirs }}
|
||||
- name: CERTCTL_DISCOVERY_DIRS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-agent
|
||||
key: discovery-dirs
|
||||
{{- end }}
|
||||
{{- with .Values.agent.env }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.agent.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: agent-keys
|
||||
mountPath: {{ .Values.agent.keyDir }}
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: server-tls
|
||||
mountPath: {{ .Values.server.tls.mountPath }}
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: agent-keys
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: server-tls
|
||||
secret:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
defaultMode: 0400
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
178
deploy/helm/certctl/templates/backup-cronjob.yaml
Normal file
178
deploy/helm/certctl/templates/backup-cronjob.yaml
Normal file
@@ -0,0 +1,178 @@
|
||||
{{- /*
|
||||
Phase 4 DEPL-H2 closure (2026-05-14): opt-in Helm CronJob for
|
||||
PostgreSQL backups.
|
||||
|
||||
OPERATOR OPT-IN. Default `backup.enabled: false`. Turning it on
|
||||
requires:
|
||||
- In-cluster Postgres (this CronJob does NOT cover managed DB
|
||||
services — for AWS RDS / GCP CloudSQL / Azure DB rely on the
|
||||
provider's PITR).
|
||||
- A sink choice (PVC or S3) configured in values.yaml.
|
||||
- For S3: a Secret holding AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
|
||||
(or use a service account with IRSA on EKS).
|
||||
|
||||
The pg_dump invocation matches the canonical shape documented in
|
||||
docs/operator/runbooks/postgres-backup.md so a manual run and a
|
||||
CronJob run produce byte-identical dumps:
|
||||
|
||||
pg_dump --format=custom --no-owner --no-acl --dbname=certctl
|
||||
|
||||
For sink choices beyond PVC + S3 (GCS, Azure Blob, NFS, restic, etc.),
|
||||
extend the `aws s3 cp` line below. The Job is intentionally minimal —
|
||||
it does ONE thing (capture + ship), not orchestrate retention or
|
||||
rotation. Off-host retention is the sink's responsibility (S3 lifecycle
|
||||
rules, PVC snapshot retention on the storage class, etc.).
|
||||
*/ -}}
|
||||
{{- if .Values.backup.enabled }}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-postgres-backup
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres-backup
|
||||
spec:
|
||||
schedule: {{ .Values.backup.schedule | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit | default 3 }}
|
||||
failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit | default 1 }}
|
||||
startingDeadlineSeconds: {{ .Values.backup.startingDeadlineSeconds | default 300 }}
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: {{ .Values.backup.backoffLimit | default 1 }}
|
||||
activeDeadlineSeconds: {{ .Values.backup.activeDeadlineSeconds | default 3600 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 12 }}
|
||||
app.kubernetes.io/component: postgres-backup
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
runAsNonRoot: true
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: backup
|
||||
image: {{ .Values.backup.image | default "postgres:16-alpine" | quote }}
|
||||
imagePullPolicy: {{ .Values.backup.imagePullPolicy | default "IfNotPresent" | quote }}
|
||||
env:
|
||||
- name: PGHOST
|
||||
value: {{ include "certctl.fullname" . }}-postgres
|
||||
- name: PGPORT
|
||||
value: {{ .Values.postgresql.service.port | default 5432 | quote }}
|
||||
- name: PGUSER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: username
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: password
|
||||
- name: PGDATABASE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: database
|
||||
{{- if eq (.Values.backup.sink | default "pvc") "s3" }}
|
||||
# S3 sink — operator provides AWS credentials via the
|
||||
# Secret referenced in backup.s3.credentialsSecret. The
|
||||
# credentials need s3:PutObject + s3:ListBucket on the
|
||||
# target bucket only; least-privilege per industry
|
||||
# standard.
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.backup.s3.credentialsSecret.name | quote }}
|
||||
key: {{ .Values.backup.s3.credentialsSecret.accessKeyIdKey | default "AWS_ACCESS_KEY_ID" }}
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.backup.s3.credentialsSecret.name | quote }}
|
||||
key: {{ .Values.backup.s3.credentialsSecret.secretAccessKeyKey | default "AWS_SECRET_ACCESS_KEY" }}
|
||||
{{- with .Values.backup.s3.region }}
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ceu
|
||||
- |
|
||||
# Phase 4 DEPL-H2: canonical pg_dump shape per
|
||||
# docs/operator/runbooks/postgres-backup.md.
|
||||
# Custom-format compressed dump, no ownership /
|
||||
# ACL embedded — produces a portable artifact
|
||||
# restorable into any Postgres ≥ source major
|
||||
# via `pg_restore -d certctl <dump>`.
|
||||
set -euo pipefail
|
||||
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
DUMP_FILE="/tmp/certctl-${TIMESTAMP}.dump"
|
||||
|
||||
echo "[backup-cronjob] capturing dump at ${TIMESTAMP}"
|
||||
pg_dump --format=custom --no-owner --no-acl --dbname="${PGDATABASE}" \
|
||||
> "${DUMP_FILE}"
|
||||
|
||||
# Integrity check — pg_restore --list parses the
|
||||
# dump's table-of-contents; a corrupt dump fails
|
||||
# here without shipping garbage off-host. Same
|
||||
# check the manual runbook performs.
|
||||
echo "[backup-cronjob] verifying dump integrity"
|
||||
pg_restore --list "${DUMP_FILE}" > /dev/null
|
||||
|
||||
{{- if eq (.Values.backup.sink | default "pvc") "s3" }}
|
||||
# S3 sink — requires aws-cli. The default
|
||||
# postgres:16-alpine image does NOT include
|
||||
# aws-cli; operators MUST set
|
||||
# backup.image to an image that bundles both
|
||||
# (e.g. ghcr.io/your-org/postgres-aws:16) OR
|
||||
# override backup.command to install aws-cli at
|
||||
# runtime. The line below assumes the image has
|
||||
# `aws` on PATH.
|
||||
S3_PATH="{{ .Values.backup.s3.bucket }}/{{ .Values.backup.s3.prefix | default "certctl" }}/certctl-${TIMESTAMP}.dump"
|
||||
echo "[backup-cronjob] uploading to s3://${S3_PATH}"
|
||||
aws s3 cp "${DUMP_FILE}" "s3://${S3_PATH}"
|
||||
rm -f "${DUMP_FILE}"
|
||||
{{- else }}
|
||||
# PVC sink — dump lands at /backups/certctl-${TIMESTAMP}.dump
|
||||
# mounted from backup.pvc.claimName. Retention is the
|
||||
# PVC's responsibility (storage-class snapshot lifecycle
|
||||
# or a separate cleanup CronJob). The Job moves the
|
||||
# file from /tmp to /backups atomically; never
|
||||
# writes partial dumps into the durable mount.
|
||||
FINAL_PATH="/backups/certctl-${TIMESTAMP}.dump"
|
||||
echo "[backup-cronjob] persisting to ${FINAL_PATH}"
|
||||
mv "${DUMP_FILE}" "${FINAL_PATH}"
|
||||
{{- end }}
|
||||
echo "[backup-cronjob] done"
|
||||
{{- if ne (.Values.backup.sink | default "pvc") "s3" }}
|
||||
volumeMounts:
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml (.Values.backup.resources | default dict) | nindent 16 }}
|
||||
{{- if ne (.Values.backup.sink | default "pvc") "s3" }}
|
||||
volumes:
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.backup.pvc.claimName | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeAffinity }}
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
{{- toYaml . | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- with .Values.backup.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
51
deploy/helm/certctl/templates/ingress.yaml
Normal file
51
deploy/helm/certctl/templates/ingress.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- if and .Values.ingress.certManager.enabled (not .Values.ingress.certManager.issuerRef.name) -}}
|
||||
{{- fail "\n\ningress.certManager.enabled=true but ingress.certManager.issuerRef.name is empty.\n\nSet:\n --set ingress.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nThis is separate from server.tls.certManager — it issues the external-facing\nIngress cert, not the in-cluster server TLS cert. See docs/tls.md.\n" -}}
|
||||
{{- end -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
{{- if .Values.ingress.certManager.enabled }}
|
||||
{{- if eq .Values.ingress.certManager.issuerRef.kind "ClusterIssuer" }}
|
||||
cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
|
||||
{{- else }}
|
||||
cert-manager.io/issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "certctl.fullname" $ }}-server
|
||||
port:
|
||||
number: {{ $.Values.server.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
89
deploy/helm/certctl/templates/migration-job.yaml
Normal file
89
deploy/helm/certctl/templates/migration-job.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
{{- /*
|
||||
Phase 4 DEPL-M1 closure (2026-05-14): Helm pre-install / pre-upgrade
|
||||
hook that runs Postgres migrations before the server Deployment rolls.
|
||||
|
||||
Pre-DEPL-M1, postgres.RunMigrations was invoked at server boot
|
||||
(cmd/server/main.go:151) as the only migration path. That works for
|
||||
Compose deployments but conflicts with Kubernetes rolling deploys:
|
||||
when a new server image lands with a schema change, multiple replicas
|
||||
race the migration during the rollout. The hook resolves the race by
|
||||
running migrations OUT OF BAND, exactly once, before any new server
|
||||
pod starts.
|
||||
|
||||
How it works:
|
||||
- The Job ships the same certctl-server image as the Deployment, so
|
||||
the migration code path is binary-identical to the boot-time path.
|
||||
- It runs `certctl-server --migrate-only` (a flag the cmd/server
|
||||
main process must support — see cmd/server/main.go for the flag
|
||||
parse + early-exit path).
|
||||
- The CERTCTL_MIGRATIONS_VIA_HOOK=true env var is ALSO set on the
|
||||
server Deployment (via values.yaml). When the server boots, it
|
||||
sees this env var and skips its own RunMigrations call — the
|
||||
hook already did the work. Compose deploys don't set the env
|
||||
var, so they keep the boot-time path unchanged.
|
||||
- hook-delete-policy hook-succeeded means the Job is cleaned up
|
||||
automatically on success but retained on failure for operator
|
||||
diagnosis.
|
||||
- The hook-weight ensures the migration Job runs before any other
|
||||
pre-install/pre-upgrade resources (the StatefulSet's PVC has to
|
||||
exist first; in practice the StatefulSet has no hook so it lands
|
||||
naturally in the install phase after the Job completes).
|
||||
|
||||
Operators on Compose: this hook is a no-op for you. The server still
|
||||
runs migrations at boot per the existing path.
|
||||
*/ -}}
|
||||
{{- if .Values.migrations.viaHook }}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-migrate
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migration
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-5"
|
||||
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
|
||||
spec:
|
||||
backoffLimit: {{ .Values.migrations.backoffLimit | default 1 }}
|
||||
activeDeadlineSeconds: {{ .Values.migrations.activeDeadlineSeconds | default 600 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: migration
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.server.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: migrate
|
||||
image: {{ include "certctl.serverImage" . }}
|
||||
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||
# Migration-only entrypoint. The server binary supports a
|
||||
# --migrate-only flag that runs postgres.RunMigrations +
|
||||
# postgres.RunSeed and exits cleanly (zero on success,
|
||||
# non-zero on migration failure). See cmd/server/main.go
|
||||
# for the implementation. The flag is hermetic — no HTTP
|
||||
# listener starts, no scheduler ticks, no signing
|
||||
# operations occur. Pure schema-mutation pass.
|
||||
command:
|
||||
- /app/server
|
||||
- --migrate-only
|
||||
env:
|
||||
- name: CERTCTL_DATABASE_URL
|
||||
value: {{ include "certctl.databaseURL" . | quote }}
|
||||
- name: CERTCTL_LOG_LEVEL
|
||||
value: {{ .Values.server.logging.level | default "info" | quote }}
|
||||
- name: CERTCTL_LOG_FORMAT
|
||||
value: {{ .Values.server.logging.format | default "json" | quote }}
|
||||
resources:
|
||||
{{- toYaml (.Values.migrations.resources | default .Values.server.resources) | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.server.securityContext | nindent 12 }}
|
||||
{{- end }}
|
||||
75
deploy/helm/certctl/templates/networkpolicy.yaml
Normal file
75
deploy/helm/certctl/templates/networkpolicy.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
{{- /*
|
||||
Bundle 3 closure (D11): NetworkPolicy for the server Deployment.
|
||||
|
||||
Pre-Bundle-3 the chart had no NetworkPolicy template at all — the
|
||||
audit-D11 "documented placeholder" finding referred to docs claiming
|
||||
deny-by-default network isolation that the rendered chart did not
|
||||
provide. Closed.
|
||||
|
||||
This template emits a single NetworkPolicy that, when enabled,
|
||||
restricts the certctl-server Pod to:
|
||||
- Ingress : from any agent Pod in the same namespace (selector
|
||||
match on app.kubernetes.io/component=agent) on the
|
||||
server port, plus optional operator-supplied
|
||||
additional from clauses (.networkPolicy.extraIngress).
|
||||
- Egress : to the postgres Pod (when postgresql.enabled=true),
|
||||
53/UDP+TCP for kube-dns, and operator-supplied
|
||||
additional to clauses for outbound CA / OIDC / SMTP
|
||||
(.networkPolicy.extraEgress).
|
||||
|
||||
Default off so existing deploys don't suddenly lose network reach.
|
||||
Operators opt in once they've mapped their actual egress surface.
|
||||
*/ -}}
|
||||
{{- if .Values.networkPolicy.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
# Allow in-cluster agent Pods to reach the server's HTTPS port.
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||
app.kubernetes.io/component: agent
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: {{ .Values.server.port }}
|
||||
{{- with .Values.networkPolicy.extraIngress }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
egress:
|
||||
# Kube-DNS (53/UDP + 53/TCP). Required for any in-cluster name
|
||||
# resolution (postgres-service, OIDC issuer hostnames, ACME).
|
||||
- to:
|
||||
- namespaceSelector: {}
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
# Bundled-Postgres egress.
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||
app.kubernetes.io/component: postgres
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
{{- end }}
|
||||
{{- with .Values.networkPolicy.extraEgress }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
31
deploy/helm/certctl/templates/pdb.yaml
Normal file
31
deploy/helm/certctl/templates/pdb.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
{{- /*
|
||||
Bundle 3 closure (D11): PodDisruptionBudget for the server Deployment.
|
||||
|
||||
Pre-Bundle-3 values.yaml carried `podDisruptionBudget.enabled` +
|
||||
`minAvailable` + `maxUnavailable` knobs but no template consumed
|
||||
them. Audit D11 closed.
|
||||
|
||||
The PDB only renders when server.replicas > 1 — a single-replica
|
||||
deployment can't satisfy minAvailable=1 during voluntary disruption
|
||||
anyway (the K8s scheduler would refuse to drain the node). Operators
|
||||
running 2+ replicas get the PDB; operators running a single replica
|
||||
get a templated-out NOTES line reminding them to bump replicas first.
|
||||
*/ -}}
|
||||
{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.server.replicas) 1) }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
{{- if .Values.podDisruptionBudget.minAvailable }}
|
||||
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||
{{- else if .Values.podDisruptionBudget.maxUnavailable }}
|
||||
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
24
deploy/helm/certctl/templates/postgres-secret.yaml
Normal file
24
deploy/helm/certctl/templates/postgres-secret.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
{{- /*
|
||||
Bundle 3 closure (D1 + D2): the bundled-Postgres Secret only renders
|
||||
when postgresql.enabled=true. Pre-Bundle-3 this template rendered
|
||||
unconditionally with `password: "changeme"` as the fallback default —
|
||||
which is exactly what the change-me-... cluster of audit findings
|
||||
was about (a deployment that uses the rendered chart with default
|
||||
values ships a known weak password). The Bundle-3 helper at
|
||||
certctl.requiredSecrets fail-closes empty password at template time
|
||||
before this template ever runs.
|
||||
*/ -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
type: Opaque
|
||||
stringData:
|
||||
password: {{ required "postgresql.auth.password is required when postgresql.enabled=true (Bundle 3: no fallback default)" .Values.postgresql.auth.password | quote }}
|
||||
username: {{ .Values.postgresql.auth.username | quote }}
|
||||
database: {{ .Values.postgresql.auth.database | quote }}
|
||||
{{- end }}
|
||||
18
deploy/helm/certctl/templates/postgres-service.yaml
Normal file
18
deploy/helm/certctl/templates/postgres-service.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: {{ .Values.postgresql.service.port }}
|
||||
targetPort: postgres
|
||||
protocol: TCP
|
||||
name: postgres
|
||||
selector:
|
||||
{{- include "certctl.postgresSelectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
94
deploy/helm/certctl/templates/postgres-statefulset.yaml
Normal file
94
deploy/helm/certctl/templates/postgres-statefulset.yaml
Normal file
@@ -0,0 +1,94 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgres
|
||||
spec:
|
||||
serviceName: {{ include "certctl.fullname" . }}-postgres
|
||||
replicas: 1
|
||||
# Phase 4 DEPL-M4 closure (2026-05-14): explicit StatefulSet update +
|
||||
# pod-management strategies. Defaults make Postgres upgrades
|
||||
# operator-controlled rather than automatic:
|
||||
# updateStrategy.type: OnDelete — Postgres pods do NOT roll
|
||||
# automatically when the StatefulSet spec changes. Operator
|
||||
# deletes the pod explicitly after taking a backup + reviewing
|
||||
# the change. Prevents an accidental Helm-template tweak from
|
||||
# triggering a database restart at an awkward time.
|
||||
# podManagementPolicy: OrderedReady — when scaling Postgres to
|
||||
# a replica >1 (future HA work), pods come up one at a time
|
||||
# and must reach Ready before the next pod is created. Aligns
|
||||
# with the standard Postgres-on-Kubernetes pattern.
|
||||
updateStrategy:
|
||||
type: OnDelete
|
||||
podManagementPolicy: OrderedReady
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.postgresSelectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.postgresSelectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
securityContext:
|
||||
{{- toYaml .Values.postgresql.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: postgres
|
||||
image: {{ include "certctl.postgresImage" . }}
|
||||
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: 5432
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: database
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: username
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: password
|
||||
- name: POSTGRES_INITDB_ARGS
|
||||
value: "--encoding=UTF8"
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.postgresql.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.postgresql.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.postgresql.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
subPath: postgres
|
||||
- name: postgres-init
|
||||
mountPath: /docker-entrypoint-initdb.d
|
||||
volumes:
|
||||
- name: postgres-init
|
||||
emptyDir: {}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.postgresql.storage.storageClass }}
|
||||
storageClassName: {{ .Values.postgresql.storage.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgresql.storage.size }}
|
||||
{{- end }}
|
||||
145
deploy/helm/certctl/templates/prometheusrules.yaml
Normal file
145
deploy/helm/certctl/templates/prometheusrules.yaml
Normal file
@@ -0,0 +1,145 @@
|
||||
{{- /*
|
||||
Phase 4 DEPL-L2 closure (2026-05-14): opt-in Prometheus AlertManager
|
||||
rules covering the four operationally-actionable alerts every certctl
|
||||
deployment wants out of the box.
|
||||
|
||||
OPERATOR OPT-IN. Default `monitoring.prometheusRules.enabled: false`.
|
||||
Turning it on requires Prometheus Operator CRDs (PrometheusRule kind)
|
||||
to be installed in-cluster. Without them this template renders an
|
||||
object Kubernetes will reject — keep the toggle off if you're scraping
|
||||
with vanilla Prometheus + a Helm-installed AlertManager rules
|
||||
ConfigMap instead.
|
||||
|
||||
Metric names + thresholds verified against the actual
|
||||
internal/api/handler/metrics.go exposition path:
|
||||
- certctl_certificate_expiring_soon: server-side count of certs with
|
||||
ExpiresAt in (now, now + 30d]. The 30-day window is computed in
|
||||
internal/service/stats.go::GetDashboardSummary.
|
||||
- certctl_agent_online: agents with heartbeat in the last 5 minutes.
|
||||
A drop below certctl_agent_total signals offline agents.
|
||||
- certctl_job_failed_total + certctl_job_completed_total: cumulative
|
||||
counters; ratio gives the failure rate over the rate() window.
|
||||
- certctl_issuance_failures_total: cumulative counter of failed
|
||||
issuance attempts (renewal failures are issuance failures with a
|
||||
specific error_class label).
|
||||
|
||||
Adjust thresholds per fleet — the defaults below are tuned for the
|
||||
demo dataset (15 certs / 1 agent) and may need raising for production
|
||||
fleets with thousands of certs where a steady rate of expiring certs
|
||||
is the normal operating state.
|
||||
*/ -}}
|
||||
{{- if and .Values.monitoring.enabled .Values.monitoring.prometheusRules.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-rules
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: monitoring
|
||||
{{- with .Values.monitoring.prometheusRules.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
groups:
|
||||
- name: certctl.alerts
|
||||
interval: {{ .Values.monitoring.prometheusRules.interval | default "60s" }}
|
||||
rules:
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlCertificateExpiringSoon
|
||||
# Series: certctl_certificate_expiring_soon
|
||||
# The certctl-server counts certs with ExpiresAt in
|
||||
# (now, now + 30d] every metrics scrape. Fires whenever any cert
|
||||
# crosses into that window — operator must triage or extend
|
||||
# automation coverage. Rapid renewal infrastructure should keep
|
||||
# this number small in steady state.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlCertificateExpiringSoon
|
||||
expr: certctl_certificate_expiring_soon > {{ .Values.monitoring.prometheusRules.thresholds.expiringCertificateCount | default 0 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.expiringCertificateFor | default "5m" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl
|
||||
annotations:
|
||||
summary: "certctl: {{`{{ $value }}`}} certificate(s) expiring within 30 days"
|
||||
description: >-
|
||||
certctl_certificate_expiring_soon has been > {{ .Values.monitoring.prometheusRules.thresholds.expiringCertificateCount | default 0 }}
|
||||
for 5+ minutes. Investigate via
|
||||
/api/v1/certificates?status=expiring or the dashboard's
|
||||
Expiring tab. If renewal automation should have covered
|
||||
these, check the renewal scheduler logs for the cert IDs
|
||||
+ the per-issuer failure rate.
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlAgentOffline
|
||||
# Series: certctl_agent_total - certctl_agent_online
|
||||
# Agents flip from online → offline after 5 minutes without a
|
||||
# heartbeat (internal/service/stats.go::GetDashboardSummary).
|
||||
# The 1h `for:` window prevents a flapping agent from paging the
|
||||
# operator on every transient network blip.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlAgentOffline
|
||||
expr: (certctl_agent_total - certctl_agent_online) > {{ .Values.monitoring.prometheusRules.thresholds.offlineAgentCount | default 0 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.offlineAgentFor | default "1h" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl-agent
|
||||
annotations:
|
||||
summary: "certctl: {{`{{ $value }}`}} agent(s) offline for >1h"
|
||||
description: >-
|
||||
One or more certctl-agent instances have been without a
|
||||
heartbeat for over an hour. Check the agent logs on the
|
||||
affected hosts. If the agent host is intentionally
|
||||
decommissioned, retire the agent via the dashboard or
|
||||
POST /api/v1/agents/{id}/retire to suppress this alert.
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlJobFailureRateHigh
|
||||
# Series: certctl_job_failed_total / (certctl_job_failed_total + certctl_job_completed_total)
|
||||
# Computes the failure rate over a 15-minute rate() window so
|
||||
# short bursts don't fire but a sustained issue does. The 5%
|
||||
# threshold is a conservative starter — adjust per fleet's
|
||||
# baseline.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlJobFailureRateHigh
|
||||
expr: >-
|
||||
(
|
||||
rate(certctl_job_failed_total[15m])
|
||||
/
|
||||
clamp_min(rate(certctl_job_failed_total[15m]) + rate(certctl_job_completed_total[15m]), 1)
|
||||
) > {{ .Values.monitoring.prometheusRules.thresholds.jobFailureRate | default 0.05 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.jobFailureRateFor | default "15m" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl
|
||||
annotations:
|
||||
summary: "certctl: job failure rate above 5% over 15m"
|
||||
description: >-
|
||||
The 15m rate of certctl_job_failed_total / total jobs
|
||||
has been above 5% for 15+ minutes. Open
|
||||
/api/v1/jobs?status=failed to see the failing job IDs
|
||||
and root-cause the recurring error class.
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Alert: CertctlIssuanceFailures
|
||||
# Series: certctl_issuance_failures_total
|
||||
# Any non-zero rate of issuance failures over a 15m window is
|
||||
# operationally significant — a single CA outage or expired
|
||||
# ACME account can cascade across the fleet.
|
||||
# ---------------------------------------------------------------
|
||||
- alert: CertctlIssuanceFailures
|
||||
expr: rate(certctl_issuance_failures_total[15m]) > {{ .Values.monitoring.prometheusRules.thresholds.issuanceFailureRate | default 0 }}
|
||||
for: {{ .Values.monitoring.prometheusRules.thresholds.issuanceFailureFor | default "15m" }}
|
||||
labels:
|
||||
severity: warning
|
||||
component: certctl
|
||||
annotations:
|
||||
summary: "certctl: certificate issuance / renewal failures over 15m"
|
||||
description: >-
|
||||
certctl_issuance_failures_total has been incrementing
|
||||
over the last 15 minutes. Check the per-issuer breakdown
|
||||
via /api/v1/issuers + the failed-job log in
|
||||
/api/v1/jobs?status=failed. Common causes: CA
|
||||
outage, ACME account rate-limit, EAB credential
|
||||
expiration, stepca provisioner key rotation without
|
||||
certctl-side update.
|
||||
{{- end }}
|
||||
31
deploy/helm/certctl/templates/server-certificate.yaml
Normal file
31
deploy/helm/certctl/templates/server-certificate.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
{{- if .Values.server.tls.certManager.enabled }}
|
||||
{{- include "certctl.tls.required" . }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server-tls
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
commonName: {{ .Values.server.tls.certManager.commonName | quote }}
|
||||
dnsNames:
|
||||
{{- range .Values.server.tls.certManager.dnsNames }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
duration: {{ .Values.server.tls.certManager.duration }}
|
||||
renewBefore: {{ .Values.server.tls.certManager.renewBefore }}
|
||||
usages:
|
||||
- server auth
|
||||
- digital signature
|
||||
- key encipherment
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
rotationPolicy: Always
|
||||
issuerRef:
|
||||
name: {{ .Values.server.tls.certManager.issuerRef.name | quote }}
|
||||
kind: {{ .Values.server.tls.certManager.issuerRef.kind }}
|
||||
group: {{ .Values.server.tls.certManager.issuerRef.group }}
|
||||
{{- end }}
|
||||
39
deploy/helm/certctl/templates/server-configmap.yaml
Normal file
39
deploy/helm/certctl/templates/server-configmap.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
data:
|
||||
log-level: {{ .Values.server.logging.level | quote }}
|
||||
auth-type: {{ .Values.server.auth.type | quote }}
|
||||
keygen-mode: {{ .Values.server.keygen.mode | quote }}
|
||||
rate-limit-rps: {{ .Values.server.rateLimiting.rps | quote }}
|
||||
rate-limit-burst: {{ .Values.server.rateLimiting.burst | quote }}
|
||||
rate-limit-backend: {{ .Values.server.rateLimiting.backend | default "memory" | quote }}
|
||||
rate-limit-janitor-interval: {{ .Values.server.rateLimiting.janitorInterval | default "5m" | quote }}
|
||||
{{- if .Values.server.cors.origins }}
|
||||
cors-origins: {{ .Values.server.cors.origins | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.server.networkScan.enabled }}
|
||||
network-scan-interval: {{ .Values.server.networkScan.interval | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.server.est.enabled }}
|
||||
est-issuer-id: {{ .Values.server.est.issuerID | quote }}
|
||||
{{- if .Values.server.est.profileID }}
|
||||
est-profile-id: {{ .Values.server.est.profileID | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.server.smtp.enabled }}
|
||||
smtp-host: {{ .Values.server.smtp.host | quote }}
|
||||
smtp-port: {{ .Values.server.smtp.port | quote }}
|
||||
smtp-username: {{ .Values.server.smtp.username | quote }}
|
||||
smtp-from-address: {{ .Values.server.smtp.fromAddress | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.server.issuer.acme.enabled }}
|
||||
acme-directory-url: {{ .Values.server.issuer.acme.directoryURL | quote }}
|
||||
acme-email: {{ .Values.server.issuer.acme.email | quote }}
|
||||
acme-challenge-type: {{ .Values.server.issuer.acme.challengeType | quote }}
|
||||
{{- end }}
|
||||
254
deploy/helm/certctl/templates/server-deployment.yaml
Normal file
254
deploy/helm/certctl/templates/server-deployment.yaml
Normal file
@@ -0,0 +1,254 @@
|
||||
{{- include "certctl.tls.required" . }}
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
{{- include "certctl.requiredSecrets" . }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
{{- if gt (int .Values.server.replicas) 1 }}
|
||||
replicas: {{ .Values.server.replicas }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 8 }}
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/server-configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/server-secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||
# Bundle 3 closure (D3): pod-level fields only. The container-only
|
||||
# fields (readOnlyRootFilesystem, allowPrivilegeEscalation,
|
||||
# capabilities, privileged) render at container scope below —
|
||||
# pre-Bundle-3 they all sat here at pod scope and the K8s API
|
||||
# silently dropped them.
|
||||
securityContext:
|
||||
{{- include "certctl.podSecurityContext" .Values.server.securityContext | nindent 8 }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: {{ include "certctl.serverImage" . }}
|
||||
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||
# Bundle 3 closure (D3): container-scope security hardening.
|
||||
# readOnlyRootFilesystem + allowPrivilegeEscalation +
|
||||
# capabilities are container-only fields per the K8s API; the
|
||||
# helper splits them out of the operator-facing
|
||||
# server.securityContext map so existing values keep working.
|
||||
securityContext:
|
||||
{{- include "certctl.containerSecurityContext" .Values.server.securityContext | nindent 12 }}
|
||||
ports:
|
||||
- name: https
|
||||
containerPort: {{ .Values.server.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
# DEPL-003 closure (Sprint 3, 2026-05-16). Pre-fix the
|
||||
# CERTCTL_MIGRATIONS_VIA_HOOK env var was documented in
|
||||
# values.yaml (L797-810) and migration-job.yaml comments
|
||||
# but was never rendered into the server Deployment env
|
||||
# block. With migrations.viaHook=true the operator's
|
||||
# intent is "the pre-install/pre-upgrade Helm Job owns
|
||||
# migrations" — but the server pods, missing the env,
|
||||
# ran their own boot-time RunMigrations alongside the
|
||||
# hook Job, racing on the schema lock. cmd/server/migrations.go
|
||||
# only short-circuits when this env is "true" (line 144).
|
||||
{{- if .Values.migrations.viaHook }}
|
||||
- name: CERTCTL_MIGRATIONS_VIA_HOOK
|
||||
value: "true"
|
||||
{{- end }}
|
||||
- name: CERTCTL_SERVER_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: CERTCTL_SERVER_PORT
|
||||
value: "{{ .Values.server.port }}"
|
||||
- name: CERTCTL_SERVER_TLS_CERT_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/tls.crt"
|
||||
- name: CERTCTL_SERVER_TLS_KEY_PATH
|
||||
value: "{{ .Values.server.tls.mountPath }}/tls.key"
|
||||
- name: CERTCTL_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: database-url
|
||||
# Bundle 3 closure (D2): POSTGRES_PASSWORD is only needed
|
||||
# for the bundled-Postgres mode. External Postgres mode
|
||||
# embeds the password directly in externalDatabase.url.
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-postgres
|
||||
key: password
|
||||
{{- end }}
|
||||
- name: CERTCTL_LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: log-level
|
||||
- name: CERTCTL_LOG_FORMAT
|
||||
value: "json"
|
||||
- name: CERTCTL_AUTH_TYPE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: auth-type
|
||||
{{- if eq .Values.server.auth.type "api-key" }}
|
||||
- name: CERTCTL_AUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: api-key
|
||||
{{- end }}
|
||||
- name: CERTCTL_KEYGEN_MODE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: keygen-mode
|
||||
- name: CERTCTL_RATE_LIMIT_RPS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: rate-limit-rps
|
||||
- name: CERTCTL_RATE_LIMIT_BURST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: rate-limit-burst
|
||||
# Phase 13 Sprint 13.3 (ARCH-M1) — cross-replica-consistent
|
||||
# sliding-window rate limiter. Default memory; flip to
|
||||
# postgres when server.replicas > 1.
|
||||
- name: CERTCTL_RATE_LIMIT_BACKEND
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: rate-limit-backend
|
||||
- name: CERTCTL_RATE_LIMIT_JANITOR_INTERVAL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: rate-limit-janitor-interval
|
||||
{{- if .Values.server.cors.origins }}
|
||||
- name: CERTCTL_CORS_ORIGINS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: cors-origins
|
||||
{{- end }}
|
||||
{{- if .Values.server.networkScan.enabled }}
|
||||
- name: CERTCTL_NETWORK_SCAN_ENABLED
|
||||
value: "true"
|
||||
- name: CERTCTL_NETWORK_SCAN_INTERVAL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: network-scan-interval
|
||||
{{- end }}
|
||||
{{- if .Values.server.est.enabled }}
|
||||
- name: CERTCTL_EST_ENABLED
|
||||
value: "true"
|
||||
- name: CERTCTL_EST_ISSUER_ID
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: est-issuer-id
|
||||
{{- if .Values.server.est.profileID }}
|
||||
- name: CERTCTL_EST_PROFILE_ID
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: est-profile-id
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.server.smtp.enabled }}
|
||||
- name: CERTCTL_SMTP_HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: smtp-host
|
||||
- name: CERTCTL_SMTP_PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: smtp-port
|
||||
- name: CERTCTL_SMTP_USERNAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: smtp-username
|
||||
- name: CERTCTL_SMTP_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: smtp-password
|
||||
- name: CERTCTL_SMTP_FROM_ADDRESS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: smtp-from-address
|
||||
{{- end }}
|
||||
{{- if .Values.server.issuer.acme.enabled }}
|
||||
- name: CERTCTL_ACME_DIRECTORY_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: acme-directory-url
|
||||
- name: CERTCTL_ACME_EMAIL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: acme-email
|
||||
- name: CERTCTL_ACME_CHALLENGE_TYPE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
key: acme-challenge-type
|
||||
{{- end }}
|
||||
{{- with .Values.server.env }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.server.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.server.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.server.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: tls
|
||||
mountPath: {{ .Values.server.tls.mountPath }}
|
||||
readOnly: true
|
||||
{{- if .Values.server.volumeMounts }}
|
||||
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: {{ include "certctl.tls.secretName" . }}
|
||||
defaultMode: 0400
|
||||
{{- if .Values.server.volumes }}
|
||||
{{- toYaml .Values.server.volumes | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.nodeAffinity }}
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
{{- toYaml .Values.nodeAffinity | nindent 10 }}
|
||||
{{- else if .Values.podAntiAffinity }}
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
{{- toYaml .Values.podAntiAffinity | nindent 10 }}
|
||||
{{- else if .Values.podAffinity }}
|
||||
affinity:
|
||||
podAffinity:
|
||||
{{- toYaml .Values.podAffinity | nindent 10 }}
|
||||
{{- end }}
|
||||
21
deploy/helm/certctl/templates/server-secret.yaml
Normal file
21
deploy/helm/certctl/templates/server-secret.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- include "certctl.validateAuthType" . }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Bundle B / Audit M-018 (PCI-DSS Req 4): sslmode wired from
|
||||
# postgresql.tls.mode. Default "disable" preserves the in-cluster
|
||||
# Helm-bundled-Postgres path; operators on PCI-scoped clusters set
|
||||
# postgresql.tls.mode to require / verify-ca / verify-full.
|
||||
database-url: {{ include "certctl.databaseURL" . | quote }}
|
||||
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
|
||||
api-key: {{ .Values.server.auth.apiKey | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.server.smtp.enabled }}
|
||||
smtp-password: {{ .Values.server.smtp.password | quote }}
|
||||
{{- end }}
|
||||
37
deploy/helm/certctl/templates/server-service.yaml
Normal file
37
deploy/helm/certctl/templates/server-service.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
{{- with .Values.server.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.server.service.type }}
|
||||
{{- /*
|
||||
DEPL-006 closure (Sprint 3, 2026-05-16). Render the optional
|
||||
sessionAffinity field. docs/operator/runbooks/ha.md instructs
|
||||
operators to set sessionAffinity: ClientIP for replicas > 1 so
|
||||
login + CSRF flows stay on the same pod; pre-fix the chart did
|
||||
not actually pass the value through. sessionAffinityConfig
|
||||
clientIP.timeoutSeconds renders only when set, otherwise
|
||||
Kubernetes applies its default (10800s / 3h).
|
||||
*/}}
|
||||
{{- if .Values.server.service.sessionAffinity }}
|
||||
sessionAffinity: {{ .Values.server.service.sessionAffinity }}
|
||||
{{- with .Values.server.service.sessionAffinityTimeoutSeconds }}
|
||||
sessionAffinityConfig:
|
||||
clientIP:
|
||||
timeoutSeconds: {{ . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- port: {{ .Values.server.service.port }}
|
||||
targetPort: https
|
||||
protocol: TCP
|
||||
name: https
|
||||
selector:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
|
||||
44
deploy/helm/certctl/templates/serviceaccount.yaml
Normal file
44
deploy/helm/certctl/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "certctl.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.rbac.create }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
rules:
|
||||
{{- if .Values.kubernetesSecrets.enabled }}
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list", "create", "update", "patch"]
|
||||
{{- else }}
|
||||
[]
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: {{ include "certctl.fullname" . }}
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "certctl.serviceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
81
deploy/helm/certctl/templates/servicemonitor.yaml
Normal file
81
deploy/helm/certctl/templates/servicemonitor.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
{{- /*
|
||||
Bundle 3 closure (D5 + OPS-M1 docs): Prometheus Operator ServiceMonitor.
|
||||
|
||||
Pre-Bundle-3 the chart had `monitoring.serviceMonitor.enabled` in
|
||||
values.yaml but no template consumed it — toggling it on rendered
|
||||
nothing. Audit D5 closed.
|
||||
|
||||
The endpoint scrapes /api/v1/metrics/prometheus which the certctl
|
||||
server already exposes in Prometheus exposition format (see
|
||||
internal/api/handler/metrics.go::GetPrometheusMetrics). Note: the
|
||||
endpoint is rbac-gated on `metrics.read`, so the ServiceMonitor needs
|
||||
a bearer token. Operators with Prometheus Operator MUST set
|
||||
`monitoring.serviceMonitor.bearerTokenSecret` pointing at a Secret
|
||||
that holds an API key with the `metrics.read` permission. Without
|
||||
that, scrapes return 401.
|
||||
|
||||
OPS-M1 caveat: the current /metrics/prometheus handler is a hand-rolled
|
||||
exposition-format emitter, not prometheus/client_golang-instrumented
|
||||
code. Histograms, exemplars, and target labels are limited to what the
|
||||
handler computes statically. Migration to client_golang tracked in
|
||||
WORKSPACE-ROADMAP.md.
|
||||
*/ -}}
|
||||
{{- if and .Values.monitoring.enabled .Values.monitoring.serviceMonitor.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "certctl.fullname" . }}-server
|
||||
labels:
|
||||
{{- include "certctl.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: server
|
||||
{{- with .Values.monitoring.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: https
|
||||
scheme: https
|
||||
path: /api/v1/metrics/prometheus
|
||||
interval: {{ .Values.monitoring.serviceMonitor.interval | default "30s" }}
|
||||
scrapeTimeout: {{ .Values.monitoring.serviceMonitor.scrapeTimeout | default "10s" }}
|
||||
tlsConfig:
|
||||
{{- /*
|
||||
Acquisition-audit DEPL-004 closure (Sprint 6 ACQ, 2026-05-16).
|
||||
Pre-Sprint-6 the default was an implicit insecureSkipVerify
|
||||
true via the template falling through the else branch.
|
||||
Post-Sprint-6 values.yaml ships a real-verify default
|
||||
(caFile + serverName matching the chart existingSecret /
|
||||
cert-manager-emitted Secret at /etc/prometheus/secrets/
|
||||
certctl-ca/), so the truthy if-branch below always fires for
|
||||
the default install. Operators who want skipVerify back must
|
||||
override with tlsConfig insecureSkipVerify true explicitly.
|
||||
Operators who blank tlsConfig entirely hit the else-branch
|
||||
below and trip the Helm fail directive at chart-render time;
|
||||
there is no way to inherit the pre-Sprint-6 implicit-skip
|
||||
behavior silently. See docs/operator/helm-deployment.md for
|
||||
the narrative explanation, including the lesson that comment
|
||||
text referencing Helm template-action delimiters must live
|
||||
in Helm-style comment blocks (this block), never in YAML
|
||||
hash-comment blocks — the Helm lexer scans for action
|
||||
delimiters everywhere in the source text, ignoring YAML
|
||||
comment markers, so descriptive references to actions inside
|
||||
YAML hash-comments are reinterpreted as template actions
|
||||
and abort the entire chart render.
|
||||
*/ -}}
|
||||
{{- if .Values.monitoring.serviceMonitor.tlsConfig }}
|
||||
{{- toYaml .Values.monitoring.serviceMonitor.tlsConfig | nindent 8 }}
|
||||
{{- else }}
|
||||
{{- fail "monitoring.serviceMonitor.tlsConfig was explicitly blanked but monitoring.serviceMonitor.enabled=true (Sprint 6 ACQ DEPL-004 closure, 2026-05-16). The values.yaml default ships caFile=/etc/prometheus/secrets/certctl-ca/ca.crt + serverName=certctl-server which matches the existingSecret mount pattern. If your Prometheus pod mounts the CA bundle at a different path, override caFile rather than blanking the block. If you genuinely need skipVerify, set tlsConfig insecureSkipVerify=true explicitly — never blank. See docs/operator/helm-deployment.md for the upgrade-path note." }}
|
||||
{{- end }}
|
||||
{{- with .Values.monitoring.serviceMonitor.bearerTokenSecret }}
|
||||
bearerTokenSecret:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.monitoring.serviceMonitor.relabelings }}
|
||||
relabelings:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
916
deploy/helm/certctl/values.yaml
Normal file
916
deploy/helm/certctl/values.yaml
Normal file
@@ -0,0 +1,916 @@
|
||||
# Default values for certctl Helm chart
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
# Namespace override (optional)
|
||||
namespace: ""
|
||||
|
||||
# Global configuration
|
||||
commonLabels: {}
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Server Configuration
|
||||
# ==============================================================================
|
||||
server:
|
||||
# Number of replicas (for HA deployments).
|
||||
# Phase 2 DEPL-H1: production HA is operator-opt-in across this field
|
||||
# + podDisruptionBudget.enabled + server.service.sessionAffinity.
|
||||
# See docs/operator/runbooks/ha.md for the smallest-possible HA overlay.
|
||||
replicas: 1
|
||||
|
||||
# Image configuration
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl
|
||||
tag: "" # defaults to Chart.appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Server port
|
||||
port: 8443
|
||||
|
||||
# Resource requests and limits
|
||||
#
|
||||
# Phase 4 DEPL-M5 (2026-05-14): per-fleet-size tuning ladder. The
|
||||
# default values below are validated against the demo dataset
|
||||
# (15 certs / 1 agent) and the baselines in
|
||||
# docs/operator/performance-baselines.md (single endpoint < 5s for
|
||||
# 100 sequential requests = ~50ms p50; cursor-paginated 1000-cert
|
||||
# inventory walk < 3s; renewal scan for 15 certs < 100ms).
|
||||
#
|
||||
# Larger fleet recommendations (TBD pending Phase 8 load-test runs;
|
||||
# operators tune empirically until then — capture readings in your
|
||||
# own loadtest-baselines log):
|
||||
#
|
||||
# ≤ 500 certs / 100 agents: defaults below (100m / 128Mi req, 500m / 512Mi lim)
|
||||
# 5K certs / 1K agents: tune up — TBD Phase 8 (suggested starter: 500m / 512Mi req, 2000m / 2Gi lim)
|
||||
# 50K certs / 10K agents: tune up — TBD Phase 8 (suggested starter: 2000m / 2Gi req, 4000m / 4Gi lim)
|
||||
#
|
||||
# The "suggested starter" values above are operator-tuning starting
|
||||
# points, NOT validated. Phase 8 (load test coverage expansion) will
|
||||
# measure them against synthetic fleets and replace the suggestions
|
||||
# with measured ceilings. Until then, treat them as a "raise CPU
|
||||
# before raising memory; raise both before scaling out" mental
|
||||
# model. Per docs/operator/performance-baselines.md, certctl-server
|
||||
# is CPU-bound on issuance / renewal scan work and memory-bound on
|
||||
# the inventory query path.
|
||||
#
|
||||
# Database scale (postgresql.* below) tracks server scale roughly
|
||||
# 1:1 — at 50K certs the Postgres instance needs 4 CPU / 4Gi RAM
|
||||
# and shared_buffers ≥ 1Gi. Postgres tuning is out of scope for
|
||||
# this comment; see docs/operator/runbooks/postgres-backup.md
|
||||
# for the production-tuning entry-point.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
# Pod security context
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
# Liveness and readiness probes (HTTPS-only as of v2.2).
|
||||
#
|
||||
# The two paths exposed for probes are `/health` and `/ready` —
|
||||
# registered in internal/api/router/router.go:76-85 and bypassing the
|
||||
# auth middleware via the no-auth list at cmd/server/main.go:920.
|
||||
# Both serve the same JSON shape today (`{"status":"healthy"}` /
|
||||
# `{"status":"ready"}`) but exist as separate routes so liveness and
|
||||
# readiness can diverge in the future without renaming.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: https
|
||||
scheme: HTTPS
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# U-2 (P1, cat-u-healthcheck_protocol_mismatch — adjacent fix): pre-U-2
|
||||
# the readiness probe pointed at `/readyz`, the conventional kube-flavor
|
||||
# name. The certctl server doesn't register `/readyz` (only `/health`
|
||||
# and `/ready`) — see cmd/server/main.go:920 and
|
||||
# internal/api/router/router.go:81. K8s readiness probes therefore
|
||||
# received a 404 (or, with auth enabled, a 401 from the api-key middleware
|
||||
# because `/readyz` was NOT in the no-auth bypass set), pods stayed
|
||||
# `NotReady` indefinitely, and Helm rollouts stalled. Post-U-2 the path
|
||||
# matches a registered route.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: https
|
||||
scheme: HTTPS
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 2
|
||||
|
||||
# TLS configuration — REQUIRED. HTTPS is the only supported mode (v2.2+).
|
||||
# Operator must configure EXACTLY ONE of:
|
||||
# (a) server.tls.existingSecret: <name> # pre-existing kubernetes.io/tls Secret
|
||||
# (b) server.tls.certManager.enabled: true # provision a cert-manager Certificate CR
|
||||
# Refusing to set either makes `helm template` fail with a diagnostic pointing at docs/tls.md.
|
||||
tls:
|
||||
# Name of a pre-existing Secret (type kubernetes.io/tls) holding tls.crt + tls.key (+ optional ca.crt).
|
||||
# Leave empty to fall through to the cert-manager path.
|
||||
existingSecret: ""
|
||||
|
||||
# Mount path for the TLS Secret inside the server + agent containers.
|
||||
mountPath: /etc/certctl/tls
|
||||
|
||||
# cert-manager auto-provisioning. Opt-in (off by default per milestone §3.4).
|
||||
certManager:
|
||||
enabled: false
|
||||
|
||||
# Secret name the cert-manager Certificate CR writes into. Agents and the server
|
||||
# both read from this Secret. If empty, defaults to "<fullname>-tls".
|
||||
secretName: ""
|
||||
|
||||
# Cert-manager issuer reference.
|
||||
issuerRef:
|
||||
name: "" # e.g. "letsencrypt-prod" or "internal-ca"
|
||||
kind: ClusterIssuer # ClusterIssuer or Issuer
|
||||
group: cert-manager.io
|
||||
|
||||
# Subject fields on the issued cert.
|
||||
commonName: "certctl-server"
|
||||
dnsNames:
|
||||
- certctl-server
|
||||
- localhost
|
||||
|
||||
# Certificate lifetime + renewal window.
|
||||
duration: 2160h # 90 days
|
||||
renewBefore: 360h # 15 days
|
||||
|
||||
# Service type (ClusterIP, LoadBalancer, NodePort)
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8443
|
||||
annotations: {}
|
||||
# DEPL-006 closure (Sprint 3, 2026-05-16). Optional sticky-session
|
||||
# routing. REQUIRED when server.replicas > 1 so login + CSRF token
|
||||
# rows stay on the same pod for the duration of a session — the
|
||||
# default round-robin load balancing breaks those flows. Set to
|
||||
# "ClientIP" for production HA (see deploy/helm/examples/values-prod-ha.yaml).
|
||||
# Leave empty for single-replica deploys.
|
||||
sessionAffinity: ""
|
||||
# When sessionAffinity is set, timeout window (in seconds) the
|
||||
# Service maps a source IP to the same pod. Default null →
|
||||
# Kubernetes applies its built-in default (10800s / 3h).
|
||||
sessionAffinityTimeoutSeconds: null
|
||||
|
||||
# Authentication configuration.
|
||||
# Valid types: "api-key" (production) or "none" (demo only — disables
|
||||
# authentication on the API and logs a loud Warn at server startup).
|
||||
# For JWT/OIDC, run an authenticating gateway in front of certctl
|
||||
# (oauth2-proxy / Envoy ext_authz / Traefik ForwardAuth / Pomerium)
|
||||
# and set type=none here so the gateway terminates federated identity.
|
||||
# See docs/architecture.md "Authenticating-gateway pattern".
|
||||
#
|
||||
# G-1 (P1): pre-G-1 the chart accepted server.auth.type=jwt and the
|
||||
# certctl-server container silently routed every request through the
|
||||
# api-key bearer middleware — silent auth downgrade. Post-G-1 the
|
||||
# chart's `certctl.validateAuthType` template helper rejects any value
|
||||
# outside {api-key, none} at template time. See
|
||||
# docs/upgrade-to-v2-jwt-removal.md if you previously set type=jwt.
|
||||
auth:
|
||||
type: api-key
|
||||
apiKey: "" # REQUIRED when type=api-key (set via --set or values override).
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
level: info # debug, info, warn, error
|
||||
format: json # json or text
|
||||
|
||||
# SMTP configuration for email notifications (optional)
|
||||
smtp:
|
||||
enabled: false
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
fromAddress: ""
|
||||
useTLS: true
|
||||
|
||||
# Certificate digest digest (periodic email summary)
|
||||
digest:
|
||||
enabled: false
|
||||
interval: "24h"
|
||||
recipients: []
|
||||
# Example:
|
||||
# - admin@example.com
|
||||
# - ops@example.com
|
||||
|
||||
# Enrollment over Secure Transport (EST) configuration
|
||||
est:
|
||||
enabled: false
|
||||
issuerID: "iss-local"
|
||||
profileID: ""
|
||||
|
||||
# Rate limiting configuration
|
||||
rateLimiting:
|
||||
rps: 100 # Requests per second (token-bucket middleware)
|
||||
burst: 200 # Burst capacity (token-bucket middleware)
|
||||
|
||||
# Sliding-window-log rate-limit backend (Phase 13 Sprint 13.2/13.3
|
||||
# ARCH-M1 closure). Selects the implementation backing the
|
||||
# break-glass / OCSP / cert-export / EST limiters. See
|
||||
# docs/operator/observability.md for the operator decision tree.
|
||||
#
|
||||
# memory — per-process (default; single-replica deploys).
|
||||
# postgres — cross-replica-consistent via rate_limit_buckets.
|
||||
# REQUIRED when server.replicas > 1 for accurate
|
||||
# cluster-wide enforcement.
|
||||
backend: memory
|
||||
|
||||
# Scheduler janitor interval for the postgres backend's
|
||||
# rate_limit_buckets sweep. Ignored when backend=memory (the
|
||||
# in-memory backend self-prunes on every Allow call).
|
||||
# Default 5m; minimum 1m.
|
||||
janitorInterval: "5m"
|
||||
|
||||
# Network scanning configuration
|
||||
networkScan:
|
||||
enabled: false
|
||||
interval: "6h"
|
||||
|
||||
# Certificate key generation mode
|
||||
keygen:
|
||||
mode: agent # Options: agent (production), server (demo with warning)
|
||||
|
||||
# CORS configuration
|
||||
cors:
|
||||
origins: "" # Comma-separated list, empty means deny all cross-origin requests
|
||||
|
||||
# Issuer connectors configuration
|
||||
issuer:
|
||||
local:
|
||||
enabled: true
|
||||
# For sub-CA mode, provide these paths:
|
||||
# caCertPath: /path/to/ca.crt
|
||||
# caKeyPath: /path/to/ca.key
|
||||
|
||||
acme:
|
||||
enabled: false
|
||||
directoryURL: ""
|
||||
email: ""
|
||||
challengeType: "http-01" # Options: http-01, dns-01, dns-persist-01
|
||||
# DNS configuration (for dns-01 or dns-persist-01)
|
||||
# dnsPresentScript: /path/to/dns-present.sh
|
||||
# dnsCleanupScript: /path/to/dns-cleanup.sh
|
||||
# dnsPropagationWait: "30s"
|
||||
# dnsPersistIssuerDomain: "validation.example.com"
|
||||
# EAB configuration (for ZeroSSL, Google Trust Services, etc.)
|
||||
# eabKid: ""
|
||||
# eabHmac: ""
|
||||
|
||||
stepca:
|
||||
enabled: false
|
||||
# rootCAPath: /path/to/root_ca.crt
|
||||
# intermediateCAPath: /path/to/intermediate_ca.crt
|
||||
# provisionerName: ""
|
||||
# provisionerPassword: ""
|
||||
|
||||
openssl:
|
||||
enabled: false
|
||||
# signScript: /path/to/sign.sh
|
||||
# revokeScript: /path/to/revoke.sh
|
||||
# crlScript: /path/to/crl.sh
|
||||
# timeoutSeconds: 30
|
||||
|
||||
# Notifier connectors configuration
|
||||
notifiers:
|
||||
slack:
|
||||
enabled: false
|
||||
# webhookUrl: ""
|
||||
# channel: ""
|
||||
# username: ""
|
||||
# iconEmoji: ""
|
||||
|
||||
teams:
|
||||
enabled: false
|
||||
# webhookUrl: ""
|
||||
|
||||
pagerduty:
|
||||
enabled: false
|
||||
# routingKey: ""
|
||||
# severity: warning
|
||||
|
||||
opsgenie:
|
||||
enabled: false
|
||||
# apiKey: ""
|
||||
# priority: P3
|
||||
|
||||
# Additional environment variables
|
||||
# Will be passed as-is to the server container
|
||||
env: {}
|
||||
# Example:
|
||||
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL: "1h"
|
||||
# CERTCTL_DATABASE_MAX_CONNS: "25"
|
||||
|
||||
# Additional volume mounts for custom configurations
|
||||
# volumeMounts: []
|
||||
# - name: ca-cert
|
||||
# mountPath: /etc/ssl/certs/ca.crt
|
||||
# subPath: ca.crt
|
||||
|
||||
# Additional volumes
|
||||
# volumes: []
|
||||
# - name: ca-cert
|
||||
# secret:
|
||||
# secretName: ca-cert
|
||||
|
||||
# ==============================================================================
|
||||
# External Database Configuration (Bundle 3 closure / D2 + OPS-L2)
|
||||
# ==============================================================================
|
||||
# When postgresql.enabled=false, the chart skips the bundled StatefulSet +
|
||||
# Secret + Service and instead consumes the URL below verbatim as the
|
||||
# server's CERTCTL_DATABASE_URL. The URL embeds username, password,
|
||||
# host, port, database, and sslmode — operators are responsible for
|
||||
# rotating credentials in this string out-of-band (Kubernetes Secret +
|
||||
# helm upgrade is the supported pattern).
|
||||
#
|
||||
# Recommended sslmode for managed Postgres (RDS, Cloud SQL, Azure DB):
|
||||
# verify-full — PCI-DSS Req 4 v4.0 §2.2.5 compliant; requires CA bundle.
|
||||
# Mount the CA via server.volumes / server.volumeMounts and
|
||||
# set sslrootcert=/path/in/pod/ca.crt in the URL.
|
||||
#
|
||||
# Example values overrides:
|
||||
# postgresql.enabled: false
|
||||
# externalDatabase.url: "postgres://certctl:HUNTER2@db.example.com:5432/certctl?sslmode=verify-full"
|
||||
#
|
||||
# Migration from the legacy `server.env.CERTCTL_DATABASE_URL` workaround:
|
||||
# both still work (env block overrides the helper-emitted Secret value at
|
||||
# pod-spec level), but the new path renders cleaner manifests with no
|
||||
# stranded postgres-* templates.
|
||||
externalDatabase:
|
||||
# Connection string used when postgresql.enabled=false.
|
||||
# Required in that mode — see certctl.requiredSecrets helper.
|
||||
url: ""
|
||||
|
||||
# ==============================================================================
|
||||
# PostgreSQL Configuration
|
||||
# ==============================================================================
|
||||
postgresql:
|
||||
# Enable/disable PostgreSQL (set to false if using external database)
|
||||
enabled: true
|
||||
|
||||
# Image configuration
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "16-alpine"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Authentication
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
# REQUIRED — set via `--set postgresql.auth.password=<value>` or values override.
|
||||
#
|
||||
# WARNING (U-1): rotating this value after first deploy does NOT change the
|
||||
# database password. The `postgres:16-alpine` image runs `initdb` only when
|
||||
# /var/lib/postgresql/data is empty, so POSTGRES_PASSWORD is written into
|
||||
# pg_authid exactly once — on the first boot of the StatefulSet's PVC.
|
||||
# Subsequent rollouts pick up the new env value in the postgres container
|
||||
# but the certctl-server container's CERTCTL_DATABASE_URL also picks up
|
||||
# the new value, while pg_authid still expects the old one — leading to
|
||||
# `pq: password authentication failed for user "certctl"` (SQLSTATE 28P01).
|
||||
#
|
||||
# The certctl-server emits guidance via internal/repository/postgres/db.go::
|
||||
# wrapPingError when it sees SQLSTATE 28P01 at startup. To resolve in a
|
||||
# Helm deployment:
|
||||
# - Non-destructive (preferred for environments with data):
|
||||
# kubectl exec -it <release>-postgres-0 -- \
|
||||
# psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new>';"
|
||||
# then update the secret/values to match and let the certctl-server
|
||||
# pod restart against the matching credential.
|
||||
# - Destructive (DESTROYS DATA — only acceptable on dev/demo PVCs):
|
||||
# helm uninstall <release> && \
|
||||
# kubectl delete pvc -l app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres && \
|
||||
# helm install <release> ... # PVC re-creates empty, initdb seeds new password
|
||||
password: ""
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Bundle B / Audit M-018 (PCI-DSS Req 4 / CWE-319): TLS to Postgres
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# postgresql.tls.mode is wired into the database-url sslmode parameter
|
||||
# (see templates/_helpers.tpl::certctl.databaseURL).
|
||||
#
|
||||
# Acceptable values (lib/pq):
|
||||
# disable — no TLS (default, preserves in-cluster pod-to-pod
|
||||
# traffic on the K8s pod network).
|
||||
# require — TLS required, no certificate verification.
|
||||
# verify-ca — TLS required + verify CA chain.
|
||||
# verify-full — TLS required + verify CA chain + verify hostname.
|
||||
#
|
||||
# PCI-DSS Req 4 v4.0 §2.2.5 requires verify-ca or verify-full when the
|
||||
# database carries sensitive data crossing untrusted networks (RDS,
|
||||
# Cloud SQL, cross-VPC, etc). The bundled Helm Postgres runs in the
|
||||
# same pod network as certctl-server; sslmode=disable is acceptable
|
||||
# there only when the cluster CNI provides L2/L3 encryption (Cilium
|
||||
# WireGuard, Calico Wireguard, Tailscale operator, etc).
|
||||
#
|
||||
# When mode != disable AND tls.caSecretRef is set, the CA bundle is
|
||||
# mounted at /etc/postgresql-ca/ca.crt and the server's PGSSLROOTCERT
|
||||
# env points there. caSecretRef must reference an existing Secret with
|
||||
# a "ca.crt" key.
|
||||
tls:
|
||||
mode: disable
|
||||
# caSecretRef: "" # Secret with ca.crt key (required for verify-ca/verify-full)
|
||||
|
||||
# Storage configuration
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClass: "" # Uses default StorageClass if empty
|
||||
# deleteOnTermination: false # Keep data on Helm uninstall
|
||||
|
||||
# Resource requests and limits
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
# Pod security context
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
|
||||
# Liveness and readiness probes
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- pg_isready -U certctl -d certctl
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- pg_isready -U certctl -d certctl
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 2
|
||||
|
||||
# Service configuration
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 5432
|
||||
|
||||
# PostgreSQL-specific settings
|
||||
postgresqlConfig: {}
|
||||
# Example:
|
||||
# max_connections: "200"
|
||||
# shared_buffers: "256MB"
|
||||
|
||||
# ==============================================================================
|
||||
# Certctl Agent Configuration
|
||||
# ==============================================================================
|
||||
agent:
|
||||
# Enable/disable agent deployment
|
||||
enabled: true
|
||||
|
||||
# Deployment strategy: DaemonSet (recommended) or Deployment
|
||||
kind: DaemonSet # Options: DaemonSet, Deployment
|
||||
|
||||
# Image configuration
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl-agent
|
||||
tag: "" # defaults to Chart.appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Number of replicas (for Deployment kind; ignored for DaemonSet)
|
||||
replicas: 1
|
||||
|
||||
# Resource requests and limits
|
||||
#
|
||||
# Phase 4 DEPL-M5 (2026-05-14): per-fleet-size tuning ladder for the
|
||||
# agent. Defaults are sized for the standard "one cert per host"
|
||||
# operating pattern: the agent polls the server every 30 seconds
|
||||
# (hardcoded in cmd/agent/main.go::pollInterval — not yet
|
||||
# env-configurable), generates ECDSA P-256 keys locally on
|
||||
# issuance/renewal events, and is otherwise idle. CPU is bursty only
|
||||
# during keygen + CSR submission.
|
||||
#
|
||||
# Tuning ladder (TBD pending Phase 8 — measure on your fleet):
|
||||
#
|
||||
# 1 cert / host (typical): defaults below (50m / 64Mi req, 200m / 256Mi lim)
|
||||
# 10 certs / host: stays at defaults — agent is poll-driven, not work-bound by cert count
|
||||
# 100 certs / host (rare): raise lim to 500m / 512Mi if you see throttling on issuance bursts
|
||||
#
|
||||
# The agent does NOT cache certs in memory — issuance is one-shot
|
||||
# generate-then-deploy. So per-host memory scales with whatever
|
||||
# truststore PEM bundles the agent's connectors load (Apache /
|
||||
# Postfix / similar), not with the cert count. Defaults are
|
||||
# appropriate for any "agent terminates ≤ 100 certs on this host"
|
||||
# deployment.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
# Pod security context
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
# Agent name (can be overridden per pod via StatefulSet ordinals)
|
||||
name: "" # If empty, uses release name
|
||||
|
||||
# Key storage directory
|
||||
keyDir: /var/lib/certctl/keys
|
||||
|
||||
# Certificate discovery directories (comma-separated)
|
||||
discoveryDirs: ""
|
||||
# Example: "/etc/ssl/certs,/etc/pki/tls"
|
||||
|
||||
# Node selector for agent pods (for DaemonSet)
|
||||
nodeSelector: {}
|
||||
# Example:
|
||||
# node-role.kubernetes.io/worker: "true"
|
||||
|
||||
# Tolerations for agent pods
|
||||
tolerations: []
|
||||
# Example:
|
||||
# - key: node-role
|
||||
# operator: Equal
|
||||
# value: worker
|
||||
# effect: NoSchedule
|
||||
|
||||
# Affinity rules
|
||||
affinity: {}
|
||||
|
||||
# Additional environment variables
|
||||
env: {}
|
||||
|
||||
# ==============================================================================
|
||||
# Ingress Configuration
|
||||
# ==============================================================================
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
|
||||
# Optional cert-manager integration for the public-facing Ingress cert.
|
||||
# This is completely independent of server.tls.* — the Ingress terminates
|
||||
# an *additional* TLS hop between the internet and the in-cluster Service.
|
||||
# Leave disabled unless an Ingress is exposing certctl to the outside world.
|
||||
certManager:
|
||||
enabled: false
|
||||
issuerRef:
|
||||
name: "" # e.g. "letsencrypt-prod"
|
||||
kind: ClusterIssuer # ClusterIssuer or Issuer
|
||||
hosts:
|
||||
- host: certctl.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: certctl-tls
|
||||
# hosts:
|
||||
# - certctl.local
|
||||
|
||||
# ==============================================================================
|
||||
# Service Account Configuration
|
||||
# ==============================================================================
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
name: "" # defaults to release name if empty
|
||||
|
||||
# ==============================================================================
|
||||
# RBAC Configuration
|
||||
# ==============================================================================
|
||||
rbac:
|
||||
create: true
|
||||
|
||||
# ==============================================================================
|
||||
# Kubernetes Secrets Target Connector (PREVIEW — Bundle 3 closure / C3)
|
||||
# ==============================================================================
|
||||
# Bundle 3 audit closure (C3): the connector framework at
|
||||
# internal/connector/target/k8ssecret/ ships the Config + interface +
|
||||
# 14 unit tests, but the production K8s client at
|
||||
# k8ssecret.go::realK8sClient is documented as "a stub placeholder for
|
||||
# the real k8s.io/client-go implementation". The repo does not import
|
||||
# k8s.io/client-go (verified via `grep -n "client-go" go.mod`), so the
|
||||
# connector cannot deploy to a real cluster today.
|
||||
#
|
||||
# Setting kubernetesSecrets.enabled=true wires up the RBAC verbs the
|
||||
# real client will need (get/create/update/patch/delete on Secrets)
|
||||
# without making the connector functional — operators trying to use it
|
||||
# get the stub's error and a pointer to this note.
|
||||
#
|
||||
# Status: PREVIEW. Production client lands when the cluster-management
|
||||
# bundle ships (tracked in WORKSPACE-ROADMAP.md). Until then,
|
||||
# in-cluster deploys use the file-based connectors (NGINX, Apache,
|
||||
# HAProxy, etc.) via a Pod-mounted Secret + DaemonSet agent.
|
||||
kubernetesSecrets:
|
||||
enabled: false
|
||||
|
||||
# ==============================================================================
|
||||
# Pod Disruption Budget (for HA deployments).
|
||||
# Phase 2 DEPL-H1: defaults to enabled=false because a PDB template
|
||||
# rendered at `replicas: 1` blocks every rolling restart on a
|
||||
# single-node cluster. Production HA flips this to true alongside
|
||||
# server.replicas ≥ 2. See docs/operator/runbooks/ha.md.
|
||||
# ==============================================================================
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
|
||||
# ==============================================================================
|
||||
# Monitoring Configuration
|
||||
# ==============================================================================
|
||||
# Bundle 3 closure (D5): the ServiceMonitor template at
|
||||
# templates/servicemonitor.yaml renders when both monitoring.enabled=true
|
||||
# AND monitoring.serviceMonitor.enabled=true. The endpoint scrapes
|
||||
# /api/v1/metrics/prometheus, which is rbac-gated on `metrics.read` —
|
||||
# operators MUST provide a bearer token via
|
||||
# monitoring.serviceMonitor.bearerTokenSecret pointing at a Secret with
|
||||
# an API key holding that permission. Without the token, scrapes 401.
|
||||
monitoring:
|
||||
enabled: false
|
||||
# Prometheus ServiceMonitor
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
# Additional labels applied to the ServiceMonitor metadata.
|
||||
# labels: {}
|
||||
# Bearer-token Secret reference (required when the certctl server's
|
||||
# /api/v1/metrics/prometheus endpoint is gated by api-key auth).
|
||||
# Example:
|
||||
# bearerTokenSecret:
|
||||
# name: certctl-prometheus-key
|
||||
# key: api-key
|
||||
# bearerTokenSecret: {}
|
||||
# TLS config for the scrape endpoint. Acquisition-audit DEPL-004
|
||||
# closure (Sprint 6 ACQ, 2026-05-16): pre-Sprint-6 the default was
|
||||
# an implicit `insecureSkipVerify: true` (fell through the
|
||||
# template's else-branch). Post-Sprint-6 the default is a real
|
||||
# verify against the chart's CA at the canonical mount path the
|
||||
# existingSecret pattern produces (Prometheus mounts the
|
||||
# certctl-ca Secret as a volume at /etc/prometheus/secrets/
|
||||
# certctl-ca/). Operators whose Prometheus pod mounts the bundle
|
||||
# at a different path override `caFile` below; operators who
|
||||
# genuinely want skipVerify back can do so explicitly. Operators
|
||||
# who blank tlsConfig entirely (`tlsConfig: null` or
|
||||
# `tlsConfig: {}`) trip the `{{ fail }}` guard in
|
||||
# templates/servicemonitor.yaml at chart-render time — there is
|
||||
# no way to inherit the pre-Sprint-6 implicit-skipVerify behavior
|
||||
# silently.
|
||||
#
|
||||
# Production default (verify against the chart's CA):
|
||||
tlsConfig:
|
||||
caFile: /etc/prometheus/secrets/certctl-ca/ca.crt
|
||||
serverName: certctl-server
|
||||
#
|
||||
# Operator override — different CA mount path:
|
||||
# tlsConfig:
|
||||
# caFile: /path/to/your/ca.crt
|
||||
# serverName: your-cert-CN
|
||||
#
|
||||
# Operator override — demo / dev-cluster escape hatch
|
||||
# (operator-acknowledged unsafe):
|
||||
# tlsConfig:
|
||||
# insecureSkipVerify: true
|
||||
# Optional relabeling for the scrape job.
|
||||
# relabelings: []
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Phase 4 DEPL-L2 closure (2026-05-14): PrometheusRule (alert rules)
|
||||
#
|
||||
# Operator opt-in. Requires Prometheus Operator CRDs (the
|
||||
# `monitoring.coreos.com/v1` PrometheusRule kind) installed in
|
||||
# cluster. Without those CRDs the rendered object is rejected by
|
||||
# `kubectl apply` — keep enabled: false if you scrape with vanilla
|
||||
# Prometheus + AlertManager rules ConfigMap instead.
|
||||
#
|
||||
# Four starter rules ship out of the box (see
|
||||
# templates/prometheusrules.yaml for the full PromQL):
|
||||
#
|
||||
# CertctlCertificateExpiringSoon — certs expiring within 30d
|
||||
# CertctlAgentOffline — agent without heartbeat for >1h
|
||||
# CertctlJobFailureRateHigh — job-failure rate over 5% (15m)
|
||||
# CertctlIssuanceFailures — any issuance failures in last 15m
|
||||
#
|
||||
# All thresholds are operator-tunable via the `thresholds:` block
|
||||
# below. The defaults are tuned for the demo dataset (15 certs / 1
|
||||
# agent); production fleets with sustained renewal volume MAY want
|
||||
# to raise the expiringCertificateCount + jobFailureRate thresholds
|
||||
# to suppress steady-state noise.
|
||||
prometheusRules:
|
||||
enabled: false
|
||||
# Evaluation interval for the rule group.
|
||||
interval: 60s
|
||||
# Additional labels applied to the PrometheusRule metadata.
|
||||
# labels: {}
|
||||
# Per-alert threshold / duration tunables.
|
||||
thresholds:
|
||||
# Fire when more than N certs are in the expiring-soon window.
|
||||
expiringCertificateCount: 0
|
||||
expiringCertificateFor: 5m
|
||||
# Fire when more than N agents are offline (server - online).
|
||||
offlineAgentCount: 0
|
||||
offlineAgentFor: 1h
|
||||
# Fire when job failure rate exceeds this fraction (15m window).
|
||||
jobFailureRate: 0.05
|
||||
jobFailureRateFor: 15m
|
||||
# Fire when issuance failure rate exceeds this value (15m window).
|
||||
issuanceFailureRate: 0
|
||||
issuanceFailureFor: 15m
|
||||
|
||||
# ==============================================================================
|
||||
# Backup CronJob (Phase 4 DEPL-H2 closure, 2026-05-14)
|
||||
# ==============================================================================
|
||||
# Operator opt-in. Default OFF. The CronJob runs `pg_dump --format=custom
|
||||
# --no-owner --no-acl --dbname=certctl` matching the canonical shape
|
||||
# documented in docs/operator/runbooks/postgres-backup.md (so manual
|
||||
# and automated dumps are byte-identical) and ships the result to a
|
||||
# sink chosen below.
|
||||
#
|
||||
# DO NOT enable this for managed Postgres deployments (AWS RDS / GCP
|
||||
# Cloud SQL / Azure DB) — those have built-in PITR backup that this
|
||||
# CronJob cannot match. For in-cluster Postgres only.
|
||||
backup:
|
||||
enabled: false
|
||||
# Cron expression (UTC). Default: 02:30 UTC daily.
|
||||
schedule: "30 2 * * *"
|
||||
# Sink: "pvc" (default — dump lands on a PersistentVolumeClaim) or
|
||||
# "s3" (uploads via aws-cli — requires an image that bundles
|
||||
# aws-cli, see backup.image below).
|
||||
sink: pvc
|
||||
# Container image. The default postgres:16-alpine has pg_dump but
|
||||
# NOT aws-cli; for sink: s3 set this to an image that bundles both
|
||||
# (e.g. ghcr.io/your-org/postgres-aws:16) or override the Job's
|
||||
# command to install aws-cli at runtime.
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
# PVC sink config — used when sink: pvc.
|
||||
pvc:
|
||||
# Name of an existing PersistentVolumeClaim mounted at /backups
|
||||
# in the Job's pod. The PVC's storage class controls durability
|
||||
# and snapshot retention. Operator creates this PVC out of band
|
||||
# via their own storage policy.
|
||||
claimName: certctl-backups
|
||||
# S3 sink config — used when sink: s3.
|
||||
s3:
|
||||
# Target bucket (without s3:// prefix).
|
||||
bucket: ""
|
||||
# Object key prefix inside the bucket. Dumps land at
|
||||
# s3://<bucket>/<prefix>/certctl-<TIMESTAMP>.dump.
|
||||
prefix: certctl
|
||||
# AWS region (sets AWS_DEFAULT_REGION). Optional if the image's
|
||||
# AWS SDK can resolve the region another way (instance profile,
|
||||
# IRSA, etc.).
|
||||
region: ""
|
||||
# Secret holding AWS credentials. The IAM principal needs
|
||||
# s3:PutObject + s3:ListBucket on the target bucket only.
|
||||
credentialsSecret:
|
||||
name: certctl-backup-aws-creds
|
||||
accessKeyIdKey: AWS_ACCESS_KEY_ID
|
||||
secretAccessKeyKey: AWS_SECRET_ACCESS_KEY
|
||||
# Job housekeeping.
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
startingDeadlineSeconds: 300
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 3600
|
||||
# Resource budget for the backup container. pg_dump is generally
|
||||
# memory-light; ~250MB RSS for fleets up to 100K certs is typical.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
# Optional tolerations for the backup Job pod.
|
||||
tolerations: []
|
||||
|
||||
# ==============================================================================
|
||||
# Migrations via Helm hook (Phase 4 DEPL-M1 closure, 2026-05-14)
|
||||
# ==============================================================================
|
||||
# When viaHook: true, the chart deploys templates/migration-job.yaml as
|
||||
# a pre-install + pre-upgrade hook that runs `certctl-server
|
||||
# --migrate-only` (a hermetic schema-mutation pass) before the server
|
||||
# Deployment rolls.
|
||||
#
|
||||
# Set CERTCTL_MIGRATIONS_VIA_HOOK=true in the server Deployment env to
|
||||
# tell the server to skip its boot-time RunMigrations call (the hook
|
||||
# already did the work; running again at boot would race across
|
||||
# replicas during rollouts).
|
||||
#
|
||||
# Default OFF — when off, the server runs migrations at boot exactly
|
||||
# as it always has (Compose deploys keep this path).
|
||||
migrations:
|
||||
viaHook: false
|
||||
# Job housekeeping.
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 600
|
||||
# Resource budget for the migration Job pod. The migration pass is
|
||||
# I/O-bound on Postgres; matches the server's resource budget by
|
||||
# default. Override here if migrations on a large database need
|
||||
# more headroom than the steady-state server.
|
||||
# resources:
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# limits:
|
||||
# cpu: 500m
|
||||
# memory: 512Mi
|
||||
|
||||
# ==============================================================================
|
||||
# Network Policy (Bundle 3 closure / D11)
|
||||
# ==============================================================================
|
||||
# Default off so existing deploys don't suddenly lose network reach.
|
||||
# When enabled, restricts the server pod to:
|
||||
# - Ingress: from in-namespace agent pods only.
|
||||
# - Egress: kube-dns + bundled Postgres (if enabled).
|
||||
# Operators add CA / OIDC / SMTP egress via extraEgress.
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
# Additional Ingress rules merged into the policy. Each entry is a
|
||||
# raw networking.k8s.io/v1 NetworkPolicyIngressRule.
|
||||
extraIngress: []
|
||||
# Additional Egress rules merged into the policy. Common operator
|
||||
# need: 443/TCP to an OIDC issuer, 443/TCP to a public CA endpoint,
|
||||
# 25/TCP to an SMTP relay.
|
||||
# Example:
|
||||
# extraEgress:
|
||||
# - to:
|
||||
# - ipBlock:
|
||||
# cidr: 0.0.0.0/0
|
||||
# except:
|
||||
# - 10.0.0.0/8
|
||||
# ports:
|
||||
# - protocol: TCP
|
||||
# port: 443
|
||||
extraEgress: []
|
||||
|
||||
# ==============================================================================
|
||||
# Advanced Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Node affinity for server pods
|
||||
nodeAffinity: {}
|
||||
|
||||
# Pod affinity for server pods
|
||||
podAffinity: {}
|
||||
|
||||
# Pod anti-affinity for server pods (for HA)
|
||||
podAntiAffinity: {}
|
||||
# Example:
|
||||
# podAntiAffinity:
|
||||
# preferredDuringSchedulingIgnoredDuringExecution:
|
||||
# - weight: 100
|
||||
# podAffinityTerm:
|
||||
# labelSelector:
|
||||
# matchExpressions:
|
||||
# - key: app.kubernetes.io/name
|
||||
# operator: In
|
||||
# values:
|
||||
# - certctl
|
||||
# topologyKey: kubernetes.io/hostname
|
||||
|
||||
# Custom labels for all resources
|
||||
customLabels: {}
|
||||
|
||||
# Custom annotations for all resources
|
||||
customAnnotations: {}
|
||||
77
deploy/helm/examples/values-acme-dns01.yaml
Normal file
77
deploy/helm/examples/values-acme-dns01.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
# Certctl with ACME DNS-01 Challenge (Let's Encrypt)
|
||||
# Enables automatic certificate issuance from Let's Encrypt
|
||||
# using DNS-01 verification (wildcard-capable)
|
||||
|
||||
server:
|
||||
auth:
|
||||
type: api-key
|
||||
apiKey: "CHANGE_ME"
|
||||
|
||||
issuer:
|
||||
local:
|
||||
enabled: true
|
||||
|
||||
acme:
|
||||
enabled: true
|
||||
directoryURL: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: admin@example.com
|
||||
challengeType: dns-01
|
||||
dnsPresentScript: /scripts/dns-present.sh
|
||||
dnsCleanupScript: /scripts/dns-cleanup.sh
|
||||
dnsPropagationWait: 30s
|
||||
# For DNS-PERSIST-01 (standing validation record, no per-renewal updates):
|
||||
# challengeType: dns-persist-01
|
||||
# dnsPersistIssuerDomain: validation.example.com
|
||||
|
||||
# Mount DNS scripts as ConfigMap
|
||||
volumes:
|
||||
- name: dns-scripts
|
||||
configMap:
|
||||
name: dns-scripts
|
||||
defaultMode: 0755
|
||||
|
||||
volumeMounts:
|
||||
- name: dns-scripts
|
||||
mountPath: /scripts
|
||||
readOnly: true
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
storage:
|
||||
size: 20Gi
|
||||
|
||||
agent:
|
||||
enabled: true
|
||||
kind: DaemonSet
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: certctl.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
|
||||
---
|
||||
# You'll need to create the DNS scripts ConfigMap separately:
|
||||
#
|
||||
# kubectl create configmap dns-scripts \
|
||||
# --from-file=dns-present.sh=./scripts/dns-present.sh \
|
||||
# --from-file=dns-cleanup.sh=./scripts/dns-cleanup.sh
|
||||
#
|
||||
# Example dns-present.sh (Cloudflare):
|
||||
# #!/bin/bash
|
||||
# DOMAIN=$1
|
||||
# TOKEN=$2
|
||||
#
|
||||
# curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
|
||||
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
# -d "{\"type\":\"TXT\",\"name\":\"_acme-challenge.${DOMAIN}\",\"content\":\"${TOKEN}\"}"
|
||||
#
|
||||
# Example dns-cleanup.sh (Cloudflare):
|
||||
# #!/bin/bash
|
||||
# DOMAIN=$1
|
||||
#
|
||||
# curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
|
||||
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}"
|
||||
99
deploy/helm/examples/values-dev.yaml
Normal file
99
deploy/helm/examples/values-dev.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
# Certctl Development Configuration
|
||||
# Lightweight setup for development and testing
|
||||
# - Single server replica
|
||||
# - Small PostgreSQL storage
|
||||
# - Minimal resource limits
|
||||
# - No ingress or monitoring
|
||||
# - Demo auth mode (no API key required)
|
||||
|
||||
server:
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl
|
||||
pullPolicy: IfNotPresent # Use latest tag
|
||||
|
||||
port: 8443
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
auth:
|
||||
type: none # Demo mode - no authentication
|
||||
|
||||
logging:
|
||||
level: debug
|
||||
format: json
|
||||
|
||||
service:
|
||||
type: LoadBalancer # Easy external access for dev
|
||||
|
||||
issuer:
|
||||
local:
|
||||
enabled: true
|
||||
|
||||
rateLimiting:
|
||||
rps: 100
|
||||
burst: 200
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "16-alpine"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
password: "dev-password-change-me"
|
||||
|
||||
storage:
|
||||
size: 5Gi
|
||||
storageClass: "" # Use default storage class
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
agent:
|
||||
enabled: true
|
||||
kind: Deployment
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl-agent
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
|
||||
rbac:
|
||||
create: true
|
||||
|
||||
monitoring:
|
||||
enabled: false
|
||||
|
||||
customLabels:
|
||||
environment: development
|
||||
50
deploy/helm/examples/values-external-db.yaml
Normal file
50
deploy/helm/examples/values-external-db.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
# Certctl with External PostgreSQL Database
|
||||
# Use this when PostgreSQL is managed externally:
|
||||
# - AWS RDS
|
||||
# - Cloud SQL (Google Cloud)
|
||||
# - Azure Database for PostgreSQL
|
||||
# - Self-managed PostgreSQL server
|
||||
|
||||
server:
|
||||
replicas: 2
|
||||
|
||||
auth:
|
||||
type: api-key
|
||||
apiKey: "CHANGE_ME"
|
||||
|
||||
issuer:
|
||||
local:
|
||||
enabled: true
|
||||
|
||||
# Pass external database URL via environment variable
|
||||
env:
|
||||
CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@postgres.example.com:5432/certctl?sslmode=require"
|
||||
|
||||
# Disable internal PostgreSQL
|
||||
postgresql:
|
||||
enabled: false
|
||||
|
||||
agent:
|
||||
enabled: true
|
||||
kind: DaemonSet
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: certctl.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
|
||||
# For AWS RDS with IAM authentication:
|
||||
# env:
|
||||
# CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@mydb.123456789.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=require"
|
||||
|
||||
# For Google Cloud SQL:
|
||||
# env:
|
||||
# CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@/certctl?host=/cloudsql/PROJECT:REGION:INSTANCE&sslmode=require"
|
||||
|
||||
# For Azure Database:
|
||||
# env:
|
||||
# CERTCTL_DATABASE_URL: "postgres://certctl@servername:CHANGE_ME@servername.postgres.database.azure.com:5432/certctl?sslmode=require"
|
||||
175
deploy/helm/examples/values-prod-ha.yaml
Normal file
175
deploy/helm/examples/values-prod-ha.yaml
Normal file
@@ -0,0 +1,175 @@
|
||||
# Certctl Production HA Configuration
|
||||
# High availability deployment with:
|
||||
# - 3 server replicas with pod anti-affinity
|
||||
# - Large PostgreSQL storage
|
||||
# - Resource limits for production
|
||||
# - Prometheus monitoring
|
||||
# - Network policies enforcement
|
||||
|
||||
namespace: certctl
|
||||
|
||||
server:
|
||||
replicas: 3
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl
|
||||
tag: "2.1.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
port: 8443
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
|
||||
auth:
|
||||
type: api-key
|
||||
apiKey: "CHANGE_ME_IN_PRODUCTION" # Use --set or sealed-secrets
|
||||
|
||||
logging:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
# DEPL-006 closure (Sprint 3, 2026-05-16): with replicas:3, the
|
||||
# default round-robin Service load balancing breaks login/CSRF
|
||||
# flows because the session cookie + the CSRF token row land on
|
||||
# different pods between requests. sessionAffinity: ClientIP
|
||||
# routes every connection from a given source IP to the same
|
||||
# pod for the configured timeout window. docs/operator/runbooks/ha.md
|
||||
# documents this; pre-fix the chart did not actually render it.
|
||||
sessionAffinity: ClientIP
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8443"
|
||||
prometheus.io/path: "/api/v1/metrics/prometheus"
|
||||
|
||||
issuer:
|
||||
local:
|
||||
enabled: true
|
||||
acme:
|
||||
enabled: true
|
||||
directoryURL: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: admin@example.com
|
||||
challengeType: dns-01
|
||||
|
||||
rateLimiting:
|
||||
rps: 500
|
||||
burst: 1000
|
||||
# DEPL-006 closure (Sprint 3, 2026-05-16): replicas > 1 REQUIRES
|
||||
# the postgres backend so per-key buckets are cross-replica-
|
||||
# consistent. The default 'memory' backend gives each pod its
|
||||
# own bucket map, so a 3-replica fleet effectively triples the
|
||||
# configured cap (a client churning across pods bypasses the
|
||||
# limit). See deploy/helm/certctl/values.yaml L217-226 for the
|
||||
# canonical comment.
|
||||
backend: postgres
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "16-alpine"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
auth:
|
||||
database: certctl
|
||||
username: certctl
|
||||
password: "CHANGE_ME_IN_PRODUCTION" # Use --set or sealed-secrets
|
||||
|
||||
storage:
|
||||
size: 100Gi
|
||||
storageClass: "fast-ssd" # Use your high-performance storage class
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
|
||||
agent:
|
||||
enabled: true
|
||||
kind: DaemonSet
|
||||
|
||||
image:
|
||||
repository: ghcr.io/certctl-io/certctl-agent
|
||||
tag: "2.1.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
|
||||
discoveryDirs: "/etc/ssl/certs,/etc/pki/tls,/etc/ssl"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
hosts:
|
||||
- host: certctl.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: certctl-tls
|
||||
hosts:
|
||||
- certctl.example.com
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations:
|
||||
eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/certctl-role # For IRSA on AWS
|
||||
|
||||
rbac:
|
||||
create: true
|
||||
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 2
|
||||
|
||||
monitoring:
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
|
||||
# Pod anti-affinity for HA
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values:
|
||||
- certctl
|
||||
- key: app.kubernetes.io/component
|
||||
operator: In
|
||||
values:
|
||||
- server
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
customLabels:
|
||||
environment: production
|
||||
team: platform
|
||||
cost-center: ops
|
||||
|
||||
customAnnotations:
|
||||
slack-alerts: "#ops"
|
||||
backup-policy: daily
|
||||
24
deploy/test/acme-integration/cert-manager-install.sh
Executable file
24
deploy/test/acme-integration/cert-manager-install.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Phase 5 — install cert-manager 1.15.0 into the kind cluster brought
|
||||
# up by kind-config.yaml. Idempotent: re-running waits for the
|
||||
# existing deployment to be Ready instead of reinstalling.
|
||||
#
|
||||
# Called from: deploy/test/acme-integration/certmanager_test.go
|
||||
# Standalone: bash deploy/test/acme-integration/cert-manager-install.sh
|
||||
set -euo pipefail
|
||||
|
||||
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.15.0}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
|
||||
echo "Installing cert-manager ${CERT_MANAGER_VERSION}..."
|
||||
${KUBECTL} apply -f \
|
||||
"https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
|
||||
|
||||
echo "Waiting for cert-manager controller to be Ready (timeout 5m)..."
|
||||
${KUBECTL} -n cert-manager wait --for=condition=Available --timeout=5m \
|
||||
deployment/cert-manager \
|
||||
deployment/cert-manager-cainjector \
|
||||
deployment/cert-manager-webhook
|
||||
|
||||
echo "cert-manager ${CERT_MANAGER_VERSION} ready."
|
||||
20
deploy/test/acme-integration/certificate-test.yaml
Normal file
20
deploy/test/acme-integration/certificate-test.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Phase 5 — Certificate resource the integration test applies and
|
||||
# waits for. The certctl-test-trust ClusterIssuer (trust_authenticated
|
||||
# mode) issues the cert without any solver round-trip; the resulting
|
||||
# Secret 'test-com-tls' is asserted to carry tls.crt + tls.key.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: test-com
|
||||
namespace: default
|
||||
spec:
|
||||
secretName: test-com-tls
|
||||
commonName: test.example.com
|
||||
dnsNames:
|
||||
- test.example.com
|
||||
- www.test.example.com
|
||||
issuerRef:
|
||||
name: certctl-test-trust
|
||||
kind: ClusterIssuer
|
||||
duration: 720h # 30d
|
||||
renewBefore: 240h # 10d
|
||||
167
deploy/test/acme-integration/certmanager_test.go
Normal file
167
deploy/test/acme-integration/certmanager_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
//go:build integration
|
||||
|
||||
// Phase 5 — kind-driven cert-manager integration test. Verifies the
|
||||
// certctl ACME server end-to-end against a real cert-manager 1.15+
|
||||
// deployment in a kind cluster. The test sequences:
|
||||
//
|
||||
// 1. Bring up the kind cluster (kind-config.yaml).
|
||||
// 2. Install cert-manager 1.15 (cert-manager-install.sh).
|
||||
// 3. Helm-install certctl-server with acmeServer.enabled=true.
|
||||
// 4. Apply the ClusterIssuer + Certificate.
|
||||
// 5. Wait for the Certificate to become Ready.
|
||||
// 6. Assert the Secret has tls.crt + tls.key.
|
||||
//
|
||||
// Gated behind KIND_AVAILABLE — CI doesn't run kind and skips this
|
||||
// cleanly. Operators run locally via `make acme-cert-manager-test`.
|
||||
|
||||
package acmeintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// kindAvailable returns true when the operator opted into the kind-
|
||||
// driven test path. CI default is opt-out (env unset → skip).
|
||||
func kindAvailable() bool {
|
||||
return os.Getenv("KIND_AVAILABLE") != ""
|
||||
}
|
||||
|
||||
// kindClusterName is the name passed to `kind create/delete cluster`.
|
||||
// Kept as a const so the test cleanup uses the exact same name as
|
||||
// setup (avoid orphan-cluster-after-flake).
|
||||
const kindClusterName = "certctl-acme-test"
|
||||
|
||||
// TestCertManagerTrustAuthenticatedIssuance is the happy-path
|
||||
// integration: cert-manager submits a new-order against a profile in
|
||||
// trust_authenticated mode; certctl auto-resolves authzs (no solver
|
||||
// round-trip in this mode); cert-manager finalizes; the Secret lands.
|
||||
//
|
||||
// Runtime: ~6-8 minutes wall-clock on a workstation (most of which is
|
||||
// kind-create + cert-manager-controller-bootstrap, both cached on
|
||||
// re-runs after the first). Skips cleanly when KIND_AVAILABLE is
|
||||
// unset.
|
||||
func TestCertManagerTrustAuthenticatedIssuance(t *testing.T) {
|
||||
if !kindAvailable() {
|
||||
t.Skip("KIND_AVAILABLE unset — kind-driven cert-manager integration test skipped")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Log("creating kind cluster")
|
||||
runCmd(t, ctx, "kind", "create", "cluster",
|
||||
"--name", kindClusterName,
|
||||
"--config", "kind-config.yaml")
|
||||
t.Cleanup(func() {
|
||||
// Best-effort cluster teardown — never fail the test on cleanup
|
||||
// failure (operator can `kind delete cluster` manually).
|
||||
_ = exec.Command("kind", "delete", "cluster", "--name", kindClusterName).Run()
|
||||
})
|
||||
|
||||
t.Log("installing cert-manager")
|
||||
runCmd(t, ctx, "bash", "cert-manager-install.sh")
|
||||
|
||||
// Step 3 — deploy certctl-server. The Helm chart at
|
||||
// deploy/helm/certctl/ takes acmeServer.enabled=true; the operator
|
||||
// is expected to have built + pushed (or kind-loaded) a `:test`
|
||||
// image tag before the test runs. Document this in docs/acme-server.md.
|
||||
t.Log("helm-installing certctl-test")
|
||||
runCmd(t, ctx, "helm", "install", "certctl-test", "../../helm/certctl/",
|
||||
"--set", "acmeServer.enabled=true",
|
||||
"--set", "acmeServer.defaultProfileId=prof-test",
|
||||
"--set", "image.tag=test",
|
||||
)
|
||||
waitForDeploymentReady(t, ctx, "default", "certctl-test", 3*time.Minute)
|
||||
|
||||
t.Log("applying ClusterIssuer + Certificate")
|
||||
runCmd(t, ctx, "kubectl", "apply", "-f", "clusterissuer-trust-authenticated.yaml")
|
||||
runCmd(t, ctx, "kubectl", "apply", "-f", "certificate-test.yaml")
|
||||
|
||||
t.Log("waiting for Certificate to become Ready")
|
||||
waitForCertificateReady(t, ctx, "default", "test-com", 3*time.Minute)
|
||||
|
||||
t.Log("asserting Secret has tls.crt")
|
||||
assertSecretHasCert(t, ctx, "default", "test-com-tls")
|
||||
|
||||
t.Log("happy-path issuance verified end-to-end")
|
||||
}
|
||||
|
||||
// runCmd runs the command; failures fail the test immediately. We
|
||||
// stream combined stdout+stderr to t.Log on completion so the operator
|
||||
// can read the kubectl/kind output in CI logs (when run there with
|
||||
// KIND_AVAILABLE=1).
|
||||
func runCmd(t *testing.T, ctx context.Context, name string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out)
|
||||
}
|
||||
t.Logf("%s %s: %s", name, strings.Join(args, " "), strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// waitForDeploymentReady polls until the named deployment reports
|
||||
// Available=True. Wraps `kubectl wait` with a Go-level timeout so test
|
||||
// hangs are bounded.
|
||||
func waitForDeploymentReady(t *testing.T, ctx context.Context, namespace, name string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "wait",
|
||||
"--for=condition=Available", fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())),
|
||||
"deployment/"+name) //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("deployment %s/%s did not become Ready in %v: %v\n%s",
|
||||
namespace, name, timeout, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForCertificateReady polls until the cert-manager Certificate
|
||||
// resource transitions to Ready=True. cert-manager's own
|
||||
// reconciliation loop is what advances the state; this just blocks
|
||||
// until the controller is happy.
|
||||
func waitForCertificateReady(t *testing.T, ctx context.Context, namespace, name string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "wait",
|
||||
"--for=condition=Ready", fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())),
|
||||
"certificate/"+name) //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Dump the Certificate's events on failure so the operator
|
||||
// can see exactly which reconciliation step failed.
|
||||
describe := exec.Command("kubectl", "-n", namespace, "describe", "certificate", name)
|
||||
describeOut, _ := describe.CombinedOutput()
|
||||
t.Fatalf("certificate %s/%s did not become Ready in %v: %v\n%s\n--- describe ---\n%s",
|
||||
namespace, name, timeout, err, out, describeOut)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSecretHasCert checks that the named Secret has a non-empty
|
||||
// tls.crt entry. We don't validate the chain itself here — that's the
|
||||
// job of certctl's own integration test layer; this just confirms
|
||||
// cert-manager wrote something into the Secret on the
|
||||
// trust_authenticated happy-path.
|
||||
func assertSecretHasCert(t *testing.T, ctx context.Context, namespace, name string) {
|
||||
t.Helper()
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "kubectl", "-n", namespace, "get", "secret", name,
|
||||
"-o", "jsonpath={.data.tls\\.crt}") //nolint:gosec // ARGS are test-controlled literals.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("get secret %s/%s: %v\n%s", namespace, name, err, out)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("secret %s/%s has empty tls.crt", namespace, name)
|
||||
}
|
||||
}
|
||||
31
deploy/test/acme-integration/clusterissuer-challenge.yaml
Normal file
31
deploy/test/acme-integration/clusterissuer-challenge.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# Phase 5 — sample ClusterIssuer for the certctl challenge auth mode
|
||||
# (RFC 8555 §8 HTTP-01 / DNS-01 / TLS-ALPN-01). Use this for public-
|
||||
# trust-style deployments where per-identifier ownership proof is
|
||||
# required.
|
||||
#
|
||||
# Same bootstrap-root caBundle requirement as the trust_authenticated
|
||||
# variant — see clusterissuer-trust-authenticated.yaml comments.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: certctl-test-challenge
|
||||
spec:
|
||||
acme:
|
||||
email: test@example.com
|
||||
# Point at a profile whose certificate_profiles.acme_auth_mode is
|
||||
# set to 'challenge'. The certctl operator manages this column
|
||||
# per-profile; see certctl/docs/acme-server.md "Per-profile auth
|
||||
# mode" section.
|
||||
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-challenge/directory
|
||||
caBundle: |
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
|
||||
privateKeySecretRef:
|
||||
name: certctl-test-challenge-account-key
|
||||
solvers:
|
||||
# HTTP-01 via the in-cluster ingress-nginx. The cert-manager
|
||||
# http-solver pod publishes the key authorization at
|
||||
# http://<identifier>/.well-known/acme-challenge/<token>; the
|
||||
# certctl HTTP01Validator (Phase 3) fetches it.
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
@@ -0,0 +1,42 @@
|
||||
# Phase 5 — sample ClusterIssuer for the certctl trust_authenticated
|
||||
# auth mode (RFC 8555 §6 + certctl auth_mode=trust_authenticated, where
|
||||
# the JWS-authenticated ACME account is trusted to issue any identifier
|
||||
# the profile policy permits — no per-identifier ownership challenges).
|
||||
#
|
||||
# Use this as the starting template for any internal-PKI rollout.
|
||||
# Replace the caBundle placeholder with the base64-encoded PEM of the
|
||||
# certctl-server's self-signed bootstrap root, then `kubectl apply`.
|
||||
#
|
||||
# Generate the caBundle via:
|
||||
# cat deploy/test/certs/ca.crt | base64 -w0
|
||||
# (See certctl/docs/acme-server.md "TLS trust bootstrap" section for the
|
||||
# end-to-end walkthrough — this is the single biggest first-time-deploy
|
||||
# footgun on cert-manager, captured as audit fix #9.)
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: certctl-test-trust
|
||||
spec:
|
||||
acme:
|
||||
email: test@example.com
|
||||
# Replace 'certctl-test' with your release name + adjust the
|
||||
# profile path segment. Default profile path:
|
||||
# https://<service>.<namespace>.svc.cluster.local:8443/acme/profile/<profile-id>/directory
|
||||
server: https://certctl-test.default.svc.cluster.local:8443/acme/profile/prof-test/directory
|
||||
# caBundle: Audit fix #9. cert-manager validates the ACME server's
|
||||
# TLS chain before submitting any account/order/finalize. With a
|
||||
# self-signed bootstrap root, the ClusterIssuer MUST carry the root
|
||||
# explicitly via this field.
|
||||
caBundle: |
|
||||
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
|
||||
privateKeySecretRef:
|
||||
name: certctl-test-trust-account-key
|
||||
solvers:
|
||||
# In trust_authenticated mode the solver is unused at the
|
||||
# validation step but cert-manager still requires at least one
|
||||
# solver in the spec. http01-via-ingress-nginx is the cheapest
|
||||
# placeholder shape that round-trips correctly through cert-
|
||||
# manager's validation webhooks.
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user