self: { config, lib, pkgs, ... }: let cfg = config.services.jetpham-website; package = cfg.package; qaApi = cfg.apiPackage; apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}"; usingDefaultWebhookSecret = cfg.webhookSecretFile == null; webhookSecretPath = if usingDefaultWebhookSecret then config.age.secrets.webhook-secret.path else cfg.webhookSecretFile; usingDefaultTorSecretKey = cfg.tor.onionSecretKeyFile == null; usingDefaultTorPublicKey = cfg.tor.onionPublicKeyFile == null; usingDefaultTorHostname = cfg.tor.onionHostnameFile == null; torOnionSecretKeyPath = if usingDefaultTorSecretKey then config.age.secrets.tor-onion-secret-key.path else cfg.tor.onionSecretKeyFile; torOnionPublicKeyPath = if usingDefaultTorPublicKey then config.age.secrets.tor-onion-public-key.path else cfg.tor.onionPublicKeyFile; torOnionHostnamePath = if usingDefaultTorHostname then config.age.secrets.tor-onion-hostname.path else cfg.tor.onionHostnameFile; caddyCommonConfig = '' header Cross-Origin-Opener-Policy "same-origin" header Cross-Origin-Embedder-Policy "require-corp" handle /api/* { reverse_proxy ${apiListen} } handle /qa/rss.xml { reverse_proxy ${apiListen} } handle { root * ${package} try_files {path} /index.html file_server } ${cfg.caddy.extraConfig} ''; in { options.services.jetpham-website = { enable = lib.mkEnableOption "Jet Pham's personal website"; package = lib.mkOption { type = lib.types.package; default = self.packages.${pkgs.system}.default; defaultText = lib.literalExpression "self.packages.${pkgs.system}.default"; description = "Static site package served by Caddy."; }; apiPackage = lib.mkOption { type = lib.types.package; default = self.packages.${pkgs.system}.qa-api; defaultText = lib.literalExpression "self.packages.${pkgs.system}.qa-api"; description = "Q&A API package run by systemd."; }; domain = lib.mkOption { type = lib.types.str; default = "jetpham.com"; description = "Domain to serve the website on."; }; openFirewall = lib.mkOption { type = lib.types.bool; default = true; description = "Open HTTP and HTTPS ports when Caddy is enabled."; }; apiListenAddress = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = "Address for the local Q&A API listener."; }; apiListenPort = lib.mkOption { type = lib.types.port; default = 3003; description = "Port for the local Q&A API listener."; }; caddy.enable = lib.mkOption { type = lib.types.bool; default = true; description = "Serve the static site and reverse proxy the API through Caddy."; }; caddy.extraConfig = lib.mkOption { type = lib.types.lines; default = ""; description = "Extra Caddy directives appended inside the virtual host block."; }; tor = { enable = lib.mkEnableOption "Tor hidden service for the website"; port = lib.mkOption { type = lib.types.port; default = 8888; description = "Local Caddy port exposed through the onion service."; }; onionSecretKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "Path to the Tor hidden service secret key file."; }; onionPublicKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "Path to the Tor hidden service public key file."; }; onionHostnameFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "Path to the Tor hidden service hostname file."; }; }; qaNotifyEmail = lib.mkOption { type = lib.types.str; default = "jet@extremist.software"; description = "Email address to receive Q&A notifications."; }; qaMailDomain = lib.mkOption { type = lib.types.str; default = "extremist.software"; description = "Mail domain for Q&A reply addresses."; }; qaReplyDomain = lib.mkOption { type = lib.types.str; default = "extremist.software"; description = "Domain used in the static Q&A Reply-To address (`qa@...`). Use a dedicated subdomain and route only that mail into the webhook to avoid impacting your main inbox if the Q&A API fails."; }; webhookSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; description = "File containing the WEBHOOK_SECRET for MTA Hook authentication. Defaults to the module-managed agenix secret when left unset."; }; }; config = lib.mkIf cfg.enable { age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret { file = "${self}/secrets/webhook-secret.age"; mode = "0400"; }; age.secrets.tor-onion-secret-key = lib.mkIf (cfg.tor.enable && usingDefaultTorSecretKey) { file = "${self}/secrets/tor-onion-secret-key.age"; owner = "tor"; group = "tor"; mode = "0400"; }; age.secrets.tor-onion-public-key = lib.mkIf (cfg.tor.enable && usingDefaultTorPublicKey) { file = "${self}/secrets/tor-onion-public-key.age"; owner = "tor"; group = "tor"; mode = "0444"; }; age.secrets.tor-onion-hostname = lib.mkIf (cfg.tor.enable && usingDefaultTorHostname) { file = "${self}/secrets/tor-onion-hostname.age"; owner = "tor"; group = "tor"; mode = "0444"; }; assertions = [ { assertion = !cfg.tor.enable || (torOnionSecretKeyPath != null && torOnionPublicKeyPath != null && torOnionHostnamePath != null); message = "services.jetpham-website.tor requires onionSecretKeyFile, onionPublicKeyFile, and onionHostnameFile."; } ]; networking.firewall.allowedTCPPorts = lib.mkIf (cfg.caddy.enable && cfg.openFirewall) [ 80 443 ]; services.caddy.enable = cfg.caddy.enable; services.tor = lib.mkIf cfg.tor.enable { enable = true; relay.onionServices.jetpham-website = { map = [ { port = 80; target = { addr = "127.0.0.1"; port = cfg.tor.port; }; } ]; }; }; systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable { description = "Copy Tor onion keys into place"; after = lib.optional ( usingDefaultTorSecretKey || usingDefaultTorPublicKey || usingDefaultTorHostname ) "agenix.service"; before = [ "tor.service" ]; wantedBy = [ "tor.service" ]; serviceConfig.Type = "oneshot"; script = '' dir="/var/lib/tor/onion/jetpham-website" mkdir -p "$dir" cp ${torOnionSecretKeyPath} "$dir/hs_ed25519_secret_key" cp ${torOnionPublicKeyPath} "$dir/hs_ed25519_public_key" cp ${torOnionHostnamePath} "$dir/hostname" chown -R tor:tor "$dir" chmod 700 "$dir" chmod 400 "$dir/hs_ed25519_secret_key" chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname" ''; }; systemd.services.jetpham-qa-api = { description = "Jet Pham Q&A API"; after = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; wants = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; wantedBy = [ "multi-user.target" ]; serviceConfig = { DynamicUser = true; StateDirectory = "jetpham-qa"; WorkingDirectory = "/var/lib/jetpham-qa"; Environment = [ "QA_DB_PATH=/var/lib/jetpham-qa/qa.db" "QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}" "QA_MAIL_DOMAIN=${cfg.qaMailDomain}" "QA_REPLY_DOMAIN=${cfg.qaReplyDomain}" ]; NoNewPrivileges = true; PrivateTmp = true; ProtectHome = true; ProtectSystem = "strict"; ReadWritePaths = [ "/var/lib/jetpham-qa" ]; Restart = "on-failure"; RestartSec = 5; LoadCredential = "webhook-secret:${webhookSecretPath}"; }; script = '' if [ ! -s "$CREDENTIALS_DIRECTORY/webhook-secret" ]; then echo "WEBHOOK_SECRET credential is empty" >&2 exit 1 fi export WEBHOOK_SECRET="$(cat "$CREDENTIALS_DIRECTORY/webhook-secret")" exec ${qaApi}/bin/jetpham-qa-api ''; }; services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable { extraConfig = caddyCommonConfig; }; services.caddy.virtualHosts."http://:${toString cfg.tor.port}" = lib.mkIf (cfg.caddy.enable && cfg.tor.enable) { extraConfig = '' bind 127.0.0.1 ${caddyCommonConfig} ''; }; }; }