Refactor NixOS module

This commit is contained in:
thiloho
2025-01-03 11:29:48 +01:00
parent 741ec830a8
commit 7d79d60132
3 changed files with 241 additions and 237 deletions

View File

@@ -1,4 +1,7 @@
{ pkgs, localArchtikaPackage, ... }: { pkgs, localArchtikaPackage, ... }:
let
domain = "qs.archtika.com";
in
{ {
imports = [ imports = [
./hardware-configuration.nix ./hardware-configuration.nix
@@ -11,14 +14,23 @@
services.archtika = { services.archtika = {
enable = true; enable = true;
package = localArchtikaPackage; package = localArchtikaPackage;
domain = "qs.archtika.com"; inherit domain;
acmeEmail = "thilo.hohlt@tutanota.com";
dnsProvider = "porkbun";
dnsEnvironmentFile = /var/lib/porkbun.env;
settings = { settings = {
disableRegistration = true; disableRegistration = true;
maxWebsiteStorageSize = 250; maxWebsiteStorageSize = 50;
maxUserWebsites = 3; 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";
}; };
}; };
} }

View File

@@ -5,41 +5,19 @@
... ...
}: }:
with lib;
let let
inherit (lib)
mkEnableOption
mkOption
mkIf
mkPackageOption
types
;
cfg = config.services.archtika; 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 in
{ {
options.services.archtika = { options.services.archtika = {
enable = mkEnableOption "archtika service"; enable = mkEnableOption "Whether to enable the archtika service";
package = mkPackageOption pkgs "archtika" { }; package = mkPackageOption pkgs "archtika" { };
@@ -81,29 +59,11 @@ in
domain = mkOption { domain = mkOption {
type = types.str; type = types.str;
default = null;
description = "Domain to use for the application."; 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 { settings = mkOption {
description = "Settings for the running archtika application.";
type = types.submodule { type = types.submodule {
options = { options = {
disableRegistration = mkOption { 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."; description = "By default any user can create an account. That behavior can be disabled by using this option.";
}; };
maxUserWebsites = mkOption { maxUserWebsites = mkOption {
type = types.int; type = types.ints.positive;
default = 2; default = 2;
description = "Maximum number of websites allowed per user by default."; description = "Maximum number of websites allowed per user by default.";
}; };
maxWebsiteStorageSize = mkOption { maxWebsiteStorageSize = mkOption {
type = types.int; type = types.ints.positive;
default = 500; default = 50;
description = "Maximum amount of disk space in MB allowed per user website by default."; description = "Maximum amount of disk space in MB allowed per user website by default.";
}; };
}; };
@@ -126,184 +86,226 @@ in
}; };
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable (
users.users.${cfg.user} = { let
isSystemUser = true; baseHardenedSystemdOptions = {
group = cfg.group; 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} = { users.groups.${cfg.group} = {
members = [ members = [
"nginx" "nginx"
"postgres" "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"
]; ];
}; };
script = '' systemd.tmpfiles.settings = {
JWT_SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c64) "10-archtika" = {
"/var/www" = {
${pkgs.postgresql_16}/bin/psql postgres://postgres@localhost:5432/${cfg.databaseName} -c "ALTER DATABASE ${cfg.databaseName} SET \"app.jwt_secret\" TO '$JWT_SECRET'" d = {
${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}" mode = "0755";
${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}" user = "root";
group = "root";
${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;
'';
}; };
}; };
}; "/var/www/archtika-websites" = {
"~^(?<subdomain>.+)\\.${cfg.domain}$" = { d = {
useACMEHost = cfg.domain; mode = "0770";
forceSSL = true; user = cfg.user;
locations = { group = cfg.group;
"/" = {
root = "/var/www/archtika-websites/$subdomain";
index = "index.html";
tryFiles = "$uri $uri/ $uri.html =404";
}; };
}; };
}; };
}; };
};
security.acme = { systemd.services.archtika-api = {
acceptTerms = true; description = "archtika API service";
defaults.email = cfg.acmeEmail; wantedBy = [ "multi-user.target" ];
certs."${cfg.domain}" = { after = [
domain = cfg.domain; "network.target"
extraDomainNames = [ "*.${cfg.domain}" ]; "postgresql.service"
dnsProvider = cfg.dnsProvider; ];
environmentFile = cfg.dnsEnvironmentFile;
group = config.services.nginx.group; 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;
'';
};
};
};
"~^(?<subdomain>.+)\\.${cfg.domain}$" = {
useACMEHost = cfg.domain;
forceSSL = true;
locations = {
"/" = {
root = "/var/www/archtika-websites/$subdomain";
index = "index.html";
tryFiles = "$uri $uri/ $uri.html =404";
};
};
};
};
};
}
);
} }

View File

@@ -10,8 +10,7 @@ let
web = buildNpmPackage { web = buildNpmPackage {
name = "web-app"; name = "web-app";
src = ../web-app; src = ../web-app;
npmDeps = importNpmLock { npmRoot = ../web-app; }; npmDepsHash = "sha256-RTyo7K/Hr1hBGtcBKynrziUInl91JqZl84NkJg16ufA=";
npmConfigHook = importNpmLock.npmConfigHook;
npmFlags = [ "--legacy-peer-deps" ]; npmFlags = [ "--legacy-peer-deps" ];
installPhase = '' installPhase = ''
mkdir -p $out/web-app mkdir -p $out/web-app
@@ -34,18 +33,9 @@ in
symlinkJoin { symlinkJoin {
name = "archtika"; name = "archtika";
pname = "archtika"; pname = "archtika";
version = "1.0.0";
paths = [ paths = [
web web
api 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;
};
} }