535 lines
17 KiB
Nix
535 lines
17 KiB
Nix
{ config, lib, pkgs, wafrnSrc ? null, ... }:
|
|
let
|
|
inherit (lib)
|
|
concatStringsSep
|
|
mapAttrsToList
|
|
mkEnableOption
|
|
mkIf
|
|
mkOption
|
|
optionalAttrs
|
|
optionalString
|
|
types;
|
|
|
|
cfg = config.services.wafrn;
|
|
|
|
defaultWafrnPackage =
|
|
if wafrnSrc != null then
|
|
pkgs.stdenvNoCC.mkDerivation {
|
|
pname = "wafrn-source";
|
|
version = "unstable";
|
|
src = wafrnSrc;
|
|
dontConfigure = true;
|
|
dontBuild = true;
|
|
installPhase = ''
|
|
mkdir -p "$out"
|
|
cp -a ./. "$out/"
|
|
chmod -R u+w "$out"
|
|
'';
|
|
}
|
|
else
|
|
null;
|
|
|
|
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";
|
|
packageSourcePath = toString cfg.package;
|
|
sourcePath = if cfg.source != null then cfg.source else "";
|
|
preparedSourcePath = "${cfg.stateDir}/source";
|
|
usingPrebuiltImages = cfg.images.backend != null && cfg.images.frontend != null;
|
|
effectiveCaddyConfigDir =
|
|
if cfg.caddyConfigDir != null then
|
|
cfg.caddyConfigDir
|
|
else if usingPrebuiltImages then
|
|
null
|
|
else
|
|
"${preparedSourcePath}/packages/caddy";
|
|
|
|
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 = preparedSourcePath;
|
|
dockerfile = "packages/backend/Dockerfile";
|
|
};
|
|
|
|
backendContainerSpec =
|
|
if usingPrebuiltImages then
|
|
{ image = cfg.images.backend; }
|
|
else
|
|
{ build = backendBuild; };
|
|
|
|
frontendContainerSpec =
|
|
if usingPrebuiltImages then
|
|
{ image = cfg.images.frontend; }
|
|
else
|
|
{
|
|
build = {
|
|
context = preparedSourcePath;
|
|
dockerfile = "packages/frontend/Dockerfile";
|
|
};
|
|
};
|
|
|
|
backendService = {
|
|
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 // backendContainerSpec;
|
|
|
|
migration = serviceCommon // backendService // backendContainerSpec // {
|
|
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 // frontendContainerSpec // {
|
|
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"
|
|
] ++ lib.optionals (effectiveCaddyConfigDir != null) [
|
|
"${effectiveCaddyConfigDir}:/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 // backendContainerSpec // {
|
|
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.nullOr types.str;
|
|
default = null;
|
|
example = "/srv/wafrn";
|
|
description = "Optional path to a Wafrn source checkout. If null, the service uses services.wafrn.package as the source.";
|
|
};
|
|
|
|
package = mkOption {
|
|
type = types.package;
|
|
default =
|
|
if defaultWafrnPackage != null then
|
|
defaultWafrnPackage
|
|
else
|
|
throw "services.wafrn.package must be set when no flake-pinned wafrn source is available";
|
|
defaultText = lib.literalExpression "<wafrn source package>";
|
|
description = "Package containing Wafrn source tree used to build backend/frontend Docker images.";
|
|
};
|
|
|
|
caddyConfigDir = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "/srv/wafrn/packages/caddy";
|
|
description = "Optional host path mounted into frontend container as /etc/caddy/config for Caddy hooks/overrides.";
|
|
};
|
|
|
|
images = {
|
|
backend = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "ghcr.io/your-org/wafrn-backend:2026.02.01";
|
|
description = "Prebuilt backend image. Set together with images.frontend to skip local source/Docker builds.";
|
|
};
|
|
|
|
frontend = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
example = "ghcr.io/your-org/wafrn-frontend:2026.02.01";
|
|
description = "Prebuilt frontend image. Set together with images.backend to skip local source/Docker builds.";
|
|
};
|
|
};
|
|
|
|
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 = usingPrebuiltImages || cfg.source != null || cfg.package != null;
|
|
message = "Provide services.wafrn.images.backend+frontend, set services.wafrn.source, or set services.wafrn.package.";
|
|
}
|
|
{
|
|
assertion = (cfg.images.backend == null) == (cfg.images.frontend == null);
|
|
message = "Set both services.wafrn.images.backend and services.wafrn.images.frontend, or neither.";
|
|
}
|
|
{
|
|
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 (!usingPrebuiltImages) [
|
|
"d ${cfg.stateDir}/source 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
|
|
|
|
${optionalString (!usingPrebuiltImages) ''
|
|
selected_source=""
|
|
|
|
if [ -n "${sourcePath}" ] && [ -d "${sourcePath}" ]; then
|
|
selected_source="${sourcePath}"
|
|
elif [ -n "${sourcePath}" ]; then
|
|
echo "wafrn-nix: configured source does not exist (${sourcePath}), falling back to services.wafrn.package" >&2
|
|
fi
|
|
|
|
if [ -z "$selected_source" ] && [ -d "${packageSourcePath}" ]; then
|
|
selected_source="${packageSourcePath}"
|
|
fi
|
|
|
|
if [ -z "$selected_source" ]; then
|
|
echo "wafrn-nix: no usable source directory found" >&2
|
|
exit 1
|
|
fi
|
|
|
|
rm -rf "${preparedSourcePath}"
|
|
mkdir -p "${preparedSourcePath}"
|
|
cp -a "$selected_source/." "${preparedSourcePath}/"
|
|
chmod -R u+w "${preparedSourcePath}"
|
|
|
|
if [ ! -e "${preparedSourcePath}/.git" ]; then
|
|
mkdir -p "${preparedSourcePath}/.git"
|
|
fi
|
|
|
|
if [ ! -f "${preparedSourcePath}/package-lock.json" ]; then
|
|
echo "wafrn-nix: package-lock.json missing, generating with npm" >&2
|
|
(cd "${preparedSourcePath}" && npm install --package-lock-only --ignore-scripts)
|
|
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"}
|
|
'';
|
|
};
|
|
};
|
|
}
|