1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 inherit (lib) types;
9 jsonFormat = pkgs.formats.json { };
10
11 cfg = config.services.anubis;
12 enabledInstances = lib.filterAttrs (_: conf: conf.enable) cfg.instances;
13 instanceName = name: if name == "" then "anubis" else "anubis-${name}";
14
15 commonSubmodule =
16 isDefault:
17 let
18 mkDefaultOption =
19 path: opts:
20 lib.mkOption (
21 opts
22 // lib.optionalAttrs (!isDefault && opts ? default) {
23 default =
24 lib.attrByPath (lib.splitString "." path)
25 (throw "This is a bug in the Anubis module. Please report this as an issue.")
26 cfg.defaultOptions;
27 defaultText = lib.literalExpression "config.services.anubis.defaultOptions.${path}";
28 }
29 );
30 in
31 { name, ... }:
32 {
33 options = {
34 enable = lib.mkEnableOption "this instance of Anubis" // {
35 default = true;
36 };
37 user = mkDefaultOption "user" {
38 default = "anubis";
39 description = ''
40 The user under which Anubis is run.
41
42 This module utilizes systemd's DynamicUser feature. See the corresponding section in
43 {manpage}`systemd.exec(5)` for more details.
44 '';
45 type = types.str;
46 };
47 group = mkDefaultOption "group" {
48 default = "anubis";
49 description = ''
50 The group under which Anubis is run.
51
52 This module utilizes systemd's DynamicUser feature. See the corresponding section in
53 {manpage}`systemd.exec(5)` for more details.
54 '';
55 type = types.str;
56 };
57
58 botPolicy = lib.mkOption {
59 default = null;
60 description = ''
61 Anubis policy configuration in Nix syntax. Set to `null` to use the baked-in policy which should be
62 sufficient for most use-cases.
63
64 This option has no effect if `settings.POLICY_FNAME` is set to a different value, which is useful for
65 importing an existing configuration.
66
67 See [the documentation](https://anubis.techaro.lol/docs/admin/policies) for details.
68 '';
69 type = types.nullOr jsonFormat.type;
70 };
71
72 extraFlags = mkDefaultOption "extraFlags" {
73 default = [ ];
74 description = "A list of extra flags to be passed to Anubis.";
75 example = [ "-metrics-bind \"\"" ];
76 type = types.listOf types.str;
77 };
78
79 settings = lib.mkOption {
80 default = { };
81 description = ''
82 Freeform configuration via environment variables for Anubis.
83
84 See [the documentation](https://anubis.techaro.lol/docs/admin/installation) for a complete list of
85 available environment variables.
86 '';
87 type = types.submodule [
88 {
89 freeformType =
90 with types;
91 attrsOf (
92 nullOr (oneOf [
93 str
94 int
95 bool
96 ])
97 );
98
99 options = {
100 # BIND and METRICS_BIND are defined in instance specific options, since global defaults don't make sense
101 BIND_NETWORK = mkDefaultOption "settings.BIND_NETWORK" {
102 default = "unix";
103 description = ''
104 The network family that Anubis should bind to.
105
106 Accepts anything supported by Go's [`net.Listen`](https://pkg.go.dev/net#Listen).
107
108 Common values are `tcp` and `unix`.
109 '';
110 example = "tcp";
111 type = types.str;
112 };
113 METRICS_BIND_NETWORK = mkDefaultOption "settings.METRICS_BIND_NETWORK" {
114 default = "unix";
115 description = ''
116 The network family that the metrics server should bind to.
117
118 Accepts anything supported by Go's [`net.Listen`](https://pkg.go.dev/net#Listen).
119
120 Common values are `tcp` and `unix`.
121 '';
122 example = "tcp";
123 type = types.str;
124 };
125 DIFFICULTY = mkDefaultOption "settings.DIFFICULTY" {
126 default = 4;
127 description = ''
128 The difficulty required for clients to solve the challenge.
129
130 Currently, this means the amount of leading zeros in a successful response.
131 '';
132 type = types.int;
133 example = 5;
134 };
135 SERVE_ROBOTS_TXT = mkDefaultOption "settings.SERVE_ROBOTS_TXT" {
136 default = false;
137 description = ''
138 Whether to serve a default robots.txt that denies access to common AI bots by name and all other
139 bots by wildcard.
140 '';
141 type = types.bool;
142 };
143 OG_PASSTHROUGH = mkDefaultOption "settings.OG_PASSTHROUGH" {
144 default = false;
145 description = ''
146 Whether to enable Open Graph tag passthrough.
147
148 This enables social previews of resources protected by
149 Anubis without having to exempt each scraper individually.
150 '';
151 type = types.bool;
152 };
153 WEBMASTER_EMAIL = mkDefaultOption "settings.WEBMASTER_EMAIL" {
154 default = null;
155 description = ''
156 If set, shows a contact email address when rendering error pages.
157
158 This email address will be how users can get in contact with administrators.
159 '';
160 example = "alice@example.com";
161 type = types.nullOr types.str;
162 };
163
164 # generated by default
165 POLICY_FNAME = mkDefaultOption "settings.POLICY_FNAME" {
166 default = null;
167 description = ''
168 The bot policy file to use. Leave this as `null` to respect the value set in
169 {option}`services.anubis.instances.<name>.botPolicy`.
170 '';
171 type = types.nullOr types.path;
172 };
173 };
174 }
175 (lib.optionalAttrs (!isDefault) (instanceSpecificOptions name))
176 ];
177 };
178 };
179 };
180
181 instanceSpecificOptions = name: {
182 options = {
183 # see other options above
184 BIND = lib.mkOption {
185 default = "/run/anubis/${instanceName name}.sock";
186 description = ''
187 The address that Anubis listens to. See Go's [`net.Listen`](https://pkg.go.dev/net#Listen) for syntax.
188
189 Defaults to Unix domain sockets. To use TCP sockets, set this to a TCP address and `BIND_NETWORK` to `"tcp"`.
190 '';
191 example = ":8080";
192 type = types.str;
193 };
194 METRICS_BIND = lib.mkOption {
195 default = "/run/anubis/${instanceName name}-metrics.sock";
196 description = ''
197 The address Anubis' metrics server listens to. See Go's [`net.Listen`](https://pkg.go.dev/net#Listen) for
198 syntax.
199
200 The metrics server is enabled by default and may be disabled. However, due to implementation details, this is
201 only possible by setting a command line flag. See {option}`services.anubis.defaultOptions.extraFlags` for an
202 example.
203
204 Defaults to Unix domain sockets. To use TCP sockets, set this to a TCP address and `METRICS_BIND_NETWORK` to
205 `"tcp"`.
206 '';
207 example = "127.0.0.1:8081";
208 type = types.str;
209 };
210 TARGET = lib.mkOption {
211 description = ''
212 The reverse proxy target that Anubis is protecting. This is a required option.
213
214 The usage of Unix domain sockets is supported by the following syntax: `unix:///path/to/socket.sock`.
215 '';
216 example = "http://127.0.0.1:8000";
217 type = types.str;
218 };
219 };
220 };
221in
222{
223 options.services.anubis = {
224 package = lib.mkPackageOption pkgs "anubis" { };
225
226 defaultOptions = lib.mkOption {
227 default = { };
228 description = "Default options for all instances of Anubis.";
229 type = types.submodule (commonSubmodule true);
230 };
231
232 instances = lib.mkOption {
233 default = { };
234 description = ''
235 An attribute set of Anubis instances.
236
237 The attribute name may be an empty string, in which case the `-<name>` suffix is not added to the service name
238 and socket paths.
239 '';
240 type = types.attrsOf (types.submodule (commonSubmodule false));
241
242 # Merge defaultOptions into each instance
243 apply = lib.mapAttrs (_: lib.recursiveUpdate cfg.defaultOptions);
244 };
245 };
246
247 config = lib.mkIf (enabledInstances != { }) {
248 users.users = lib.mkIf (cfg.defaultOptions.user == "anubis") {
249 anubis = {
250 isSystemUser = true;
251 group = cfg.defaultOptions.group;
252 };
253 };
254
255 users.groups = lib.mkIf (cfg.defaultOptions.group == "anubis") {
256 anubis = { };
257 };
258
259 systemd.services = lib.mapAttrs' (
260 name: instance:
261 lib.nameValuePair "${instanceName name}" {
262 description = "Anubis (${if name == "" then "default" else name} instance)";
263 wantedBy = [ "multi-user.target" ];
264 after = [ "network-online.target" ];
265 wants = [ "network-online.target" ];
266
267 environment = lib.mapAttrs (lib.const (lib.generators.mkValueStringDefault { })) (
268 lib.filterAttrs (_: v: v != null) instance.settings
269 );
270
271 serviceConfig = {
272 User = instance.user;
273 Group = instance.group;
274 DynamicUser = true;
275
276 ExecStart = lib.concatStringsSep " " (
277 (lib.singleton (lib.getExe cfg.package)) ++ instance.extraFlags
278 );
279 RuntimeDirectory =
280 if
281 lib.any (lib.hasPrefix "/run/anubis") (
282 with instance.settings;
283 [
284 BIND
285 METRICS_BIND
286 ]
287 )
288 then
289 "anubis"
290 else
291 null;
292
293 # hardening
294 NoNewPrivileges = true;
295 CapabilityBoundingSet = null;
296 SystemCallFilter = [
297 "@system-service"
298 "~@privileged"
299 ];
300 SystemCallArchitectures = "native";
301 MemoryDenyWriteExecute = true;
302
303 PrivateUsers = true;
304 PrivateTmp = true;
305 PrivateDevices = true;
306 ProtectHome = true;
307 ProtectClock = true;
308 ProtectHostname = true;
309 ProtectKernelLogs = true;
310 ProtectKernelModules = true;
311 ProtectKernelTunables = true;
312 ProtectProc = "invisible";
313 ProtectSystem = "strict";
314 ProtectControlGroups = "strict";
315 LockPersonality = true;
316 RestrictRealtime = true;
317 RestrictSUIDSGID = true;
318 RestrictNamespaces = true;
319 RestrictAddressFamilies = [
320 "AF_UNIX"
321 "AF_INET"
322 "AF_INET6"
323 ];
324 };
325 }
326 ) enabledInstances;
327 };
328
329 meta.maintainers = with lib.maintainers; [
330 soopyc
331 nullcube
332 ];
333 meta.doc = ./anubis.md;
334}