From bc8ad1fd71defe6a957acb77bb857a26679408e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 28 Aug 2025 17:45:42 +0200 Subject: [PATCH] Add static.nix, add wwwRedirect, simplify mkUsername (#6) --- README.md | 47 ++++++++++++++++++- laravel.nix | 52 ++++++++++++--------- static.nix | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 static.nix diff --git a/README.md b/README.md index efe0f40..2e002e5 100644 --- a/README.md +++ b/README.md @@ -170,9 +170,20 @@ Simply `scp laravel.nix root@:/etc/nixos/` and start writing con ### www redirects -The module doesn't handle www redirects automatically. This may be added in the future. +To redirect `www.acme.com` to `acme.com`, you can use the `wwwRedirect` attribute. It should be +null for no redirect, or an integer status code for an enabled redirect. -At this time, I'd recommend handling basic redirects like that on Cloudflare. +```nix +(laravelSite { + name = "foo"; + domains = [ "foo.com" ] + wwwRedirect = 301; # permanent redirect + # ... +}) +``` + +With the config above, `www.foo.com/bar` will return a redirect to `foo.com/bar`, with the schema +matching the site's `ssl` config. ### Default nginx server @@ -299,6 +310,38 @@ curl -s https://www.cloudflare.com/ips-v4 | sha256 | xargs nix hash convert --ha curl -s https://www.cloudflare.com/ips-v6 | sha256 | xargs nix hash convert --hash-algo sha256 --to nix32 ``` +## Static sites + +For hosting static sites, you can use `static.nix` very similarly to `laravel.nix`. Notable differences: +1. `root` is required, e.g. `name="foo"; root="build";` means `/srv/foo/build` will be served. In other + words, even though this is for static sites, we do not serve the entire `/srv/{name}` dir to allow + for version control and build steps. +2. By default, the `static-generic` user is used. Static sites do not always need strict user separation + since there's no request runtime. That said, the user is *very* limited and only has `pkgs.git` and + `pkgs.unzip`. Therefore it's only suited for static sites that are at most pulled from somewhere, + rather than built using Node.js. Also note that GitHub generally doesn't allow using a single SSH key + as the deploy key on multiple repos. For these reasons, it's still recommended to enable user creation + via `user = true;`. + +Full usage: +```nix +(staticSite { + name = "foo"; # name of the site + root = "build"; # directory within /srv/foo to be served by nginx + + user = true; # if false, static-generic is used. Default: false + domains = [ "foo.com" "bar.com" ]; # domains to serve the site on + ssl = true; # enableACME + forceSSL. Default: false + # Status code for www-to-non-www redirects. No redirect if null. Applies to all sites + wwwRedirect = 301; # Default: null + cloudflareOnly = true; # use Authenticated Origin Pulls. See the dedicated section. Default: false + extraPackages = [ pkgs.nodejs_24 ]; # only applies if user=true + generateSshKey = true; # defaults to true, used even with user=false + sshKeys = [ "array" "of" "public" "ssh" "keys" ]; # optional + extraNginxConfig = "nginx configuration string"; # optional +}) +``` + ## Maintenance It's a good idea to have `/etc/nixos` tracked in version control so you can easily revert the config diff --git a/laravel.nix b/laravel.nix index 30f8bd6..2b8825a 100644 --- a/laravel.nix +++ b/laravel.nix @@ -3,6 +3,7 @@ phpPackage, # e.g. pkgs.php84 domains ? [], # e.g. [ "example.com" "acme.com" ] ssl ? false, # Should SSL be used + wwwRedirect ? null, # The status code used for www-to-non-www redirects. Null means no redirect cloudflareOnly ? false, # Should CF Authenticated Origin Pulls be used extraNginxConfig ? null, # Extra nginx config string sshKeys ? null, # SSH public keys used to log into the site's user for deployments @@ -31,7 +32,7 @@ { config, lib, pkgs, ... }: let - mkUsername = siteName: "laravel-${siteName}"; + username = "laravel-${name}"; in { services.nginx.enable = true; security.acme.acceptTerms = lib.mkIf ssl true; @@ -41,13 +42,14 @@ in { # Create welcome message for user # todo: the created /etc file should ideally be 0750 + # Note: keep in sync with static.nix environment.etc."laravel-${name}-bashrc".text = '' export PATH="$HOME/.config/composer/vendor/bin/:$PATH" # Laravel site welcome message echo "Welcome to ${name} Laravel site!" echo "Domains: ${lib.concatStringsSep ", " domains}" - echo "User home: /home/${mkUsername name}" + echo "User home: /home/${username}" echo "Site: /srv/${name}" echo "Restart php-fpm: sudo systemctl reload phpfpm-${name}" ${lib.optionalString queue ''echo "Restart queue: php artisan queue:restart"''} @@ -60,12 +62,12 @@ in { systemd.tmpfiles.rules = [ "d /srv 0751 root root - -" "d /home 0751 root root - -" - "d /srv/${name} 0750 ${mkUsername name} ${mkUsername name} - -" - "C /home/${mkUsername name}/.bashrc 0640 ${mkUsername name} ${mkUsername name} - /etc/laravel-${name}-bashrc" + "d /srv/${name} 0750 ${username} ${username} - -" + "C /home/${username}/.bashrc 0640 ${username} ${username} - /etc/laravel-${name}-bashrc" ]; services.cron.systemCronJobs = [ - "* * * * * ${mkUsername name} cd /srv/${name} && ${phpPackage}/bin/php artisan schedule:run > /dev/null 2>&1" + "* * * * * ${username} cd /srv/${name} && ${phpPackage}/bin/php artisan schedule:run > /dev/null 2>&1" ]; # Laravel queue worker service @@ -75,8 +77,8 @@ in { wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; - User = mkUsername name; - Group = mkUsername name; + User = username; + Group = username; WorkingDirectory = "/srv/${name}"; ExecStart = "${phpPackage}/bin/php artisan queue:work ${queueArgs}"; Restart = "always"; @@ -88,8 +90,9 @@ in { }; # SSH key generation for git deployments + # Note: keep in sync with static.nix systemd.services."generate-ssh-key-${name}" = lib.mkIf generateSshKey { - description = "Generate SSH key for ${mkUsername name}"; + description = "Generate SSH key for ${username}"; wantedBy = [ "multi-user.target" ]; after = [ "users.target" ]; serviceConfig = { @@ -98,15 +101,15 @@ in { User = "root"; }; script = '' - USER_HOME="/home/${mkUsername name}" + USER_HOME="/home/${username}" SSH_DIR="$USER_HOME/.ssh" KEY_FILE="$SSH_DIR/id_ed25519" if [[ ! -f "$KEY_FILE" ]]; then - echo "Generating SSH key for ${mkUsername name}" + echo "Generating SSH key for ${username}" mkdir -p "$SSH_DIR" - ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$KEY_FILE" -N "" -C "${mkUsername name}" - chown -R ${mkUsername name}:${mkUsername name} "$SSH_DIR" + ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$KEY_FILE" -N "" -C "${username}" + chown -R ${username}:${username} "$SSH_DIR" chmod 700 "$SSH_DIR" chmod 600 "$KEY_FILE" chmod 640 "$KEY_FILE.pub" @@ -114,7 +117,7 @@ in { echo "Public key for deploy key:" cat "$KEY_FILE.pub" else - echo "SSH key already exists for ${mkUsername name}" + echo "SSH key already exists for ${username}" fi ''; }; @@ -169,11 +172,18 @@ in { deny all; ''; }; - }); + }) // lib.optionalAttrs (wwwRedirect != null) (lib.genAttrs (map (domain: "www.${domain}") domains) (wwwDomain: { + enableACME = ssl; + forceSSL = ssl; + + locations."/" = { + return = "${toString wwwRedirect} ${if ssl then "https" else "http"}://${lib.removePrefix "www." wwwDomain}$request_uri"; + }; + })); # PHP-FPM pool configuration services.phpfpm.pools.${name} = { - user = mkUsername name; + user = username; phpPackage = phpPackage; settings = poolSettings // extraPoolSettings // { "listen.owner" = config.services.nginx.user; @@ -181,11 +191,11 @@ in { }; # User and group settings - users.users.${mkUsername name} = { - group = mkUsername name; + users.users.${username} = { + group = username; isSystemUser = true; createHome = true; - home = "/home/${mkUsername name}"; + home = "/home/${username}"; homeMode = "750"; shell = pkgs.bashInteractive; packages = [ phpPackage pkgs.git pkgs.unzip phpPackage.packages.composer ] ++ extraPackages; @@ -193,14 +203,14 @@ in { openssh.authorizedKeys.keys = sshKeys; }; - users.groups.${mkUsername name} = {}; + users.groups.${username} = {}; # Add site group to nginx service - systemd.services.nginx.serviceConfig.SupplementaryGroups = [ (mkUsername name) ]; + systemd.services.nginx.serviceConfig.SupplementaryGroups = [ username ]; # Sudo rules for service management security.sudo.extraRules = [{ - users = [ (mkUsername name) ]; + users = [ username ]; commands = [ { command = "/run/current-system/sw/bin/systemctl reload phpfpm-${name}"; diff --git a/static.nix b/static.nix new file mode 100644 index 0000000..0a7e18c --- /dev/null +++ b/static.nix @@ -0,0 +1,132 @@ +{ + name, # Name of the site, /srv/{name} will be based on this as well as the username if user=true + root, # The directory within /srv/{name} that should be served by nginx + user ? false, # Should a user be created. If false, static-generic is used + domains ? [], # e.g. [ "example.com" "acme.com" ] + ssl ? false, # Should SSL be used + wwwRedirect ? null, # The status code used for www-to-non-www redirects. Null means no redirect + cloudflareOnly ? false, # Should CF Authenticated Origin Pulls be used + extraNginxConfig ? null, # Extra nginx config string + sshKeys ? null, # SSH public keys used to log into the site's user for deployments + extraPackages ? [], # Any extra packages the user should have in $PATH (only used with user=true) + generateSshKey ? true, # Generate an SSH key for the user (used for GH deploy keys) + ... +}: + +{ lib, pkgs, ... }: +let + username = if user then "static-${name}" else "static-generic"; +in { + services.nginx.enable = true; + security.acme.acceptTerms = true; + networking.firewall.allowedTCPPorts = [ 80 ] ++ lib.optionals ssl [ 443 ]; + + services.nginx.virtualHosts = lib.genAttrs domains (domain: { + enableACME = ssl; + forceSSL = ssl; + root = "/srv/${name}/${root}"; + + extraConfig = '' + ${lib.optionalString cloudflareOnly '' + ssl_verify_client on; + ssl_client_certificate ${pkgs.fetchurl { + url = "https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem"; + sha256 = "0hxqszqfzsbmgksfm6k0gp0hsx9k1gqx24gakxqv0391wl6fsky1"; + }}; + ''} + ${lib.optionalString (extraNginxConfig != null) extraNginxConfig} + ''; + + locations = { + "= /favicon.ico".extraConfig = '' + access_log off; + log_not_found off; + ''; + + "= /robots.txt".extraConfig = '' + access_log off; + log_not_found off; + ''; + }; + }) // lib.optionalAttrs (wwwRedirect != null) (lib.genAttrs (map (domain: "www.${domain}") domains) (wwwDomain: { + enableACME = ssl; + forceSSL = ssl; + + locations."/" = { + return = "${toString wwwRedirect} ${if ssl then "https" else "http"}://${lib.removePrefix "www." wwwDomain}$request_uri"; + }; + })); + + systemd.tmpfiles.rules = [ + "d /srv 0751 root root - -" + "d /home 0751 root root - -" + "d /srv/${name} 0750 ${username} ${username} - -" + ] ++ lib.optional user + "C /home/${username}/.bashrc 0640 ${username} ${username} - /etc/static-${name}-bashrc"; + + # User and group settings + users.users.${username} = { + group = username; + isSystemUser = true; + createHome = true; + home = "/home/${username}"; + homeMode = "750"; + shell = pkgs.bashInteractive; + packages = [ pkgs.git pkgs.unzip ] ++ lib.optionals user extraPackages; + } // lib.optionalAttrs (sshKeys != null && user) { + openssh.authorizedKeys.keys = sshKeys; + }; + + users.groups.${username} = {}; + + # Add site group to nginx service + systemd.services.nginx.serviceConfig.SupplementaryGroups = [ username ]; + + # SSH key generation for git deployments + # Note: keep in sync with laravel.nix + # Unlike laravel.nix, the key here includes username, not the site name since static-generic + # can be used for multiple sites + systemd.services."generate-ssh-key-${username}" = lib.mkIf generateSshKey { + description = "Generate SSH key for ${username}"; + wantedBy = [ "multi-user.target" ]; + after = [ "users.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "root"; + }; + script = '' + USER_HOME="/home/${username}" + SSH_DIR="$USER_HOME/.ssh" + KEY_FILE="$SSH_DIR/id_ed25519" + + if [[ ! -f "$KEY_FILE" ]]; then + echo "Generating SSH key for ${username}" + mkdir -p "$SSH_DIR" + ${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f "$KEY_FILE" -N "" -C "${username}" + chown -R ${username}:${username} "$SSH_DIR" + chmod 700 "$SSH_DIR" + chmod 600 "$KEY_FILE" + chmod 640 "$KEY_FILE.pub" + echo "SSH key generated: $KEY_FILE.pub" + echo "Public key for deploy key:" + cat "$KEY_FILE.pub" + else + echo "SSH key already exists for ${username}" + fi + ''; + }; + + # Create welcome message for user + # Note: keep in sync with laravel.nix (same block, minor changes here) + environment.etc."static-${name}-bashrc" = lib.mkIf user { + text = '' + echo "Welcome to ${name} static site!" + echo "Domains: ${lib.concatStringsSep ", " domains}" + echo "User home: /home/${username}" + echo "Site: /srv/${name}" + ${lib.optionalString generateSshKey ''echo "SSH public key: cat ~/.ssh/id_ed25519.pub"''} + echo "---" + ''; + } ; +}