fec7d62acb
LibreSign had been silently broken since it was first deployed in January. Every step of the old before-starting hook ended in `|| echo`, so six months of failures logged nothing. LibreSign repair - Root cause was a stale config_path: a valid OpenSSL root CA existed at generation 1, a failed CFSSL attempt left an empty generation 2, and config_path was left pointing at the empty one. Regenerated as "Skudak LLP" (was the pre-rename "Skudak Rennsport LLP"). - Deleted the hook. Java/PDFtk/jSignPdf live under data/appdata_*, a persisted volume, so they only ever needed installing once. Install and verification are now explicit tasks that actually fail. - PHP_MEMORY_LIMIT 1024M -- the 512M image default fails opaquely mid-signature. LC_ALL/LANG so the JVM is not ANSI_X3.4-1968. - signature_render_mode=GRAPHIC_ONLY. Any other mode halves the stamp width and overlays a name/date block that collides with the drawn mark and duplicates what our documents already typeset. The value must be exactly GRAPHIC_ONLY; a bare "GRAPHIC" is accepted by occ, matches no radio in the UI, and silently reverts to default. - write_qrcode_on_footer=false, written with --type=boolean because FooterHandler reads it via getValueBool and the typed appconfig API does not coerce a string "0". The validation URL text is kept. - identification_documents=0 -- the default gates signing behind an ID upload plus admin approval, so signers saw no way to sign. - shareapi_restrict_user_enumeration_full_match=no, so an email owned by an existing account can be added as a signer. Root cause is in core (MailPlugin.php:163), not LibreSign. Do NOT set full_match_email=no -- that disables email signer search entirely. Mail branding (skudakmail app) - Two supported extension points, no core patch and no LibreSign fork: mail_template_class for layout, subjects, button labels and the footer LibreSign never adds; and a BeforeMessageSent listener to embed the wordmark as a cid: part so it survives remote-image blocking. - A third listener adds scoped CSS fixing the signing page being clipped on iOS Safari (100vh -> 100dvh). Patched upstream too. - skudakmail-verify.php.j2 asserts all of the above through the real useTemplate() path and fails the play on drift. Every assertion was proven to fail when deliberately regressed. Redis - memcache.locking was unset, so Nextcloud used DBLockingProvider and every file lock became a MariaDB write -- the contention behind the intermittent multi-second stalls. Verified after: db locks static, redis keys growing. - requirepass lives in a mounted 0640 conf, not --requirepass, which would leak it into podman inspect, the systemd unit and ps. The file is chowned to uid 999 because redis-server does not run as root and the :ro mount stops the image fixing it itself. - No maxmemory: cache is evictable, locks are NOT, and evicting a held lock permits concurrent writers to one file. No persistence either -- a restored RDB could reinstate locks whose owner is long dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
617 lines
26 KiB
YAML
617 lines
26 KiB
YAML
---
|
|
- name: create required skudak cloud volumes
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ item }}"
|
|
state: directory
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_subuid.stdout }}"
|
|
mode: 0755
|
|
notify: restorecon podman
|
|
loop:
|
|
- "{{ cloud_skudak_path }}/apps"
|
|
- "{{ cloud_skudak_path }}/config"
|
|
- "{{ cloud_skudak_path }}/data"
|
|
- "{{ cloud_skudak_path }}/mysql"
|
|
- "{{ cloud_skudak_path }}/scripts"
|
|
|
|
- name: unshare chown the skudak cloud volumes
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
changed_when: false
|
|
ansible.builtin.command: |
|
|
podman unshare chown -R 33:33 {{ cloud_skudak_path }}/apps {{ cloud_skudak_path }}/data {{ cloud_skudak_path}}/config
|
|
|
|
- name: flush handlers
|
|
ansible.builtin.meta: flush_handlers
|
|
|
|
# The former libresign-setup.sh before-starting hook re-ran
|
|
# `occ libresign:install --java/--pdftk/--jsignpdf` on every container start,
|
|
# with every line ending in `|| echo`, so six months of failures logged
|
|
# nothing. Those binaries live under data/appdata_*/libresign, which IS a
|
|
# persisted volume, so they only ever needed installing once. Installation and
|
|
# verification are now explicit Ansible tasks below that actually fail.
|
|
- name: remove obsolete skudak cloud libresign setup hook
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ cloud_skudak_path }}/scripts/libresign-setup.sh"
|
|
state: absent
|
|
|
|
- import_tasks: podman/podman-check.yml
|
|
vars:
|
|
container_name: skudak-cloud-db
|
|
container_image: "{{ db_image }}"
|
|
|
|
- name: create skudak-cloud-db container
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
containers.podman.podman_container:
|
|
name: skudak-cloud-db
|
|
image: "{{ db_image }}"
|
|
restart_policy: on-failure:3
|
|
log_driver: journald
|
|
network:
|
|
- shared
|
|
env:
|
|
MYSQL_ROOT_PASSWORD: "{{ cloud_skudak_db_root_pass }}"
|
|
MYSQL_DATABASE: skucloud
|
|
MYSQL_PASSWORD: "{{ cloud_skudak_db_pass }}"
|
|
MYSQL_USER: skucloud
|
|
volumes:
|
|
- "{{ cloud_skudak_path }}/mysql:/var/lib/mysql"
|
|
|
|
- name: create systemd startup job for skudak-cloud-db
|
|
include_tasks: podman/systemd-generate.yml
|
|
vars:
|
|
container_name: skudak-cloud-db
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Redis: Nextcloud distributed cache + transactional file locking.
|
|
#
|
|
# Without it, memcache.locking is unset and Nextcloud falls back to
|
|
# DBLockingProvider (lib/private/Server.php:977) -- every file lock becomes a
|
|
# MariaDB write against oc_file_locks. A single directory PROPFIND takes dozens
|
|
# of locks and two desktop sync clients issue them continuously, which is the
|
|
# contention behind the intermittent multi-second stalls.
|
|
#
|
|
# Also fixes a second problem: memcache.local is APCu, which is PER-PROCESS,
|
|
# and Apache here runs mpm_prefork -- so every child holds its own cold cache.
|
|
# A distributed cache is shared across all of them.
|
|
#
|
|
# MUST be created before skudak-cloud below. The Nextcloud entrypoint writes
|
|
# its redis config on start; if the host does not resolve at that moment the
|
|
# instance comes up pointing at nothing.
|
|
- name: create skudak cloud redis config directory
|
|
become: true
|
|
ansible.builtin.file:
|
|
path: "{{ cloud_skudak_path }}/redis"
|
|
state: directory
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_subuid.stdout }}"
|
|
mode: 0755
|
|
notify: restorecon podman
|
|
|
|
- name: template skudak cloud redis config
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: nextcloud/redis-skudak.conf.j2
|
|
dest: "{{ cloud_skudak_path }}/redis/redis.conf"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_subuid.stdout }}"
|
|
mode: 0640
|
|
notify: restorecon podman
|
|
no_log: true
|
|
|
|
- name: flush handlers
|
|
ansible.builtin.meta: flush_handlers
|
|
|
|
# The redis:alpine image runs redis-server as uid 999 / gid 1000, NOT root, and
|
|
# the config is mounted :ro so the image's own entrypoint cannot chown it --
|
|
# it logs "cannot change owner ... Read-only file system" and then dies with
|
|
# "Fatal error, can't open config file: Permission denied", crash-looping.
|
|
# Nextcloud, already pointed at redis by then, answers HTTP 500.
|
|
#
|
|
# Same idiom as the `podman unshare chown -R 33:33` for www-data above: map the
|
|
# in-container uid through the rootless userns. 0640 owned by 999:1000 keeps
|
|
# the password unreadable to other users on the host while letting redis read
|
|
# it -- which is the entire reason for using a file over --requirepass.
|
|
- name: unshare chown the skudak redis config to the redis uid
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
changed_when: false
|
|
ansible.builtin.command: >
|
|
podman unshare chown 999:1000 {{ cloud_skudak_path }}/redis/redis.conf
|
|
|
|
- import_tasks: podman/podman-check.yml
|
|
vars:
|
|
container_name: skudak-cloud-redis
|
|
container_image: "{{ redis_image }}"
|
|
|
|
- name: create skudak-cloud-redis container
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
containers.podman.podman_container:
|
|
name: skudak-cloud-redis
|
|
image: "{{ redis_image }}"
|
|
restart_policy: on-failure:3
|
|
log_driver: journald
|
|
network:
|
|
- shared
|
|
# No `ports:` -- deliberately unpublished. Service discovery is by
|
|
# container name over `shared`, the same way MYSQL_HOST reaches
|
|
# skudak-cloud-db.
|
|
volumes:
|
|
- "{{ cloud_skudak_path }}/redis/redis.conf:/etc/redis/redis.conf:ro"
|
|
command: redis-server /etc/redis/redis.conf
|
|
|
|
- name: create systemd startup job for skudak-cloud-redis
|
|
include_tasks: podman/systemd-generate.yml
|
|
vars:
|
|
container_name: skudak-cloud-redis
|
|
|
|
- import_tasks: podman/podman-check.yml
|
|
vars:
|
|
container_name: skudak-cloud
|
|
container_image: "{{ image }}"
|
|
|
|
- name: create skudak cloud container
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
containers.podman.podman_container:
|
|
name: skudak-cloud
|
|
image: "{{ image }}"
|
|
restart_policy: on-failure:3
|
|
log_driver: journald
|
|
network:
|
|
- shared
|
|
env:
|
|
MYSQL_PASSWORD: "{{ cloud_skudak_db_pass }}"
|
|
MYSQL_DATABASE: skucloud
|
|
MYSQL_HOST: skudak-cloud-db
|
|
MYSQL_USER: skucloud
|
|
# LibreSign signs PDFs in-process; the image default of 512M is not
|
|
# enough and manifests as an opaque failure mid-signature.
|
|
PHP_MEMORY_LIMIT: 1024M
|
|
PHP_UPLOAD_LIMIT: 512M
|
|
# Without these the JVM comes up as ANSI_X3.4-1968 and LibreSign's
|
|
# config check warns that accented characters in signer names will be
|
|
# mangled. See LibreSign issue #4872.
|
|
LC_ALL: C.UTF-8
|
|
LANG: C.UTF-8
|
|
# These three env vars are the WHOLE redis wiring. The image ships
|
|
# config/redis.config.php, which -- when REDIS_HOST is set -- declares
|
|
# memcache.distributed, memcache.locking AND the connection block.
|
|
# Verified against the copy in this instance's persisted config volume.
|
|
#
|
|
# Do NOT also `occ config:system:set` those keys. occ writes config.php,
|
|
# but Nextcloud merges every *.config.php drop-in AFTER it, so the
|
|
# drop-in wins -- config.php would read as authoritative while being
|
|
# silently overridden. memcache.local stays APCu (apcu.config.php).
|
|
#
|
|
# REDIS_HOST_PASSWORD_FILE is NOT usable here: the drop-in in this
|
|
# volume predates that feature and reads only REDIS_HOST_PASSWORD.
|
|
REDIS_HOST: skudak-cloud-redis
|
|
REDIS_HOST_PORT: "6379"
|
|
REDIS_HOST_PASSWORD: "{{ cloud_skudak_redis_pass }}"
|
|
volumes:
|
|
- "{{ cloud_skudak_path }}/apps:/var/www/html/custom_apps"
|
|
- "{{ cloud_skudak_path }}/data:/var/www/html/data"
|
|
- "{{ cloud_skudak_path }}/config:/var/www/html/config"
|
|
ports:
|
|
- "8090:80"
|
|
|
|
- name: create systemd startup job for cloud
|
|
include_tasks: podman/systemd-generate.yml
|
|
vars:
|
|
container_name: skudak-cloud
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LibreSign (e-signature for Skudak agreements)
|
|
#
|
|
# poppler-utils supplies pdfsig/pdfinfo; ghostscript is used for PDF
|
|
# normalisation. Both land in /usr, which is NOT a persisted volume, so they
|
|
# must be reinstalled after every container recreation. Java, PDFtk and
|
|
# jSignPdf are different -- LibreSign installs those under
|
|
# data/appdata_*/libresign, which IS persisted, so they survive.
|
|
- name: install libresign runtime dependencies in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command:
|
|
cmd: >
|
|
podman exec -u 0 skudak-cloud
|
|
sh -c "apt-get update && apt-get install -y --no-install-recommends
|
|
poppler-utils ghostscript && rm -rf /var/lib/apt/lists/*"
|
|
register: libresign_deps
|
|
changed_when: "'is already the newest version' not in libresign_deps.stdout"
|
|
|
|
# When the container is recreated, the entrypoint re-extracts Nextcloud into
|
|
# the /var/www/html volume before Apache starts. Every occ call below races
|
|
# that: it fails with "Failed opening required .../lib/versioncheck.php" until
|
|
# the extraction completes. Poll until occ answers rather than sleeping a
|
|
# fixed interval, which would be both slower and still unreliable.
|
|
- name: wait for nextcloud to be ready in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ status --output=json
|
|
register: skudak_occ_ready
|
|
until: skudak_occ_ready.rc == 0 and 'installed' in skudak_occ_ready.stdout
|
|
retries: 30
|
|
delay: 5
|
|
changed_when: false
|
|
|
|
# A disabled app deregisters every `occ libresign:*` command, which makes the
|
|
# app look uninstalled rather than switched off. Found disabled on 2026-07-31.
|
|
- name: ensure libresign app is enabled in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ app:enable libresign
|
|
register: libresign_enable
|
|
changed_when: "'already enabled' not in libresign_enable.stdout"
|
|
|
|
# Ensure-installed: LibreSign no-ops when the binaries are already present
|
|
# under data/appdata_*/libresign (a persisted volume). It prints "Finished with
|
|
# success." either way and gives no signal distinguishing a fresh download from
|
|
# a no-op, so this never reports changed rather than reporting it every run.
|
|
- name: install libresign java/pdftk/jsignpdf binaries in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ libresign:install --java --pdftk --jsignpdf
|
|
register: libresign_install
|
|
changed_when: false
|
|
failed_when: "'Finished with success' not in libresign_install.stdout"
|
|
|
|
- name: check whether libresign root certificate is configured
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ libresign:configure:check --certificate
|
|
register: libresign_cert_check
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
# Guarded deliberately. Running this unconditionally would mint a new root CA
|
|
# on every deploy and invalidate every certificate already issued to a signer,
|
|
# breaking the trust chain on documents that were already signed.
|
|
#
|
|
# Do NOT add --ou here: LibreSign appends its own `libresign-ca-id:...` entry
|
|
# to the OU field, and the combined value overruns the 64-character ASN.1
|
|
# limit for organizationalUnitName, failing with "string too long".
|
|
- name: generate libresign root certificate for skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ libresign:configure:openssl
|
|
--cn="{{ libresign_skudak_cert_cn }}"
|
|
-o "{{ libresign_skudak_cert_o }}"
|
|
-c "{{ libresign_skudak_cert_c }}"
|
|
-s "{{ libresign_skudak_cert_st }}"
|
|
-l "{{ libresign_skudak_cert_l }}"
|
|
when: "'error' in libresign_cert_check.stdout"
|
|
changed_when: true
|
|
|
|
# LibreSign defaults to requiring every signer to upload an identification
|
|
# document, which then needs approval by a member of `approval_group` before
|
|
# the sign action unlocks. For three partners signing their own partnership
|
|
# instruments that is pure friction -- the emailed invitation is the identity
|
|
# check. Without this, signers see "Upload file" and no way to sign.
|
|
- name: relax libresign identification-document gate in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:app:set libresign identification_documents --value=0
|
|
register: libresign_ident
|
|
changed_when: "'is now set to' in libresign_ident.stdout"
|
|
|
|
# SIGNAME_AND_DESCRIPTION (the LibreSign default) typesets the signer's NAME as
|
|
# text and offers no drawing surface at all. GRAPHIC is the mode that asks for
|
|
# an actual signature graphic -- drawn, uploaded or typed -- and stamps ONLY
|
|
# that mark, with no description block.
|
|
#
|
|
# Deliberately GRAPHIC_ONLY rather than the LibreSign default of
|
|
# GRAPHIC_AND_DESCRIPTION. In the latter,
|
|
# SignatureTextService::getSignatureWidth() returns `$current / 2` whenever a
|
|
# text template is set, splitting the stamp into a graphic half and a text
|
|
# half. Our documents already typeset the signer's printed name and the date
|
|
# either side of the signature rule (\signatureblock in skudak-contract.cls),
|
|
# so LibreSign's own name/date block is both redundant and prone to colliding
|
|
# with the drawn mark. GRAPHIC_ONLY takes the early return in that method,
|
|
# using the full width to stamp the signature alone.
|
|
#
|
|
# The value MUST be exactly 'GRAPHIC_ONLY' -- see
|
|
# SignerElementsService::RENDER_MODE_GRAPHIC_ONLY (line 25). 'GRAPHIC' is NOT
|
|
# a valid constant; setting it writes a value the admin UI cannot match to any
|
|
# radio button, silently reverting the effective behaviour to the default.
|
|
- name: use signature-only stamp in skudak-cloud libresign
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:app:set libresign signature_render_mode --value=GRAPHIC_ONLY
|
|
register: libresign_render
|
|
changed_when: "'is now set to' in libresign_render.stdout"
|
|
|
|
# LibreSign stamps a validation footer onto EVERY page of a signed PDF
|
|
# (FooterHandler::getFooter(), default on). The QR block within it is a large
|
|
# square that lands in the same band as our own document footer -- the rule,
|
|
# "Page N of M" and the Skudak mark set by skudak-contract.cls -- and overlaps
|
|
# it.
|
|
#
|
|
# The QR is dropped; the "Digitally signed by ... Validate in <url>" TEXT is
|
|
# deliberately KEPT. That line is how a recipient independently verifies who
|
|
# signed, when, and under which certificate, which matters for instruments that
|
|
# may have to stand up in diligence. Only the redundant graphic goes -- the URL
|
|
# it encodes remains printed beside it.
|
|
#
|
|
# Must be written with --type=boolean: FooterHandler reads it via
|
|
# getValueBool() (line 158), and the typed appconfig API does not coerce a
|
|
# string "0" to false.
|
|
# Lets an email that belongs to an existing Nextcloud account be added as a
|
|
# LibreSign signer. Arbitrary external addresses already worked; ONLY
|
|
# account-owned ones failed, with a bare "No signers." and nothing logged.
|
|
#
|
|
# Root cause is in Nextcloud core, not LibreSign --
|
|
# lib/private/Collaboration/Collaborators/MailPlugin.php:128-164. On an exact
|
|
# email match against the local system address book, with
|
|
# shareeEnumerationFullMatch on (its default), the plugin adds a TYPE_USER
|
|
# result and returns false. LibreSign registers that plugin as
|
|
# MailByMailPlugin with shareType = TYPE_EMAIL, so the TYPE_USER branch is
|
|
# skipped, nothing is added, and the early return still fires -- never
|
|
# reaching line 243 where the free-form email result is synthesised.
|
|
#
|
|
# Safe here: shareapi_allow_share_dialog_user_enumeration is already at its
|
|
# default 'yes', so users are discoverable by partial search regardless. This
|
|
# changes how exact matches are handled, not who can be found.
|
|
#
|
|
# DO NOT set shareapi_restrict_user_enumeration_full_match_email to 'no'. That
|
|
# hits an early bail at MailPlugin.php:67-69 and disables email signer search
|
|
# ENTIRELY, including the arbitrary-address case that works today. The verify
|
|
# script asserts it has not been set that way.
|
|
- name: allow account-owned emails as libresign signers
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:app:set core
|
|
shareapi_restrict_user_enumeration_full_match --value=no
|
|
register: skudak_enum_fullmatch
|
|
changed_when: "'is now set to' in skudak_enum_fullmatch.stdout"
|
|
|
|
- name: drop libresign validation QR code from signed-PDF footer
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:app:set libresign write_qrcode_on_footer
|
|
--value=0 --type=boolean
|
|
register: libresign_qr
|
|
changed_when: "'is now set to' in libresign_qr.stdout"
|
|
|
|
# The whole point of this block. Previously every step ended in `|| echo`, so
|
|
# a broken LibreSign deployed clean and stayed broken for six months.
|
|
- name: verify libresign configuration in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ libresign:configure:check
|
|
register: libresign_verify
|
|
changed_when: false
|
|
failed_when: libresign_verify.stdout is search('\berror\b')
|
|
|
|
- name: disable nextcloud signup link in config
|
|
become: true
|
|
ansible.builtin.lineinfile:
|
|
path: "{{ cloud_skudak_path }}/config/config.php"
|
|
regexp: "^\\s*'simpleSignUpLink\\.shown'\\s*=>"
|
|
line: " 'simpleSignUpLink.shown' => false,"
|
|
insertbefore: '^\);'
|
|
create: false
|
|
failed_when: false
|
|
|
|
# Add cloud.skudak.com to Nextcloud trusted_domains
|
|
- name: add cloud.skudak.com to nextcloud trusted_domains
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:system:set trusted_domains 1 --value="cloud.skudak.com"
|
|
register: trusted_domain_result
|
|
changed_when: "'System config value trusted_domains' in trusted_domain_result.stdout"
|
|
failed_when: false
|
|
|
|
# This instance was left at loglevel 0 (DEBUG) and had written a 64 GB
|
|
# nextcloud.log, almost entirely repeated deprecation notices. 2 = Warning,
|
|
# which is both the Nextcloud default and what the home instance already uses.
|
|
- name: set nextcloud loglevel for skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:system:set loglevel --value 2 --type integer
|
|
register: skudak_loglevel
|
|
changed_when: "'System config value loglevel' in skudak_loglevel.stdout"
|
|
failed_when: false
|
|
|
|
# Unbounded by default; see the equivalent task in containers/home/cloud.yml.
|
|
- name: cap nextcloud log rotation size for skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:system:set log_rotate_size --value 10485760 --type integer
|
|
register: skudak_log_rotate
|
|
changed_when: "'System config value log_rotate_size' in skudak_log_rotate.stdout"
|
|
failed_when: false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skudak mail branding
|
|
#
|
|
# custom_apps IS a persisted bind mount, so the app survives container
|
|
# recreation; only enabling it and the config values need reasserting.
|
|
- name: deploy skudakmail email-template app to skudak-cloud
|
|
become: true
|
|
ansible.builtin.copy:
|
|
src: skudakmail/
|
|
dest: "{{ cloud_skudak_path }}/apps/skudakmail/"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_subuid.stdout }}"
|
|
mode: 0644
|
|
directory_mode: 0755
|
|
notify: restorecon podman
|
|
|
|
- name: unshare chown skudakmail app
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
changed_when: false
|
|
ansible.builtin.command: >
|
|
podman unshare chown -R 33:33 {{ cloud_skudak_path }}/apps/skudakmail
|
|
|
|
- name: enable skudakmail app in skudak-cloud
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud php occ app:enable skudakmail
|
|
register: skudakmail_enable
|
|
changed_when: "'already enabled' not in skudakmail_enable.stdout"
|
|
|
|
# Supported extension point -- Mailer::createEMailTemplate() checks this and
|
|
# instantiates the named class if it extends EMailTemplate. Not a core patch.
|
|
- name: point nextcloud at the skudak email template
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:system:set mail_template_class
|
|
--value={{ "OCA\\Skudakmail\\Mail\\SkudakEMailTemplate" }}
|
|
register: skudak_mail_class
|
|
changed_when: "'set to' in skudak_mail_class.stdout"
|
|
|
|
# Email asset URLs and every link LibreSign puts in a signature invitation are
|
|
# built from overwrite.cli.url when sending from a background job. It pointed
|
|
# at the pre-rename cloud.skudakrennsport.com, so invitations carried the old
|
|
# domain and the logo <img> resolved against it.
|
|
- name: set skudak-cloud canonical cli url
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud
|
|
php occ config:system:set overwrite.cli.url
|
|
--value=https://{{ cloud_skudak_server_name_new }}
|
|
register: skudak_cli_url
|
|
changed_when: "'set to' in skudak_cli_url.stdout"
|
|
|
|
# Theming that the email template reads. The logo MUST be a wide, tightly
|
|
# cropped image: Mailer clamps to MAX_LOGO_SIZE=105 preserving aspect, so a
|
|
# SQUARE logo renders as a 105x105 block in a coloured band -- which is
|
|
# exactly how an 8334x8334 upload turned the header into a giant blue blob.
|
|
- name: set skudak-cloud theming
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.command: >
|
|
podman exec -u www-data skudak-cloud php occ theming:config {{ item.k }} "{{ item.v }}"
|
|
loop:
|
|
- {k: name, v: "Skudak"}
|
|
- {k: slogan, v: "Aftermarket vintage car parts and restoration"}
|
|
- {k: url, v: "https://skudak.com"}
|
|
- {k: primary_color, v: "{{ theming_skudak_primary }}"}
|
|
- {k: background_color, v: "{{ theming_skudak_primary }}"}
|
|
register: skudak_theming
|
|
changed_when: "'Updated' in skudak_theming.stdout"
|
|
loop_control:
|
|
label: "{{ item.k }}"
|
|
|
|
# Branding rides on OC\Mail\EMailTemplate, which is Nextcloud's PRIVATE
|
|
# namespace with no API stability guarantee. A Nextcloud major upgrade disables
|
|
# the app (info.xml pins max-version), Mailer falls back to the stock template,
|
|
# and mail keeps sending -- unbranded and silent. This turns that silence into
|
|
# a failed play. Renders through Message::useTemplate(), the real path, and
|
|
# also re-asserts the LibreSign signing settings.
|
|
- name: template skudakmail verification script
|
|
become: true
|
|
ansible.builtin.template:
|
|
src: nextcloud/skudakmail-verify.php.j2
|
|
dest: "{{ cloud_skudak_path }}/scripts/skudakmail-verify.php"
|
|
owner: "{{ podman_subuid.stdout }}"
|
|
group: "{{ podman_subuid.stdout }}"
|
|
mode: 0644
|
|
notify: restorecon podman
|
|
|
|
- name: verify skudak mail branding is live
|
|
become: true
|
|
become_user: "{{ podman_user }}"
|
|
ansible.builtin.shell: >
|
|
set -o pipefail;
|
|
podman exec -i -u www-data skudak-cloud php
|
|
< {{ cloud_skudak_path }}/scripts/skudakmail-verify.php
|
|
args:
|
|
executable: /bin/bash
|
|
register: skudakmail_verify
|
|
changed_when: false
|
|
|
|
- include_tasks: containers/cloud-cron.yml
|
|
vars:
|
|
cron_name: skudak-cloud
|
|
cron_container: skudak-cloud
|
|
cron_script_path: /usr/local/bin/skudak-cloud-cron.sh
|
|
|
|
# This instance is BUSINESS data and deliberately backs up to TrueNAS ONLY.
|
|
#
|
|
# It used to reach personal cloud storage too: the TrueNAS "iDrive E2 Backup"
|
|
# cloud-sync task pushes /mnt/glacier to a personal iDrive e2 bucket, which
|
|
# swept skudakcloud/ along with it. That task now carries an explicit
|
|
# `/skudakcloud/**` exclude, and on 2026-07-30 the stranded copy was purged
|
|
# from the bucket -- business data does not belong in personal storage.
|
|
#
|
|
# The copy was also worthless as a backup: 30 objects against 20,802 files on
|
|
# TrueNAS (0.14%), stale since 2026-05-20. Worse, the bucket is VERSIONED and
|
|
# the sync runs in COPY mode (never deletes), so every daily run retained
|
|
# another ~60 GB version of the pre-cap nextcloud.log -- 56 of them, 3.46 TB,
|
|
# 99.3% of a 3.49 TB footprint. Deleting current objects alone reclaims
|
|
# nothing on a versioned bucket; the versions must be purged explicitly.
|
|
#
|
|
# Offsite is now BUSINESS-OWNED: Skudak's own iDrive e2 account, bucket
|
|
# `backup-all`, pushed by TrueNAS cloud-sync task "Skudak iDrive - Nextcloud"
|
|
# (id 8, /mnt/glacier/skudakcloud -> /skudakcloud, daily 06:00). That bucket
|
|
# has a 90-day NoncurrentVersionExpiration policy so the version bloat above
|
|
# cannot repeat. The personal task's `/skudakcloud/**` exclude is PERMANENT --
|
|
# it is what keeps business data out of personal storage, not a stopgap.
|
|
#
|
|
# A direct host-to-iDrive S3 stage was built here and then REMOVED on
|
|
# 2026-07-31. It would have written the same data into the same `backup-all`
|
|
# bucket that the TrueNAS cloud-sync task above already fills -- duplicate
|
|
# storage, two writers to one prefix, for no additional coverage. Offsite to
|
|
# business-owned storage was already solved by that cloud-sync task; the
|
|
# earlier note in this file proposed adding S3 *and then dropping the rsync*,
|
|
# i.e. replacement, and building both was a misreading of it.
|
|
#
|
|
# If offsite is ever moved onto this host, it must REPLACE the rsync below,
|
|
# not run beside it. The open question to settle first is whether the
|
|
# TrueNAS -> iDrive leg is independently verifiable; nobody has confirmed that
|
|
# task's run history end to end, and keeping this chain means trusting it.
|
|
- include_tasks: containers/cloud-backup.yml
|
|
vars:
|
|
backup_name: skudak-cloud
|
|
data_path: "{{ cloud_skudak_path }}/data"
|
|
config_path: "{{ cloud_skudak_path }}/config"
|
|
db_container: skudak-cloud-db
|
|
ssh_key_path: /etc/ssh/backup_keys/skudak-cloud
|
|
ssh_key_content: "{{ cloud_skudak_backup_ssh_key }}"
|
|
ssh_user: skucloud
|
|
remote_path: /mnt/glacier/skudakcloud
|
|
script_path: /usr/local/bin/skudak-cloud-backup.sh
|
|
# skudakcloud/data is mode 770, so the receiving side needs traversable
|
|
# dirs. This flag was hand-added on the host and was being silently
|
|
# reverted by every `make deploy TAGS=skudak-cloud`; it now lives in git.
|
|
backup_rsync_extra_args: "--chmod=Du=rwx,Dgo=rx"
|
|
# Staggered so both instances finish before the 05:00 TrueNAS snapshot.
|
|
backup_oncalendar: "*-*-* 04:30:00"
|