nixpkgs/nixos/modules/services/web-apps/invidious.nix
2024-09-12 09:08:02 +02:00

464 lines
16 KiB
Nix

{ 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";
};
};
};
sigHelperConfig = lib.mkIf cfg.sig-helper.enable {
services.invidious.settings.signature_server = "tcp://${cfg.sig-helper.listenAddress}";
systemd.services.invidious-sig-helper = {
script = ''
exec ${lib.getExe cfg.sig-helper.package} --tcp "${cfg.sig-helper.listenAddress}"
'';
wantedBy = [ "multi-user.target" ];
before = [ "invidious.service" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
User = "invidious-sig-helper";
DynamicUser = true;
Restart = "always";
PrivateTmp = true;
PrivateUsers = true;
ProtectSystem = true;
ProtectProc = "invisible";
ProtectHome = true;
PrivateDevices = true;
NoNewPrivileges = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
CapabilityBoundingSet = "";
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" "@network-io" ];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
};
};
};
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 "Invidious";
package = lib.mkPackageOption pkgs "invidious" { };
settings = lib.mkOption {
type = settingsFormat.type;
default = { };
description = ''
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 = ''
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 = ''
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 = ''
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 = ''
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 = ''
The IP address Invidious should bind to.
'';
};
port = lib.mkOption {
type = types.port;
# Default from https://docs.invidious.io/Configuration.md
default = 3000;
description = ''
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 = ''
Whether to create a local database with PostgreSQL.
'';
};
host = lib.mkOption {
type = types.nullOr types.str;
default = null;
description = ''
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 = config.services.postgresql.settings.port;
defaultText = lib.literalExpression "config.services.postgresql.settings.port";
description = ''
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 = ''
Path to file containing the database password.
'';
};
};
nginx.enable = lib.mkOption {
type = types.bool;
default = false;
description = ''
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 = ''
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.mkPackageOption pkgs "http3-ytproxy" { };
};
sig-helper = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable and configure inv-sig-helper to emulate the youtube client's javascript. This is required
to make certain videos playable.
This will download and run completely untrusted javascript from youtube! While this service is sandboxed,
this may still be an issue!
'';
};
package = lib.mkPackageOption pkgs "inv-sig-helper" { };
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1:2999";
description = ''
The IP address/port where inv-sig-helper should listen.
'';
};
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
serviceConfig
localDatabaseConfig
nginxConfig
ytproxyConfig
sigHelperConfig
]);
}