Skip to content

Commit 0bd982a

Browse files
agnos: init at 0.1.0, nixos/agnos: init (#351678)
2 parents 5449da7 + 7757648 commit 0bd982a

File tree

5 files changed

+558
-0
lines changed

5 files changed

+558
-0
lines changed

nixos/modules/module-list.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@
361361
./programs/zsh/zsh.nix
362362
./rename.nix
363363
./security/acme
364+
./security/agnos.nix
364365
./security/apparmor.nix
365366
./security/audit.nix
366367
./security/auditd.nix

nixos/modules/security/agnos.nix

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
{
2+
config,
3+
lib,
4+
pkgs,
5+
...
6+
}:
7+
let
8+
cfg = config.security.agnos;
9+
format = pkgs.formats.toml { };
10+
name = "agnos";
11+
stateDir = "/var/lib/${name}";
12+
13+
accountType =
14+
let
15+
inherit (lib) types mkOption;
16+
in
17+
types.submodule {
18+
freeformType = format.type;
19+
20+
options = {
21+
email = mkOption {
22+
type = types.str;
23+
description = ''
24+
Email associated with this account.
25+
'';
26+
};
27+
private_key_path = mkOption {
28+
type = types.str;
29+
description = ''
30+
Path of the PEM-encoded private key for this account.
31+
Currently, only RSA keys are supported.
32+
33+
If this path does not exist, then the behavior depends on `generateKeys.enable`.
34+
When this option is `true`,
35+
the key will be automatically generated and saved to this path.
36+
When it is `false`, agnos will fail.
37+
38+
If a relative path is specified,
39+
the key will be looked up (or generated and saved to) under `${stateDir}`.
40+
'';
41+
};
42+
certificates = mkOption {
43+
type = types.listOf certificateType;
44+
description = ''
45+
Certificates for agnos to issue or renew.
46+
'';
47+
};
48+
};
49+
};
50+
51+
certificateType =
52+
let
53+
inherit (lib) types literalExpression mkOption;
54+
in
55+
types.submodule {
56+
freeformType = format.type;
57+
58+
options = {
59+
domains = mkOption {
60+
type = types.listOf types.str;
61+
description = ''
62+
Domains the certificate represents
63+
'';
64+
example = literalExpression ''["a.example.com", "b.example.com", "*b.example.com"]'';
65+
};
66+
fullchain_output_file = mkOption {
67+
type = types.str;
68+
description = ''
69+
Output path for the full chain including the acquired certificate.
70+
If a relative path is specified, the file will be created in `${stateDir}`.
71+
'';
72+
};
73+
key_output_file = mkOption {
74+
type = types.str;
75+
description = ''
76+
Output path for the certificate private key.
77+
If a relative path is specified, the file will be created in `${stateDir}`.
78+
'';
79+
};
80+
};
81+
};
82+
in
83+
{
84+
options.security.agnos =
85+
let
86+
inherit (lib) types mkEnableOption mkOption;
87+
in
88+
{
89+
enable = mkEnableOption name;
90+
91+
settings = mkOption {
92+
description = "Settings";
93+
type = types.submodule {
94+
freeformType = format.type;
95+
96+
options = {
97+
dns_listen_addr = mkOption {
98+
type = types.str;
99+
default = "0.0.0.0:53";
100+
description = ''
101+
Address for agnos to listen on.
102+
Note that this needs to be reachable by the outside world,
103+
and 53 is required in most situations
104+
since `NS` records do not allow specifying the port.
105+
'';
106+
};
107+
108+
accounts = mkOption {
109+
type = types.listOf accountType;
110+
description = ''
111+
A list of ACME accounts.
112+
Each account is associated with an email address
113+
and can be used to obtain an arbitrary amount of certificate
114+
(subject to provider's rate limits,
115+
see e.g. [Let's Encrypt Rate Limits](https://letsencrypt.org/docs/rate-limits/)).
116+
'';
117+
};
118+
};
119+
};
120+
};
121+
122+
generateKeys = {
123+
enable = mkOption {
124+
type = types.bool;
125+
default = false;
126+
description = ''
127+
Enable automatic generation of account keys.
128+
129+
When this is `true`, a key will be generated for each account where
130+
the file referred to by the `private_key` path does not exist yet.
131+
132+
Currently, only RSA keys can be generated.
133+
'';
134+
};
135+
136+
keySize = mkOption {
137+
type = types.int;
138+
default = 4096;
139+
description = ''
140+
Key size in bits to use when generating new keys.
141+
'';
142+
};
143+
};
144+
145+
server = mkOption {
146+
type = types.nullOr types.str;
147+
default = null;
148+
description = ''
149+
ACME Directory Resource URI. Defaults to Let's Encrypt's production endpoint,
150+
`https://acme-v02.api.letsencrypt.org/directory`, if unset.
151+
'';
152+
};
153+
154+
serverCa = mkOption {
155+
type = types.nullOr types.path;
156+
default = null;
157+
description = ''
158+
The root certificate (in PEM format) of the ACME server's HTTPS interface.
159+
'';
160+
};
161+
162+
persistent = mkOption {
163+
type = types.bool;
164+
default = true;
165+
description = ''
166+
When `true`, use a persistent systemd timer.
167+
'';
168+
};
169+
170+
startAt = mkOption {
171+
type = types.either types.str (types.listOf types.str);
172+
default = "daily";
173+
example = "02:00";
174+
description = ''
175+
How often or when to run agnos.
176+
177+
The format is described in
178+
{manpage}`systemd.time(7)`.
179+
'';
180+
};
181+
182+
temporarilyOpenFirewall = mkOption {
183+
type = types.bool;
184+
default = false;
185+
description = ''
186+
When `true`, will open the port specified in `settings.dns_listen_addr`
187+
before running the agnos service, and close it when agnos finishes running.
188+
'';
189+
};
190+
191+
group = mkOption {
192+
type = types.str;
193+
default = name;
194+
description = ''
195+
Group to run Agnos as. The acquired certificates will be owned by this group.
196+
'';
197+
};
198+
199+
user = mkOption {
200+
type = types.str;
201+
default = name;
202+
description = ''
203+
User to run Agnos as. The acquired certificates will be owned by this user.
204+
'';
205+
};
206+
};
207+
208+
config =
209+
let
210+
configFile = format.generate "agnos.toml" cfg.settings;
211+
port = lib.toInt (lib.last (builtins.split ":" cfg.settings.dns_listen_addr));
212+
213+
useNftables = config.networking.nftables.enable;
214+
215+
# nftables implementation for temporarilyOpenFirewall
216+
nftablesSetup = pkgs.writeShellScript "agnos-fw-setup" ''
217+
${lib.getExe pkgs.nftables} add element inet nixos-fw temp-ports "{ tcp . ${toString port} }"
218+
${lib.getExe pkgs.nftables} add element inet nixos-fw temp-ports "{ udp . ${toString port} }"
219+
'';
220+
nftablesTeardown = pkgs.writeShellScript "agnos-fw-teardown" ''
221+
${lib.getExe pkgs.nftables} delete element inet nixos-fw temp-ports "{ tcp . ${toString port} }"
222+
${lib.getExe pkgs.nftables} delete element inet nixos-fw temp-ports "{ udp . ${toString port} }"
223+
'';
224+
225+
# iptables implementation for temporarilyOpenFirewall
226+
helpers = ''
227+
function ip46tables() {
228+
${lib.getExe' pkgs.iptables "iptables"} -w "$@"
229+
${lib.getExe' pkgs.iptables "ip6tables"} -w "$@"
230+
}
231+
'';
232+
fwFilter = ''--dport ${toString port} -j ACCEPT -m comment --comment "agnos"'';
233+
iptablesSetup = pkgs.writeShellScript "agnos-fw-setup" ''
234+
${helpers}
235+
ip46tables -I INPUT 1 -p tcp ${fwFilter}
236+
ip46tables -I INPUT 1 -p udp ${fwFilter}
237+
'';
238+
iptablesTeardown = pkgs.writeShellScript "agnos-fw-setup" ''
239+
${helpers}
240+
ip46tables -D INPUT -p tcp ${fwFilter}
241+
ip46tables -D INPUT -p udp ${fwFilter}
242+
'';
243+
in
244+
lib.mkIf cfg.enable {
245+
assertions = [
246+
{
247+
assertion = !cfg.temporarilyOpenFirewall || config.networking.firewall.enable;
248+
message = "temporarilyOpenFirewall is only useful when firewall is enabled";
249+
}
250+
];
251+
252+
systemd.services.agnos = {
253+
serviceConfig = {
254+
ExecStartPre =
255+
lib.optional cfg.generateKeys.enable ''
256+
${pkgs.agnos}/bin/agnos-generate-accounts-keys \
257+
--no-confirm \
258+
--key-size ${toString cfg.generateKeys.keySize} \
259+
${configFile}
260+
''
261+
++ lib.optional cfg.temporarilyOpenFirewall (
262+
"+" + (if useNftables then nftablesSetup else iptablesSetup)
263+
);
264+
ExecStopPost = lib.optional cfg.temporarilyOpenFirewall (
265+
"+" + (if useNftables then nftablesTeardown else iptablesTeardown)
266+
);
267+
ExecStart = ''
268+
${pkgs.agnos}/bin/agnos \
269+
${if cfg.server != null then "--acme-url=${cfg.server}" else "--no-staging"} \
270+
${lib.optionalString (cfg.serverCa != null) "--acme-serv-ca=${cfg.serverCa}"} \
271+
${configFile}
272+
'';
273+
Type = "oneshot";
274+
User = cfg.user;
275+
Group = cfg.group;
276+
StateDirectory = name;
277+
StateDirectoryMode = "0750";
278+
WorkingDirectory = "${stateDir}";
279+
280+
# Allow binding privileged ports if necessary
281+
CapabilityBoundingSet = lib.mkIf (port < 1024) [ "CAP_NET_BIND_SERVICE" ];
282+
AmbientCapabilities = lib.mkIf (port < 1024) [ "CAP_NET_BIND_SERVICE" ];
283+
};
284+
285+
after = [
286+
"firewall.target"
287+
"network-online.target"
288+
"nftables.service"
289+
];
290+
wants = [ "network-online.target" ];
291+
};
292+
293+
systemd.timers.agnos = {
294+
timerConfig = {
295+
OnCalendar = cfg.startAt;
296+
Persistent = cfg.persistent;
297+
Unit = "agnos.service";
298+
};
299+
wantedBy = [ "timers.target" ];
300+
};
301+
302+
users.groups = lib.mkIf (cfg.group == name) {
303+
${cfg.group} = { };
304+
};
305+
306+
users.users = lib.mkIf (cfg.user == name) {
307+
${cfg.user} = {
308+
isSystemUser = true;
309+
description = "Agnos service user";
310+
group = cfg.group;
311+
};
312+
};
313+
};
314+
}

0 commit comments

Comments
 (0)