(define-module (tw services nextcloud) #: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 services certbot) #:use-module (gnu services mcron) #:use-module (gnu services web) #:use-module (guix gexp) #:use-module (tw services) #:use-module (tw services restic)) (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, bcmath, bz2, exif, gd, iconv, intl (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-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 "httpd"))) ; while still root (setuid (passwd:uid (getpw "httpd"))) (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 httpd-service-type ;; The certbot service redirects everything on port 80 to ;; port 443 by default, modulo its own /.well-known paths. (list (httpd-virtualhost "*:443" (list "\ # For Nextcloud. ServerName cloud.wilkenfamily.de DocumentRoot /var/www/nextcloud SSLEngine on SSLCertificateFile \"/etc/letsencrypt/live/cloud.wilkenfamily.de/fullchain.pem\" SSLCertificateKeyFile \"/etc/letsencrypt/live/cloud.wilkenfamily.de/privkey.pem\" Header always set Strict-Transport-Security \"max-age=15552000\" # Don't check for .htaccess files above DocumentRoot. AllowOverride None Options +FollowSymlinks AllowOverride All Dav off SetEnv HOME /var/www/nextcloud SetEnv HTTP_HOME /var/www/nextcloud # Redirect to local php-fpm if mod_php is not available # Enable http authorization headers SetEnvIfNoCase ^Authorization$ \"(.+)\" HTTP_AUTHORIZATION=$1 SetHandler \"proxy:unix:/var/run/php-fpm.sock|fcgi://localhost/\" # Deny access to raw PHP sources and files without filename (e.g. '.php') Require all denied ")))) (service (@ (tw services php-fpm) php-fpm-service-type) (php-fpm-configuration (user "httpd") (group "httpd") (socket "/var/run/php-fpm.sock") (socket-user "httpd") (socket-group "httpd") (php-ini-file %nextcloud-php.ini))) (simple-service 'nextcloud-certificates certbot-service-type (list (certificate-configuration (domains '("cloud.wilkenfamily.de")) (deploy-hook %httpd-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 "httpd") ;; TODO: try `with-mail-out' from `(mcron redirect)'? #~(job "0 6 * * *" #$nextcloud-backup-program)))))