diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index be891678c454..37b953d7268c 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -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) diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index ea45817fcf9b..9dcd3e88bfe8 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -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 diff --git a/nixos/modules/services/web-apps/grav.nix b/nixos/modules/services/web-apps/grav.nix new file mode 100644 index 000000000000..25743bc3e3f0 --- /dev/null +++ b/nixos/modules/services/web-apps/grav.nix @@ -0,0 +1,333 @@ +{ + 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 ]; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index c4c83fa7f798..de8d97bd2e5f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -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 {}; diff --git a/nixos/tests/web-apps/grav.nix b/nixos/tests/web-apps/grav.nix new file mode 100644 index 000000000000..ff371be7a7d5 --- /dev/null +++ b/nixos/tests/web-apps/grav.nix @@ -0,0 +1,25 @@ +{ 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}'" + ''; +} diff --git a/pkgs/by-name/gr/grav/package.nix b/pkgs/by-name/gr/grav/package.nix index 6818d77f0501..c351065280d9 100644 --- a/pkgs/by-name/gr/grav/package.nix +++ b/pkgs/by-name/gr/grav/package.nix @@ -2,6 +2,7 @@ stdenvNoCC, lib, fetchzip, + nixosTests, }: let @@ -29,6 +30,10 @@ stdenvNoCC.mkDerivation { runHook postInstall ''; + passthru.tests = { + grav = nixosTests.grav; + }; + meta = with lib; { description = "Fast, simple, and flexible, file-based web platform"; homepage = "https://getgrav.com";