1{
2 lib,
3 config,
4 pkgs,
5 options,
6 ...
7}:
8let
9 cfg = config.services.invidious;
10 # To allow injecting secrets with jq, json (instead of yaml) is used
11 settingsFormat = pkgs.formats.json { };
12 inherit (lib) types;
13
14 settingsFile = settingsFormat.generate "invidious-settings" cfg.settings;
15
16 generatedHmacKeyFile = "/var/lib/invidious/hmac_key";
17 generateHmac = cfg.hmacKeyFile == null;
18
19 commonInvidousServiceConfig = {
20 description = "Invidious (An alternative YouTube front-end)";
21 wants = [ "network-online.target" ];
22 after = [ "network-online.target" ] ++ lib.optional cfg.database.createLocally "postgresql.service";
23 requires = lib.optional cfg.database.createLocally "postgresql.service";
24 wantedBy = [ "multi-user.target" ];
25
26 serviceConfig = {
27 RestartSec = "2s";
28 DynamicUser = true;
29 User = lib.mkIf (cfg.database.createLocally || cfg.serviceScale > 1) "invidious";
30 StateDirectory = "invidious";
31 StateDirectoryMode = "0750";
32
33 CapabilityBoundingSet = "";
34 PrivateDevices = true;
35 PrivateUsers = true;
36 ProtectHome = true;
37 ProtectKernelLogs = true;
38 ProtectProc = "invisible";
39 RestrictAddressFamilies = [
40 "AF_UNIX"
41 "AF_INET"
42 "AF_INET6"
43 ];
44 RestrictNamespaces = true;
45 SystemCallArchitectures = "native";
46 SystemCallFilter = [
47 "@system-service"
48 "~@privileged"
49 "~@resources"
50 ];
51
52 # Because of various issues Invidious must be restarted often, at least once a day, ideally
53 # every hour.
54 # This option enables the automatic restarting of the Invidious instance.
55 # To ensure multiple instances of Invidious are not restarted at the exact same time, a
56 # randomized extra offset of up to 5 minutes is added.
57 Restart = lib.mkDefault "always";
58 RuntimeMaxSec = lib.mkDefault "1h";
59 RuntimeRandomizedExtraSec = lib.mkDefault "5min";
60 };
61 };
62 mkInvidiousService =
63 scaleIndex:
64 lib.foldl' lib.recursiveUpdate commonInvidousServiceConfig [
65 # only generate the hmac file in the first service
66 (lib.optionalAttrs (scaleIndex == 0) {
67 preStart = lib.optionalString generateHmac ''
68 if [[ ! -e "${generatedHmacKeyFile}" ]]; then
69 ${pkgs.pwgen}/bin/pwgen 20 1 > "${generatedHmacKeyFile}"
70 chmod 0600 "${generatedHmacKeyFile}"
71 fi
72 '';
73 })
74 # configure the secondary services to run after the first service
75 (lib.optionalAttrs (scaleIndex > 0) {
76 after = commonInvidousServiceConfig.after ++ [ "invidious.service" ];
77 wants = commonInvidousServiceConfig.wants ++ [ "invidious.service" ];
78 })
79 {
80 script =
81 ''
82 configParts=()
83 ''
84 # autogenerated hmac_key
85 + lib.optionalString generateHmac ''
86 configParts+=("$(${pkgs.jq}/bin/jq -R '{"hmac_key":.}' <"${generatedHmacKeyFile}")")
87 ''
88 # generated settings file
89 + ''
90 configParts+=("$(< ${lib.escapeShellArg settingsFile})")
91 ''
92 # optional database password file
93 + lib.optionalString (cfg.database.host != null) ''
94 configParts+=("$(${pkgs.jq}/bin/jq -R '{"db":{"password":.}}' ${lib.escapeShellArg cfg.database.passwordFile})")
95 ''
96 # optional extra settings file
97 + lib.optionalString (cfg.extraSettingsFile != null) ''
98 configParts+=("$(< ${lib.escapeShellArg cfg.extraSettingsFile})")
99 ''
100 # explicitly specified hmac key file
101 + lib.optionalString (cfg.hmacKeyFile != null) ''
102 configParts+=("$(< ${lib.escapeShellArg cfg.hmacKeyFile})")
103 ''
104 # configure threads for secondary instances
105 + lib.optionalString (scaleIndex > 0) ''
106 configParts+=('{"channel_threads":0, "feed_threads":0}')
107 ''
108 # configure different ports for the instances
109 + ''
110 configParts+=('{"port":${toString (cfg.port + scaleIndex)}}')
111 ''
112 # merge all parts into a single configuration with later elements overriding previous elements
113 + ''
114 export INVIDIOUS_CONFIG="$(${pkgs.jq}/bin/jq -s 'reduce .[] as $item ({}; . * $item)' <<<"''${configParts[*]}")"
115 exec ${cfg.package}/bin/invidious
116 '';
117 }
118 ];
119
120 serviceConfig = {
121 systemd.services = builtins.listToAttrs (
122 builtins.genList (scaleIndex: {
123 name = "invidious" + lib.optionalString (scaleIndex > 0) "-${builtins.toString scaleIndex}";
124 value = mkInvidiousService scaleIndex;
125 }) cfg.serviceScale
126 );
127
128 services.invidious.settings =
129 {
130 # Automatically initialises and migrates the database if necessary
131 check_tables = true;
132
133 db = {
134 user = lib.mkDefault (
135 if (lib.versionAtLeast config.system.stateVersion "24.05") then "invidious" else "kemal"
136 );
137 dbname = lib.mkDefault "invidious";
138 port = cfg.database.port;
139 # Blank for unix sockets, see
140 # https://github.com/will/crystal-pg/blob/1548bb255210/src/pq/conninfo.cr#L100-L108
141 host = lib.optionalString (cfg.database.host != null) cfg.database.host;
142 # Not needed because peer authentication is enabled
143 password = lib.mkIf (cfg.database.host == null) "";
144 };
145
146 host_binding = cfg.address;
147 }
148 // (lib.optionalAttrs (cfg.domain != null) {
149 inherit (cfg) domain;
150 });
151
152 assertions = [
153 {
154 assertion = cfg.database.host != null -> cfg.database.passwordFile != null;
155 message = "If database host isn't null, database password needs to be set";
156 }
157 {
158 assertion = cfg.serviceScale >= 1;
159 message = "Service can't be scaled below one instance";
160 }
161 ];
162 };
163
164 # Settings necessary for running with an automatically managed local database
165 localDatabaseConfig = lib.mkIf cfg.database.createLocally {
166 assertions = [
167 {
168 assertion = cfg.settings.db.user == cfg.settings.db.dbname;
169 message = ''
170 For local automatic database provisioning (services.invidious.database.createLocally == true)
171 to work, the username used to connect to PostgreSQL must match the database name, that is
172 services.invidious.settings.db.user must match services.invidious.settings.db.dbname.
173 This is the default since NixOS 24.05. For older systems, it is normally safe to manually set
174 the user to "invidious" as the new user will be created with permissions
175 for the existing database. `REASSIGN OWNED BY kemal TO invidious;` may also be needed, it can be
176 run as `sudo -u postgres env psql --user=postgres --dbname=invidious -c 'reassign OWNED BY kemal to invidious;'`.
177 '';
178 }
179 ];
180 # Default to using the local database if we create it
181 services.invidious.database.host = lib.mkDefault null;
182
183 services.postgresql = {
184 enable = true;
185 ensureUsers = lib.singleton {
186 name = cfg.settings.db.user;
187 ensureDBOwnership = true;
188 };
189 ensureDatabases = lib.singleton cfg.settings.db.dbname;
190 };
191 };
192
193 ytproxyConfig = lib.mkIf cfg.http3-ytproxy.enable {
194 systemd.services.http3-ytproxy = {
195 description = "HTTP3 ytproxy for Invidious";
196 wants = [ "network-online.target" ];
197 after = [ "network-online.target" ];
198 wantedBy = [ "multi-user.target" ];
199
200 script = ''
201 mkdir -p socket
202 exec ${lib.getExe cfg.http3-ytproxy.package};
203 '';
204
205 serviceConfig = {
206 RestartSec = "2s";
207 DynamicUser = true;
208 User = lib.mkIf cfg.nginx.enable config.services.nginx.user;
209 RuntimeDirectory = "http3-ytproxy";
210 WorkingDirectory = "/run/http3-ytproxy";
211 };
212 };
213
214 services.nginx.virtualHosts.${cfg.domain} = lib.mkIf cfg.nginx.enable {
215 locations."~ (^/videoplayback|^/vi/|^/ggpht/|^/sb/)" = {
216 proxyPass = "http://unix:/run/http3-ytproxy/socket/http-proxy.sock";
217 };
218 };
219 };
220
221 sigHelperConfig = lib.mkIf cfg.sig-helper.enable {
222 services.invidious.settings.signature_server = "tcp://${cfg.sig-helper.listenAddress}";
223 systemd.services.invidious-sig-helper = {
224 script = ''
225 exec ${lib.getExe cfg.sig-helper.package} --tcp "${cfg.sig-helper.listenAddress}"
226 '';
227 wantedBy = [ "multi-user.target" ];
228 before = [ "invidious.service" ];
229 wants = [ "network-online.target" ];
230 after = [ "network-online.target" ];
231 serviceConfig = {
232 User = "invidious-sig-helper";
233 DynamicUser = true;
234 Restart = "always";
235
236 PrivateTmp = true;
237 PrivateUsers = true;
238 ProtectSystem = true;
239 ProtectProc = "invisible";
240 ProtectHome = true;
241 PrivateDevices = true;
242 NoNewPrivileges = true;
243 ProtectKernelTunables = true;
244 ProtectKernelModules = true;
245 ProtectControlGroups = true;
246 ProtectKernelLogs = true;
247 CapabilityBoundingSet = "";
248 SystemCallArchitectures = "native";
249 SystemCallFilter = [
250 "@system-service"
251 "~@privileged"
252 "~@resources"
253 "@network-io"
254 ];
255 RestrictAddressFamilies = [
256 "AF_INET"
257 "AF_INET6"
258 ];
259 RestrictNamespaces = true;
260 };
261 };
262 };
263
264 nginxConfig = lib.mkIf cfg.nginx.enable {
265 services.invidious.settings = {
266 https_only = config.services.nginx.virtualHosts.${cfg.domain}.forceSSL;
267 external_port = 80;
268 };
269
270 services.nginx =
271 let
272 ip = if cfg.address == "0.0.0.0" then "127.0.0.1" else cfg.address;
273 in
274 {
275 enable = true;
276 virtualHosts.${cfg.domain} = {
277 locations."/".proxyPass =
278 if cfg.serviceScale == 1 then "http://${ip}:${toString cfg.port}" else "http://upstream-invidious";
279
280 enableACME = lib.mkDefault true;
281 forceSSL = lib.mkDefault true;
282 };
283 upstreams = lib.mkIf (cfg.serviceScale > 1) {
284 "upstream-invidious".servers = builtins.listToAttrs (
285 builtins.genList (scaleIndex: {
286 name = "${ip}:${toString (cfg.port + scaleIndex)}";
287 value = { };
288 }) cfg.serviceScale
289 );
290 };
291 };
292
293 assertions = [
294 {
295 assertion = cfg.domain != null;
296 message = "To use services.invidious.nginx, you need to set services.invidious.domain";
297 }
298 ];
299 };
300in
301{
302 options.services.invidious = {
303 enable = lib.mkEnableOption "Invidious";
304
305 package = lib.mkPackageOption pkgs "invidious" { };
306
307 settings = lib.mkOption {
308 type = settingsFormat.type;
309 default = { };
310 description = ''
311 The settings Invidious should use.
312
313 See [config.example.yml](https://github.com/iv-org/invidious/blob/master/config/config.example.yml) for a list of all possible options.
314 '';
315 };
316
317 hmacKeyFile = lib.mkOption {
318 type = types.nullOr types.path;
319 default = null;
320 description = ''
321 A path to a file containing the `hmac_key`. If `null`, a key will be generated automatically on first
322 start.
323
324 If non-`null`, this option overrides any `hmac_key` specified in {option}`services.invidious.settings` or
325 via {option}`services.invidious.extraSettingsFile`.
326 '';
327 };
328
329 extraSettingsFile = lib.mkOption {
330 type = types.nullOr types.str;
331 default = null;
332 description = ''
333 A file including Invidious settings.
334
335 It gets merged with the settings specified in {option}`services.invidious.settings`
336 and can be used to store secrets like `hmac_key` outside of the nix store.
337 '';
338 };
339
340 serviceScale = lib.mkOption {
341 type = types.int;
342 default = 1;
343 description = ''
344 How many invidious instances to run.
345
346 See <https://docs.invidious.io/improve-public-instance/#2-multiple-invidious-processes> for more details
347 on how this is intended to work. All instances beyond the first one have the options `channel_threads`
348 and `feed_threads` set to 0 to avoid conflicts with multiple instances refreshing subscriptions. Instances
349 will be configured to bind to consecutive ports starting with {option}`services.invidious.port` for the
350 first instance.
351 '';
352 };
353
354 # This needs to be outside of settings to avoid infinite recursion
355 # (determining if nginx should be enabled and therefore the settings
356 # modified).
357 domain = lib.mkOption {
358 type = types.nullOr types.str;
359 default = null;
360 description = ''
361 The FQDN Invidious is reachable on.
362
363 This is used to configure nginx and for building absolute URLs.
364 '';
365 };
366
367 address = lib.mkOption {
368 type = types.str;
369 # default from https://github.com/iv-org/invidious/blob/master/config/config.example.yml
370 default = if cfg.nginx.enable then "127.0.0.1" else "0.0.0.0";
371 defaultText = lib.literalExpression ''if config.services.invidious.nginx.enable then "127.0.0.1" else "0.0.0.0"'';
372 description = ''
373 The IP address Invidious should bind to.
374 '';
375 };
376
377 port = lib.mkOption {
378 type = types.port;
379 # Default from https://docs.invidious.io/Configuration.md
380 default = 3000;
381 description = ''
382 The port Invidious should listen on.
383
384 To allow access from outside,
385 you can use either {option}`services.invidious.nginx`
386 or add `config.services.invidious.port` to {option}`networking.firewall.allowedTCPPorts`.
387 '';
388 };
389
390 database = {
391 createLocally = lib.mkOption {
392 type = types.bool;
393 default = true;
394 description = ''
395 Whether to create a local database with PostgreSQL.
396 '';
397 };
398
399 host = lib.mkOption {
400 type = types.nullOr types.str;
401 default = null;
402 description = ''
403 The database host Invidious should use.
404
405 If `null`, the local unix socket is used. Otherwise
406 TCP is used.
407 '';
408 };
409
410 port = lib.mkOption {
411 type = types.port;
412 default = config.services.postgresql.settings.port;
413 defaultText = lib.literalExpression "config.services.postgresql.settings.port";
414 description = ''
415 The port of the database Invidious should use.
416
417 Defaults to the the default postgresql port.
418 '';
419 };
420
421 passwordFile = lib.mkOption {
422 type = types.nullOr types.str;
423 apply = lib.mapNullable toString;
424 default = null;
425 description = ''
426 Path to file containing the database password.
427 '';
428 };
429 };
430
431 nginx.enable = lib.mkOption {
432 type = types.bool;
433 default = false;
434 description = ''
435 Whether to configure nginx as a reverse proxy for Invidious.
436
437 It serves it under the domain specified in {option}`services.invidious.settings.domain` with enabled TLS and ACME.
438 Further configuration can be done through {option}`services.nginx.virtualHosts.''${config.services.invidious.settings.domain}.*`,
439 which can also be used to disable AMCE and TLS.
440 '';
441 };
442
443 http3-ytproxy = {
444 enable = lib.mkOption {
445 type = lib.types.bool;
446 default = false;
447 description = ''
448 Whether to enable http3-ytproxy for faster loading of images and video playback.
449
450 If {option}`services.invidious.nginx.enable` is used, nginx will be configured automatically. If not, you
451 need to configure a reverse proxy yourself according to
452 <https://docs.invidious.io/improve-public-instance/#3-speed-up-video-playback-with-http3-ytproxy>.
453 '';
454 };
455
456 package = lib.mkPackageOption pkgs "http3-ytproxy" { };
457 };
458
459 sig-helper = {
460 enable = lib.mkOption {
461 type = lib.types.bool;
462 default = false;
463 description = ''
464 Whether to enable and configure inv-sig-helper to emulate the youtube client's javascript. This is required
465 to make certain videos playable.
466
467 This will download and run completely untrusted javascript from youtube! While this service is sandboxed,
468 this may still be an issue!
469 '';
470 };
471
472 package = lib.mkPackageOption pkgs "inv-sig-helper" { };
473
474 listenAddress = lib.mkOption {
475 type = lib.types.str;
476 default = "127.0.0.1:2999";
477 description = ''
478 The IP address/port where inv-sig-helper should listen.
479 '';
480 };
481 };
482 };
483
484 config = lib.mkIf cfg.enable (
485 lib.mkMerge [
486 serviceConfig
487 localDatabaseConfig
488 nginxConfig
489 ytproxyConfig
490 sigHelperConfig
491 ]
492 );
493}