From 7d79d6013222177873e424a9d5e569142231d839 Mon Sep 17 00:00:00 2001 From: thiloho <123883702+thiloho@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:29:48 +0100 Subject: [PATCH] Refactor NixOS module --- nix/deploy/qs/default.nix | 24 ++- nix/module.nix | 442 +++++++++++++++++++------------------- nix/package.nix | 12 +- 3 files changed, 241 insertions(+), 237 deletions(-) diff --git a/nix/deploy/qs/default.nix b/nix/deploy/qs/default.nix index fad8b18..8b2dcc4 100644 --- a/nix/deploy/qs/default.nix +++ b/nix/deploy/qs/default.nix @@ -1,4 +1,7 @@ { pkgs, localArchtikaPackage, ... }: +let + domain = "qs.archtika.com"; +in { imports = [ ./hardware-configuration.nix @@ -11,14 +14,23 @@ services.archtika = { enable = true; package = localArchtikaPackage; - domain = "qs.archtika.com"; - acmeEmail = "thilo.hohlt@tutanota.com"; - dnsProvider = "porkbun"; - dnsEnvironmentFile = /var/lib/porkbun.env; + inherit domain; settings = { disableRegistration = true; - maxWebsiteStorageSize = 250; - maxUserWebsites = 3; + maxWebsiteStorageSize = 50; + maxUserWebsites = 2; + }; + }; + + security.acme = { + acceptTerms = true; + defaults.email = "thilo.hohlt@tutanota.com"; + certs."${domain}" = { + inherit domain; + extraDomainNames = [ "*.${domain}" ]; + dnsProvider = "porkbun"; + environmentFile = /var/lib/porkbun.env; + group = "nginx"; }; }; } diff --git a/nix/module.nix b/nix/module.nix index bb95ca6..f2ad9e3 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -5,41 +5,19 @@ ... }: -with lib; - let + inherit (lib) + mkEnableOption + mkOption + mkIf + mkPackageOption + types + ; cfg = config.services.archtika; - baseHardenedSystemdOptions = { - CapabilityBoundingSet = ""; - LockPersonality = true; - NoNewPrivileges = true; - PrivateDevices = true; - PrivateTmp = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectSystem = "strict"; - RemoveIPC = true; - RestrictNamespaces = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - SystemCallArchitectures = "native"; - SystemCallFilter = [ - "@system-service" - "~@privileged" - "~@resources" - ]; - - ReadWritePaths = [ "/var/www/archtika-websites" ]; - }; in { options.services.archtika = { - enable = mkEnableOption "archtika service"; + enable = mkEnableOption "Whether to enable the archtika service"; package = mkPackageOption pkgs "archtika" { }; @@ -81,29 +59,11 @@ in domain = mkOption { type = types.str; - default = null; description = "Domain to use for the application."; }; - acmeEmail = mkOption { - type = types.str; - default = null; - description = "Email to notify for the SSL certificate renewal process."; - }; - - dnsProvider = mkOption { - type = types.str; - default = null; - description = "DNS provider for the DNS-01 challenge (required for wildcard domains)."; - }; - - dnsEnvironmentFile = mkOption { - type = types.path; - default = null; - description = "API secrets for the DNS-01 challenge (required for wildcard domains)."; - }; - settings = mkOption { + description = "Settings for the running archtika application."; type = types.submodule { options = { disableRegistration = mkOption { @@ -112,13 +72,13 @@ in description = "By default any user can create an account. That behavior can be disabled by using this option."; }; maxUserWebsites = mkOption { - type = types.int; + type = types.ints.positive; default = 2; description = "Maximum number of websites allowed per user by default."; }; maxWebsiteStorageSize = mkOption { - type = types.int; - default = 500; + type = types.ints.positive; + default = 50; description = "Maximum amount of disk space in MB allowed per user website by default."; }; }; @@ -126,184 +86,226 @@ in }; }; - config = mkIf cfg.enable { - users.users.${cfg.user} = { - isSystemUser = true; - group = cfg.group; - }; + config = mkIf cfg.enable ( + let + baseHardenedSystemdOptions = { + CapabilityBoundingSet = ""; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + ReadWritePaths = [ "/var/www/archtika-websites" ]; + }; + in + { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + }; - users.groups.${cfg.group} = { - members = [ - "nginx" - "postgres" - ]; - }; - - systemd.tmpfiles.rules = [ - "d /var/www 0755 root root -" - "d /var/www/archtika-websites 0770 ${cfg.user} ${cfg.group} -" - ]; - - systemd.services.archtika-api = { - description = "archtika API service"; - wantedBy = [ "multi-user.target" ]; - after = [ - "network.target" - "postgresql.service" - ]; - - serviceConfig = baseHardenedSystemdOptions // { - User = cfg.user; - Group = cfg.group; - Restart = "always"; - WorkingDirectory = "${cfg.package}/rest-api"; - - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_UNIX" + users.groups.${cfg.group} = { + members = [ + "nginx" + "postgres" ]; }; - script = '' - JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64) - - ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'" - ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_storage_size\" TO ${toString cfg.settings.maxWebsiteStorageSize}" - ${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_number_user\" TO ${toString cfg.settings.maxUserWebsites}" - - ${pkgs.dbmate}/bin/dbmate --url postgres://postgres@localhost:5432/archtika?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up - - PGRST_SERVER_CORS_ALLOWED_ORIGINS="https://${cfg.domain}" PGRST_ADMIN_SERVER_PORT=${toString cfg.apiAdminPort} PGRST_SERVER_PORT=${toString cfg.apiPort} PGRST_DB_SCHEMAS="api" PGRST_DB_ANON_ROLE="anon" PGRST_OPENAPI_MODE="ignore-privileges" PGRST_DB_URI="postgres://authenticator@localhost:5432/${cfg.databaseName}" PGRST_JWT_SECRET="$JWT_SECRET" ${pkgs.postgrest}/bin/postgrest - ''; - }; - - systemd.services.archtika-web = { - description = "archtika Web App service"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; - - serviceConfig = baseHardenedSystemdOptions // { - User = cfg.user; - Group = cfg.group; - Restart = "always"; - WorkingDirectory = "${cfg.package}/web-app"; - - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - ]; - }; - - script = '' - REGISTRATION_IS_DISABLED=${toString cfg.settings.disableRegistration} BODY_SIZE_LIMIT=10M ORIGIN=https://${cfg.domain} PORT=${toString cfg.webAppPort} ${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app - ''; - }; - - services.postgresql = { - enable = true; - package = pkgs.postgresql_16; - ensureDatabases = [ cfg.databaseName ]; - authentication = lib.mkForce '' - # IPv4 local connections: - host all all 127.0.0.1/32 trust - # IPv6 local connections: - host all all ::1/128 trust - # Local socket connections: - local all all trust - ''; - extraPlugins = with pkgs.postgresql16Packages; [ pgjwt ]; - }; - - systemd.services.postgresql = { - path = with pkgs; [ - # Tar and gzip are needed for tar.gz exports - gnutar - gzip - ]; - }; - - services.nginx = { - enable = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; - recommendedZstdSettings = true; - recommendedOptimisation = true; - - appendHttpConfig = '' - limit_req_zone $binary_remote_addr zone=requestLimit:10m rate=5r/s; - limit_req_status 429; - limit_req zone=requestLimit burst=20 nodelay; - - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "accelerometer=(),autoplay=(),camera=(),cross-origin-isolated=(),display-capture=(),encrypted-media=(),fullscreen=(self),geolocation=(),gyroscope=(),keyboard-map=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(self),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),xr-spatial-tracking=(),clipboard-read=(self),clipboard-write=(self),gamepad=(),hid=(),idle-detection=(),interest-cohort=(),serial=(),unload=()" always; - - map $http_cookie $auth_header { - default ""; - "~*session_token=([^;]+)" "Bearer $1"; - } - ''; - - virtualHosts = { - "${cfg.domain}" = { - useACMEHost = cfg.domain; - forceSSL = true; - locations = { - "/" = { - proxyPass = "http://localhost:${toString cfg.webAppPort}"; - }; - "/previews/" = { - alias = "/var/www/archtika-websites/previews/"; - index = "index.html"; - tryFiles = "$uri $uri/ $uri.html =404"; - }; - "/api/rpc/export_articles_zip" = { - proxyPass = "http://localhost:${toString cfg.apiPort}/rpc/export_articles_zip"; - extraConfig = '' - default_type application/json; - proxy_set_header Authorization $auth_header; - ''; - }; - "/api/" = { - proxyPass = "http://localhost:${toString cfg.apiPort}/"; - extraConfig = '' - default_type application/json; - ''; - }; - "/api/rpc/register" = mkIf cfg.settings.disableRegistration { - extraConfig = '' - deny all; - ''; + systemd.tmpfiles.settings = { + "10-archtika" = { + "/var/www" = { + d = { + mode = "0755"; + user = "root"; + group = "root"; }; }; - }; - "~^(?.+)\\.${cfg.domain}$" = { - useACMEHost = cfg.domain; - forceSSL = true; - locations = { - "/" = { - root = "/var/www/archtika-websites/$subdomain"; - index = "index.html"; - tryFiles = "$uri $uri/ $uri.html =404"; + "/var/www/archtika-websites" = { + d = { + mode = "0770"; + user = cfg.user; + group = cfg.group; }; }; }; }; - }; - security.acme = { - acceptTerms = true; - defaults.email = cfg.acmeEmail; - certs."${cfg.domain}" = { - domain = cfg.domain; - extraDomainNames = [ "*.${cfg.domain}" ]; - dnsProvider = cfg.dnsProvider; - environmentFile = cfg.dnsEnvironmentFile; - group = config.services.nginx.group; + systemd.services.archtika-api = { + description = "archtika API service"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network.target" + "postgresql.service" + ]; + + path = [ config.services.postgresql.package ]; + + serviceConfig = baseHardenedSystemdOptions // { + User = cfg.user; + Group = cfg.group; + Restart = "always"; + WorkingDirectory = "${cfg.package}/rest-api"; + + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + }; + + script = + let + dbUrl = + user: + "postgres://${user}@127.0.0.1:${toString config.services.postgresql.settings.port}/${cfg.databaseName}"; + in + '' + JWT_SECRET=$(tr -dc "A-Za-z0-9" < /dev/urandom | head -c64) + + psql ${dbUrl "postgres"} \ + -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'" \ + -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_storage_size\" TO ${toString cfg.settings.maxWebsiteStorageSize}" \ + -c "ALTER DATABASE ${cfg.databaseName} SET \"app.website_max_number_user\" TO ${toString cfg.settings.maxUserWebsites}" + + dbmate --url ${dbUrl "postgres"}?sslmode=disable --migrations-dir ${cfg.package}/rest-api/db/migrations up + + PGRST_SERVER_CORS_ALLOWED_ORIGINS="https://${cfg.domain}" \ + PGRST_ADMIN_SERVER_PORT=${toString cfg.apiAdminPort} \ + PGRST_SERVER_PORT=${toString cfg.apiPort} \ + PGRST_DB_SCHEMAS="api" \ + PGRST_DB_ANON_ROLE="anon" \ + PGRST_OPENAPI_MODE="ignore-privileges" \ + PGRST_DB_URI=${dbUrl "authenticator"} \ + PGRST_JWT_SECRET="$JWT_SECRET" \ + ${pkgs.postgrest}/bin/postgrest + ''; }; - }; - }; + + systemd.services.archtika-web = { + description = "archtika Web App service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = baseHardenedSystemdOptions // { + User = cfg.user; + Group = cfg.group; + Restart = "always"; + WorkingDirectory = "${cfg.package}/web-app"; + + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + }; + + environment = { + REGISTRATION_IS_DISABLED = toString cfg.settings.disableRegistration; + BODY_SIZE_LIMIT = "10M"; + ORIGIN = "https://${cfg.domain}"; + PORT = toString cfg.webAppPort; + }; + + script = "${pkgs.nodejs_22}/bin/node ${cfg.package}/web-app"; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ cfg.databaseName ]; + authentication = lib.mkOverride 51 '' + host all all 127.0.0.1/32 trust + host all all ::1/128 trust + local all all trust + ''; + extensions = ps: with ps; [ pgjwt ]; + }; + + systemd.services.postgresql.path = builtins.attrValues { + inherit (pkgs) gnutar gzip; + }; + + services.nginx = { + enable = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + recommendedZstdSettings = true; + recommendedOptimisation = true; + + appendHttpConfig = '' + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "accelerometer=(),autoplay=(),camera=(),cross-origin-isolated=(),display-capture=(),encrypted-media=(),fullscreen=(self),geolocation=(),gyroscope=(),keyboard-map=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(self),publickey-credentials-get=(),screen-wake-lock=(),sync-xhr=(self),usb=(),xr-spatial-tracking=(),clipboard-read=(self),clipboard-write=(self),gamepad=(),hid=(),idle-detection=(),interest-cohort=(),serial=(),unload=()" always; + + map $http_cookie $auth_header { + default ""; + "~*session_token=([^;]+)" "Bearer $1"; + } + ''; + + virtualHosts = { + "${cfg.domain}" = { + useACMEHost = cfg.domain; + forceSSL = true; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${toString cfg.webAppPort}"; + }; + "/previews/" = { + alias = "/var/www/archtika-websites/previews/"; + index = "index.html"; + tryFiles = "$uri $uri/ $uri.html =404"; + }; + "/api/rpc/export_articles_zip" = { + proxyPass = "http://127.0.0.1:${toString cfg.apiPort}/rpc/export_articles_zip"; + extraConfig = '' + default_type application/json; + proxy_set_header Authorization $auth_header; + ''; + }; + "/api/" = { + proxyPass = "http://127.0.0.1:${toString cfg.apiPort}/"; + extraConfig = '' + default_type application/json; + ''; + }; + "/api/rpc/register" = mkIf cfg.settings.disableRegistration { + extraConfig = '' + deny all; + ''; + }; + }; + }; + "~^(?.+)\\.${cfg.domain}$" = { + useACMEHost = cfg.domain; + forceSSL = true; + locations = { + "/" = { + root = "/var/www/archtika-websites/$subdomain"; + index = "index.html"; + tryFiles = "$uri $uri/ $uri.html =404"; + }; + }; + }; + }; + }; + } + ); } diff --git a/nix/package.nix b/nix/package.nix index 1ce2b71..2329edd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -10,8 +10,7 @@ let web = buildNpmPackage { name = "web-app"; src = ../web-app; - npmDeps = importNpmLock { npmRoot = ../web-app; }; - npmConfigHook = importNpmLock.npmConfigHook; + npmDepsHash = "sha256-RTyo7K/Hr1hBGtcBKynrziUInl91JqZl84NkJg16ufA="; npmFlags = [ "--legacy-peer-deps" ]; installPhase = '' mkdir -p $out/web-app @@ -34,18 +33,9 @@ in symlinkJoin { name = "archtika"; pname = "archtika"; - version = "1.0.0"; paths = [ web api ]; - - meta = with lib; { - description = "A modern, performant and lightweight CMS"; - homepage = "https://archtika.com"; - license = licenses.mit; - maintainers = with maintainers; [ thiloho ]; - platforms = platforms.unix; - }; }