(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)))))