From a5da0d991f07113822585542b4e4ecc39f36c14d Mon Sep 17 00:00:00 2001 From: Patrick Date: Wed, 6 Mar 2024 13:04:44 +0100 Subject: [PATCH] feat: your_spotify module --- hosts/elisabeth/guests.nix | 6 +- .../secrets/yourspotify/spotifyPublic.age | Bin 0 -> 747 bytes .../secrets/yourspotify/spotifySecret.age | 16 ++ .../secrets/yourspotify/yourspotify.age | 15 -- modules/services/your_spotify_m.nix | 159 ++++++++---------- modules/services/yourspotify.nix | 21 ++- pkgs/default.nix | 1 + pkgs/mongodb-bin.nix | 22 +++ pkgs/your_spotify.nix | 28 ++- pkgs/your_spotify_client.nix | 20 ++- secrets/secrets.nix.age | Bin 5609 -> 5670 bytes 11 files changed, 167 insertions(+), 121 deletions(-) create mode 100644 hosts/elisabeth/secrets/yourspotify/spotifyPublic.age create mode 100644 hosts/elisabeth/secrets/yourspotify/spotifySecret.age delete mode 100644 hosts/elisabeth/secrets/yourspotify/yourspotify.age create mode 100644 pkgs/mongodb-bin.nix diff --git a/hosts/elisabeth/guests.nix b/hosts/elisabeth/guests.nix index 5bf2458..ea1a6cd 100644 --- a/hosts/elisabeth/guests.nix +++ b/hosts/elisabeth/guests.nix @@ -15,8 +15,8 @@ paperlessdomain = "ppl.${config.secrets.secrets.global.domains.web}"; ttrssdomain = "rss.${config.secrets.secrets.global.domains.web}"; vaultwardendomain = "pw.${config.secrets.secrets.global.domains.web}"; - spotifydomain = "spotify.${config.secrets.secrets.global.domains.web}"; - apispotifydomain = "api.spotify.${config.secrets.secrets.global.domains.web}"; + spotifydomain = "sptfy.${config.secrets.secrets.global.domains.web}"; + apispotifydomain = "apisptfy.${config.secrets.secrets.global.domains.web}"; kanidmdomain = "auth.${config.secrets.secrets.global.domains.web}"; ipOf = hostName: lib.net.cidr.host config.secrets.secrets.global.net.ips."${config.guests.${hostName}.nodeName}" config.secrets.secrets.global.net.privateSubnetv4; in { @@ -182,7 +182,7 @@ in { ''; }; upstreams.apispotify = { - servers."${ipOf "yourspotify"}:8080" = {}; + servers."${ipOf "yourspotify"}:3000" = {}; extraConfig = '' zone spotify 64k ; diff --git a/hosts/elisabeth/secrets/yourspotify/spotifyPublic.age b/hosts/elisabeth/secrets/yourspotify/spotifyPublic.age new file mode 100644 index 0000000000000000000000000000000000000000..adc3e170cb06c33a342733a3018c46825df84bff GIT binary patch literal 747 zcmY+=JFC-R0Dxg}5X>(SU5Y3Op{GrgG>wDs<+N$iG)dDWZQ9AS`I_d^FPApgvpcyt zxVeZZE)EKUlW-<=brf|F1QEgI%;tO5IjYV9h%Xt3%TS z+fa6z!1y%BY&+XXUEt1vYy+>HatFCwDt(|CENK$E0U?;!SRL1OCUym{E%(NH3RX5> zfo^P}iQDsuS-$DA#X=D&-2ErSUfzs5bhlv{T3~_(K+{gLLavjyhGCl)OirsEmBM{~ zwP(<5Cm&D1kV=*aDr0Pow$_4HX+ATke#HBuzB(kp!YV=nZpu9=7g5Qp%JI0}J7+C< z*At5dNMk|PU>Z}AXxE|YtqW;Z1pzP*N**ZlT^Y;=out?XYaQbxp0;dJ;x;@#6csfx zBrrfW7f*wd3$2BoW^!f~iWL^;EIQv!4q5|HC>LN0cd}8(Fji`6T07QSX*&6Log_eG zZ^?E6jHNTnv3k~nrc?D%zSlN6>9mkXIL6>m$2pl~ z39nTqwbvsQ!?hGmj|&B~r83=@SvH;oqE!pn1TpRs=6=U$b?YGTHnU7%;0%bfpyJ)h za2`UcKJKP#SN_)pnr;g1J;cavxDzHDcRVsslv{d0CPv_8w!dky&|9&hQtH#gG}kG) zPcITU$HmriNG5w7&nA^2Vid=Xux7K_KvfLPoay<#&Nw_|hHTpaiv#U3rz4&_N`OjZ>U%h X25519 NZi/SK85OEWu7w/yvCOn3/l+obNYPsoTZJfBwSSL8gI +7dJPCeMxo1TKGrTH0wWxMB6Y46sph5xGv4JzNeTDtfI +-> piv-p256 XTQkUA AkuxoMo1sCU9PHemSyv5b0xBIK+n5x6OI1Q/VmflaK/o +71KGxc8CXfBhbIBp5NqItWDRvTAyrsJMFSz+O5xRms4 +-> piv-p256 ZFgiIw AtA2xJwEhw2jX85m/9JsNyOHdmv2u7tfAMvQdfKkZ6N+ +7MbtW54kju2yaIKwn2enFlO6t6othyMP05GPurOB7YM +-> piv-p256 5vmPtQ Ah/kiptBGGyYTGSpjvXoFW7yV33gNE7DuXzSIcupOm1I +cVhABpZEkaPkXEbtk4PPq4z0BTH9kazY2n6jFlaa9YQ +-> piv-p256 ZFgiIw A3GbyGhthzf+oAeMKiI/39MiHvEykf7EkiiW81000Wq5 +vMTauia+psU/AtxhriecJci6uONm8BR3db1qPbTANzk +-> *-grease +6agZ0e3ziYasFWtZqR76cVifklgY8kmv531Z79Fr +--- j9TlSPpi3L50WqQw3YD8P0zI0cboA18D6LqzJ1TAYKQ +ؘ5[b-_CBXJt\BE)m;{2a +$ \ No newline at end of file diff --git a/hosts/elisabeth/secrets/yourspotify/yourspotify.age b/hosts/elisabeth/secrets/yourspotify/yourspotify.age deleted file mode 100644 index 4d4ac2d..0000000 --- a/hosts/elisabeth/secrets/yourspotify/yourspotify.age +++ /dev/null @@ -1,15 +0,0 @@ -age-encryption.org/v1 --> X25519 ggSXy/sQbB5RIx9y+7b9gx+Osn4CDC1llDZpEurSUlQ -eaRyyBSWaPjuY0VQOIKef+jeJrKP/bjn0A3ptY1Yi1c --> piv-p256 XTQkUA A712bv8pNfgCw6BY8uko50ZT6ctKw0aKGMzw21ntFoH9 -Od/YRbbeDhrsjrydRLpbJ29fb7FVVLNdHrqHIqADD90 --> piv-p256 ZFgiIw A7KV41jrxMfKZvJVInfcLH0SX22uRKrGx3Ce1RBK1ba0 -o6DUQEhob61zHAj6o4l0wPLudMjsg8w2qyanKWn7ZsQ --> piv-p256 5vmPtQ AjgfvHuq6ZktpH4hS5aMnT8OJnFLN0D0+ELXNvuaNyi/ -ALCCRjJYI6CExt9Di4/p5Gcok5IO/nmuFV5wN7ZJYx0 --> piv-p256 ZFgiIw Amn+6yW9k49wRmdkooDqE185U/oZq69mcP2NbOq4l5Ty -aQbjyUaiBbf34Fg6HXxgcuVy5s69j4nhmZKelxlGx2Y --> Zb-grease Kj7 -L0fTYguif9Le7qsrbF1YsD43CgE ---- lGupwRLVGo0u7OcziXOmEFo6kA7NsvnMuCLWiIRdqA0 -#y^uf1ʶ6DAVInhzG&eGVg&MìqT]95/39.=w~4ۧw~H3\c>ΚZْ \ No newline at end of file diff --git a/modules/services/your_spotify_m.nix b/modules/services/your_spotify_m.nix index da8cc19..579573f 100644 --- a/modules/services/your_spotify_m.nix +++ b/modules/services/your_spotify_m.nix @@ -15,7 +15,6 @@ mkIf mkOption mkPackageOption - optional optionalAttrs types ; @@ -23,7 +22,7 @@ configEnv = concatMapAttrs (name: value: optionalAttrs (value != null) { - name = + ${name} = if isBool value then boolToString value else toString value; @@ -33,7 +32,7 @@ configFile = pkgs.writeText "your_spotify.env" (concatStrings (mapAttrsToList (name: value: "${name}=${value}\n") configEnv)); in { options.services.your_spotify = let - inherit (types) nullOr port str bool package; + inherit (types) nullOr port str path bool package; in { enable = mkEnableOption "your_spotify"; @@ -47,6 +46,23 @@ in { default = cfg.package.client.override {apiEndpoint = cfg.settings.API_ENDPOINT;}; description = "Client package to use."; }; + spotifyPublicFile = mkOption { + type = path; + description = '' + The public key of your Spotify application + [Creating the Spotify Application](https://github.com/Yooooomi/your_spotify#creating-the-spotify-application) + ''; + }; + + spotifySecretFile = mkOption { + type = path; + description = '' + The secret key of your Spotify application + [Creating the Spotify Application](https://github.com/Yooooomi/your_spotify#creating-the-spotify-application) + Note that you may want to set this using the `environmentFile` config option to prevent + your secret from being world-readable in the nix store. + ''; + }; settings = mkOption { type = types.submodule { @@ -65,34 +81,16 @@ in { This means that for example you may need two nginx virtual hosts if you want to expose this on the internet. ''; - default = "http://localhost:8080"; + default = "https://localhost:3000"; }; - spotifyPublic = mkOption { - type = nullOr str; - description = '' - The public key of your Spotify application - [Creating the Spotify Application](https://github.com/Yooooomi/your_spotify#creating-the-spotify-application) - ''; - default = null; - }; - spotifySecret = mkOption { - type = nullOr str; - description = '' - The secret key of your Spotify application - [Creating the Spotify Application](https://github.com/Yooooomi/your_spotify#creating-the-spotify-application) - Note that you may want to set this using the `environmentFile` config option to prevent - your secret from being world-readable in the nix store. - ''; - default = null; - }; - cors = mkOption { + CORS = mkOption { type = nullOr str; description = '' List of comma-separated origin allowed, or nothing to allow any origin ''; default = null; }; - maxImportCacheSize = mkOption { + MAX_IMPORT_CACHESIZE = mkOption { type = str; description = '' The maximum element in the cache when importing data from an outside source, @@ -100,40 +98,40 @@ in { ''; default = "Infinite"; }; - mongoEndpoint = mkOption { + MONGO_ENDPOINT = mkOption { type = str; description = '' The endpoint of the Mongo database. ''; default = "mongodb://localhost:27017/your_spotify"; }; - port = mkOption { + PORT = mkOption { type = port; description = "The port of the api server"; - default = 8080; + default = 3000; }; - timezone = mkOption { + TIMEZONE = mkOption { type = str; description = '' The timezone of your stats, only affects read requests since data is saved with UTC time ''; default = "Europe/Paris"; }; - logLevel = mkOption { + LOG_LEVEL = mkOption { type = str; description = '' The log level, debug is useful if you encouter any bugs ''; default = "info"; }; - cookieValidityMs = mkOption { + COOKIE_VALIDITY_MS = mkOption { type = str; description = '' Validity time of the authentication cookie ''; default = "1h"; }; - mongoNoAdminRights = mkOption { + MONGO_NO_ADMIN_RIGHTS = mkOption { type = bool; description = '' Do not ask for admin right on the Mongo database @@ -143,33 +141,22 @@ in { }; }; }; - - environmentFile = mkOption { - type = with types; nullOr path; - default = null; - example = "/var/lib/your_spotify.env"; - description = '' - Additional environment file as defined in {manpage}`systemd.exec(5)`. - - Secrets like {env}`SPOTIFY_SECRET` - may be passed to the service without adding them to the world-readable Nix store. - - Note that this file needs to be available on the host on which - `your_spotify` is running. - ''; - }; }; config = mkIf cfg.enable { systemd.services.your_spotify = { after = ["network.target"]; + script = '' + export SPOTIFY_PUBLIC=$(< "$CREDENTIALS_DIRECTORY/SPOTIFY_PUBLIC") + export SPOTIFY_SECRET=$(< "$CREDENTIALS_DIRECTORY/SPOTIFY_SECRET") + exec ${pkgs.your_spotify}/bin/your_spotify_server + ''; serviceConfig = { User = "your_spotify"; Group = "your_spotify"; DynamicUser = true; - EnvironmentFile = [configFile] ++ optional (cfg.environmentFile != null) cfg.environmentFile; + EnvironmentFile = [configFile]; ExecStartPre = "${pkgs.your_spotify}/bin/your_spotify_migrate"; - ExecStart = "${pkgs.your_spotify}/bin/your_spotify_server"; StateDirectory = "your_spotify"; LimitNOFILE = "1048576"; PrivateTmp = true; @@ -177,49 +164,50 @@ in { StateDirectoryMode = "0700"; Restart = "always"; - # Hardening - CapabilityBoundingSet = ""; - LockPersonality = true; - MemoryDenyWriteExecute = true; - DevicePolicy = "closed"; - SupplementaryGroups = ["dialout"]; - #NoNewPrivileges = true; # Implied by DynamicUser - PrivateUsers = true; - #PrivateTmp = true; # Implied by DynamicUser - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = false; # breaks bwrap - ProtectKernelLogs = false; # breaks bwrap - ProtectKernelModules = true; - ProtectKernelTunables = false; # breaks bwrap - ProtectProc = "invisible"; - ProcSubset = "all"; # Using "pid" breaks bwrap - ProtectSystem = "strict"; - #RemoveIPC = true; # Implied by DynamicUser - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_NETLINK" - "AF_UNIX" - ]; - RestrictNamespaces = true; - RestrictRealtime = true; - #RestrictSUIDSGID = true; # Implied by DynamicUser - SystemCallArchitectures = "native"; - SystemCallFilter = [ - "@system-service" - "@mount" # Required by platformio for chroot - ]; - UMask = "0077"; + LoadCredential = ["SPOTIFY_PUBLIC:${cfg.spotifyPublicFile}" "SPOTIFY_SECRET:${cfg.spotifySecretFile}"]; + + ## Hardening + #CapabilityBoundingSet = ""; + #LockPersonality = true; + ##MemoryDenyWriteExecute = true; + ##NoNewPrivileges = true; # Implied by DynamicUser + #PrivateUsers = true; + ##PrivateTmp = true; # Implied by DynamicUser + #ProtectClock = true; + #ProtectControlGroups = true; + #ProtectHome = true; + #ProtectHostname = false; # breaks bwrap + #ProtectKernelLogs = false; # breaks bwrap + #ProtectKernelModules = true; + #ProtectKernelTunables = false; # breaks bwrap + #ProtectProc = "invisible"; + #ProcSubset = "all"; # Using "pid" breaks bwrap + #ProtectSystem = "strict"; + ##RemoveIPC = true; # Implied by DynamicUser + #RestrictAddressFamilies = [ + # "AF_INET" + # "AF_INET6" + # "AF_NETLINK" + # "AF_UNIX" + #]; + #RestrictNamespaces = true; + #RestrictRealtime = true; + ##RestrictSUIDSGID = true; # Implied by DynamicUser + #SystemCallArchitectures = "native"; + #SystemCallFilter = [ + # "@system-service" + # "@mount" # Required by platformio for chroot + #]; + #UMask = "0077"; }; wantedBy = ["multi-user.target"]; }; services.nginx = mkIf cfg.enableNginxVirtualHost { enable = true; virtualHosts.${cfg.settings.CLIENT_ENDPOINT} = { + root = cfg.clientPackage; locations."/".extraConfig = '' - try_files = "$uri $uri/ /index.html; + try_files = $uri $uri/ /index.html ; ''; }; }; @@ -228,6 +216,3 @@ in { }; }; } -# nginx gaten -# systemd hardening(e.g. esphome) - diff --git a/modules/services/yourspotify.nix b/modules/services/yourspotify.nix index 5f04321..61a5bd0 100644 --- a/modules/services/yourspotify.nix +++ b/modules/services/yourspotify.nix @@ -1,19 +1,26 @@ {config, ...}: { + networking.firewall.allowedTCPPorts = [3000 80]; imports = [./your_spotify_m.nix]; - age.secrets.spotify = { - owner = "your_spotify"; + age.secrets.spotifySecret = { + owner = "root"; mode = "440"; - rekeyFile = config.node.secretsDir + "/yourspotify.age"; + rekeyFile = config.node.secretsDir + "/spotifySecret.age"; + }; + age.secrets.spotifyPublic = { + owner = "root"; + mode = "440"; + rekeyFile = config.node.secretsDir + "/spotifyPublic.age"; }; services.your_spotify = { - #enable = true; + enable = true; + spotifySecretFile = config.age.secrets.spotifySecret.path; + spotifyPublicFile = config.age.secrets.spotifyPublic.path; settings = { - CLIENT_ENDPOINT = "https://spotify.${config.secrets.secrets.global.domains.web}"; - API_ENDPOINT = "https://api.spotify.${config.secrets.secrets.global.domains.web}"; + CLIENT_ENDPOINT = "https://sptfy.${config.secrets.secrets.global.domains.web}"; + API_ENDPOINT = "https://apisptfy.${config.secrets.secrets.global.domains.web}"; }; enableLocalDB = true; enableNginxVirtualHost = true; - environmentFile = config.age.secrets.spotify.path; }; environment.persistence."/persist".directories = [ { diff --git a/pkgs/default.nix b/pkgs/default.nix index b6df4ca..30a6ec9 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -6,6 +6,7 @@ your_spotify = super.callPackage ./your_spotify.nix {}; deploy = super.callPackage ./deploy.nix {}; minify = super.callPackage ./minify {}; + mongodb-bin = super.callPackage ./mongodb-bin.nix {}; awakened-poe-trade = super.callPackage ./awakened-poe-trade.nix {}; neovim-clean = super.neovim-unwrapped.overrideAttrs (_neovimFinal: neovimPrev: { nativeBuildInputs = (neovimPrev.nativeBuildInputs or []) ++ [super.makeWrapper]; diff --git a/pkgs/mongodb-bin.nix b/pkgs/mongodb-bin.nix new file mode 100644 index 0000000..1d7b94b --- /dev/null +++ b/pkgs/mongodb-bin.nix @@ -0,0 +1,22 @@ +{ + stdenv, + fetchurl, +}: +stdenv.mkDerivation { + pname = "mongodb-bin"; + version = "1.0.0"; + srcs = [ + ( + fetchurl { + url = "https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2204-6.0.14.tgz"; + #hash = ""; + } + ) + ( + fetchurl { + url = "https://downloads.mongodb.com/compass/mongosh-2.1.5-linux-x64.tgz"; + #hash = ""; + } + ) + ]; +} diff --git a/pkgs/your_spotify.nix b/pkgs/your_spotify.nix index b70d5d8..6bacf5a 100644 --- a/pkgs/your_spotify.nix +++ b/pkgs/your_spotify.nix @@ -4,6 +4,8 @@ fetchYarnDeps, makeWrapper, nodejs, + yarn, + prefetch-yarn-deps, lib, callPackage, }: let @@ -23,23 +25,37 @@ in yarnLock = src + "/yarn.lock"; hash = "sha256-pj6owoEPx9gdtFvXF8E89A+Thhe/7m0+OJU6Ttc6ooA="; }; + + configurePhase = '' + runHook preConfigure + + export HOME=$(mktemp -d) + yarn config --offline set yarn-offline-mirror $offlineCache + fixup-yarn-lock yarn.lock + yarn install --offline --frozen-lockfile --ignore-platform --ignore-scripts --no-progress --non-interactive + patchShebangs node_modules/ + + runHook postConfigure + ''; + buildPhase = '' runHook preBuild - pushd ./deps/@your_spotify/root/apps/server/ - yarn --offline --production + ls -lah + pushd ./apps/server/ + yarn --offline run build popd runHook postBuild ''; - nativeBuildInputs = [makeWrapper]; + nativeBuildInputs = [makeWrapper yarn prefetch-yarn-deps]; installPhase = '' mkdir -p $out - cp -r $node_modules $out/node_modules - cp -r ./deps/your_spotify/apps/server/{lib,package.json} $out + cp -r node_modules $out/node_modules + cp -r ./apps/server/{lib,package.json} $out mkdir -p $out/bin makeWrapper ${lib.escapeShellArg (lib.getExe nodejs)} "$out/bin/your_spotify_migrate" \ --add-flags "$out/lib/migrations.js" makeWrapper ${lib.escapeShellArg (lib.getExe nodejs)} "$out/bin/your_spotify_server" \ - --add-flags "$out/lib/bin/www.js" + --add-flags "$out/lib/index.js" ''; doDist = false; passthru = { diff --git a/pkgs/your_spotify_client.nix b/pkgs/your_spotify_client.nix index aacd8ab..05fbcf6 100644 --- a/pkgs/your_spotify_client.nix +++ b/pkgs/your_spotify_client.nix @@ -5,6 +5,8 @@ apiEndpoint ? "localhost:8080", src, version, + yarn, + prefetch-yarn-deps, }: mkYarnPackage rec { inherit version src; @@ -13,18 +15,30 @@ mkYarnPackage rec { yarnLock = src + "/yarn.lock"; hash = "sha256-pj6owoEPx9gdtFvXF8E89A+Thhe/7m0+OJU6Ttc6ooA="; }; + configurePhase = '' + runHook preConfigure + + export HOME=$(mktemp -d) + yarn config --offline set yarn-offline-mirror $offlineCache + fixup-yarn-lock yarn.lock + yarn install --offline --frozen-lockfile --ignore-platform --ignore-scripts --no-progress --non-interactive + patchShebangs node_modules/ + + runHook postConfigure + ''; buildPhase = '' runHook preBuild - pushd ./deps/@your_spotify/root/apps/client/ + pushd ./apps/client/ pwd yarn --offline run build popd runHook postBuild ''; - nativeBuildInputs = [makeWrapper]; + nativeBuildInputs = [makeWrapper yarn prefetch-yarn-deps]; + installPhase = '' mkdir -p $out - cp -r ./deps/your_spotify/apps/client/build/* $out + cp -r ./apps/client/build/* $out substituteInPlace $out/variables-template.js --replace-quiet '__API_ENDPOINT__' "${apiEndpoint}" mv $out/variables-template.js $out/variables.js ''; diff --git a/secrets/secrets.nix.age b/secrets/secrets.nix.age index ed9d4819f1afb00525d3a9641c80b1bb31b8b059..6a38c06541d415f4f8b84859759e98ebfe223f29 100644 GIT binary patch literal 5670 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