Commit 44a5b1bd authored by Robert Helgesson's avatar Robert Helgesson
Browse files

nixos/grav: init module

parent f5557443
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -123,6 +123,8 @@

- [nfc-nci](https://github.com/StarGate01/ifdnfc-nci), an alternative NFC stack and PC/SC driver for the NXP PN54x chipset, commonly found in Lenovo systems as NXP1001 (NPC300). Available as [hardware.nfc-nci](#opt-hardware.nfc-nci.enable).

- [grav](https://getgrav.org/), a modern flat-file CMS. Available with [services.grav](options.html#opt-services.grav.enable).

- [duckdns](https://www.duckdns.org), free dynamic DNS. Available with [services.duckdns](options.html#opt-services.duckdns.enable)

- [victorialogs][https://docs.victoriametrics.com/victorialogs/], log database from VictoriaMetrics. Available as [services.victorialogs](#opt-services.victorialogs.enable)
+1 −0
Original line number Diff line number Diff line
@@ -1500,6 +1500,7 @@
  ./services/web-apps/glance.nix
  ./services/web-apps/gotify-server.nix
  ./services/web-apps/gotosocial.nix
  ./services/web-apps/grav.nix
  ./services/web-apps/grocy.nix
  ./services/web-apps/pixelfed.nix
  ./services/web-apps/goatcounter.nix
+333 −0
Original line number Diff line number Diff line
{
  config,
  lib,
  pkgs,
  ...
}:

let

  inherit (lib)
    generators
    mapAttrs
    mkDefault
    mkEnableOption
    mkIf
    mkPackageOption
    mkOption
    types
    ;

  cfg = config.services.grav;

  yamlFormat = pkgs.formats.yaml { };

  poolName = "grav";

  servedRoot = pkgs.runCommand "grav-served-root" { } ''
    cp --reflink=auto --no-preserve=mode -r ${cfg.package} $out

    for p in assets images user system/config; do
      rm -rf $out/$p
      ln -sf /var/lib/grav/$p $out/$p
    done
  '';

  systemSettingsYaml = yamlFormat.generate "grav-settings.yaml" cfg.systemSettings;

in
{
  options.services.grav = {
    enable = mkEnableOption "grav";

    package = mkPackageOption pkgs "grav" { };

    root = mkOption {
      type = types.path;
      default = "/var/lib/grav";
      description = ''
        Root of the application.
      '';
    };

    pool = mkOption {
      type = types.str;
      default = "${poolName}";
      description = ''
        Name of existing phpfpm pool that is used to run web-application.
        If not specified a pool will be created automatically with
        default values.
      '';
    };

    virtualHost = mkOption {
      type = types.nullOr types.str;
      default = "grav";
      description = ''
        Name of the nginx virtualhost to use and setup. If null, do not setup
        any virtualhost.
      '';
    };

    phpPackage = mkPackageOption pkgs "php" { };

    maxUploadSize = mkOption {
      type = types.str;
      default = "128M";
      description = ''
        The upload limit for files. This changes the relevant options in
        {file}`php.ini` and nginx if enabled.
      '';
    };

    systemSettings = mkOption {
      type = yamlFormat.type;
      default = {
        log = {
          handler = "syslog";
        };
      };
      description = ''
        Settings written to {file}`user/config/system.yaml`.
      '';
    };
  };

  config = mkIf cfg.enable {
    services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
      ${poolName} = {
        user = "grav";
        group = "grav";

        phpPackage = cfg.phpPackage.buildEnv {
          extensions =
            { all, enabled }:
            with all;
            [
              apcu
              ctype
              curl
              dom
              exif
              filter
              gd
              mbstring
              opcache
              openssl
              session
              simplexml
              xml
              yaml
              zip
            ];

          extraConfig = generators.toKeyValue { mkKeyValue = generators.mkKeyValueDefault { } " = "; } {
            output_buffering = "0";
            short_open_tag = "Off";
            expose_php = "Off";
            error_reporting = "E_ALL";
            display_errors = "stderr";
            "opcache.interned_strings_buffer" = "8";
            "opcache.max_accelerated_files" = "10000";
            "opcache.memory_consumption" = "128";
            "opcache.revalidate_freq" = "1";
            "opcache.fast_shutdown" = "1";
            "openssl.cafile" = "/etc/ssl/certs/ca-certificates.crt";
            catch_workers_output = "yes";

            upload_max_filesize = cfg.maxUploadSize;
            post_max_size = cfg.maxUploadSize;
            memory_limit = cfg.maxUploadSize;
            "apc.enable_cli" = "1";
          };
        };

        phpEnv = {
          GRAV_ROOT = toString servedRoot;
          GRAV_SYSTEM_PATH = "${servedRoot}/system";
          GRAV_CACHE_PATH = "/var/cache/grav";
          GRAV_BACKUP_PATH = "/var/lib/grav/backup";
          GRAV_LOG_PATH = "/var/log/grav";
          GRAV_TMP_PATH = "/var/tmp/grav";
        };

        settings = mapAttrs (name: mkDefault) {
          "listen.owner" = config.services.nginx.user;
          "listen.group" = config.services.nginx.group;
          "listen.mode" = "0600";
          "pm" = "dynamic";
          "pm.max_children" = 75;
          "pm.start_servers" = 10;
          "pm.min_spare_servers" = 5;
          "pm.max_spare_servers" = 20;
          "pm.max_requests" = 500;
          "catch_workers_output" = 1;
        };
      };
    };

    services.nginx = mkIf (cfg.virtualHost != null) {
      enable = true;
      virtualHosts = {
        ${cfg.virtualHost} = {
          root = "${servedRoot}";

          locations = {
            "= /robots.txt" = {
              priority = 100;
              extraConfig = ''
                allow all;
                access_log off;
              '';
            };

            "~ \\.php$" = {
              priority = 200;
              extraConfig = ''
                fastcgi_split_path_info ^(.+\.php)(/.+)$;
                fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
                fastcgi_index index.php;
              '';
            };

            "~* /(\\.git|cache|bin|logs|backup|tests)/.*$" = {
              priority = 300;
              extraConfig = ''
                return 403;
              '';
            };

            # deny running scripts inside core system folders
            "~* /(system|vendor)/.*\\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$" =
              {
                priority = 300;
                extraConfig = ''
                  return 403;
                '';
              };

            # deny running scripts inside user folder
            "~* /user/.*\\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$" = {
              priority = 300;
              extraConfig = ''
                return 403;
              '';
            };

            # deny access to specific files in the root folder
            "~ /(LICENSE\\.txt|composer\\.lock|composer\\.json|nginx\\.conf|web\\.config|htaccess\\.txt|\\.htaccess)" =
              {
                priority = 300;
                extraConfig = ''
                  return 403;
                '';
              };

            # deny all files and folder beginning with a dot (hidden files & folders)
            "~ (^|/)\\." = {
              priority = 300;
              extraConfig = ''
                return 403;
              '';
            };

            "/" = {
              priority = 400;
              index = "index.php";
              extraConfig = ''
                try_files $uri $uri/ /index.php?$query_string;
              '';
            };
          };

          extraConfig = ''
            index index.php index.html /index.php$request_uri;
            add_header X-Content-Type-Options nosniff;
            add_header X-XSS-Protection "1; mode=block";
            add_header X-Download-Options noopen;
            add_header X-Permitted-Cross-Domain-Policies none;
            add_header X-Frame-Options sameorigin;
            add_header Referrer-Policy no-referrer;
            client_max_body_size ${cfg.maxUploadSize};
            fastcgi_buffers 64 4K;
            fastcgi_hide_header X-Powered-By;
            gzip on;
            gzip_vary on;
            gzip_comp_level 4;
            gzip_min_length 256;
            gzip_proxied expired no-cache no-store private no_last_modified no_etag auth;
            gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
          '';
        };
      };
    };

    systemd.tmpfiles.rules =
      let
        datadir = "/var/lib/grav";
      in
      map (dir: "d '${dir}' 0750 grav grav - -") [
        "/var/cache/grav"
        "${datadir}/assets"
        "${datadir}/backup"
        "${datadir}/images"
        "${datadir}/system/config"
        "${datadir}/user/accounts"
        "${datadir}/user/config"
        "${datadir}/user/data"
        "/var/log/grav"
      ]
      ++ [ "L+ ${datadir}/user/config/system.yaml - - - - ${systemSettingsYaml}" ];

    systemd.services = {
      "phpfpm-${poolName}" = mkIf (cfg.pool == "${poolName}") {
        restartTriggers = [
          servedRoot
          systemSettingsYaml
        ];

        serviceConfig = {
          ExecStartPre = pkgs.writeShellScript "grav-pre-start" ''
            function setPermits() {
              chmod -R o-rx "$1"
              chown -R grav:grav "$1"
            }

            tmpDir=/var/tmp/grav
            dataDir=/var/lib/grav

            mkdir $tmpDir
            setPermits $tmpDir

            for path in config/site.yaml pages plugins themes; do
              fullPath="$dataDir/user/$path"
              if [[ ! -e $fullPath ]]; then
                cp --reflink=auto --no-preserve=mode -r \
                  ${cfg.package}/user/$path $fullPath
              fi
              setPermits $fullPath
            done

            systemConfigDir=$dataDir/system/config
            if [[ ! -e $systemConfigDir/system.yaml ]]; then
              cp --reflink=auto --no-preserve=mode -r \
                ${cfg.package}/system/config/* $systemConfigDir/
            fi
            setPermits $systemConfigDir
          '';
        };
      };
    };

    users.users.grav = {
      isSystemUser = true;
      description = "Grav service user";
      home = "/var/lib/grav";
      group = "grav";
    };

    users.groups.grav = {
      members = [ config.services.nginx.user ];
    };
  };
}
+1 −0
Original line number Diff line number Diff line
@@ -414,6 +414,7 @@ in {
  grafana = handleTest ./grafana {};
  grafana-agent = handleTest ./grafana-agent.nix {};
  graphite = handleTest ./graphite.nix {};
  grav = runTest ./web-apps/grav.nix;
  graylog = handleTest ./graylog.nix {};
  greetd-no-shadow = handleTest ./greetd-no-shadow.nix {};
  grocy = handleTest ./grocy.nix {};
+25 −0
Original line number Diff line number Diff line
{ pkgs, ... }:
{
  name = "grav";

  nodes = {
    machine =
      { pkgs, ... }:
      {
        services.grav.enable = true;
      };
  };

  testScript = ''
    start_all()
    machine.wait_for_unit("phpfpm-grav.service")
    machine.wait_for_open_port(80)

    # The first request to a fresh install should result in a redirect to the
    # admin page, where the user is expected to set up an admin user.
    actual = machine.succeed("curl -v --stderr - http://localhost/", timeout=10).splitlines()
    expected = "< Location: /admin"
    assert expected in actual, \
      f"unexpected reply from Grav: '{actual}'"
  '';
}
Loading