--- - 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 " 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 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"