about summary refs log tree commit diff
path: root/nixpkgs/nixos/modules/services/web-apps/invidious.nix
blob: 359aaabfe673aafca70463be69a2b7a592f2589a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
{ lib, config, pkgs, options, ... }:
let
  cfg = config.services.invidious;
  # To allow injecting secrets with jq, json (instead of yaml) is used
  settingsFormat = pkgs.formats.json { };
  inherit (lib) types;

  settingsFile = settingsFormat.generate "invidious-settings" cfg.settings;

  generatedHmacKeyFile = "/var/lib/invidious/hmac_key";
  generateHmac = cfg.hmacKeyFile == null;

  commonInvidousServiceConfig = {
    description = "Invidious (An alternative YouTube front-end)";
    wants = [ "network-online.target" ];
    after = [ "network-online.target" ] ++ lib.optional cfg.database.createLocally "postgresql.service";
    requires = lib.optional cfg.database.createLocally "postgresql.service";
    wantedBy = [ "multi-user.target" ];

    serviceConfig = {
      RestartSec = "2s";
      DynamicUser = true;
      User = lib.mkIf (cfg.database.createLocally || cfg.serviceScale > 1) "invidious";
      StateDirectory = "invidious";
      StateDirectoryMode = "0750";

      CapabilityBoundingSet = "";
      PrivateDevices = true;
      PrivateUsers = true;
      ProtectHome = true;
      ProtectKernelLogs = true;
      ProtectProc = "invisible";
      RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
      RestrictNamespaces = true;
      SystemCallArchitectures = "native";
      SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];

      # Because of various issues Invidious must be restarted often, at least once a day, ideally
      # every hour.
      # This option enables the automatic restarting of the Invidious instance.
      # To ensure multiple instances of Invidious are not restarted at the exact same time, a
      # randomized extra offset of up to 5 minutes is added.
      Restart = lib.mkDefault "always";
      RuntimeMaxSec = lib.mkDefault "1h";
      RuntimeRandomizedExtraSec = lib.mkDefault "5min";
    };
  };
  mkInvidiousService = scaleIndex:
    lib.foldl' lib.recursiveUpdate commonInvidousServiceConfig [
      # only generate the hmac file in the first service
      (lib.optionalAttrs (scaleIndex == 0) {
        preStart = lib.optionalString generateHmac ''
          if [[ ! -e "${generatedHmacKeyFile}" ]]; then
            ${pkgs.pwgen}/bin/pwgen 20 1 > "${generatedHmacKeyFile}"
            chmod 0600 "${generatedHmacKeyFile}"
          fi
        '';
      })
      # configure the secondary services to run after the first service
      (lib.optionalAttrs (scaleIndex > 0) {
        after = commonInvidousServiceConfig.after ++ [ "invidious.service" ];
        wants = commonInvidousServiceConfig.wants ++ [ "invidious.service" ];
      })
      {
        script = ''
          configParts=()
        ''
        # autogenerated hmac_key
        + lib.optionalString generateHmac ''
          configParts+=("$(${pkgs.jq}/bin/jq -R '{"hmac_key":.}' <"${generatedHmacKeyFile}")")
        ''
        # generated settings file
        + ''
          configParts+=("$(< ${lib.escapeShellArg settingsFile})")
        ''
        # optional database password file
        + lib.optionalString (cfg.database.host != null) ''
          configParts+=("$(${pkgs.jq}/bin/jq -R '{"db":{"password":.}}' ${lib.escapeShellArg cfg.database.passwordFile})")
        ''
        # optional extra settings file
        + lib.optionalString (cfg.extraSettingsFile != null) ''
          configParts+=("$(< ${lib.escapeShellArg cfg.extraSettingsFile})")
        ''
        # explicitly specified hmac key file
        + lib.optionalString (cfg.hmacKeyFile != null) ''
          configParts+=("$(< ${lib.escapeShellArg cfg.hmacKeyFile})")
        ''
        # configure threads for secondary instances
        + lib.optionalString (scaleIndex > 0) ''
          configParts+=('{"channel_threads":0, "feed_threads":0}')
        ''
        # configure different ports for the instances
        + ''
          configParts+=('{"port":${toString (cfg.port + scaleIndex)}}')
        ''
        # merge all parts into a single configuration with later elements overriding previous elements
        + ''
          export INVIDIOUS_CONFIG="$(${pkgs.jq}/bin/jq -s 'reduce .[] as $item ({}; . * $item)' <<<"''${configParts[*]}")"
          exec ${cfg.package}/bin/invidious
        '';
      }
    ];

  serviceConfig = {
    systemd.services = builtins.listToAttrs (builtins.genList
      (scaleIndex: {
        name = "invidious" + lib.optionalString (scaleIndex > 0) "-${builtins.toString scaleIndex}";
        value = mkInvidiousService scaleIndex;
      })
      cfg.serviceScale);

    services.invidious.settings = {
      # Automatically initialises and migrates the database if necessary
      check_tables = true;

      db = {
        user = lib.mkDefault (
          if (lib.versionAtLeast config.system.stateVersion "24.05")
          then "invidious"
          else "kemal"
        );
        dbname = lib.mkDefault "invidious";
        port = cfg.database.port;
        # Blank for unix sockets, see
        # https://github.com/will/crystal-pg/blob/1548bb255210/src/pq/conninfo.cr#L100-L108
        host = lib.optionalString (cfg.database.host != null) cfg.database.host;
        # Not needed because peer authentication is enabled
        password = lib.mkIf (cfg.database.host == null) "";
      };

      host_binding = cfg.address;
    } // (lib.optionalAttrs (cfg.domain != null) {
      inherit (cfg) domain;
    });

    assertions = [
      {
        assertion = cfg.database.host != null -> cfg.database.passwordFile != null;
        message = "If database host isn't null, database password needs to be set";
      }
      {
        assertion = cfg.serviceScale >= 1;
        message = "Service can't be scaled below one instance";
      }
    ];
  };

  # Settings necessary for running with an automatically managed local database
  localDatabaseConfig = lib.mkIf cfg.database.createLocally {
    assertions = [
      {
        assertion = cfg.settings.db.user == cfg.settings.db.dbname;
        message = ''
          For local automatic database provisioning (services.invidious.database.createLocally == true)
          to  work, the username used to connect to PostgreSQL must match the database name, that is
          services.invidious.settings.db.user must match services.invidious.settings.db.dbname.
          This is the default since NixOS 24.05. For older systems, it is normally safe to manually set
          the user to "invidious" as the new user will be created with permissions
          for the existing database. `REASSIGN OWNED BY kemal TO invidious;` may also be needed, it can be
          run as `sudo -u postgres env psql --user=postgres --dbname=invidious -c 'reassign OWNED BY kemal to invidious;'`.
        '';
      }
    ];
    # Default to using the local database if we create it
    services.invidious.database.host = lib.mkDefault null;

    services.postgresql = {
      enable = true;
      ensureUsers = lib.singleton { name = cfg.settings.db.user; ensureDBOwnership = true; };
      ensureDatabases = lib.singleton cfg.settings.db.dbname;
    };
  };

  ytproxyConfig = lib.mkIf cfg.http3-ytproxy.enable {
    systemd.services.http3-ytproxy = {
      description = "HTTP3 ytproxy for Invidious";
      wants = [ "network-online.target" ];
      after = [ "network-online.target" ];
      wantedBy = [ "multi-user.target" ];

      script = ''
        mkdir -p socket
        exec ${lib.getExe cfg.http3-ytproxy.package};
      '';

      serviceConfig = {
        RestartSec = "2s";
        DynamicUser = true;
        User = lib.mkIf cfg.nginx.enable config.services.nginx.user;
        RuntimeDirectory = "http3-ytproxy";
        WorkingDirectory = "/run/http3-ytproxy";
      };
    };

    services.nginx.virtualHosts.${cfg.domain} = lib.mkIf cfg.nginx.enable {
      locations."~ (^/videoplayback|^/vi/|^/ggpht/|^/sb/)" = {
        proxyPass = "http://unix:/run/http3-ytproxy/socket/http-proxy.sock";
      };
    };
  };

  nginxConfig = lib.mkIf cfg.nginx.enable {
    services.invidious.settings = {
      https_only = config.services.nginx.virtualHosts.${cfg.domain}.forceSSL;
      external_port = 80;
    };

    services.nginx = let
      ip = if cfg.address == "0.0.0.0" then "127.0.0.1" else cfg.address;
    in
    {
      enable = true;
      virtualHosts.${cfg.domain} = {
        locations."/".proxyPass =
          if cfg.serviceScale == 1 then
            "http://${ip}:${toString cfg.port}"
          else "http://upstream-invidious";

        enableACME = lib.mkDefault true;
        forceSSL = lib.mkDefault true;
      };
      upstreams = lib.mkIf (cfg.serviceScale > 1) {
        "upstream-invidious".servers = builtins.listToAttrs (builtins.genList
          (scaleIndex: {
            name = "${ip}:${toString (cfg.port + scaleIndex)}";
            value = { };
          })
          cfg.serviceScale);
      };
    };

    assertions = [{
      assertion = cfg.domain != null;
      message = "To use services.invidious.nginx, you need to set services.invidious.domain";
    }];
  };
