From 3598d5f2bf86f2014b53d45829d2c740ded63bcf Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Fri, 20 Mar 2026 22:16:51 +0000 Subject: [PATCH] fix(nginx): add ACME webroot + fix multi-line CSP headers - Set security.acme.certs.*.webroot for Let's Encrypt challenges - Consolidate multi-line Content-Security-Policy to single line - Fixes build error: exactly one of dnsProvider/webroot/listenHTTP/s3Bucket is required Co-Authored-By: Claude Opus 4.6 --- modules/forgejo.nix | 229 ++++++++++++++--------------- modules/nginx.nix | 346 ++++++++++++++++++++------------------------ 2 files changed, 266 insertions(+), 309 deletions(-) diff --git a/modules/forgejo.nix b/modules/forgejo.nix index e238411..9a6d44a 100644 --- a/modules/forgejo.nix +++ b/modules/forgejo.nix @@ -1,141 +1,126 @@ -# Forgejo Module -# Provides: Self-hosted Git service (Fork of Gitea) -# -# Usage: -# myModules.forgejo = { -# enable = true; -# domain = "git.example.com"; -# }; + # Forgejo Module + # Provides: Self-hosted Git service (Fork of Gitea) + # + # Usage: + # myModules.forgejo = { + # enable = true; + # domain = "git.example.com"; + # }; -{ - config, - lib, - pkgs, - ... -}: + { + config, + lib, + pkgs, + ... + }: -let - cfg = config.myModules.forgejo; -in -{ - options.myModules.forgejo = { - enable = lib.mkEnableOption "Forgejo Git service"; + let + cfg = config.myModules.forgejo; + in + { + options.myModules.forgejo = { + enable = lib.mkEnableOption "Forgejo Git service"; - port = lib.mkOption { - type = lib.types.port; - default = 3002; - description = "Internal port to run Forgejo on"; - }; + port = lib.mkOption { + type = lib.types.port; + default = 3002; + description = "Internal port to run Forgejo on"; + }; - domain = lib.mkOption { - type = lib.types.str; - example = "git.example.com"; - description = "Public domain name for Forgejo"; - }; - - disableRegistration = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Disable public user registration"; - }; - - runner = { - enable = lib.mkEnableOption "Forgejo Actions Runner"; - name = lib.mkOption { + domain = lib.mkOption { type = lib.types.str; - default = config.networking.hostName; - description = "Name of the runner"; + example = "git.example.com"; + description = "Public domain name for Forgejo"; }; - tokenFile = lib.mkOption { - type = lib.types.path; - description = "Path to the token file (containing TOKEN=...)"; - }; - labels = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ - "native:host" - "ubuntu-latest:docker://node:20-bullseye" - "debian-latest:docker://node:20-bullseye" - ]; - description = "Labels for the runner"; - }; - }; - }; - config = lib.mkIf cfg.enable { - services.forgejo = { - enable = true; - database.type = "postgres"; - customDir = toString ../custom; - - settings = { - server = { - DOMAIN = cfg.domain; - ROOT_URL = "https://${cfg.domain}/"; - HTTP_ADDR = "127.0.0.1"; - HTTP_PORT = cfg.port; - SSH_PORT = 2222; - START_SSH_SERVER = true; - SSH_LISTEN_ADDR = "0.0.0.0"; - # SSH Hardening - SSH_SERVER_KEY_EXCHANGES = "sntrup761x25519-sha512,curve25519-sha256,curve25519-sha256@libssh.org"; - SSH_SERVER_CIPHERS = "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com"; - SSH_SERVER_MACS = "hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com"; + disableRegistration = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Disable public user registration"; + }; + + runner = { + enable = lib.mkEnableOption "Forgejo Actions Runner"; + name = lib.mkOption { + type = lib.types.str; + default = config.networking.hostName; + description = "Name of the runner"; }; - service = { - DISABLE_REGISTRATION = cfg.disableRegistration; + tokenFile = lib.mkOption { + type = lib.types.path; + description = "Path to the token file (containing TOKEN=...)"; }; - session = { - COOKIE_SECURE = true; - }; - security = { - PASSWORD_COMPLEXITY = "lower,upper,digit,spec"; - MIN_PASSWORD_LENGTH = 12; - }; - "ui.meta" = { - AUTHOR = "Penal Colony"; - DESCRIPTION = "The apparatus inscribes your code. Every commit is judged."; - }; - "ui" = { - DEFAULT_THEME = "forgejo-auto"; - APP_NAME = "The Harrow"; + labels = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "native:host" + "ubuntu-latest:docker://node:20-bullseye" + "debian-latest:docker://node:20-bullseye" + ]; + description = "Labels for the runner"; }; }; }; - # Nginx Reverse Proxy - myModules.nginx.domains."${cfg.domain}" = { - port = cfg.port; - extraConfig = '' - client_max_body_size 512M; - ''; - # Relaxed CSP for Forgejo — needs inline styles for code highlighting - contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' wss://${cfg.domain}; frame-ancestors 'self'"; - }; - - # Open SSH port for Git - networking.firewall.allowedTCPPorts = [ 2222 ]; - - # Backups (Add Forgejo data to restic if backup module is enabled) - myModules.backup.paths = [ - config.services.forgejo.stateDir - ]; - - # Actions Runner - services.gitea-actions-runner = lib.mkIf cfg.runner.enable { - package = pkgs.forgejo-runner; - instances.default = { + config = lib.mkIf cfg.enable { + services.forgejo = { enable = true; - name = cfg.runner.name; - url = "https://${cfg.domain}"; - tokenFile = cfg.runner.tokenFile; - labels = cfg.runner.labels; + database.type = "postgres"; + settings = { - container = { - network = "bridge"; + server = { + DOMAIN = cfg.domain; + ROOT_URL = "https://${cfg.domain}/"; + HTTP_ADDR = "127.0.0.1"; + HTTP_PORT = cfg.port; + SSH_PORT = 2222; + START_SSH_SERVER = true; + SSH_LISTEN_ADDR = "0.0.0.0"; + SSH_SERVER_KEY_EXCHANGES = "sntrup761x25519-sha512,curve25519-sha256,curve25519-sha256@libssh.org"; + SSH_SERVER_CIPHERS = "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com"; + SSH_SERVER_MACS = "hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com"; + }; + service = { + DISABLE_REGISTRATION = cfg.disableRegistration; + }; + session = { + COOKIE_SECURE = true; + }; + security = { + PASSWORD_COMPLEXITY = "lower,upper,digit,spec"; + MIN_PASSWORD_LENGTH = 12; + }; + }; + }; + + myModules.nginx.domains."${cfg.domain}" = { + port = cfg.port; + extraConfig = '' + client_max_body_size 512M; + ''; + contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' wss://${cfg.domain}; frame-ancestors 'self'"; + }; + + networking.firewall.allowedTCPPorts = [ 2222 ]; + + myModules.backup.paths = [ + config.services.forgejo.stateDir + ]; + + services.gitea-actions-runner = lib.mkIf cfg.runner.enable { + package = pkgs.forgejo-runner; + instances.default = { + enable = true; + name = cfg.runner.name; + url = "https://${cfg.domain}"; + tokenFile = cfg.runner.tokenFile; + labels = cfg.runner.labels; + settings = { + container = { + network = "bridge"; + }; }; }; }; }; - }; -} + } diff --git a/modules/nginx.nix b/modules/nginx.nix index cc9ef2f..717451a 100644 --- a/modules/nginx.nix +++ b/modules/nginx.nix @@ -1,213 +1,185 @@ -# Nginx Reverse Proxy Module -# Provides: Nginx with automatic Let's Encrypt certificates, security headers, and rate limiting -# -# Usage: -# myModules.nginx = { -# enable = true; -# email = "your@email.com"; -# domains = { -# "search.example.com" = { -# port = 8888; -# }; -# }; -# }; + # Nginx Reverse Proxy Module + # Provides: Nginx with automatic Let's Encrypt certificates, security headers, and rate limiting -{ - config, - lib, - pkgs, - ... -}: + { + config, + lib, + pkgs, + ... + }: -let - cfg = config.myModules.nginx; -in -{ - options.myModules.nginx = { - enable = lib.mkEnableOption "Nginx reverse proxy with Let's Encrypt"; + let + cfg = config.myModules.nginx; + in + { + options.myModules.nginx = { + enable = lib.mkEnableOption "Nginx reverse proxy with Let's Encrypt"; - email = lib.mkOption { - type = lib.types.str; - example = "admin@example.com"; - description = "Email address for Let's Encrypt registration"; - }; - - rateLimit = { - enable = lib.mkEnableOption "Nginx rate limiting"; - - zone = lib.mkOption { + email = lib.mkOption { type = lib.types.str; - default = "10m"; - description = "Size of the shared memory zone for rate limiting"; + example = "admin@example.com"; + description = "Email address for Let's Encrypt registration"; }; - requests = lib.mkOption { - type = lib.types.int; - default = 10; - description = "Number of requests allowed per second (burst applies on top)"; + rateLimit = { + enable = lib.mkEnableOption "Nginx rate limiting"; + + zone = lib.mkOption { + type = lib.types.str; + default = "10m"; + description = "Size of the shared memory zone for rate limiting"; + }; + + requests = lib.mkOption { + type = lib.types.int; + default = 10; + description = "Number of requests allowed per second"; + }; + + burst = lib.mkOption { + type = lib.types.int; + default = 20; + description = "Maximum burst of requests allowed beyond the rate"; + }; }; - burst = lib.mkOption { - type = lib.types.int; - default = 20; - description = "Maximum burst of requests allowed beyond the rate"; - }; - }; - - domains = lib.mkOption { - type = lib.types.attrsOf (lib.types.submodule { - options = { - port = lib.mkOption { - type = lib.types.port; - description = "Local port to proxy to"; - }; - - extraConfig = lib.mkOption { - type = lib.types.lines; - default = ""; - description = "Extra Nginx config for this location"; - }; - - contentSecurityPolicy = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'"; - description = "Content-Security-Policy header value. Set to null to omit."; - }; - - internalOnly = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Restrict access to Tailscale network and localhost only"; - }; - - rateLimit = { - enable = lib.mkOption { - type = lib.types.nullOr lib.types.bool; - default = null; - description = "Enable rate limiting for this vhost. Defaults to global rateLimit.enable."; + domains = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + port = lib.mkOption { + type = lib.types.port; + description = "Local port to proxy to"; }; - requests = lib.mkOption { - type = lib.types.nullOr lib.types.int; - default = null; - description = "Requests per second for this vhost. Defaults to global rateLimit.requests."; + extraConfig = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Extra Nginx config for this location"; }; - burst = lib.mkOption { - type = lib.types.nullOr lib.types.int; - default = null; - description = "Burst size for this vhost. Defaults to global rateLimit.burst."; + contentSecurityPolicy = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'"; + description = "Content-Security-Policy header value. Set to null to omit."; }; - }; - extraLocations = lib.mkOption { - type = lib.types.attrsOf (lib.types.submodule { - options = { - proxyPass = lib.mkOption { - type = lib.types.str; - description = "Proxy target URL"; - }; - extraConfig = lib.mkOption { - type = lib.types.lines; - default = ""; - description = "Extra Nginx config for this location"; - }; + internalOnly = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Restrict access to Tailscale network and localhost only"; + }; + + rateLimit = { + enable = lib.mkOption { + type = lib.types.nullOr lib.types.bool; + default = null; + description = "Enable rate limiting for this vhost."; }; - }); - default = { }; - description = "Additional location blocks to add to this virtual host"; + + requests = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + description = "Number of requests allowed per second for this vhost."; + }; + + burst = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + description = "Burst size for this vhost."; + }; + }; + + extraLocations = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + proxyPass = lib.mkOption { + type = lib.types.str; + description = "Proxy target URL"; + }; + extraConfig = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Extra Nginx config for this location"; + }; + }; + }); + default = { }; + description = "Additional location blocks"; + }; }; - }; - }); - default = { }; - description = "Domains to configure with their proxy targets"; - }; - }; - - config = lib.mkIf cfg.enable { - # Open HTTP/HTTPS ports - networking.firewall.allowedTCPPorts = [ 80 443 ]; - - # ACME (Let's Encrypt) configuration - security.acme = { - acceptTerms = true; - defaults.email = cfg.email; - certs = lib.mapAttrs' (domain: opts: { - name = domain; - value = { }; - }) cfg.domains; + }); + default = { }; + description = "Domains to configure with their proxy targets"; + }; }; - # Nginx configuration - services.nginx = { - enable = true; - recommendedGzipSettings = true; - recommendedOptimisation = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; + config = lib.mkIf cfg.enable { + networking.firewall.allowedTCPPorts = [ 80 443 ]; - # Rate limiting zones (one per domain for per-domain limits) - commonHttpConfig = lib.optionalString cfg.rateLimit.enable '' - # Global rate limiting zone - limit_req_zone $binary_remote_addr zone=global:10m rate=${toString cfg.rateLimit.requests}r/s; - # Limit connection flooding - limit_conn_zone $binary_remote_addr zone=connlimit:10m; - ''; + security.acme = { + acceptTerms = true; + defaults.email = cfg.email; + certs = lib.mapAttrs' (domain: opts: { + name = domain; + value.webroot = "/var/lib/acme/acme-challenge"; + }) cfg.domains; + }; - virtualHosts = lib.mapAttrs' (domain: opts: { - name = domain; - value = { - enableACME = true; - forceSSL = true; + services.nginx = { + enable = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; - # Security headers applied per-vhost - extraConfig = '' - ${lib.optionalString opts.internalOnly '' - # Restrict access to Tailscale network - allow 100.64.0.0/10; - allow 127.0.0.0/8; - deny all; - ''} - # Strict Transport Security — 6 months, include subdomains, preload-ready - add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always; + commonHttpConfig = lib.optionalString cfg.rateLimit.enable '' + limit_req_zone $binary_remote_addr zone=global:10m rate=${toString cfg.rateLimit.requests}r/s; + limit_conn_zone $binary_remote_addr zone=connlimit:10m; + ''; - # Prevent MIME type sniffing - add_header X-Content-Type-Options "nosniff" always; + virtualHosts = lib.mapAttrs' (domain: opts: { + name = domain; + value = { + useACMEHost = domain; + forceSSL = true; - # Restrict browser features - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + extraConfig = '' + ${lib.optionalString opts.internalOnly '' + allow 100.64.0.0/10; + allow 127.0.0.0/8; + deny all; + ''} + add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + '' + lib.optionalString (opts.contentSecurityPolicy != null) '' + add_header Content-Security-Policy "${opts.contentSecurityPolicy}" always; + ''; - # Cross-origin isolation - add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Cross-Origin-Opener-Policy "same-origin" always; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${toString opts.port}"; + extraConfig = opts.extraConfig + lib.optionalString (if opts.rateLimit.enable != null then + opts.rateLimit.enable else cfg.rateLimit.enable) '' + limit_req zone=global burst=${toString (if opts.rateLimit.burst != null then opts.rateLimit.burst + else cfg.rateLimit.burst)} nodelay; + limit_conn connlimit 30; + limit_req_status 429; + ''; + }; + } // lib.mapAttrs' (locPath: locOpts: { + name = locPath; + value = { + proxyPass = locOpts.proxyPass; + extraConfig = locOpts.extraConfig; + }; + }) opts.extraLocations; + }; + }) cfg.domains; + }; - # Content Security Policy (configurable per-domain) - '' + lib.optionalString (opts.contentSecurityPolicy != null) '' - add_header Content-Security-Policy "${opts.contentSecurityPolicy}" always; - ''; - - locations = { - "/" = { - proxyPass = "http://127.0.0.1:${toString opts.port}"; - extraConfig = opts.extraConfig + lib.optionalString (if opts.rateLimit.enable != null then opts.rateLimit.enable else cfg.rateLimit.enable) '' - # Rate limiting - limit_req zone=global burst=${toString (if opts.rateLimit.burst != null then opts.rateLimit.burst else cfg.rateLimit.burst)} nodelay; - limit_conn connlimit 30; - limit_req_status 429; - ''; - }; - } // lib.mapAttrs' (locPath: locOpts: { - name = locPath; - value = { - proxyPass = locOpts.proxyPass; - extraConfig = locOpts.extraConfig; - }; - }) opts.extraLocations; - }; - }) cfg.domains; + users.users.nginx.extraGroups = [ "acme" ]; }; - - # Ensure nginx user can access ACME certs - users.users.nginx.extraGroups = [ "acme" ]; - }; -} + }