From a5e11b99185fd7d720d0d8e9c85de38e58818d55 Mon Sep 17 00:00:00 2001 From: chaos Date: Tue, 31 Oct 2023 19:40:51 +0000 Subject: [PATCH] add jellyfin and rclone fuse mount capability finally --- .../jellyfin/data/rclone_config.template | 6 + .../containers/jellyfin/default.nix | 100 +++++++ .../containers/jellyfin/profiles/jellyfin.nix | 8 + .../jellyfin/profiles/mediaMount.nix | 53 ++++ .../containers/jellyfin/profiles/restic.nix | 39 +++ .../containers/jellyfin/secrets.nix | 54 ++++ .../mail/modules/mailserver/webmail.nix | 34 +++ .../containers/music/modules/mpd-fork.nix | 269 ++++++++++++++++++ .../containers/postgresql/data/databases.nix | 0 .../containers/roundcube/default.nix | 55 ++++ .../roundcube/profiles/roundcube.nix | 55 ++++ .../containers/storage/data/ports.nix | 1 + .../containers/storage/default.nix | 26 +- .../storage/profiles/rcloneServe.nix | 10 + .../containers/storage/secrets.nix | 9 + .../containers/stream/data/ports.nix | 9 + .../containers/stream/music/data/ports.nix | 9 + .../containers/stream/music/default.nix | 123 ++++++++ .../containers/stream/music/profiles/mpd.nix | 69 +++++ .../stream/music/profiles/musicSync.nix | 49 ++++ .../stream/music/profiles/soulseek.nix | 43 +++ .../containers/stream/music/secrets.nix | 57 ++++ .../containers/stream/profiles/mpd.nix | 69 +++++ .../containers/stream/profiles/soulseek.nix | 43 +++ .../hetzner-arm/containers/stream/secrets.nix | 57 ++++ hosts/hetzner-arm/data/containerAddresses.nix | 1 + hosts/hetzner-arm/hetzner-arm.nix | 1 + profiles/minimalServer.nix | 8 + 28 files changed, 1256 insertions(+), 1 deletion(-) create mode 100644 hosts/hetzner-arm/containers/jellyfin/data/rclone_config.template create mode 100644 hosts/hetzner-arm/containers/jellyfin/default.nix create mode 100644 hosts/hetzner-arm/containers/jellyfin/profiles/jellyfin.nix create mode 100644 hosts/hetzner-arm/containers/jellyfin/profiles/mediaMount.nix create mode 100644 hosts/hetzner-arm/containers/jellyfin/profiles/restic.nix create mode 100644 hosts/hetzner-arm/containers/jellyfin/secrets.nix create mode 100644 hosts/hetzner-arm/containers/mail/modules/mailserver/webmail.nix create mode 100644 hosts/hetzner-arm/containers/music/modules/mpd-fork.nix create mode 100644 hosts/hetzner-arm/containers/postgresql/data/databases.nix create mode 100644 hosts/hetzner-arm/containers/roundcube/default.nix create mode 100644 hosts/hetzner-arm/containers/roundcube/profiles/roundcube.nix create mode 100644 hosts/hetzner-arm/containers/stream/data/ports.nix create mode 100644 hosts/hetzner-arm/containers/stream/music/data/ports.nix create mode 100644 hosts/hetzner-arm/containers/stream/music/default.nix create mode 100644 hosts/hetzner-arm/containers/stream/music/profiles/mpd.nix create mode 100644 hosts/hetzner-arm/containers/stream/music/profiles/musicSync.nix create mode 100644 hosts/hetzner-arm/containers/stream/music/profiles/soulseek.nix create mode 100644 hosts/hetzner-arm/containers/stream/music/secrets.nix create mode 100644 hosts/hetzner-arm/containers/stream/profiles/mpd.nix create mode 100644 hosts/hetzner-arm/containers/stream/profiles/soulseek.nix create mode 100644 hosts/hetzner-arm/containers/stream/secrets.nix create mode 100644 profiles/minimalServer.nix diff --git a/hosts/hetzner-arm/containers/jellyfin/data/rclone_config.template b/hosts/hetzner-arm/containers/jellyfin/data/rclone_config.template new file mode 100644 index 0000000..e53956b --- /dev/null +++ b/hosts/hetzner-arm/containers/jellyfin/data/rclone_config.template @@ -0,0 +1,6 @@ +[Media] +type = webdav +url = https://storage-webdav.owo.monster/Media/ +vendor = other +user = media +pass = MEDIA_PASSWORD \ No newline at end of file diff --git a/hosts/hetzner-arm/containers/jellyfin/default.nix b/hosts/hetzner-arm/containers/jellyfin/default.nix new file mode 100644 index 0000000..2a08baa --- /dev/null +++ b/hosts/hetzner-arm/containers/jellyfin/default.nix @@ -0,0 +1,100 @@ +{ + self, + hostPath, + tree, + inputs, + pkgs, + config, + ... +}: let + containerName = "jellyfin"; + + containerAddresses = import "${hostPath}/data/containerAddresses.nix"; + + hostIP = containerAddresses.host; + containerIP = containerAddresses.containers.${containerName}; +in { + containers.jellyfin = { + autoStart = true; + privateNetwork = true; + hostAddress = hostIP; + localAddress = containerIP; + + bindMounts = { + "/dev/fuse" = { + hostPath = "/dev/fuse"; + isReadOnly = false; + }; + }; + + # Allow rclone mount in container + allowedDevices = [ + { + modifier = "rwm"; + node = "/dev/fuse"; + } + { + modifier = "rwm"; + node = "/dev/mapper/control"; + } + ]; + + specialArgs = { + inherit inputs; + inherit tree; + inherit self; + inherit hostPath; + }; + + config = {...}: { + nixpkgs.pkgs = pkgs; + + imports = with tree; + [ + presets.nixos.containerBase + ./secrets.nix + ] + ++ (with hosts.hetzner-arm.containers.jellyfin.profiles; [ + mediaMount + jellyfin + restic + ]); + + home-manager.users.root.home.stateVersion = "23.05"; + system.stateVersion = "23.05"; + }; + }; + + services.nginx.virtualHosts."jellyfin.owo.monster" = { + forceSSL = true; + enableACME = true; + extraConfig = '' + client_max_body_size 512M; + + # Security / XSS Mitigation Headers + # NOTE: X-Frame-Options may cause issues with the webOS app + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-XSS-Protection "0"; # Do NOT enable. This is obsolete/dangerous + add_header X-Content-Type-Options "nosniff"; + + # COOP/COEP. Disable if you use external plugins/images/assets + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + + # Permissions policy. May cause issues on some clients + add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; + + # Tell browsers to use per-origin process isolation + add_header Origin-Agent-Cluster "?1" always; + ''; + + locations."/" = { + proxyPass = "http://${containerIP}:8096"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + ''; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/jellyfin/profiles/jellyfin.nix b/hosts/hetzner-arm/containers/jellyfin/profiles/jellyfin.nix new file mode 100644 index 0000000..e838d4d --- /dev/null +++ b/hosts/hetzner-arm/containers/jellyfin/profiles/jellyfin.nix @@ -0,0 +1,8 @@ +{...}: { + services.jellyfin = { + enable = true; + openFirewall = true; + }; + users.users.jellyfin.uid = 1000; + users.groups.jellyfin.gid = 1000; +} diff --git a/hosts/hetzner-arm/containers/jellyfin/profiles/mediaMount.nix b/hosts/hetzner-arm/containers/jellyfin/profiles/mediaMount.nix new file mode 100644 index 0000000..32b7005 --- /dev/null +++ b/hosts/hetzner-arm/containers/jellyfin/profiles/mediaMount.nix @@ -0,0 +1,53 @@ +{ + config, + pkgs, + ... +}: let + secrets = config.services.secrets.secrets; + rcloneMedia = pkgs.writeShellScriptBin "rclone-media" '' + ${pkgs.rclone}/bin/rclone --config ${secrets.rclone_config.path} "$@" + ''; + mountMedia = pkgs.writeShellScriptBin "mount-media" '' + ${rcloneMedia}/bin/rclone-media mount Media: /Media \ + --allow-other \ + --uid=${toString config.users.users.jellyfin.uid} \ + --gid=${toString config.users.groups.jellyfin.gid} \ + --fast-list \ + --umask=666 \ + --log-level=INFO "$@" + ''; +in { + environment.systemPackages = with pkgs; [ + rclone + rcloneMedia + fuse + fuse3 + mountMedia + ]; + + programs.fuse.userAllowOther = true; + + systemd.services.jellyfin = { + wants = ["media-mount.service"]; + after = ["media-mount.service"]; + serviceConfig.ReadWritePaths = "/Media"; + }; + + systemd.services.media-mount = { + wantedBy = ["jellyfin.service"]; + partOf = ["jellyfin.service"]; + path = with pkgs; [ + fuse + fuse3 + ]; + serviceConfig.ExecStart = "${mountMedia}/bin/mount-media --syslog"; + }; + + systemd.tmpfiles.rules = [ + "d /Media - jellyfin jellyfin" + + "d /root/.config - root root" + "d /root/.config/rclone - root root" + "L /root/.config/rclone/rclone.conf - - - - ${secrets.rclone_config.path}" + ]; +} diff --git a/hosts/hetzner-arm/containers/jellyfin/profiles/restic.nix b/hosts/hetzner-arm/containers/jellyfin/profiles/restic.nix new file mode 100644 index 0000000..8cc7a64 --- /dev/null +++ b/hosts/hetzner-arm/containers/jellyfin/profiles/restic.nix @@ -0,0 +1,39 @@ +{ + pkgs, + config, + ... +}: let + secrets = config.services.secrets.secrets; +in { + environment.systemPackages = with pkgs; [ + restic + (pkgs.writeShellScriptBin "restic-jellyfin" '' + env \ + RESTIC_PASSWORD_FILE=${secrets.restic_password.path} \ + $(cat ${secrets.restic_env.path}) \ + ${pkgs.restic}/bin/restic $@ + '') + ]; + + services.restic.backups.jellyfin = { + user = "root"; + paths = [ + "/var/lib/jellyfin" + ]; + + # repository is overrided in environmentFile to contain auth + # make sure to keep up to date when changing repository + repository = "rest:https://storage-restic.owo.monster/Jellyfin"; + passwordFile = "${secrets.restic_password.path}"; + environmentFile = "${secrets.restic_env.path}"; + + pruneOpts = [ + "--keep-last 5" + ]; + + timerConfig = { + OnBootSec = "10m"; + OnCalendar = "8h"; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/jellyfin/secrets.nix b/hosts/hetzner-arm/containers/jellyfin/secrets.nix new file mode 100644 index 0000000..7654849 --- /dev/null +++ b/hosts/hetzner-arm/containers/jellyfin/secrets.nix @@ -0,0 +1,54 @@ +{pkgs, ...}: { + services.secrets = { + enable = true; + + packages = with pkgs; [ + rclone + ]; + + vaultLogin = { + enable = true; + loginUsername = "hetzner-arm-container-jellyfin"; + }; + + autoSecrets = { + enable = true; + }; + + requiredVaultPaths = [ + "api-keys/data/storage/webdav/Media" + "api-keys/data/storage/restic/Jellyfin" + + "private-public-keys/data/restic/Jellyfin" + ]; + + secrets = { + vault_password = { + manual = true; + }; + + rclone_config = { + user = "jellyfin"; + group = "jellyfin"; + fetchScript = '' + cp ${./data/rclone_config.template} "$secretFile" + MEDIA_PASSWORD="$(simple_get "/api-keys/storage/webdav/Media" .media)" + MEDIA_PASSWORD="$(rclone obscure "$MEDIA_PASSWORD")" + sed -i "s/MEDIA_PASSWORD/$MEDIA_PASSWORD/" "$secretFile" + ''; + }; + + restic_password = { + fetchScript = '' + simple_get "/private-public-keys/restic/Jellyfin" .password > "$secretFile" + ''; + }; + restic_env = { + fetchScript = '' + RESTIC_PASSWORD=$(simple_get "/api-keys/storage/restic/Jellyfin" .restic) + echo "RESTIC_REPOSITORY=rest:https://restic:$RESTIC_PASSWORD@storage-restic.owo.monster/Jellyfin" > "$secretFile" + ''; + }; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/mail/modules/mailserver/webmail.nix b/hosts/hetzner-arm/containers/mail/modules/mailserver/webmail.nix new file mode 100644 index 0000000..77fa9a3 --- /dev/null +++ b/hosts/hetzner-arm/containers/mail/modules/mailserver/webmail.nix @@ -0,0 +1,34 @@ +{ + config, + lib, + ... +}: let + inherit (lib.modules) mkIf mkForce; + + mailConfig = config.services.mailserver; +in { + config = mkIf (mailConfig.enable && mailConfig.roundcube.enable) { + services.roundcube = { + enable = true; + package = mailConfig.roundcube.package; + plugins = + mailConfig.roundcube.plugins + ++ [ + "managesieve" + ]; + hostName = "${mailConfig.roundcube.domain}"; + extraConfig = '' + $config['smtp_server'] = "tls://${mailConfig.fqdn}"; + $config['smtp_user'] = "%u"; + $config['smtp_pass'] = "%p"; + $config['managesieve_host'] = 'tls://${mailConfig.fqdn}'; + ${mailConfig.roundcube.extraConfig} + ''; + }; + + services.nginx.virtualHosts."${mailConfig.roundcube.domain}" = { + forceSSL = mkForce mailConfig.roundcube.forceSSL; + enableACME = mkForce mailConfig.roundcube.enableACME; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/music/modules/mpd-fork.nix b/hosts/hetzner-arm/containers/music/modules/mpd-fork.nix new file mode 100644 index 0000000..3fd5721 --- /dev/null +++ b/hosts/hetzner-arm/containers/music/modules/mpd-fork.nix @@ -0,0 +1,269 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + name = "mpd"; + + uid = config.ids.uids.mpd; + gid = config.ids.gids.mpd; + cfg = config.services.mpd-fork; + + credentialsPlaceholder = creds: let + placeholders = + imap0 + (i: c: ''password "{{password-${toString i}}}@${concatStringsSep "," c.permissions}"'') + creds; + in + concatStringsSep "\n" placeholders; + + mpdConf = pkgs.writeText "mpd.conf" '' + # This file was automatically generated by NixOS. Edit mpd's configuration + # via NixOS' configuration.nix, as this file will be rewritten upon mpd's + # restart. + + music_directory "${cfg.musicDirectory}" + playlist_directory "${cfg.playlistDirectory}" + ${lib.optionalString (cfg.dbFile != null) '' + db_file "${cfg.dbFile}" + ''} + state_file "${cfg.dataDir}/state" + sticker_file "${cfg.dataDir}/sticker.sql" + + ${optionalString (cfg.network.listenAddress != "any") ''bind_to_address "${cfg.network.listenAddress}"''} + ${optionalString (cfg.network.port != 6600) ''port "${toString cfg.network.port}"''} + ${optionalString (cfg.fluidsynth) '' + decoder { + plugin "fluidsynth" + soundfont "${pkgs.soundfont-fluid}/share/soundfonts/FluidR3_GM2-2.sf2" + } + ''} + + ${optionalString (cfg.credentials != []) (credentialsPlaceholder cfg.credentials)} + + ${cfg.extraConfig} + ''; +in { + ###### interface + + options = { + services.mpd-fork = { + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Whether to enable MPD, the music player daemon. + ''; + }; + + package = mkPackageOption pkgs "mpd" {}; + + startWhenNeeded = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + If set, {command}`mpd` is socket-activated; that + is, instead of having it permanently running as a daemon, + systemd will start it on the first incoming connection. + ''; + }; + + musicDirectory = mkOption { + type = with types; either path (strMatching "(http|https|nfs|smb)://.+"); + default = "${cfg.dataDir}/music"; + defaultText = literalExpression ''"''${dataDir}/music"''; + description = lib.mdDoc '' + The directory or NFS/SMB network share where MPD reads music from. If left + as the default value this directory will automatically be created before + the MPD server starts, otherwise the sysadmin is responsible for ensuring + the directory exists with appropriate ownership and permissions. + ''; + }; + + playlistDirectory = mkOption { + type = types.path; + default = "${cfg.dataDir}/playlists"; + defaultText = literalExpression ''"''${dataDir}/playlists"''; + description = lib.mdDoc '' + The directory where MPD stores playlists. If left as the default value + this directory will automatically be created before the MPD server starts, + otherwise the sysadmin is responsible for ensuring the directory exists + with appropriate ownership and permissions. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = lib.mdDoc '' + Extra directives added to to the end of MPD's configuration file, + mpd.conf. Basic configuration like file location and uid/gid + is added automatically to the beginning of the file. For available + options see {manpage}`mpd.conf(5)`. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/${name}"; + description = lib.mdDoc '' + The directory where MPD stores its state, tag cache, playlists etc. If + left as the default value this directory will automatically be created + before the MPD server starts, otherwise the sysadmin is responsible for + ensuring the directory exists with appropriate ownership and permissions. + ''; + }; + + user = mkOption { + type = types.str; + default = name; + description = lib.mdDoc "User account under which MPD runs."; + }; + + group = mkOption { + type = types.str; + default = name; + description = lib.mdDoc "Group account under which MPD runs."; + }; + + network = { + listenAddress = mkOption { + type = types.str; + default = "127.0.0.1"; + example = "any"; + description = lib.mdDoc '' + The address for the daemon to listen on. + Use `any` to listen on all addresses. + ''; + }; + + port = mkOption { + type = types.port; + default = 6600; + description = lib.mdDoc '' + This setting is the TCP port that is desired for the daemon to get assigned + to. + ''; + }; + }; + + dbFile = mkOption { + type = types.nullOr types.str; + default = "${cfg.dataDir}/tag_cache"; + defaultText = literalExpression ''"''${dataDir}/tag_cache"''; + description = lib.mdDoc '' + The path to MPD's database. If set to `null` the + parameter is omitted from the configuration. + ''; + }; + + credentials = mkOption { + type = types.listOf (types.submodule { + options = { + passwordFile = mkOption { + type = types.path; + description = lib.mdDoc '' + Path to file containing the password. + ''; + }; + permissions = let + perms = ["read" "add" "control" "admin"]; + in + mkOption { + type = types.listOf (types.enum perms); + default = ["read"]; + description = lib.mdDoc '' + List of permissions that are granted with this password. + Permissions can be "${concatStringsSep "\", \"" perms}". + ''; + }; + }; + }); + description = lib.mdDoc '' + Credentials and permissions for accessing the mpd server. + ''; + default = []; + example = [ + { + passwordFile = "/var/lib/secrets/mpd_readonly_password"; + permissions = ["read"]; + } + { + passwordFile = "/var/lib/secrets/mpd_admin_password"; + permissions = ["read" "add" "control" "admin"]; + } + ]; + }; + + fluidsynth = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + If set, add fluidsynth soundfont and configure the plugin. + ''; + }; + }; + }; + + ###### implementation + + config = mkIf cfg.enable { + # install mpd units + systemd.packages = [cfg.package]; + + systemd.sockets.mpd = mkIf cfg.startWhenNeeded { + wantedBy = ["sockets.target"]; + listenStreams = [ + "" # Note: this is needed to override the upstream unit + ( + if pkgs.lib.hasPrefix "/" cfg.network.listenAddress + then cfg.network.listenAddress + else "${optionalString (cfg.network.listenAddress != "any") "${cfg.network.listenAddress}:"}${toString cfg.network.port}" + ) + ]; + }; + + systemd.services.mpd = { + wantedBy = optional (!cfg.startWhenNeeded) "multi-user.target"; + + preStart = + '' + set -euo pipefail + install -m 600 ${mpdConf} /run/mpd/mpd.conf + '' + + optionalString (cfg.credentials != []) + (concatStringsSep "\n" + (imap0 + (i: c: ''${pkgs.replace-secret}/bin/replace-secret '{{password-${toString i}}}' '${c.passwordFile}' /run/mpd/mpd.conf'') + cfg.credentials)); + + serviceConfig = { + User = "${cfg.user}"; + # Note: the first "" overrides the ExecStart from the upstream unit + ExecStart = ["" "${cfg.package}/bin/mpd --systemd /run/mpd/mpd.conf"]; + RuntimeDirectory = "mpd"; + StateDirectory = + [] + ++ optionals (cfg.dataDir == "/var/lib/${name}") [name] + ++ optionals (cfg.playlistDirectory == "/var/lib/${name}/playlists") [name "${name}/playlists"] + ++ optionals (cfg.musicDirectory == "/var/lib/${name}/music") [name "${name}/music"]; + }; + }; + + users.users = optionalAttrs (cfg.user == name) { + "${name}" = { + inherit uid; + group = cfg.group; + extraGroups = ["audio"]; + description = "Music Player Daemon user"; + home = "${cfg.dataDir}"; + }; + }; + + users.groups = optionalAttrs (cfg.group == name) { + "${name}".gid = gid; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/postgresql/data/databases.nix b/hosts/hetzner-arm/containers/postgresql/data/databases.nix new file mode 100644 index 0000000..e69de29 diff --git a/hosts/hetzner-arm/containers/roundcube/default.nix b/hosts/hetzner-arm/containers/roundcube/default.nix new file mode 100644 index 0000000..333af68 --- /dev/null +++ b/hosts/hetzner-arm/containers/roundcube/default.nix @@ -0,0 +1,55 @@ +{ + self, + tree, + inputs, + config, + pkgs, + hostPath, + ... +}: let + containerAddresses = import "${hostPath}/data/containerAddresses.nix"; + hostIP = containerAddresses.host; + containerIP = containerAddresses.containers.roundcube; +in { + containers.roundcube = { + autoStart = true; + privateNetwork = true; + hostAddress = hostIP; + localAddress = containerIP; + + specialArgs = { + inherit inputs; + inherit tree; + inherit self; + inherit hostPath; + }; + + config = {...}: { + nixpkgs.pkgs = pkgs; + + imports = with tree; [ + presets.nixos.containerBase + + profiles.nginx + profiles.sshd + profiles.firewallAllow.ssh + + ./profiles/roundcube.nix + ]; + + home-manager.users.root.home.stateVersion = "23.05"; + system.stateVersion = "23.05"; + }; + }; + + services.nginx = { + enable = true; + virtualHosts."mail.owo.monster" = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://unix:/var/lib/nixos-containers/roundcube/var/sockets/roundcube.sock"; + }; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/roundcube/profiles/roundcube.nix b/hosts/hetzner-arm/containers/roundcube/profiles/roundcube.nix new file mode 100644 index 0000000..2ccd373 --- /dev/null +++ b/hosts/hetzner-arm/containers/roundcube/profiles/roundcube.nix @@ -0,0 +1,55 @@ +{ + pkgs, + lib, + hostPath, + ... +}: let + inherit (lib.modules) mkForce; + + localContainersAddresses = import "${hostPath}/data/containerAddresses.nix"; +in { + services.roundcube = { + enable = true; + hostName = "mail.owo.monster"; + package = pkgs.roundcube.withPlugins (_plugins: + with pkgs.roundcubePlugins; [ + persistent_login + ]); + plugins = [ + "persistent_login" + "managesieve" + ]; + + database = { + host = localContainersAddresses.containers.postgresql; + passwordFile = builtins.toFile "pw" ""; + }; + + extraConfig = '' + $config['smtp_server'] = "tls://mail.owo.monster"; + $config['smtp_user'] = "%u"; + $config['smtp_pass'] = "%p"; + $config['managesieve_host'] = 'tls://mail.owo.monster'; + $config['session_lifetime'] = (60 * 24 * 7 * 2); # 2 Weeks + $config['product_name'] = 'Chaos Mail'; + $config['username_domain'] = "owo.monster"; + $config['username_domain_forced'] = true; + $config['log_driver'] = 'syslog'; + $config['smtp_debug'] = true; + ''; + }; + + systemd.tmpfiles.rules = [ + "d /var/sockets - nginx nginx" + ]; + + systemd.services.nginx.serviceConfig.ReadWritePaths = [ + "/var/sockets" + ]; + + services.nginx.virtualHosts."mail.owo.monster" = { + forceSSL = mkForce false; + enableACME = mkForce false; + extraConfig = "listen unix:/var/sockets/roundcube.sock;"; + }; +} diff --git a/hosts/hetzner-arm/containers/storage/data/ports.nix b/hosts/hetzner-arm/containers/storage/data/ports.nix index 9d1100e..66f79d6 100644 --- a/hosts/hetzner-arm/containers/storage/data/ports.nix +++ b/hosts/hetzner-arm/containers/storage/data/ports.nix @@ -18,6 +18,7 @@ in { restic_forgejo = restic + 6; restic_caldav = restic + 7; restic_owncast = restic + 8; + restic_jellyfin = restic + 9; http_music = http + 0; http_public = http + 1; diff --git a/hosts/hetzner-arm/containers/storage/default.nix b/hosts/hetzner-arm/containers/storage/default.nix index af116e1..d697f55 100644 --- a/hosts/hetzner-arm/containers/storage/default.nix +++ b/hosts/hetzner-arm/containers/storage/default.nix @@ -24,6 +24,25 @@ in { hostAddress = hostIP; localAddress = containerIP; + bindMounts = { + "/dev/fuse" = { + hostPath = "/dev/fuse"; + isReadOnly = false; + }; + }; + + # Allow rclone mount in container + allowedDevices = [ + { + modifier = "rwm"; + node = "/dev/fuse"; + } + { + modifier = "rwm"; + node = "/dev/mapper/control"; + } + ]; + specialArgs = { inherit inputs; inherit tree; @@ -46,7 +65,11 @@ in { users ]); - environment.systemPackages = with pkgs; [rclone]; + environment.systemPackages = with pkgs; [ + rclone + fuse + fuse3 + ]; networking.firewall = { enable = true; @@ -99,6 +122,7 @@ in { "/Forgejo/".proxyPass = "http://${containerIP}:${toString ports.restic_forgejo}"; "/CalDAV/".proxyPass = "http://${containerIP}:${toString ports.restic_caldav}"; "/Owncast/".proxyPass = "http://${containerIP}:${toString ports.restic_owncast}"; + "/Jellyfin/".proxyPass = "http://${containerIP}:${toString ports.restic_jellyfin}"; }; extraConfig = '' client_max_body_size ${clientMaxBodySize}; diff --git a/hosts/hetzner-arm/containers/storage/profiles/rcloneServe.nix b/hosts/hetzner-arm/containers/storage/profiles/rcloneServe.nix index e9194a2..35b7da7 100644 --- a/hosts/hetzner-arm/containers/storage/profiles/rcloneServe.nix +++ b/hosts/hetzner-arm/containers/storage/profiles/rcloneServe.nix @@ -197,6 +197,16 @@ in { "--baseurl=/Owncast/" ]; } + { + id = "restic-jellyfin"; + remote = "StorageBox:Backups/Restic/Jellyfin"; + type = "restic"; + extraArgs = [ + "--addr=0.0.0.0:${toString ports.restic_jellyfin}" + "--htpasswd=${secrets.restic_jellyfin_htpasswd.path}" + "--baseurl=/Jellyfin/" + ]; + } ]; }; } diff --git a/hosts/hetzner-arm/containers/storage/secrets.nix b/hosts/hetzner-arm/containers/storage/secrets.nix index 0df271a..599f71c 100644 --- a/hosts/hetzner-arm/containers/storage/secrets.nix +++ b/hosts/hetzner-arm/containers/storage/secrets.nix @@ -32,6 +32,7 @@ "api-keys/data/storage/restic/Forgejo" "api-keys/data/storage/restic/CalDAV" "api-keys/data/storage/restic/Owncast" + "api-keys/data/storage/restic/Jellyfin" "api-keys/data/storage/webdav/Main" "api-keys/data/storage/webdav/Media" @@ -169,6 +170,14 @@ ''; }; + restic_jellyfin_htpasswd = { + user = "storage"; + group = "storage"; + fetchScript = '' + simple_get_htpasswd "/api-keys/storage/restic/Jellyfin" "$secretFile" + ''; + }; + webdav_main_htpasswd = { user = "storage"; group = "storage"; diff --git a/hosts/hetzner-arm/containers/stream/data/ports.nix b/hosts/hetzner-arm/containers/stream/data/ports.nix new file mode 100644 index 0000000..c8e15c5 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/data/ports.nix @@ -0,0 +1,9 @@ +{ + mpd = 6600; + mpd-opus-low = 4242; + mpd-opus-medium = 4243; + mpd-opus-high = 4244; + mpd-flac = 4245; + slskd = 5000; + slskd-web = 5001; +} diff --git a/hosts/hetzner-arm/containers/stream/music/data/ports.nix b/hosts/hetzner-arm/containers/stream/music/data/ports.nix new file mode 100644 index 0000000..c8e15c5 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/music/data/ports.nix @@ -0,0 +1,9 @@ +{ + mpd = 6600; + mpd-opus-low = 4242; + mpd-opus-medium = 4243; + mpd-opus-high = 4244; + mpd-flac = 4245; + slskd = 5000; + slskd-web = 5001; +} diff --git a/hosts/hetzner-arm/containers/stream/music/default.nix b/hosts/hetzner-arm/containers/stream/music/default.nix new file mode 100644 index 0000000..a167e5f --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/music/default.nix @@ -0,0 +1,123 @@ +{ + self, + hostPath, + tree, + lib, + inputs, + pkgs, + config, + ... +}: let + inherit (lib.modules) mkMerge; + inherit (lib.lists) forEach; + + containerName = "music"; + + containerAddresses = import "${hostPath}/data/containerAddresses.nix"; + + hostIP = containerAddresses.host; + containerIP = containerAddresses.containers.${containerName}; + + ports = import ./data/ports.nix; + + # these secrets should probs be in host but im lazy + containerSecrets = config.containers.${containerName}.config.services.secrets.secrets; + pathInContainer = path: "/var/lib/nixos-containers/${containerName}" + path; +in { + containers.music = { + autoStart = true; + privateNetwork = true; + hostAddress = hostIP; + localAddress = containerIP; + + specialArgs = { + inherit inputs; + inherit tree; + inherit self; + inherit hostPath; + }; + + config = {...}: { + nixpkgs.pkgs = pkgs; + + imports = with tree; + [ + presets.nixos.containerBase + + profiles.nginx + profiles.firewallAllow.httpCommon + + ./secrets.nix + ] + ++ (with hosts.hetzner-arm.containers.music.profiles; [ + mpd + musicSync + soulseek + ]); + + networking.firewall.allowedTCPPorts = with ports; [ + mpd + mpd-opus-low + mpd-opus-medium + mpd-opus-high + mpd-flac + slskd + slskd-web + ]; + + home-manager.users.root.home.stateVersion = "23.05"; + system.stateVersion = "23.05"; + }; + }; + + services.nginx.virtualHosts."soulseek.owo.monster" = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://${containerIP}:${toString ports.slskd-web}"; + proxyWebsockets = true; + }; + }; + + services.nginx.virtualHosts."mpd.owo.monster" = let + extraConfig = '' + auth_basic "Music Password"; + auth_basic_user_file ${pathInContainer containerSecrets.music_stream_passwd.path}; + ''; + in { + forceSSL = true; + enableACME = true; + locations = mkMerge [ + { + "/flac" = { + proxyPass = "http://${containerIP}:${toString ports.mpd-flac}"; + inherit extraConfig; + }; + } + (mkMerge (forEach ["low" "medium" "high"] (quality: { + "/opus-${quality}" = { + proxyPass = "http://${containerIP}:${toString ports."mpd-opus-${quality}"}"; + inherit extraConfig; + }; + }))) + ]; + }; + + networking = { + nat.forwardPorts = [ + { + sourcePort = ports.mpd; + destination = "${containerIP}\:${toString ports.mpd}"; + } + { + sourcePort = ports.slskd; + destination = "${containerIP}\:${toString ports.slskd}"; + } + ]; + + firewall.allowedTCPPorts = with ports; [ + mpd + slskd + ]; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/music/profiles/mpd.nix b/hosts/hetzner-arm/containers/stream/music/profiles/mpd.nix new file mode 100644 index 0000000..d8d949c --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/music/profiles/mpd.nix @@ -0,0 +1,69 @@ +{ + lib, + pkgs, + config, + ... +}: let + inherit (lib.strings) concatStringsSep; + inherit (lib.lists) forEach; + + ports = import ../data/ports.nix; + secrets = config.services.secrets.secrets; +in { + environment.systemPackages = with pkgs; [ + mpc_cli + ]; + + services.mpd = { + enable = true; + network.listenAddress = "0.0.0.0"; + musicDirectory = "/Music"; + credentials = [ + { + passwordFile = "${secrets.mpd_control_password.path}"; + permissions = ["read" "add" "control" "admin"]; + } + ]; + extraConfig = + '' + host_permissions "127.0.0.1 read,add,control,admin" + samplerate_converter "0" + metadata_to_use "title,artist" + auto_update "yes" + audio_buffer_size "4096" + replaygain "track" + audio_output_format "44100:16:2" + '' + + concatStringsSep "\n" (forEach ["low" "medium" "high"] (quality: let + bitrates = { + "low" = "64"; + "medium" = "96"; + "high" = "128"; + }; + bitrate = bitrates.${quality}; + in '' + audio_output { + type "httpd" + name "HTTP Opus ${bitrate}k" + encoder "opus" + port "${toString ports."mpd-opus-${quality}"}" + bitrate "${bitrate}000" + format "44100:16:2" + always_on "yes" + tags "yes" + signal "music" + } + '')) + + '' + audio_output { + type "httpd" + name "HTTP FLAC" + encoder "flac" + port "${toString ports.mpd-flac}" + format "44100:16:2" + always_on "yes" + tags "yes" + } + ''; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/music/profiles/musicSync.nix b/hosts/hetzner-arm/containers/stream/music/profiles/musicSync.nix new file mode 100644 index 0000000..6c3e8e8 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/music/profiles/musicSync.nix @@ -0,0 +1,49 @@ +{pkgs, ...}: let + inherit (pkgs) writeShellScriptBin; + inherit (builtins) toFile; + + rcloneConfig = toFile "rclone.conf" '' + [Music] + type = webdav + url = https://storage-webdav.owo.monster/MusicRO/ + vendor = other + ''; +in { + environment.systemPackages = with pkgs; [ + rclone + (writeShellScriptBin "rclone-music" '' + rclone --config ${rcloneConfig} "$@" + '') + ]; + + systemd.tmpfiles.rules = [ + "d /Music - mpd mpd" + ]; + + systemd.services.music-sync = { + wantedBy = ["multi-user.target"]; + after = ["network.target"]; + partOf = ["mpd.service"]; + + path = with pkgs; [bash rclone]; + + script = '' + set -e + rclone --config ${rcloneConfig} sync Music: /Music + chown -R mpd:mpd /Music + ''; + }; + + systemd.timers.music-sync = { + wantedBy = ["timers.target"]; + partOf = ["music-sync.service"]; + timerConfig.OnCalendar = "hourly"; + }; + + systemd.services.mpd = { + after = ["music-copy.service"]; + serviceConfig = { + ReadOnlyPaths = "/Music"; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/music/profiles/soulseek.nix b/hosts/hetzner-arm/containers/stream/music/profiles/soulseek.nix new file mode 100644 index 0000000..4f66336 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/music/profiles/soulseek.nix @@ -0,0 +1,43 @@ +{ + lib, + config, + ... +}: let + ports = import ../data/ports.nix; + secrets = config.services.secrets.secrets; + + inherit (lib.modules) mkForce; +in { + services.slskd = { + enable = true; + openFirewall = true; + environmentFile = secrets.slskd_env.path; + settings = { + remote_configuration = false; + remote_file_management = true; + soulseek = { + username = "chaoticryptidz"; + description = "chaos's soulseek"; + listen_port = ports.slskd; + }; + web = { + port = ports.slskd-web; + authentication = { + username = "chaos"; + }; + }; + shares.directories = [ + "/Music" + ]; + }; + nginx = { + enable = true; # I don't think this is even cheked + domainName = "soulseek.owo.monster"; + }; + }; + + services.nginx.virtualHosts."soulseek.owo.monster" = { + forceSSL = mkForce false; + enableACME = mkForce false; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/music/secrets.nix b/hosts/hetzner-arm/containers/stream/music/secrets.nix new file mode 100644 index 0000000..1bdf745 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/music/secrets.nix @@ -0,0 +1,57 @@ +{pkgs, ...}: { + services.secrets = { + enable = true; + + vaultLogin = { + enable = true; + loginUsername = "hetzner-arm-container-music"; + }; + + autoSecrets = { + enable = true; + }; + + requiredVaultPaths = [ + "api-keys/data/mpd" + "api-keys/data/music-stream" + "passwords/data/soulseek" + "passwords/data/slskd" + ]; + + packages = with pkgs; [ + apacheHttpd + ]; + + secrets = { + vault_password = { + manual = true; + }; + + mpd_control_password = { + user = "mpd"; + group = "mpd"; + fetchScript = '' + simple_get "/api-keys/mpd" .password > "$secretFile" + ''; + }; + music_stream_passwd = { + user = "nginx"; + group = "nginx"; + fetchScript = '' + username=$(simple_get "/api-keys/music-stream" .username) + password=$(simple_get "/api-keys/music-stream" .password) + htpasswd -bc "$secretFile" "$username" "$password" 2>/dev/null + ''; + }; + slskd_env = { + fetchScript = '' + soulseek_password=$(simple_get "/passwords/soulseek" .password) + slskd_password=$(simple_get "/passwords/slskd" .password) + echo > "$secretFile" + echo "SLSKD_SLSK_PASSWORD=$soulseek_password" >> "$secretFile" + echo "SLSKD_PASSWORD=$slskd_password" >> "$secretFile" + ''; + }; + }; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/profiles/mpd.nix b/hosts/hetzner-arm/containers/stream/profiles/mpd.nix new file mode 100644 index 0000000..d8d949c --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/profiles/mpd.nix @@ -0,0 +1,69 @@ +{ + lib, + pkgs, + config, + ... +}: let + inherit (lib.strings) concatStringsSep; + inherit (lib.lists) forEach; + + ports = import ../data/ports.nix; + secrets = config.services.secrets.secrets; +in { + environment.systemPackages = with pkgs; [ + mpc_cli + ]; + + services.mpd = { + enable = true; + network.listenAddress = "0.0.0.0"; + musicDirectory = "/Music"; + credentials = [ + { + passwordFile = "${secrets.mpd_control_password.path}"; + permissions = ["read" "add" "control" "admin"]; + } + ]; + extraConfig = + '' + host_permissions "127.0.0.1 read,add,control,admin" + samplerate_converter "0" + metadata_to_use "title,artist" + auto_update "yes" + audio_buffer_size "4096" + replaygain "track" + audio_output_format "44100:16:2" + '' + + concatStringsSep "\n" (forEach ["low" "medium" "high"] (quality: let + bitrates = { + "low" = "64"; + "medium" = "96"; + "high" = "128"; + }; + bitrate = bitrates.${quality}; + in '' + audio_output { + type "httpd" + name "HTTP Opus ${bitrate}k" + encoder "opus" + port "${toString ports."mpd-opus-${quality}"}" + bitrate "${bitrate}000" + format "44100:16:2" + always_on "yes" + tags "yes" + signal "music" + } + '')) + + '' + audio_output { + type "httpd" + name "HTTP FLAC" + encoder "flac" + port "${toString ports.mpd-flac}" + format "44100:16:2" + always_on "yes" + tags "yes" + } + ''; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/profiles/soulseek.nix b/hosts/hetzner-arm/containers/stream/profiles/soulseek.nix new file mode 100644 index 0000000..4f66336 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/profiles/soulseek.nix @@ -0,0 +1,43 @@ +{ + lib, + config, + ... +}: let + ports = import ../data/ports.nix; + secrets = config.services.secrets.secrets; + + inherit (lib.modules) mkForce; +in { + services.slskd = { + enable = true; + openFirewall = true; + environmentFile = secrets.slskd_env.path; + settings = { + remote_configuration = false; + remote_file_management = true; + soulseek = { + username = "chaoticryptidz"; + description = "chaos's soulseek"; + listen_port = ports.slskd; + }; + web = { + port = ports.slskd-web; + authentication = { + username = "chaos"; + }; + }; + shares.directories = [ + "/Music" + ]; + }; + nginx = { + enable = true; # I don't think this is even cheked + domainName = "soulseek.owo.monster"; + }; + }; + + services.nginx.virtualHosts."soulseek.owo.monster" = { + forceSSL = mkForce false; + enableACME = mkForce false; + }; +} diff --git a/hosts/hetzner-arm/containers/stream/secrets.nix b/hosts/hetzner-arm/containers/stream/secrets.nix new file mode 100644 index 0000000..1bdf745 --- /dev/null +++ b/hosts/hetzner-arm/containers/stream/secrets.nix @@ -0,0 +1,57 @@ +{pkgs, ...}: { + services.secrets = { + enable = true; + + vaultLogin = { + enable = true; + loginUsername = "hetzner-arm-container-music"; + }; + + autoSecrets = { + enable = true; + }; + + requiredVaultPaths = [ + "api-keys/data/mpd" + "api-keys/data/music-stream" + "passwords/data/soulseek" + "passwords/data/slskd" + ]; + + packages = with pkgs; [ + apacheHttpd + ]; + + secrets = { + vault_password = { + manual = true; + }; + + mpd_control_password = { + user = "mpd"; + group = "mpd"; + fetchScript = '' + simple_get "/api-keys/mpd" .password > "$secretFile" + ''; + }; + music_stream_passwd = { + user = "nginx"; + group = "nginx"; + fetchScript = '' + username=$(simple_get "/api-keys/music-stream" .username) + password=$(simple_get "/api-keys/music-stream" .password) + htpasswd -bc "$secretFile" "$username" "$password" 2>/dev/null + ''; + }; + slskd_env = { + fetchScript = '' + soulseek_password=$(simple_get "/passwords/soulseek" .password) + slskd_password=$(simple_get "/passwords/slskd" .password) + echo > "$secretFile" + echo "SLSKD_SLSK_PASSWORD=$soulseek_password" >> "$secretFile" + echo "SLSKD_PASSWORD=$slskd_password" >> "$secretFile" + ''; + }; + }; + }; +} diff --git a/hosts/hetzner-arm/data/containerAddresses.nix b/hosts/hetzner-arm/data/containerAddresses.nix index 8902a19..19a1c12 100644 --- a/hosts/hetzner-arm/data/containerAddresses.nix +++ b/hosts/hetzner-arm/data/containerAddresses.nix @@ -10,5 +10,6 @@ piped-fi = "10.0.1.8"; caldav = "10.0.1.9"; owncast = "10.0.1.10"; + jellyfin = "10.0.1.11"; }; } diff --git a/hosts/hetzner-arm/hetzner-arm.nix b/hosts/hetzner-arm/hetzner-arm.nix index 6f99f1e..8b2da7a 100644 --- a/hosts/hetzner-arm/hetzner-arm.nix +++ b/hosts/hetzner-arm/hetzner-arm.nix @@ -30,6 +30,7 @@ in { "forgejo" "caldav" "owncast" + "jellyfin" ] (name: ./containers + "/${name}")) ++ (with hosts.hetzner-arm.profiles; [ staticSites diff --git a/profiles/minimalServer.nix b/profiles/minimalServer.nix new file mode 100644 index 0000000..5de54bc --- /dev/null +++ b/profiles/minimalServer.nix @@ -0,0 +1,8 @@ +{lib, ...}: let + inherit (lib.modules) mkDefault; +in { + environment.noXlibs = mkDefault true; + documentation.man.enable = mkDefault false; + documentation.doc.enable = mkDefault false; + fonts.fontconfig.enable = mkDefault false; +}