Files
wafrn-nix/modules/wafrn.nix
2026-02-19 19:44:28 +02:00

430 lines
13 KiB
Nix

{ config, lib, pkgs, ... }:
let
inherit (lib)
concatStringsSep
mapAttrsToList
mkEnableOption
mkIf
mkOption
optionalAttrs
optionalString
types;
cfg = config.services.wafrn;
toEnvString = value:
if builtins.isBool value then
(if value then "true" else "false")
else if builtins.isInt value || builtins.isFloat value then
toString value
else
value;
quoteEnv = value:
let
s = toEnvString value;
escaped = lib.replaceStrings [ "\\" "\"" "\n" "\r" ] [ "\\\\" "\\\"" "\\n" "" ] s;
in
"\"${escaped}\"";
baseEnv = {
ADMIN_USER = "admin";
ADMIN_EMAIL = "admin@example.com";
ADMIN_PASSWORD = "change-me";
JWT_SECRET = "change-me";
DOMAIN_NAME = "wafrn.example.com";
CACHE_DOMAIN = "cache.wafrn.example.com";
MEDIA_DOMAIN = "media.wafrn.example.com";
FRONTEND_MEDIA_URL = "https://media.wafrn.example.com";
FRONTEND_CACHE_URL = "https://cache.wafrn.example.com/api/cache?media=";
FRONTEND_FQDN_URL = "https://wafrn.example.com";
FRONTEND_SHORT_TITLE = "Wafrn";
FRONTEND_LONG_TITLE = "Wafrn, the social media that respects you";
FRONTEND_DESCRIPTION = "Wafrn is a federated social media inspired by tumblr that connects with the fediverse and bluesky";
DONATION_URL = "https://wafrn.example.com/about";
REGISTRATION_LEVEL = "PUBLIC";
REGISTRATIONS_DISABLED_TEXT = "This instance is a private instance, and does not allow registrations";
REVIEW_REGISTRATIONS = true;
REGISTRATION_MINIMUM_AGE = 18;
BUBBLE_SERVERS_SHOW_TYPE = "PUBLIC";
BLOCKED_SERVERS_SHOW_TYPE = "LOGGEDIN";
AUTOFOLLOW_MAIN_ADMIN = true;
DISABLE_REQUIRE_SEND_EMAIL = false;
SMTP_HOST = "smtp.example.com";
SMTP_USER = "wafrn";
SMTP_PORT = 587;
SMTP_PASSWORD = "change-me";
SMTP_FROM = "wafrn@example.com";
POSTGRES_USER = "root";
POSTGRES_PASSWORD = "root";
POSTGRES_DBNAME = "wafrn";
POSTGRES_DB = "wafrn";
ACME_EMAIL = "admin@example.com";
WEBPUSH_EMAIL = "mailto:info@wafrn.net";
WEBPUSH_PRIVATE = "";
WEBPUSH_PUBLIC = "";
ENABLE_BSKY = cfg.bluesky.enable;
PDS_DOMAIN_NAME = cfg.bluesky.pdsDomain;
PDS_JWT_SECRET = "change-me";
PDS_ADMIN_PASSWORD = "change-me";
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = "";
ENABLE_RAW_OUTPUT = false;
LOG_SQL_QUERIES = "";
UPLOAD_LIMIT = "";
POSTS_PER_PAGE = "";
LOG_LEVEL = "";
BLOCKLIST_URI = "";
FRONTEND_PATH = "";
BLOCKED_IPS = "";
IGNORE_BLOCK_HOSTS = "";
FRONTEND_LOGO = "";
FRONTEND_API_URL = "";
FRONTEND_CACHE_BACKUP_URLS = "";
FRONTEND_SHORTEN_POSTS = "3";
FRONTEND_DISABLE_PWA = false;
FRONTEND_MAINTENANCE = false;
};
effectiveEnv = baseEnv // cfg.environment;
envFileContents =
concatStringsSep "\n" (mapAttrsToList (name: value: "${name}=${quoteEnv value}") effectiveEnv) + "\n";
envTemplate = pkgs.writeText "wafrn.env.base" envFileContents;
serviceEnvFile = "${cfg.stateDir}/.env";
composeFile = "${cfg.stateDir}/docker-compose.yml";
sourcePath = cfg.source;
publishedPorts =
lib.optionals (cfg.httpPort != null) [ "${toString cfg.httpPort}:80" ]
++ lib.optionals (cfg.httpsPort != null) [ "${toString cfg.httpsPort}:443" ];
firewallPorts =
lib.optionals (cfg.httpPort != null) [ cfg.httpPort ]
++ lib.optionals (cfg.httpsPort != null) [ cfg.httpsPort ];
serviceCommon = {
restart = "unless-stopped";
env_file = [ serviceEnvFile ];
};
backendBuild = {
context = sourcePath;
dockerfile = "packages/backend/Dockerfile";
};
backendService = {
build = backendBuild;
depends_on = {
db = { condition = "service_started"; };
redis = { condition = "service_started"; };
frontend = { condition = "service_started"; };
migration = { condition = "service_completed_successfully"; };
};
environment = {
NODE_ENV = "production";
USE_WORKERS = "true";
};
volumes = [
"${cfg.stateDir}/uploads:/app/packages/backend/uploads"
"${cfg.stateDir}/cache:/app/packages/backend/cache"
"${cfg.stateDir}/frontend:/app/packages/frontend:ro"
];
};
composeConfig = {
services = {
backend = serviceCommon // backendService;
migration = serviceCommon // backendService // {
restart = "no";
depends_on = {
db = { condition = "service_started"; };
redis = { condition = "service_started"; };
frontend = { condition = "service_started"; };
};
command = "npm exec tsx migrate.ts init-container";
};
frontend = serviceCommon // {
build = {
context = sourcePath;
dockerfile = "packages/frontend/Dockerfile";
};
restart = "unless-stopped";
ports = publishedPorts;
extra_hosts = [ "host.docker.internal:host-gateway" ];
volumes = [
"${cfg.stateDir}/caddy:/data"
"${cfg.stateDir}/frontend:/var/www/html/frontend"
"${cfg.stateDir}/uploads:/var/www/html/uploads"
"${sourcePath}/packages/caddy:/etc/caddy/config"
];
};
db = {
image = "postgres:17";
restart = "unless-stopped";
shm_size = "2gb";
env_file = [ serviceEnvFile ];
environment = {
POSTGRES_DB = "${effectiveEnv.POSTGRES_DBNAME}";
};
volumes = [ "${cfg.stateDir}/postgres:/var/lib/postgresql/data" ];
};
redis = {
image = "redis:7.2.4";
restart = "unless-stopped";
volumes = [ "${cfg.stateDir}/redis:/data" ];
};
}
// optionalAttrs cfg.adminer.enable {
adminer = {
image = "adminer";
restart = "unless-stopped";
};
}
// optionalAttrs cfg.bluesky.enable {
pds_worker = serviceCommon // {
build = backendBuild;
command = "npm exec tsx atproto.ts";
depends_on = {
db = { condition = "service_started"; };
redis = { condition = "service_started"; };
migration = { condition = "service_completed_successfully"; };
};
environment = {
NODE_ENV = "production";
USE_WORKERS = "true";
};
volumes = [
"${cfg.stateDir}/uploads:/app/packages/backend/uploads"
"${cfg.stateDir}/cache:/app/packages/backend/cache"
"${cfg.stateDir}/frontend:/app/packages/frontend:ro"
];
};
}
// optionalAttrs (cfg.bluesky.enable && cfg.bluesky.useBundledPds) {
pds = {
image = "ghcr.io/bluesky-social/pds:0.4";
restart = "unless-stopped";
env_file = [ serviceEnvFile ];
environment = {
PDS_DATA_DIRECTORY = "/pds";
PDS_BLOBSTORE_DISK_LOCATION = "/pds/blocks";
PDS_BLOB_UPLOAD_LIMIT = "157286400";
PDS_DID_PLC_URL = "https://plc.directory";
PDS_BSKY_APP_VIEW_URL = "https://api.bsky.app";
PDS_BSKY_APP_VIEW_DID = "did:web:api.bsky.app";
PDS_REPORT_SERVICE_URL = "https://mod.bsky.app";
PDS_REPORT_SERVICE_DID = "did:plc:ar7c4by46qjdydhdevvrndac";
PDS_CRAWLERS = "https://bsky.network, https://atproto.africa";
PDS_EMAIL_SMTP_URL = "smtps://${effectiveEnv.SMTP_USER}:${effectiveEnv.SMTP_PASSWORD}@${toString effectiveEnv.SMTP_HOST}:${toString effectiveEnv.SMTP_PORT}";
PDS_EMAIL_FROM_ADDRESS = "${effectiveEnv.SMTP_FROM}";
LOG_ENABLED = "true";
};
volumes = [ "${cfg.stateDir}/pds:/pds" ];
};
};
networks = {
default = {
enable_ipv6 = true;
};
};
};
composeTemplate = pkgs.writeText "wafrn-compose.yml" (lib.generators.toYAML { } composeConfig);
composeCmd = "${pkgs.docker-compose}/bin/docker-compose";
in
{
options.services.wafrn = {
enable = mkEnableOption "Wafrn social platform";
source = mkOption {
type = types.str;
example = "/srv/wafrn";
description = "Path to a Wafrn source checkout (used as Docker build context).";
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/wafrn";
description = "Persistent state directory for databases, uploads, caddy data, and optional PDS data.";
};
composeProjectName = mkOption {
type = types.str;
default = "wafrn";
description = "Docker Compose project name.";
};
secretsFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/secrets/wafrn.env";
description = "Optional dotenv file with secret overrides appended at runtime.";
};
environment = mkOption {
type = types.attrsOf (types.oneOf [ types.str types.int types.float types.bool ]);
default = { };
description = "Non-secret environment overrides written to the generated .env file.";
};
httpPort = mkOption {
type = types.nullOr types.port;
default = 80;
description = "Host port mapped to Wafrn HTTP (container port 80). Set to null to disable publishing HTTP.";
};
httpsPort = mkOption {
type = types.nullOr types.port;
default = 443;
description = "Host port mapped to Wafrn HTTPS (container port 443). Set to null to disable publishing HTTPS.";
};
autoBuild = mkOption {
type = types.bool;
default = true;
description = "Whether to pass --build when starting containers.";
};
openFirewall = mkOption {
type = types.bool;
default = true;
description = "Open configured HTTP/HTTPS ports in the NixOS firewall.";
};
adminer.enable = mkOption {
type = types.bool;
default = false;
description = "Enable Adminer container.";
};
bluesky = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable Bluesky integration and ATProto worker.";
};
useBundledPds = mkOption {
type = types.bool;
default = false;
description = "Run Wafrn's bundled Bluesky PDS container. Set false to use an external PDS.";
};
pdsDomain = mkOption {
type = types.str;
default = "bsky.example.com";
description = "PDS domain used by Wafrn (can point to an external PDS).";
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = config.virtualisation.docker.enable;
message = "services.wafrn requires virtualisation.docker.enable = true;";
}
{
assertion = cfg.httpPort != null || cfg.httpsPort != null;
message = "services.wafrn requires at least one published port (httpPort or httpsPort).";
}
];
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0750 root root -"
"d ${cfg.stateDir}/postgres 0750 root root -"
"d ${cfg.stateDir}/redis 0750 root root -"
"d ${cfg.stateDir}/uploads 0750 root root -"
"d ${cfg.stateDir}/cache 0750 root root -"
"d ${cfg.stateDir}/caddy 0750 root root -"
"d ${cfg.stateDir}/frontend 0750 root root -"
] ++ lib.optionals (cfg.bluesky.enable && cfg.bluesky.useBundledPds) [
"d ${cfg.stateDir}/pds 0750 root root -"
];
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall firewallPorts;
systemd.services.wafrn-prepare = {
description = "Prepare Wafrn runtime files";
wantedBy = [ "multi-user.target" ];
before = [ "wafrn.service" ];
after = [ "docker.service" ];
path = [ pkgs.nodejs pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
if [ ! -d "${cfg.source}" ]; then
echo "wafrn-nix: source directory does not exist: ${cfg.source}" >&2
exit 1
fi
if [ ! -f "${cfg.source}/package-lock.json" ]; then
echo "wafrn-nix: package-lock.json missing, generating with npm" >&2
if ! (cd "${cfg.source}" && npm install --package-lock-only --ignore-scripts); then
echo "wafrn-nix: failed to generate package-lock.json, continuing with existing source" >&2
fi
fi
install -m 0600 ${envTemplate} ${serviceEnvFile}
${optionalString (cfg.secretsFile != null) ''
if [ ! -f "${cfg.secretsFile}" ]; then
echo "wafrn-nix: secretsFile does not exist: ${cfg.secretsFile}" >&2
exit 1
fi
cat "${cfg.secretsFile}" >> ${serviceEnvFile}
''}
install -m 0644 ${composeTemplate} ${composeFile}
'';
};
systemd.services.wafrn = {
description = "Wafrn Docker Compose stack";
wantedBy = [ "multi-user.target" ];
requires = [ "docker.service" "wafrn-prepare.service" ];
after = [ "docker.service" "wafrn-prepare.service" ];
path = [ pkgs.docker-compose pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = cfg.stateDir;
};
script = ''
${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} up -d ${optionalString cfg.autoBuild "--build"}
'';
preStop = ''
${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} down
'';
reload = ''
${composeCmd} --project-name ${cfg.composeProjectName} --file ${composeFile} up -d ${optionalString cfg.autoBuild "--build"}
'';
};
};
}