From ee68e27f1583d450c13401c693776eeb09e18220 Mon Sep 17 00:00:00 2001 From: Sivert Sliper <10866270+sivertism@users.noreply.github.com> Date: Mon, 4 Mar 2024 02:01:00 +0100 Subject: [PATCH] Audiobookshelf service (#123) Hi, I tried adding [Audiobookshelf](https://www.audiobookshelf.org/) as a new service to SHB. Not sure whether you want this service in SHB at all, but thought I'd create a PR just in case. The service runs, but seemingly fails to add an entry to the nginx config, so it is not reachable. I created the service by basically just copying deluge and then adapting. Any idea why the nginx subdomain isn't being created? The config I used to add this to my SHB server is: ```nix shb.audiobookshelf = { enable = true; domain = "sliper.xyz"; subdomain = "abs"; dataDir = "audiobookshelf"; #turns out this is actually the working dir of the service (/var/lib/) authEndpoint = "https://auth.sliper.xyz"; }; // ... in shb.authelia.oidcClients redirect_uris = [ "https://deluge.sliper.xyz" "https://abs.sliper.xyz" ]; ``` ps. I also need to fix tabs->spaces. Forgot to set up nvim. --------- Co-authored-by: sivert Co-authored-by: ibizaman Co-authored-by: Pierre Penninckx --- README.md | 1 + flake.nix | 2 + modules/services/audiobookshelf.nix | 160 ++++++++++++++++++ test/vm/audiobookshelf.nix | 243 ++++++++++++++++++++++++++++ 4 files changed, 406 insertions(+) create mode 100644 modules/services/audiobookshelf.nix create mode 100644 test/vm/audiobookshelf.nix diff --git a/README.md b/README.md index bfda243..281aa8a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ That being said, I am personally using all the blocks and services in this proje - [`jellyfin.nix`](./modules/services/jellyfin.nix) for watching media https://jellyfin.org/. - [Nextcloud Server](https://shb.skarabox.com/services-nextcloud.html) for private documents, contacts, calendar, etc https://nextcloud.com. - [`vaultwarden.nix`](./modules/services/vaultwarden.nix) for passwords https://github.com/dani-garcia/vaultwarden. +- [`audiobookshelf.nix`](./modules/services/audiobookshelf.nix) for hosting podcasts and audio books https://www.audiobookshelf.org/. ## Demos diff --git a/flake.nix b/flake.nix index 2c171bb..86a25f8 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,7 @@ modules/services/jellyfin.nix modules/services/nextcloud-server.nix modules/services/vaultwarden.nix + modules/services/audiobookshelf.nix ]; in { @@ -99,6 +100,7 @@ tests = pkgs.callPackage ./test/modules/lib.nix {}; }; } + // (vm_test "audiobookshelf" ./test/vm/audiobookshelf.nix) // (vm_test "authelia" ./test/vm/authelia.nix) // (vm_test "jellyfin" ./test/vm/jellyfin.nix) // (vm_test "ldap" ./test/vm/ldap.nix) diff --git a/modules/services/audiobookshelf.nix b/modules/services/audiobookshelf.nix new file mode 100644 index 0000000..c9a1140 --- /dev/null +++ b/modules/services/audiobookshelf.nix @@ -0,0 +1,160 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.audiobookshelf; + + contracts = pkgs.callPackage ../contracts {}; + + fqdn = "${cfg.subdomain}.${cfg.domain}"; +in +{ + options.shb.audiobookshelf = { + enable = lib.mkEnableOption "selfhostblocks.audiobookshelf"; + + subdomain = lib.mkOption { + type = lib.types.str; + description = "Subdomain under which audiobookshelf will be served."; + example = "abs"; + }; + + domain = lib.mkOption { + type = lib.types.str; + description = "domain under which audiobookshelf will be served."; + example = "mydomain.com"; + }; + + webPort = lib.mkOption { + type = lib.types.int; + description = "Audiobookshelf web port"; + default = 8113; + }; + + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + + extraServiceConfig = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + description = "Extra configuration given to the systemd service file."; + default = {}; + example = lib.literalExpression '' + { + MemoryHigh = "512M"; + MemoryMax = "900M"; + } + ''; + }; + + oidcProvider = lib.mkOption { + type = lib.types.str; + description = "OIDC provider name"; + default = "Authelia"; + }; + + authEndpoint = lib.mkOption { + type = lib.types.str; + description = "OIDC endpoint for SSO"; + example = "https://authelia.example.com"; + }; + + oidcClientID = lib.mkOption { + type = lib.types.str; + description = "Client ID for the OIDC endpoint"; + default = "audiobookshelf"; + }; + + oidcAdminUserGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC admin group"; + default = "audiobookshelf_admin"; + }; + + oidcUserGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC user group"; + default = "audiobookshelf_user"; + }; + + ssoSecretFile = lib.mkOption { + type = lib.types.path; + description = "File containing the SSO shared secret."; + }; + + logLevel = lib.mkOption { + type = lib.types.nullOr (lib.types.enum ["critical" "error" "warning" "info" "debug"]); + description = "Enable logging."; + default = false; + example = true; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [{ + + services.audiobookshelf = { + enable = true; + openFirewall = true; + dataDir = "audiobookshelf"; + host = "127.0.0.1"; + port = cfg.webPort; + }; + + services.nginx.enable = true; + services.nginx.virtualHosts."${fqdn}" = { + http2 = true; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; + + # https://github.com/advplyr/audiobookshelf#nginx-reverse-proxy + extraConfig = '' + set $audiobookshelf 127.0.0.1; + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_http_version 1.1; + + proxy_pass http://$audiobookshelf:${builtins.toString cfg.webPort}; + proxy_redirect http:// https://; + } + ''; + }; + + shb.authelia.oidcClients = [ + { + id = cfg.oidcClientID; + description = "Audiobookshelf"; + secret.source = cfg.ssoSecretFile; + public = false; + authorization_policy = "one_factor"; + redirect_uris = [ + "https://${cfg.subdomain}.${cfg.domain}/auth/openid/callback" + "https://${cfg.subdomain}.${cfg.domain}/auth/openid/mobile-redirect" + ]; + } + ]; + + # We want audiobookshelf to create files in the media group and to make those files group readable. + users.users.audiobookshelf = { + extraGroups = [ "media" ]; + }; + systemd.services.audiobookshelfd.serviceConfig.Group = lib.mkForce "media"; + systemd.services.audiobookshelfd.serviceConfig.UMask = lib.mkForce "0027"; + + # We backup the whole audiobookshelf directory and set permissions for the backup user accordingly. + users.groups.audiobookshelf.members = [ "backup" ]; + users.groups.media.members = [ "backup" ]; + shb.backup.instances.audiobookshelf = { + sourceDirectories = [ + /var/lib/${config.services.audiobookshelf.dataDir} + ]; + }; + } { + systemd.services.audiobookshelfd.serviceConfig = cfg.extraServiceConfig; + }]); +} diff --git a/test/vm/audiobookshelf.nix b/test/vm/audiobookshelf.nix new file mode 100644 index 0000000..101bb73 --- /dev/null +++ b/test/vm/audiobookshelf.nix @@ -0,0 +1,243 @@ +{ pkgs, lib, ... }: +{ + basic = pkgs.nixosTest { + name = "audiobookshelf-basic"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/services/audiobookshelf.nix + ]; + + shb.audiobookshelf = { + enable = true; + domain = "example.com"; + subdomain = "a"; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 ]; + }; + + nodes.client = {}; + + # TODO: Test login + testScript = { nodes, ... }: '' + import json + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to a.example.com:443:server:443" + + " --connect-to a.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("audiobookshelf.service") + server.wait_for_unit("nginx.service") + server.wait_for_open_port(${builtins.toString nodes.server.shb.audiobookshelf.webPort}) + + response = curl(client, """{"code":%{response_code}}""", "http://a.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; + + cert = pkgs.nixosTest { + name = "audiobookshelf-cert"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/blocks/nginx.nix + ../../modules/blocks/ssl.nix + ../../modules/services/audiobookshelf.nix + ]; + + shb.certs = { + cas.selfsigned.myca = { + name = "My CA"; + }; + certs.selfsigned = { + n = { + ca = config.shb.certs.cas.selfsigned.myca; + domain = "*.example.com"; + group = "nginx"; + }; + }; + }; + + systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + + shb.audiobookshelf = { + enable = true; + domain = "example.com"; + subdomain = "a"; + ssl = config.shb.certs.certs.selfsigned.n; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 443 ]; + + shb.nginx.accessLog = true; + }; + + nodes.client = {}; + + # TODO: Test login + testScript = { nodes, ... }: '' + import json + import os + import pathlib + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to a.example.com:443:server:443" + + " --connect-to a.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("audiobookshelf.service") + server.wait_for_unit("nginx.service") + server.wait_for_open_port(${builtins.toString nodes.server.shb.audiobookshelf.webPort}) + + server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") + client.succeed("rm -r /etc/ssl/certs") + client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt") + + response = curl(client, """{"code":%{response_code}}""", "https://a.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; + + sso = pkgs.nixosTest { + name = "audiobookshelf-sso"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/blocks/authelia.nix + ../../modules/blocks/ldap.nix + ../../modules/blocks/postgresql.nix + ../../modules/blocks/ssl.nix + ../../modules/services/audiobookshelf.nix + ]; + + shb.ldap = { + enable = true; + domain = "example.com"; + subdomain = "ldap"; + ldapPort = 3890; + webUIListenPort = 17170; + dcdomain = "dc=example,dc=com"; + ldapUserPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + }; + + shb.certs = { + cas.selfsigned.myca = { + name = "My CA"; + }; + certs.selfsigned = { + n = { + ca = config.shb.certs.cas.selfsigned.myca; + domain = "*.example.com"; + group = "nginx"; + }; + }; + }; + + systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + + shb.authelia = { + enable = true; + domain = "example.com"; + subdomain = "auth"; + ssl = config.shb.certs.certs.selfsigned.n; + + ldapEndpoint = "ldap://127.0.0.1:${builtins.toString config.shb.ldap.ldapPort}"; + dcdomain = config.shb.ldap.dcdomain; + + secrets = { + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + ldapAdminPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + sessionSecretFile = pkgs.writeText "sessionSecret" "sessionSecret"; + storageEncryptionKeyFile = pkgs.writeText "storageEncryptionKey" "storageEncryptionKey"; + identityProvidersOIDCHMACSecretFile = pkgs.writeText "identityProvidersOIDCHMACSecret" "identityProvidersOIDCHMACSecret"; + identityProvidersOIDCIssuerPrivateKeyFile = (pkgs.runCommand "gen-private-key" {} '' + mkdir $out + ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096 + '') + "/private.pem"; + }; + }; + + shb.audiobookshelf = { + enable = true; + domain = "example.com"; + subdomain = "a"; + ssl = config.shb.certs.certs.selfsigned.n; + + authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + ssoSecretFile = pkgs.writeText "ssoSecretFile" "ssoSecretFile"; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 443 ]; + }; + + nodes.client = {}; + + # TODO: Test login with ldap user + testScript = { nodes, ... }: '' + import json + import os + import pathlib + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to a.example.com:443:server:443" + + " --connect-to a.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("audiobookshelf.service") + server.wait_for_unit("nginx.service") + server.wait_for_unit("lldap.service") + server.wait_for_unit("authelia-auth.example.com.service") + server.wait_for_open_port(${builtins.toString nodes.server.shb.audiobookshelf.webPort}) + + server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") + client.succeed("rm -r /etc/ssl/certs") + client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt") + + response = curl(client, """{"code":%{response_code}}""", "https://a.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; +}