in
{
  options.services.invidious = {
    enable = lib.mkEnableOption (lib.mdDoc "Invidious");

    package = lib.mkPackageOption pkgs "invidious" { };

    settings = lib.mkOption {
      type = settingsFormat.type;
      default = { };
      description = lib.mdDoc ''
        The settings Invidious should use.

        See [config.example.yml](https://github.com/iv-org/invidious/blob/master/config/config.example.yml) for a list of all possible options.
      '';
    };

    hmacKeyFile = lib.mkOption {
      type = types.nullOr types.path;
      default = null;
      description = lib.mdDoc ''
        A path to a file containing the `hmac_key`. If `null`, a key will be generated automatically on first
        start.

        If non-`null`, this option overrides any `hmac_key` specified in {option}`services.invidious.settings` or
        via {option}`services.invidious.extraSettingsFile`.
      '';
    };

    extraSettingsFile = lib.mkOption {
      type = types.nullOr types.str;
      default = null;
      description = lib.mdDoc ''
        A file including Invidious settings.

        It gets merged with the settings specified in {option}`services.invidious.settings`
        and can be used to store secrets like `hmac_key` outside of the nix store.
      '';
    };

    serviceScale = lib.mkOption {
      type = types.int;
      default = 1;
      description = lib.mdDoc ''
        How many invidious instances to run.

        See https://docs.invidious.io/improve-public-instance/#2-multiple-invidious-processes for more details
        on how this is intended to work. All instances beyond the first one have the options `channel_threads`
        and `feed_threads` set to 0 to avoid conflicts with multiple instances refreshing subscriptions. Instances
        will be configured to bind to consecutive ports starting with {option}`services.invidious.port` for the
        first instance.
      '';
    };

    # This needs to be outside of settings to avoid infinite recursion
    # (determining if nginx should be enabled and therefore the settings
    # modified).
    domain = lib.mkOption {
      type = types.nullOr types.str;
      default = null;
      description = lib.mdDoc ''
        The FQDN Invidious is reachable on.

        This is used to configure nginx and for building absolute URLs.
      '';
    };

    address = lib.mkOption {
      type = types.str;
      # default from https://github.com/iv-org/invidious/blob/master/config/config.example.yml
      default = if cfg.nginx.enable then "127.0.0.1" else "0.0.0.0";
      defaultText = lib.literalExpression ''if config.services.invidious.nginx.enable then "127.0.0.1" else "0.0.0.0"'';
      description = lib.mdDoc ''
        The IP address Invidious should bind to.
      '';
    };

    port = lib.mkOption {
      type = types.port;
      # Default from https://docs.invidious.io/Configuration.md
      default = 3000;
      description = lib.mdDoc ''
        The port Invidious should listen on.

        To allow access from outside,
        you can use either {option}`services.invidious.nginx`
        or add `config.services.invidious.port` to {option}`networking.firewall.allowedTCPPorts`.
      '';
    };

    database = {
      createLocally = lib.mkOption {
        type = types.bool;
        default = true;
        description = lib.mdDoc ''
          Whether to create a local database with PostgreSQL.
        '';
      };

      host = lib.mkOption {
        type = types.nullOr types.str;
        default = null;
        description = lib.mdDoc ''
          The database host Invidious should use.

          If `null`, the local unix socket is used. Otherwise
          TCP is used.
        '';
      };

      port = lib.mkOption {
        type = types.port;
        default = options.services.postgresql.port.default;
        defaultText = lib.literalExpression "options.services.postgresql.port.default";
        description = lib.mdDoc ''
          The port of the database Invidious should use.

          Defaults to the the default postgresql port.
        '';
      };

      passwordFile = lib.mkOption {
        type = types.nullOr types.str;
        apply = lib.mapNullable toString;
        default = null;
        description = lib.mdDoc ''
          Path to file containing the database password.
        '';
      };
    };

    nginx.enable = lib.mkOption {
      type = types.bool;
      default = false;
      description = lib.mdDoc ''
        Whether to configure nginx as a reverse proxy for Invidious.

        It serves it under the domain specified in {option}`services.invidious.settings.domain` with enabled TLS and ACME.
        Further configuration can be done through {option}`services.nginx.virtualHosts.''${config.services.invidious.settings.domain}.*`,
        which can also be used to disable AMCE and TLS.
      '';
    };

    http3-ytproxy = {
      enable = lib.mkOption {
        type = lib.types.bool;
        default = false;
        description = lib.mdDoc ''
          Whether to enable http3-ytproxy for faster loading of images and video playback.

          If {option}`services.invidious.nginx.enable` is used, nginx will be configured automatically. If not, you
          need to configure a reverse proxy yourself according to
          https://docs.invidious.io/improve-public-instance/#3-speed-up-video-playback-with-http3-ytproxy.
        '';
      };

      package = lib.mkPackageOptionMD pkgs "http3-ytproxy" { };
    };
  };

  config = lib.mkIf cfg.enable (lib.mkMerge [
    serviceConfig
    localDatabaseConfig
    nginxConfig
    ytproxyConfig
  ]);
}