From b0664108ee3bb24bcc7bd101f2fac780978dc40c Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 19 Feb 2026 19:27:01 +0200 Subject: [PATCH] first commit --- README.md | 47 +++++ flake.lock | 136 ++++++++++++++ flake.nix | 19 ++ modules/wafrn.nix | 457 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 659 insertions(+) create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 modules/wafrn.nix diff --git a/README.md b/README.md new file mode 100644 index 0000000..df75859 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# wafrn-nix + +example: + +```nix +{ + inputs.wafrn-nix.url = "git+https://git.ocbwoy3.dev/kris/wafrn-nix"; + + outputs = { self, nixpkgs, wafrn-nix, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + wafrn-nix.nixosModules.default + ({ ... }: { + virtualisation.docker.enable = true; + + services.wafrn = { + enable = true; + source = "/srv/wafrn"; + stateDir = "/var/lib/wafrn"; + secretsFile = "/run/secrets/wafrn.env"; + + # cloudflared doesnt need https: + # httpPort = 8080; + # httpsPort = null; + + bun2nix = { + enable = true; + outputFile = "bun.nix"; + }; + + environment = { + 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"; + ACME_EMAIL = "admin@example.com"; + }; + }; + }) + ]; + }; + }; +} +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2ad145d --- /dev/null +++ b/flake.lock @@ -0,0 +1,136 @@ +{ + "nodes": { + "bun2nix": { + "inputs": { + "flake-parts": "flake-parts", + "import-tree": "import-tree", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1770895533, + "narHash": "sha256-v3QaK9ugy9bN9RXDnjw0i2OifKmz2NnKM82agtqm/UY=", + "owner": "nix-community", + "repo": "bun2nix", + "rev": "c843f477b15f51151f8c6bcc886954699440a6e1", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "bun2nix", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1769996383, + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "import-tree": { + "locked": { + "lastModified": 1763762820, + "narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=", + "owner": "vic", + "repo": "import-tree", + "rev": "3c23749d8013ec6daa1d7255057590e9ca726646", + "type": "github" + }, + "original": { + "owner": "vic", + "repo": "import-tree", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771419570, + "narHash": "sha256-bxAlQgre3pcQcaRUm/8A0v/X8d2nhfraWSFqVmMcBcU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6d41bc27aaf7b6a3ba6b169db3bd5d6159cfaa47", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1769909678, + "narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "72716169fe93074c333e8d0173151350670b824c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "bun2nix": "bun2nix", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "bun2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770228511, + "narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "337a4fe074be1042a35086f15481d763b8ddc0e7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ba58a24 --- /dev/null +++ b/flake.nix @@ -0,0 +1,19 @@ +{ + description = "NixOS module for running Wafrn"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + bun2nix.url = "github:nix-community/bun2nix"; + bun2nix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { self, bun2nix, ... }: { + nixosModules = { + wafrn = { ... }: { + imports = [ ./modules/wafrn.nix ]; + _module.args.bun2nixFlake = bun2nix; + }; + default = self.nixosModules.wafrn; + }; + }; +} diff --git a/modules/wafrn.nix b/modules/wafrn.nix new file mode 100644 index 0000000..87e4b67 --- /dev/null +++ b/modules/wafrn.nix @@ -0,0 +1,457 @@ +{ config, lib, pkgs, bun2nixFlake ? null, ... }: +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"; + bunNixFile = "${cfg.stateDir}/${cfg.bun2nix.outputFile}"; + sourcePath = cfg.source; + + bun2nixCli = + if bun2nixFlake != null then + "${bun2nixFlake.packages.${pkgs.system}.default}/bin/bun2nix" + else + "${pkgs.bun}/bin/bunx bun2nix"; + + 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."; + }; + + bun2nix = { + enable = mkOption { + type = types.bool; + default = true; + description = "Generate a bun2nix expression from the Wafrn bun lock file before starting."; + }; + + lockFile = mkOption { + type = types.str; + default = "bun.lock"; + description = "Lock file path relative to services.wafrn.source."; + }; + + outputFile = mkOption { + type = types.str; + default = "bun.nix"; + description = "Generated bun2nix expression filename under services.wafrn.stateDir."; + }; + + copyPrefix = mkOption { + type = types.str; + default = "./"; + description = "Value passed to bun2nix --copy-prefix for workspace/file dependencies."; + }; + }; + + 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)."; + } + { + assertion = (!cfg.bun2nix.enable) || (bun2nixFlake != null); + message = "services.wafrn.bun2nix.enable requires using this module from the flake output so bun2nix is available."; + } + ]; + + 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" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + ${optionalString cfg.bun2nix.enable '' + if [ ! -f "${cfg.source}/${cfg.bun2nix.lockFile}" ]; then + echo "missing lockfile: ${cfg.source}/${cfg.bun2nix.lockFile}" >&2 + exit 1 + fi + ${bun2nixCli} \ + --lock-file "${cfg.source}/${cfg.bun2nix.lockFile}" \ + --output-file "${bunNixFile}" \ + --copy-prefix "${cfg.bun2nix.copyPrefix}" + ''} + + install -m 0600 ${envTemplate} ${serviceEnvFile} + ${optionalString (cfg.secretsFile != null) '' + 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"} + ''; + }; + }; +} + 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 ];