(define-module (tw services nextcloud) #:use-module (ice-9 match) #:use-module (gnu) #:use-module (gnu packages backup) #:use-module (gnu packages certs) #:use-module (gnu packages databases) #:use-module (gnu packages linux) #:use-module (gnu packages php) #:use-module (gnu packages web) #:use-module (gnu services certbot) #:use-module (gnu services mcron) #:use-module (gnu services web) #:use-module (guix gexp) #:use-module ((guix packages) #:select (package-version)) #:use-module ((guix utils) #:select (version-major)) #:use-module (tw services restic) #:use-module (tw services web)) (define-public %nextcloud-php.ini (computed-file "nextcloud-php.ini" #~(begin (use-modules (ice-9 popen) (ice-9 rdelim)) (let* ((php-config #$(file-append php "/bin/php-config")) (pipe (open-pipe* OPEN_READ php-config "--extension-dir")) (php-extdir (read-line pipe))) (unless (zero? (status:exit-val (close-pipe pipe))) (error "Failed to get PHP extension dir")) (with-output-to-file #$output ;; Guix's PHP comes with the following extensions built-in, ;; so no extension= line necessary: ;; pdo_mysql, pdo_pgsql, pgsql, bcmath, bz2, exif, gd, iconv, intl ;; The default pgsql.* settings are fine. (lambda () (display (string-append "\ memory_limit=512M extension_dir=/run/current-system/profile/lib/php/extensions/" (basename php-extdir) " extension=imagick ; Caching extensions for Nextcloud extension=apcu apc.enable_cli=1 zend_extension=opcache ; https://www.php.net/manual/en/opcache.configuration.php opcache.enable=1 opcache.interned_strings_buffer=32 opcache.max_accelerated_files=10000 opcache.memory_consumption=256 opcache.save_comments=1 ; It will take up to revalidate_freq seconds for changes to config.php to be applied. opcache.revalidate_freq=120 ")))))))) (define nextcloud-domain "cloud.wilkenfamily.de") (define nextcloud-backup-repository "/var/backups/nextcloud") (define nextcloud-backup-password-file "/etc/restic/lud-nextcloud") (define nextcloud-backup-program (program-file "nextcloud-backup-command" #~(begin (use-modules (srfi srfi-1) (srfi srfi-26) (ice-9 popen) (ice-9 receive) (ice-9 textual-ports)) (define nextcloud-dir "/var/www/nextcloud") (define nextcloud-data-partition "/var/data") ; mountpoint of the partition containing Nextcloud data dir (define nextcloud-data-path "nextcloud") ; relative to `nextcloud-data-partition' (define snapshot (string-append nextcloud-data-partition "/tmp-nextcloud-backup")) (define btrfs #$(file-append btrfs-progs "/bin/btrfs")) (define restic #$(file-append restic "/bin/restic")) (setenv "RESTIC_REPOSITORY" #$nextcloud-backup-repository) (setenv "RESTIC_PASSWORD_FILE" #$nextcloud-backup-password-file) (define (nc-maintenance enable?) (let ((child-pid (primitive-fork))) (if (zero? child-pid) (begin ; this is the child (setgid (group:gid (getgr "php-fpm"))) ; while still root (setuid (passwd:uid (getpw "php-fpm"))) (execl #$(file-append php "/bin/php") "php" "-c" #$%nextcloud-php.ini (string-append nextcloud-dir "/occ") "maintenance:mode" (if enable? "--on" "--off"))) (zero? (status:exit-val (cdr (waitpid child-pid))))))) (define* (cleanup #:optional (recv-signal #f) #:key (rethrow #f)) (nc-maintenance #f) (when (and (file-exists? snapshot) (file-is-directory? snapshot)) (system* btrfs "subvolume" "delete" "-c" snapshot)) (cond (recv-signal (exit (- recv-signal))) (rethrow (raise-exception rethrow)))) (define (run-pipeline . commands) (receive (from to pids) (pipeline commands) (close to) (do ((char (read-char from) (read-char from))) ((eof-object? char)) (display char)) (close from) (let ((failing-index (list-index (compose not zero? status:exit-val cdr waitpid) (reverse pids)))) (when failing-index (apply error "Command exited with error status" (list-ref commands failing-index)))))) (define (read-file name) (string-trim-right (call-with-input-file name get-string-all) #\newline)) (define (main) (unless (nc-maintenance #t) (error "Could not enter maintenance mode")) ;; Backup the database. This can only be done offline. (run-pipeline ;; `mysql-configuration' uses mariadb by default, so match it here. (list #$(file-append mariadb "/bin/mysqldump") "--single-transaction" "--quick" "--default-character-set=utf8mb4" (string-append "-p" (read-file "/etc/default/nextcloud-database-password")) "-u" "nextcloud" "nextcloud") (list restic "backup" "--no-cache" "--stdin" "--stdin-filename=nextcloud.sql")) ;; These shouldn't be copied while Nextcloud is online. They're also ;; not in the data folder, so they won't be in the snapshot below. (run-pipeline (list restic "backup" "--no-cache" (string-append nextcloud-dir "/config") (string-append nextcloud-dir "/themes"))) ;; Make sure everything is synced to disk so it's in our snapshot. (run-pipeline (list btrfs "filesystem" "sync" (string-append nextcloud-data-partition "/" nextcloud-data-path))) (run-pipeline (list btrfs "subvolume" "snapshot" "-r" nextcloud-data-partition snapshot)) ;; At this point, the data directory is in the snapshot, so Nextcloud ;; can be turned on again. (nc-maintenance #f) ;; We don't need files under preview/, as those are thumbnails from ;; the Previews "app" and can be regenerated using `php -f occ ;; preview:pre-generate`. (run-pipeline (list restic "backup" "--no-cache" "--exclude=appdata_*/preview" "--exclude=appdata_*/passwords/*Cache" (string-append snapshot "/" nextcloud-data-path)))) (for-each (cut sigaction <> cleanup) (list SIGHUP SIGINT SIGTERM)) (with-exception-handler (cut cleanup #:rethrow <>) main) (cleanup)))) (define-public %nextcloud-services (list (simple-service 'nextcloud-https-server nginx-service-type ;; https://docs.nextcloud.com/server/latest/admin_manual/installation/nginx.html (list (nginx-server-configuration ;; The certbot service redirects everything on port 80 to ;; port 443 by default, modulo its own /.well-known paths. (listen '("443 ssl http2")) (server-name (list nextcloud-domain)) (root "/var/www/nextcloud") (index '("index.php" "index.html" "/index.php$request_uri")) (try-files '("$uri" "$uri/" "/index.php$request_uri")) (ssl-certificate (string-append "/etc/letsencrypt/live/" nextcloud-domain "/fullchain.pem")) (ssl-certificate-key (string-append "/etc/letsencrypt/live/" nextcloud-domain "/privkey.pem")) (server-tokens? #f) (raw-content `(;; Set max upload size and increase upload timeout "client_max_body_size 512M;" "client_body_timeout 300s;" "fastcgi_buffers 64 4K;" ;; Enable gzip but do not remove ETag headers "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 text/javascript application/javascript " "application/json application/ld+json application/manifest+json " "application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject " "application/wasm 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/vtt " "text/vnd.rim.location.xloc text/x-component text/x-cross-domain-policy;") ;; HTTP response headers borrowed from Nextcloud `.htaccess` ,@(map (match-lambda ((hdr . value) `("add_header " ,hdr " \"" ,value "\" always;"))) '(("Referrer-Policy" . "no-referrer") ("X-Content-Type-Options" . "nosniff") ("X-Frame-Options" . "SAMEORIGIN") ("X-Permitted-Cross-Domain-Policies" . "none") ("X-Robots-Tag" . "noindex, nofollow") ("X-XSS-Protection" . "1; mode=block"))) ;; Remove X-Powered-By, which is an information leak "fastcgi_hide_header X-Powered-By;" ;; Add .mjs as a file extension for JavaScript ("include " ,nginx "/share/nginx/conf/mime.types;") "types { text/javascript mjs; }")) (locations (list (nginx-location-configuration ; Handle Microsoft DAV clients (uri "= /") (body '("if ( $http_user_agent ~ ^DavClnt ) { return 302 /remote.php/webdav/$is_args$args; }"))) (nginx-location-configuration (uri "= /robots.txt") (body '("allow all;" "log_not_found off;" "access_log off;"))) ;; The rules in these blocks are an adaptation of the rules ;; in `.htaccess` that concern `/.well-known`. (nginx-location-configuration (uri "= /.well-known/carddav") (body '("return 301 /remote.php/dav/;"))) (nginx-location-configuration (uri "= /.well-known/caldav") (body '("return 301 /remote.php/dav/;"))) (nginx-location-configuration (uri "= /.well-known/acme-challenge") (body '("try_files $uri $uri/ =404;"))) (nginx-location-configuration (uri "= /.well-known/pki-validation") (body '("try_files $uri $uri/ =404;"))) ;; Let Nextcloud's API for `/.well-known` URIs handle all other ;; requests by passing them to the front-end controller. (nginx-location-configuration (uri "^~ /.well-known") (body '("return 301 /index.php$request_uri;"))) ;; Rules borrowed from `.htaccess` to hide certain paths from clients (nginx-location-configuration (uri "~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/)") (body '("return 404;"))) (nginx-location-configuration ;; Exclude .well-known to avoid overriding rules above (regexes take precedence). (uri "~ ^/(?:\\.(?!well-known)|autotest|occ|issue|indie|db_|console)") (body '("return 404;"))) ;; Ensure this block, which passes PHP files to the PHP process, is above the blocks ;; which handle static assets (as seen below). If this block is not declared first, ;; then Nginx will encounter an infinite rewriting loop when it prepends `/index.php` ;; to the URI, resulting in a HTTP 500 error response. (nginx-location-configuration (uri "~ \\.php(?:$|/)") (body (let ((phpver (version-major (package-version php)))) `(("rewrite ^/(?!index|remote|public|cron|core\\/ajax\\/update|status|ocs\\/v[12]|" ; Legacy support "updater\\/.+|ocs-provider\\/.+|.+\\/richdocumentscode\\/proxy) /index.php$request_uri;") "fastcgi_request_buffering off;" "fastcgi_split_path_info ^(.+?\\.php)(/.*)$;" "try_files $fastcgi_script_name =404;" ("include " ,nginx "/share/nginx/conf/fastcgi.conf;") "fastcgi_param HTTP_PROXY \"\";" ; Mitigate https://httpoxy.org/ "fastcgi_param modHeadersAvailable true;" ; Avoid sending the security headers twice "fastcgi_param front_controller_active true;" ; Enable pretty urls ("fastcgi_pass unix:/var/run/php" ,phpver "-fpm.sock;"))))) ; Match php-fpm-configuration (nginx-location-configuration (uri "~ \\.(?:css|js|mjs|svg|gif|png|jpg|ico|wasm|tflite|map|ogg|flac)$") (body '("try_files $uri /index.php$request_uri;" "add_header Cache-Control \"public, max-age=15778463, immutable\";"))) (nginx-location-configuration (uri "~ \\.woff2?$") (body '("try_files $uri /index.php$request_uri;" "expires 7d;"))) (nginx-location-configuration (uri "/remote") (body '("return 301 /remote.php$request_uri;")))))))) (service (@ (tw services php-fpm) php-fpm-service-type) (php-fpm-configuration (php-ini-file %nextcloud-php.ini))) (simple-service 'nextcloud-certificates certbot-service-type (list (certificate-configuration (domains (list nextcloud-domain)) (deploy-hook %nginx-cert-deploy-hook)))) (simple-service 'nextcloud-backup-cleanup restic-cleanup-service-type (list (restic-scheduled-cleanup (schedule #~"0 7 * * *") (repo (restic-local-repository (path nextcloud-backup-repository))) (password (restic-password-source (type 'file) (name nextcloud-backup-password-file))) (keep-daily 30) (keep-monthly -1)))) ;; Nextcloud cron (simple-service 'nextcloud-cron mcron-service-type (list #~(job "*/5 * * * *" #$(program-file "nextcloud-cron-command" #~(begin ;; Nextcloud News needs this to fetch HTTPS feeds. (setenv "SSL_CERT_DIR" #$(file-append nss-certs "/etc/ssl/certs")) (execl #$(file-append php "/bin/php") "php" "-c" #$%nextcloud-php.ini "/var/www/nextcloud/cron.php"))) #:user "php-fpm") ;; TODO: try `with-mail-out' from `(mcron redirect)'? #~(job "0 6 * * *" #$nextcloud-backup-program)))))