feat(debyltech-cloud): add cloud.debyltech.com Nextcloud

A de Byl Technologies LLC Nextcloud cloned from the Skudak instance:
LibreSign signing for people without an account, registration off
(admin-created accounts only), no Group Folders. DNS is a terraform-managed
ALIAS to fulfillr.debyltech.com.

- containers/debyltech/cloud.yml: nextcloud/mariadb/redis on port 8091.
  It installs unattended on the first deploy, sends mail through SES as
  noreply@debyltech.com, and re-asserts the Skudak LibreSign settings.
- files/debyltechmail: skudakmail rebranded, with a new black-and-white
  wordmark and white web-UI logos.
- LibreSign is pinned to 14.2.2 from the GitHub release (sha256-checked)
  rather than `occ app:install`. The app store served a same-day 14.2.3
  whose tarball has no binary-signature metadata. 14.2.x also doesn't
  create its own download dirs, so they're pre-created.
- The backup runs nightly at 04:15 to TrueNAS /mnt/glacier/debyltechcloud and
  reaches personal iDrive via the "iDrive E2 Backup" task; the TrueNAS side
  excludes /debyltechcloud/_backup/config/**.
- Fix the libresign:configure:check gate in both instances: '\berror\b'
  becomes a backspace in Jinja and never matched, so a check reporting three
  errors passed clean. Now '\\berror\\b'.
- vault: cloud_debyltech_* secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-09-28 17:04:04 -04:00
co-authored by Claude Opus 5.5
parent 0eca63d4b7
commit fa5bbf8e54
19 changed files with 1653 additions and 12 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ ansible/
Containers are organized in `ansible/roles/podman/tasks/containers/`: Containers are organized in `ansible/roles/podman/tasks/containers/`:
- `base/` - Core infrastructure containers (Caddy web server, AWS DDNS) - `base/` - Core infrastructure containers (Caddy web server, AWS DDNS)
- `home/` - Home-specific services (Home Assistant, PartKeepr, Immich photos, Nextcloud, Redis) - `home/` - Home-specific services (Home Assistant, PartKeepr, Immich photos, Nextcloud, Redis)
- `debyltech/` - Personal/business services (Fulfillr) - `debyltech/` - Personal/business services (Fulfillr, Nextcloud at cloud.debyltech.com - cloned from the Skudak instance, backs up to personal iDrive via TrueNAS)
- `skudak/` - Additional services (BookStack wiki, Nextcloud) - `skudak/` - Additional services (BookStack wiki, Nextcloud)
### Security Model ### Security Model
+16 -10
View File
@@ -39,9 +39,13 @@ before you need it, and record the date you last did.
Dumps live on the host at `/var/backups/nextcloud/<name>/db/<name>-YYYYMMDD.sql.gz` Dumps live on the host at `/var/backups/nextcloud/<name>/db/<name>-YYYYMMDD.sql.gz`
and on TrueNAS at `<remote_path>/_backup/db/`. TrueNAS in turn cloud-syncs and on TrueNAS at `<remote_path>/_backup/db/`. TrueNAS in turn cloud-syncs
`/mnt/glacier/skudakcloud` to Skudak's own iDrive e2 bucket, so a third copy each dataset offsite, so a third copy exists there — but restoring from it
exists there — but restoring from it means going through the TrueNAS console, means going through the TrueNAS console, not this host:
not this host.
| Dataset | Offsite |
|---|---|
| `skudakcloud`, `skudakapps`, `skudakgit` | Skudak's own iDrive e2 bucket (excluded from the personal task) |
| `nextcloud`, `gitea`, `debyltechcloud` | Personal iDrive e2 bucket, via the "iDrive E2 Backup" task over `/mnt/glacier` |
Verify the dump before trusting it: Verify the dump before trusting it:
@@ -102,23 +106,25 @@ The signing CA lives in the data tree at
brings it back with everything else. After restoring, confirm it: brings it back with everything else. After restoring, confirm it:
```bash ```bash
sudo -H -u podman bash -c 'cd; podman exec -u www-data skudak-cloud php occ libresign:configure:check' sudo -H -u podman bash -c 'cd; podman exec -u www-data <skudak-cloud|debyltech-cloud> php occ libresign:configure:check'
``` ```
Every check must report `success`. If `openssl-configure` reports an error, the Every check must report `success`. If `openssl-configure` reports an error, the
`certificate_engine` / `config_path` app config is pointing somewhere without a `certificate_engine` / `config_path` app config is pointing somewhere without a
CA — see the guarded generate task in `tasks/containers/skudak/cloud.yml`. CA — see the guarded generate task in `tasks/containers/{skudak,debyltech}/cloud.yml`.
**Do not** simply re-run `libresign:configure:openssl` on a restored instance **Do not** simply re-run `libresign:configure:openssl` on a restored instance
without understanding why: it mints a *new* root CA and invalidates the trust without understanding why: it mints a *new* root CA and invalidates the trust
chain on every document already signed under the old one. chain on every document already signed under the old one.
## LibreSign ## LibreSign
Deployed on `skudak-cloud` only. LibreSign 14.1.0 requires Nextcloud server Deployed on `skudak-cloud` and `debyltech-cloud`. LibreSign 14.1.0 requires
`>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.2-apache` satisfies. If the Nextcloud server `>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.3-apache`
Nextcloud tag is bumped to 35, LibreSign must be held or upgraded in step — the satisfies. If the Nextcloud tag is bumped to 35, LibreSign must be held or
two instances are pinned independently in `tasks/main.yml`, so `skudak-cloud` upgraded in step — each instance is pinned independently in `tasks/main.yml`,
can lag `cloud` if needed. so the LibreSign instances can lag `cloud` if needed. The branding apps
(`files/skudakmail`, `files/debyltechmail`) pin `max-version="34"` too and
must be bumped alongside.
Dependency split, which drives what survives a container recreate: Dependency split, which drives what survives a container recreate:
+25
View File
@@ -5,6 +5,7 @@ bookstack_path: "{{ podman_volumes }}/bookstack"
cam2ip_path: "{{ podman_volumes }}/cam2ip" cam2ip_path: "{{ podman_volumes }}/cam2ip"
cloud_path: "{{ podman_volumes }}/cloud" cloud_path: "{{ podman_volumes }}/cloud"
cloud_skudak_path: "{{ podman_volumes }}/skudakcloud" cloud_skudak_path: "{{ podman_volumes }}/skudakcloud"
cloud_debyltech_path: "{{ podman_volumes }}/debyltechcloud"
debyltech_path: "{{ podman_volumes }}/debyltech" debyltech_path: "{{ podman_volumes }}/debyltech"
# drone_path: removed - Drone CI decommissioned # drone_path: removed - Drone CI decommissioned
factorio_path: "{{ podman_volumes }}/factorio" factorio_path: "{{ podman_volumes }}/factorio"
@@ -218,6 +219,29 @@ libresign_skudak_cert_c: US
libresign_skudak_cert_st: New Hampshire libresign_skudak_cert_st: New Hampshire
libresign_skudak_cert_l: Newbury libresign_skudak_cert_l: Newbury
# de Byl Technologies Nextcloud (containers/debyltech/cloud.yml). DNS is a
# Route53 ALIAS to fulfillr.debyltech.com, managed in ~/src/debyltech/terraform
# (aws/cloud.tf), so no awsddns container of its own.
cloud_debyltech_server_name: cloud.debyltech.com
# debyltech-com $primary-color (copper). Drives the web UI theming; the mail
# CTA carries the same value as a constant in files/debyltechmail.
theming_debyltech_primary: "#bc804d"
# Login/header background. Dark site ink rather than copper so the white
# wordmark and mark shipped in files/debyltechmail/img stay legible on it.
theming_debyltech_background: "#0a1a2b"
# LibreSign root CA identity: the issuer on every signed document. Same caveat
# as the Skudak block above -- changing these does not re-issue the CA.
libresign_debyltech_cert_cn: de Byl Technologies LLC
libresign_debyltech_cert_o: de Byl Technologies LLC
libresign_debyltech_cert_c: US
libresign_debyltech_cert_st: New Hampshire
libresign_debyltech_cert_l: Newbury
# Outbound mail via AWS SES SMTP as noreply@debyltech.com. The IAM user is
# NextcloudSMTP in the terraform repo; its SMTP username/password are
# cloud_debyltech_smtp_user / cloud_debyltech_smtp_pass in the vault.
cloud_debyltech_smtp_host: email-smtp.us-east-1.amazonaws.com
cloud_debyltech_smtp_port: 465
# Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security # Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security
@@ -284,6 +308,7 @@ caddy_log_names:
- graylog - graylog
- cloud - cloud
- cloud-skudak - cloud-skudak
- cloud-debyltech
- gitea-debyl - gitea-debyl
- gitea-skudak - gitea-skudak
- fulfillr - fulfillr
@@ -0,0 +1,44 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>debyltechmail</id>
<name>de Byl Tech Customisations</name>
<summary>de Byl Technologies-branded email templates and UI overrides for Nextcloud and LibreSign</summary>
<description><![CDATA[
Restyles outgoing Nextcloud and LibreSign mail to match the de Byl
Technologies brand (~/src/debyltech/debyltech-com). Cloned from the Skudak
instance's skudakmail app. Two supported extension points, no core
patch and no LibreSign fork:
1. `OCA\Debyltechmail\Mail\DebyltechEMailTemplate` extends Nextcloud's EMailTemplate
and is wired in via the `mail_template_class` system config value, which
Nextcloud checks in `lib/private/Mail/Mailer.php::createEMailTemplate()`.
It owns layout, typography, subject rewriting, button labels and the
footer LibreSign never adds.
2. `OCA\Debyltechmail\Listener\DebyltechMailListener` listens on
`OCP\Mail\Events\BeforeMessageSent` to embed the wordmark as an inline
(cid:) MIME part, so the logo survives the remote-image blocking that
Apple Mail, Gmail and Outlook apply by default. This cannot be done from
the template class, which has no reference to the message.
3. `OCA\Debyltechmail\Listener\DebyltechStyleListener` listens on
`OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent` and adds
css/libresign-mobile.css, which fixes the LibreSign public signing page
being clipped at the bottom on iOS Safari. Serving it from here rather
than patching LibreSign keeps the app's integrity signature intact and
survives app updates, which wipe the app directory.
The app has no routes, no UI, no settings and no database tables. The id
`debyltechmail` is referenced by the `mail_template_class` system config;
its scope is instance-wide customisation, not mail alone.
]]></description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>de Byl Technologies LLC</author>
<namespace>Debyltechmail</namespace>
<category>customization</category>
<dependencies>
<nextcloud min-version="34" max-version="34"/>
</dependencies>
</info>
@@ -0,0 +1,40 @@
/*
* Mobile fix for the LibreSign public signing page.
*
* PROBLEM: src/ExternalApp.vue sets `height: 100vh` on `html body #content`
* and again on `#app-sidebar` under `@media (max-width: 512px)`. iOS Safari
* resolves 100vh against the LARGE viewport -- as though the browser chrome
* were hidden -- so the element extends behind the bottom toolbar and the
* signing action bar is clipped off-screen. The built `external` chunk uses
* 100vh seven times and dvh/svh/safe-area zero times.
*
* WHY NOT safe-area-inset: the page's viewport meta is
* `width=device-width, initial-scale=1.0, minimum-scale=1.0` with no
* `viewport-fit=cover`, so env(safe-area-inset-bottom) resolves to 0 here.
*
* WHY dvh: the dynamic viewport unit tracks the chrome as it shows and hides,
* which is exactly the behaviour wanted. Browsers without dvh support drop the
* declaration entirely and keep LibreSign's own 100vh -- so this degrades to
* today's behaviour rather than to something broken. No @supports needed.
*
* SCOPING IS LOad-BEARING. `#content` and `#app-sidebar` are Nextcloud-wide
* IDs used throughout the authenticated UI. Every rule below is scoped to
* `#body-public` + `.app-public`, which the public signing page sets:
* <body id="body-public" class="layout-base">
* <div id="content" class="app-public" role="main">
* Widening these selectors would restyle the whole instance.
*
* UPSTREAM: patched at source in src/ExternalApp.vue (lines 34 and 46) and
* submitted to LibreSign. Once that lands and this instance runs a release
* containing it, this file can be deleted.
*/
#body-public #content.app-public {
height: 100dvh;
}
@media (max-width: 512px) {
#body-public #app-sidebar {
height: 100dvh;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace OCA\Debyltechmail\AppInfo;
use OCA\Debyltechmail\Listener\DebyltechMailListener;
use OCA\Debyltechmail\Listener\DebyltechStyleListener;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\Mail\Events\BeforeMessageSent;
class Application extends App implements IBootstrap {
public const APP_ID = 'debyltechmail';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
// BeforeMessageSent fires in Mailer::send() (lib/private/Mail/Mailer.php:186),
// AFTER useTemplate() has flattened the template into subject/plain/html on
// the message, and BEFORE setRecipients() and the Symfony transport. That
// window is the only place an inline (cid:) logo can be attached -- see the
// listener for why the template class alone cannot do it.
$context->registerEventListener(BeforeMessageSent::class, DebyltechMailListener::class);
// BeforeTemplateRenderedEvent is dispatched from
// lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php:35 and
// lib/private/Template/TemplateManager.php:82 -- the latter covers public
// (unauthenticated) pages, which is the case that matters here since the
// LibreSign signing page is a #[PublicPage].
$context->registerEventListener(BeforeTemplateRenderedEvent::class, DebyltechStyleListener::class);
}
public function boot(IBootContext $context): void {
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* Embeds the de Byl Technologies wordmark as an inline (cid:) MIME part.
*
* WHY A LISTENER AND NOT THE TEMPLATE CLASS: Apple Mail, Gmail and Outlook all
* block remote images by default, and Apple Mail draws its own placeholder box
* rather than styled alt text -- so no amount of styling in the HTML rescues a
* remote <img>. The fix is a cid: reference backed by an inline MIME part, and
* that part must be attached to the MESSAGE. An IEMailTemplate subclass has no
* reference to the message, so it physically cannot do this; the template emits
* the <img>, this listener supplies the bytes and rewrites the src.
*
* BeforeMessageSent is the sanctioned hook -- "Emitted before a system mail is
* sent. It can be used to alter the message." (lib/public/Mail/Events/
* BeforeMessageSent.php). It fires at lib/private/Mail/Mailer.php:186, after
* useTemplate() has already rendered subject/plain/html onto the message and
* before setRecipients() and the transport, so a body rewrite here takes
* effect. No core patch, no LibreSign fork.
*
* FAILURE POSTURE: every step is defensive. If the asset is missing, the body
* is not ours, or anything throws, the listener leaves the message untouched
* and mail still goes out with a remote <img> -- degraded, never blocked. Mail
* that carries signature requests must not fail to send because branding
* broke.
*/
namespace OCA\Debyltechmail\Listener;
use OC\Mail\Message;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Mail\Events\BeforeMessageSent;
use Psr\Log\LoggerInterface;
/** @template-implements IEventListener<BeforeMessageSent> */
class DebyltechMailListener implements IEventListener {
/** Must match DebyltechEMailTemplate::LOGO_PATH. */
private const LOGO_PATH_FRAGMENT = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
/** Content-ID. Symfony emits this as <debyltech-wordmark.png>. */
private const CID = 'debyltech-wordmark.png';
public function __construct(
private LoggerInterface $logger,
) {
}
public function handle(Event $event): void {
if (!$event instanceof BeforeMessageSent) {
return;
}
try {
$this->embedWordmark($event->getMessage());
} catch (\Throwable $e) {
// Never let branding break delivery of a signature request.
$this->logger->warning('debyltechmail: inline logo embed skipped', [
'exception' => $e,
]);
}
}
private function embedWordmark(\OCP\Mail\IMessage $message): void {
// Mailer::send() guards `instanceof Message` before dispatching this
// event, so the concrete type is guaranteed -- but getSymfonyEmail()
// is not on the interface, so narrow explicitly rather than assume.
if (!$message instanceof Message) {
return;
}
$email = $message->getSymfonyEmail();
$html = $email->getHtmlBody();
if (!is_string($html) || $html === '') {
return;
}
// Only touch mail that actually renders our wordmark. Anything else --
// password resets, share notifications, other apps -- passes through.
if (!str_contains($html, self::LOGO_PATH_FRAGMENT)) {
return;
}
$asset = $this->assetPath();
if ($asset === null) {
return;
}
$bytes = @file_get_contents($asset);
if ($bytes === false || $bytes === '') {
return;
}
// Rewrite the absolute URL to a cid: reference. Matched on the path
// fragment with an optional query string so a cachebuster or a change
// of host still resolves.
$rewritten = preg_replace(
'#https?://[^"\']*' . preg_quote(self::LOGO_PATH_FRAGMENT, '#') . '(\?[^"\']*)?#',
'cid:' . self::CID,
$html,
);
if (!is_string($rewritten) || $rewritten === $html) {
return;
}
$email->embed($bytes, self::CID, 'image/png');
$message->setHtmlBody($rewritten);
}
/**
* Resolves img/debyltech-wordmark.png relative to this file, so the app works
* from whatever apps directory Nextcloud has it in.
*/
private function assetPath(): ?string {
$path = dirname(__DIR__, 2) . '/img/debyltech-wordmark.png';
return is_readable($path) ? $path : null;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/**
* Injects de Byl Tech's CSS overrides into rendered Nextcloud pages.
*
* Currently one override: the LibreSign public signing page clips its bottom
* action bar on iOS Safari, because ExternalApp.vue sizes #content to 100vh and
* Safari resolves that against the large viewport (chrome hidden). See
* css/libresign-mobile.css for the full reasoning.
*
* WHY A LISTENER RATHER THAN PATCHING LIBRESIGN: an app-store app carries
* appinfo/signature.json, so editing a single byte of it raises INVALID_HASH in
* the admin security check, and an app update wipes the directory outright
* (Installer::downloadApp() calls Files::rmdirr on it). A stylesheet served
* from our own app survives both, and survives Nextcloud upgrades.
*
* The stylesheet itself is tightly scoped to #body-public / .app-public. This
* listener is deliberately NOT scoped further -- adding a stylesheet is
* idempotent and cheap, and gating on which app is rendering would couple this
* to LibreSign's route structure for no benefit. The CSS decides where it
* applies; this only decides that it is available.
*/
namespace OCA\Debyltechmail\Listener;
use OCA\Debyltechmail\AppInfo\Application;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class DebyltechStyleListener implements IEventListener {
public function handle(Event $event): void {
if (!$event instanceof BeforeTemplateRenderedEvent) {
return;
}
// Never let a styling concern break page rendering. A signing page that
// loads unstyled is recoverable; one that 500s is not.
try {
Util::addStyle(Application::APP_ID, 'libresign-mobile');
} catch (\Throwable $e) {
// Intentionally swallowed -- no logger dependency is worth adding
// for a stylesheet, and a failure here has no user-visible effect
// beyond the override not applying.
}
}
}
@@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
/**
* de Byl Technologies-branded email template. Cloned from the Skudak
* instance's skudakmail app (roles/podman/files/skudakmail); keep fixes to
* the shared mechanics in sync between the two.
*
* Wired in via the `mail_template_class` system config value, which Nextcloud
* checks in lib/private/Mail/Mailer.php::createEMailTemplate(). That is a
* supported extension point -- core is not patched, so Nextcloud upgrades do
* not clobber this.
*
* WHY THIS EXISTS AT ALL: LibreSign's outgoing mail is generic open-source
* boilerplate -- subject "LibreSign: There is a file for you to sign", heading
* "File to sign", button "Sign »filename«", and NO footer whatsoever (it never
* calls addFooter(); verified: zero hits for addFooter in custom_apps/libresign).
* That mail carries customer agreements to signers, so it needs to read as an
* official de Byl Technologies LLC communication.
*
* DESIGN INTENT (palette from ~/src/debyltech/debyltech-com, theme
* assets/scss/_variables.scss):
* - Light ground, near-black text, NO coloured header band, and a pure
* black-and-white wordmark. Transactional mail from Stripe/Linear/DocuSign
* is likewise restrained, and a band leaves an ugly empty slab when the
* logo is blocked (see LOGO note).
* - $primary-color copper #bc804d on the CTA button only -- the one place
* this template spends colour.
* - Open Sans, matching the site's $primary-font, with the stock stack as
* fallback.
*
* LOGO: served from this app's own img/ directory rather than the theming app.
* Two reasons. (1) The theming logo is white-on-transparent because the web UI
* and login page are dark; a white mark is invisible on this template's white
* ground. (2) Decoupling means restyling mail can never disturb the web UI.
* /custom_apps/<app>/img/<file> is served publicly without auth (verified).
*
* Note that remote images are blocked by default in Apple Mail, Gmail and
* Outlook, and Apple Mail renders its own placeholder box rather than styled
* alt text -- so alt styling cannot rescue it. Surviving that requires a CID
* inline part via IMessage::attachInline(), which lives on the MESSAGE and is
* unreachable from a template subclass. Mitigated instead by dropping the
* band: a blocked logo now leaves plain white space, not a black slab.
*
* IMPLEMENTATION NOTE: font restyling is done by string-substitution against
* the PARENT's own markup rather than by redefining it. Those properties are
* large inline-CSS blobs with positional sprintf placeholders; copying them
* wholesale would mean re-auditing every placeholder on every upgrade, and a
* mismatch renders broken mail. Substitution degrades safely -- if upstream
* changes markup the replacements no-op and mail still sends, just unstyled.
* The header IS replaced wholesale, deliberately, because "no band" cannot be
* expressed as a substitution; its placeholder order is documented at its
* definition and must be kept in sync with upstream.
*/
namespace OCA\Debyltechmail\Mail;
use OC\Mail\EMailTemplate;
class DebyltechEMailTemplate extends EMailTemplate {
/** Stock Nextcloud font stack, replaced wholesale. Must match exactly. */
private const STOCK_FONTS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
/** $primary-font, with the stock stack retained as fallback. */
private const BRAND_FONTS = "'Open Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
private const ACCENT = '#BC804D'; // $primary-color (copper)
private const ON_ACCENT = '#FFFFFF';
private const INK = '#0A0A0A';
private const MUTED = '#525252';
private const FAINT = '#A3A3A3';
private const RULE = '#E5E5E5';
private const ENTITY = 'de Byl Technologies LLC';
private const SITE = 'https://debyltech.com';
private const LOGO_PATH = '/custom_apps/debyltechmail/img/debyltech-wordmark.png';
/** Displayed width in px. The asset is 600px wide for retina. */
private const LOGO_DISPLAY_WIDTH = 190;
/**
* LibreSign's l10n wraps document names in German guillemets -- "Sign
* »contract«" -- regardless of locale. Mapped to US curly quotes, matching
* the ``...'' convention in the LaTeX document templates.
*/
private const QUOTE_MAP = ['»' => "\u{201C}", '«' => "\u{201D}"];
/**
* LibreSign subject -> de Byl Tech subject. Keys are the exact English msgids
* from custom_apps/libresign/lib/Service/MailService.php (lines 51, 87,
* 121, 150, 172). Anything unmatched passes through untouched, so an
* upstream string change degrades to the original subject rather than a
* blank one.
*/
private const SUBJECT_MAP = [
'LibreSign: There is a file for you to sign' => 'Document for your signature',
'LibreSign: Changes into a file for you to sign' => 'Updated document for your signature',
'LibreSign: A file has been signed' => 'A document has been signed',
'LibreSign: A signature request has been canceled' => 'Signature request cancelled',
'LibreSign: Code to sign file' => 'Your signing verification code',
];
/**
* LibreSign heading -> de Byl Tech heading. Exact English msgids from
* MailService.php lines 53/89, 123, 152.
*/
private const HEADING_MAP = [
'File to sign' => 'Review and sign',
'File signed' => 'Document signed',
'Signature request canceled' => 'Signature request cancelled',
];
/**
* LibreSign body copy -> de Byl Tech body copy (MailService.php lines 60, 96,
* 174). Only the strings with NO %s interpolation are mapped; the two that
* carry a name or filename (lines 125, 154) arrive already substituted and
* so cannot be matched exactly -- they pass through unchanged.
*/
private const BODY_MAP = [
'There is a document for you to sign. Access the link below:'
=> 'de Byl Technologies LLC has sent you a document that requires your signature. Review it and sign using the link below.',
'Changes have been made in a file that you have to sign. Access the link below:'
=> 'A document awaiting your signature has been updated by de Byl Technologies LLC. Review the current version and sign using the link below.',
'Use this code to sign the document:'
=> 'Use this verification code to complete your signature:',
];
/**
* Template properties carrying the font stack. Listed explicitly rather
* than discovered reflectively so an upstream rename fails loudly in
* testing instead of silently skipping a block.
*/
private const STYLED_PARTS = [
'head', 'tail', 'heading', 'bodyBegin', 'bodyText',
'listBegin', 'listItem', 'listEnd', 'buttonGroup', 'button',
'bodyEnd', 'footer',
];
/**
* Own flag, deliberately NOT the parent's $footerAdded.
*
* Message::useTemplate() (lib/private/Mail/Message.php:289-296) calls
* renderText() at :291 BEFORE renderHtml() at :293, and renderText() sets
* $footerAdded = true. Guarding footer injection on !$footerAdded therefore
* never fires on the real send path -- the footer silently vanished from
* every mail while a renderHtml()-only test passed. Both renderers below
* call inject() and this flag makes the second call inert.
*/
private bool $brandFooterInjected = false;
public function __construct(
\OCP\Defaults $themingDefaults,
\OCP\IURLGenerator $urlGenerator,
\OCP\L10N\IFactory $l10nFactory,
?int $logoWidth,
?int $logoHeight,
string $emailId,
array $data,
) {
$this->applyBrandStyling();
// Must run AFTER the substitutions: the parent constructor copies
// $this->head into $htmlBody as its first act, so restyling head
// afterwards would leave the already-emitted copy untouched.
parent::__construct(
$themingDefaults,
$urlGenerator,
$l10nFactory,
$logoWidth,
$logoHeight,
$emailId,
$data,
);
}
private function applyBrandStyling(): void {
foreach (self::STYLED_PARTS as $part) {
if (!property_exists($this, $part)) {
continue;
}
$this->$part = str_replace(self::STOCK_FONTS, self::BRAND_FONTS, $this->$part);
}
// Light, tightly tracked headings, carried over from the Skudak template.
$this->heading = str_replace(
'font-size:24px;font-weight:400',
'font-size:26px;font-weight:300;letter-spacing:-0.02em',
$this->heading,
);
}
/**
* Rewrites LibreSign's subjects. Called by LibreSign on the TEMPLATE
* (MailService.php:51 etc.), not on the message, which is what makes this
* interceptable at all -- Message::useTemplate() later pulls the result via
* renderSubject(). Prefixed with the entity so the sender is unambiguous in
* an inbox list.
*/
public function setSubject(string $subject): void {
$mapped = self::SUBJECT_MAP[$subject] ?? null;
parent::setSubject(
$mapped === null ? $subject : self::ENTITY . ' — ' . $mapped,
);
}
/**
* Replaces the stock header wholesale: no coloured band, wordmark centred
* on white.
*
* Does NOT use the parent's $header property or its placeholder order --
* this is independent markup, so upstream changes to $header cannot break
* it (and equally cannot improve it). $logoWidth/$logoHeight from the
* Mailer are ignored on purpose: they are clamped to MAX_LOGO_SIZE = 105
* (lib/private/Mail/Mailer.php:60), which is too small for a wordmark to
* be legible.
*/
public function addHeader(): void {
if ($this->headerAdded) {
return;
}
$this->headerAdded = true;
$logoUrl = $this->urlGenerator->getAbsoluteURL(self::LOGO_PATH);
$alt = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$w = self::LOGO_DISPLAY_WIDTH;
$fonts = self::BRAND_FONTS;
$ink = self::INK;
$this->htmlBody .= <<<HTML
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
<td align="center" style="border-collapse:collapse!important;margin:0;padding:40px 30px 28px 30px;text-align:center;vertical-align:top">
<img src="{$logoUrl}" alt="{$alt}" width="{$w}" style="-ms-interpolation-mode:bicubic;border:0;clear:both;display:block;margin:0 auto;outline:0;text-decoration:none;width:{$w}px;max-width:{$w}px;height:auto;color:{$ink};font-family:{$fonts};font-size:22px;font-weight:300;letter-spacing:-0.02em"/>
</td>
</tr></tbody>
</table>
HTML;
}
/**
* Both renderers inject the footer -- see $brandFooterInjected.
*
* Mirrors the parent's own guard structure (renderHtml at
* lib/private/Mail/EMailTemplate.php:643, renderText at :656): close the
* body, append $tail, flip $footerAdded. The brand block goes in before
* $tail.
*/
public function renderHtml(): string {
$this->injectBrandFooter();
return parent::renderHtml();
}
public function renderText(): string {
$this->injectBrandFooter();
return parent::renderText();
}
private function injectBrandFooter(): void {
if ($this->brandFooterInjected || $this->footerAdded) {
return;
}
$this->brandFooterInjected = true;
// Close the body ourselves so the footer lands INSIDE the layout
// rather than after it. The parent's render methods are then a no-op
// for body closing and only append $tail.
$this->ensureBodyIsClosed();
$this->htmlBody .= $this->brandFooterHtml();
$this->plainBody .= $this->brandFooterText();
}
private function brandFooterHtml(): string {
$year = date('Y');
$entity = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$fonts = self::BRAND_FONTS;
$site = self::SITE;
[$muted, $faint, $rule, $ink] = [self::MUTED, self::FAINT, self::RULE, self::INK];
// Table-based and fully inline-styled: <style> blocks, flex and grid
// are stripped or unsupported across Outlook and most webmail.
return <<<HTML
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:left;vertical-align:top;width:100%">
<tbody><tr style="padding:0;text-align:left;vertical-align:top">
<td align="center" style="border-collapse:collapse!important;margin:0;padding:0 30px 44px 30px;text-align:center;vertical-align:top">
<table align="center" style="border-collapse:collapse;border-spacing:0;margin:0 auto;padding:0;text-align:center;width:100%;max-width:550px">
<tbody>
<tr><td style="border-collapse:collapse!important;border-top:1px solid {$rule};font-size:0;line-height:0;height:1px;margin:0;padding:0">&#xA0;</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:22px 0 0 0;text-align:center">
This is an official document-signing request from <strong style="color:{$ink};font-weight:600">{$entity}</strong>.<br/>
Nothing is signed unless you open the document and complete it yourself. If you were not expecting this, you can safely ignore it.
</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$muted};font-family:{$fonts};font-size:13px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
<a href="{$site}/legal/privacy" style="color:{$muted};text-decoration:underline">Privacy Policy</a>
&#160;&#183;&#160;
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
&#160;&#183;&#160;
<a href="{$site}" style="color:{$muted};text-decoration:underline">debyltech.com</a>
</td></tr>
<tr><td align="center" style="border-collapse:collapse!important;color:{$faint};font-family:{$fonts};font-size:12px;font-weight:400;line-height:1.6;margin:0;padding:16px 0 0 0;text-align:center">
&copy; {$year} {$entity}. All rights reserved.<br/>
Automated message &mdash; please do not reply to this address.
</td></tr>
</tbody>
</table>
</td>
</tr></tbody>
</table>
HTML;
}
private function brandFooterText(): string {
$year = date('Y');
$entity = self::ENTITY;
$site = self::SITE;
return <<<TEXT
--
This is an official document-signing request from {$entity}.
Nothing is signed unless you open the document and complete it yourself.
If you were not expecting this, you can safely ignore it.
Privacy Policy: {$site}/legal/privacy
Terms of Use: {$site}/legal/tos
© {$year} {$entity}. All rights reserved.
Automated message — please do not reply to this address.
TEXT;
}
private function tidyQuotes(string $text): string {
return strtr($text, self::QUOTE_MAP);
}
// Signatures below mirror the parent EXACTLY. $plainTitle/$plainText are
// deliberately untyped there (they accept string|bool -- false suppresses
// the plain-text variant), and narrowing a parameter type in an override
// is a fatal error in PHP.
public function addHeading(string $title, $plainTitle = ''): void {
$mapped = self::HEADING_MAP[$title] ?? $this->tidyQuotes($title);
parent::addHeading(
$mapped,
is_string($plainTitle) && $plainTitle !== ''
? (self::HEADING_MAP[$plainTitle] ?? $this->tidyQuotes($plainTitle))
: $plainTitle,
);
}
public function addBodyText(string $text, $plainText = ''): void {
$mapped = self::BODY_MAP[$text] ?? $this->tidyQuotes($text);
parent::addBodyText(
$mapped,
is_string($plainText) && $plainText !== ''
? (self::BODY_MAP[$plainText] ?? $this->tidyQuotes($plainText))
: $plainText,
);
}
/**
* Reimplemented for two reasons: the accent colour, and a fixed label.
*
* LibreSign builds "Sign »%s«" with the raw filename
* (MailService.php:64,100). Real documents here are named things like
* acme-master-services-agreement-rev3, which makes an ungainly button and
* leaks the document name to anyone who sees the inbox preview. Replaced
* with a fixed call to action; the document is identified on the landing
* page behind the link.
*
* Mirrors the parent's vsprintf argument order exactly:
* [$color, $color, $url, $color, $textColor, $textColor, $text].
* Kept in sync with parent::addBodyButton() -- if that changes upstream,
* this needs revisiting.
*/
public function addBodyButton(string $text, string $url, $plainText = ''): void {
if ($this->footerAdded) {
return;
}
$this->ensureBodyIsOpened();
$this->ensureBodyListClosed();
$label = $this->buttonLabelFor($text);
if ($plainText === '') {
$plainText = $label;
} elseif (is_string($plainText)) {
$plainText = $this->tidyQuotes($plainText);
}
$this->htmlBody .= vsprintf($this->button, [
self::ACCENT,
self::ACCENT,
$url,
self::ACCENT,
self::ON_ACCENT,
self::ON_ACCENT,
htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
]);
if ($plainText !== false) {
$this->plainBody .= $plainText . ': ';
}
$this->plainBody .= $url . PHP_EOL;
}
/**
* Maps LibreSign's filename-bearing labels onto fixed calls to action.
* Matched on the stable leading verb rather than the whole string, since
* the tail is a filename. Unknown labels pass through with quotes tidied.
*/
private function buttonLabelFor(string $text): string {
if (str_starts_with($text, 'Sign ')) {
return 'Review document';
}
if (str_starts_with($text, 'View signed file')) {
return 'View signed document';
}
return $this->tidyQuotes($text);
}
}
@@ -0,0 +1,615 @@
---
# de Byl Technologies Nextcloud (cloud.debyltech.com).
#
# Cloned from containers/skudak/cloud.yml, which carries the full reasoning for
# nearly every task below -- read the matching comment there before changing
# one here. Comments in this file cover only where the two instances differ.
#
# Differences from Skudak, by design:
# - Fresh install: NEXTCLOUD_ADMIN_* makes the first deploy install
# unattended, and LibreSign is installed from the app store rather than
# assumed present.
# - No Group Folders. Registration stays off, matching Skudak's live state:
# every account, staff and customer alike, is created by the admin, with
# customers in per-customer groups.
# - Outbound mail is AWS SES SMTP (noreply@debyltech.com), set here via occ
# so it lives in git rather than only in the admin UI.
# - Backups go to personal iDrive e2 via TrueNAS -- see the backup include
# at the bottom.
- name: create required debyltech 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_debyltech_path }}/apps"
- "{{ cloud_debyltech_path }}/config"
- "{{ cloud_debyltech_path }}/data"
- "{{ cloud_debyltech_path }}/mysql"
- "{{ cloud_debyltech_path }}/scripts"
- "{{ cloud_debyltech_path }}/redis"
- name: unshare chown the debyltech cloud volumes
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: |
podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps {{ cloud_debyltech_path }}/data {{ cloud_debyltech_path }}/config
- name: flush handlers
ansible.builtin.meta: flush_handlers
- import_tasks: podman/podman-check.yml
vars:
container_name: debyltech-cloud-db
container_image: "{{ db_image }}"
- name: create debyltech-cloud-db container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: debyltech-cloud-db
image: "{{ db_image }}"
restart_policy: on-failure:3
log_driver: journald
network:
- shared
env:
MYSQL_ROOT_PASSWORD: "{{ cloud_debyltech_db_root_pass }}"
MYSQL_DATABASE: dtcloud
MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}"
MYSQL_USER: dtcloud
volumes:
- "{{ cloud_debyltech_path }}/mysql:/var/lib/mysql"
- name: create systemd startup job for debyltech-cloud-db
include_tasks: podman/systemd-generate.yml
vars:
container_name: debyltech-cloud-db
# ---------------------------------------------------------------------------
# Redis: distributed cache + file locking. MUST exist before debyltech-cloud
# below -- see the Skudak equivalent for why.
- name: template debyltech cloud redis config
become: true
ansible.builtin.template:
src: nextcloud/redis-debyltech.conf.j2
dest: "{{ cloud_debyltech_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
- name: unshare chown the debyltech redis config to the redis uid
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: >
podman unshare chown 999:1000 {{ cloud_debyltech_path }}/redis/redis.conf
- import_tasks: podman/podman-check.yml
vars:
container_name: debyltech-cloud-redis
container_image: "{{ redis_image }}"
- name: create debyltech-cloud-redis container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: debyltech-cloud-redis
image: "{{ redis_image }}"
restart_policy: on-failure:3
log_driver: journald
network:
- shared
volumes:
- "{{ cloud_debyltech_path }}/redis/redis.conf:/etc/redis/redis.conf:ro"
command: redis-server /etc/redis/redis.conf
- name: create systemd startup job for debyltech-cloud-redis
include_tasks: podman/systemd-generate.yml
vars:
container_name: debyltech-cloud-redis
- import_tasks: podman/podman-check.yml
vars:
container_name: debyltech-cloud
container_image: "{{ image }}"
- name: create debyltech cloud container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: debyltech-cloud
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
network:
- shared
env:
MYSQL_PASSWORD: "{{ cloud_debyltech_db_pass }}"
MYSQL_DATABASE: dtcloud
MYSQL_HOST: debyltech-cloud-db
MYSQL_USER: dtcloud
# Read by the entrypoint ONLY on first start against an empty config
# volume, to run the install unattended; ignored on every start after.
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: "{{ cloud_debyltech_admin_pass }}"
NEXTCLOUD_TRUSTED_DOMAINS: "{{ cloud_debyltech_server_name }}"
PHP_MEMORY_LIMIT: 1024M
PHP_UPLOAD_LIMIT: 512M
LC_ALL: C.UTF-8
LANG: C.UTF-8
REDIS_HOST: debyltech-cloud-redis
REDIS_HOST_PORT: "6379"
REDIS_HOST_PASSWORD: "{{ cloud_debyltech_redis_pass }}"
volumes:
- "{{ cloud_debyltech_path }}/apps:/var/www/html/custom_apps"
- "{{ cloud_debyltech_path }}/data:/var/www/html/data"
- "{{ cloud_debyltech_path }}/config:/var/www/html/config"
ports:
- "8091:80"
- name: create systemd startup job for debyltech-cloud
include_tasks: podman/systemd-generate.yml
vars:
container_name: debyltech-cloud
# ---------------------------------------------------------------------------
# LibreSign
- name: install libresign runtime dependencies in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command:
cmd: >
podman exec -u 0 debyltech-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"
# On the FIRST deploy this also waits out the unattended install, which takes
# noticeably longer than a restart -- hence the larger budget than Skudak's.
- name: wait for nextcloud to be ready in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ status --output=json
register: debyltech_occ_ready
# String match, not from_json: before the install finishes occ can print a
# plain-text warning ahead of the JSON, and a parse error would abort the
# retry loop instead of waiting. Skudak's bare 'installed' check would also
# match "installed":false, which is exactly the state being waited out here.
until: >-
debyltech_occ_ready.rc == 0
and '"installed":true' in debyltech_occ_ready.stdout
retries: 60
delay: 5
changed_when: false
# LibreSign is PINNED (libresign_version / libresign_sha256 in tasks/main.yml)
# and installed from the upstream GitHub release, NOT `occ app:install`, which
# always takes whatever the app store has that day. On 2026-09-28 that was a
# same-day 14.2.3 whose tarball shipped without appinfo/install-*.json -- the
# maintainer-signed metadata LibreSign verifies its java/pdftk/jsignpdf
# downloads against -- so configure:check failed all three on a clean install.
#
# Upgrading: bump both pins together (the sha256 is on the GitHub release
# asset) and deploy; the tree is replaced and `occ upgrade` runs the app's
# migrations. Downgrading is refused below: Nextcloud does not support it, and
# the only way back is removing the app, which discards its config and CA.
- name: read installed libresign version in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:get libresign installed_version
register: libresign_installed
changed_when: false
failed_when: false
- name: refuse to downgrade libresign in debyltech-cloud
ansible.builtin.fail:
msg: >-
LibreSign {{ libresign_installed.stdout }} is installed but the pin is
{{ libresign_version }}. Nextcloud cannot downgrade an app in place --
raise the pin, or remove the app deliberately if nothing has been signed.
when:
- libresign_installed.rc == 0
- libresign_installed.stdout is version(libresign_version, '>')
- name: install pinned libresign release in debyltech-cloud
when: libresign_installed.rc != 0 or libresign_installed.stdout != libresign_version
block:
- name: fetch pinned libresign release
become: true
ansible.builtin.get_url:
url: "https://github.com/LibreSign/libresign/releases/download/v{{ libresign_version }}/libresign-v{{ libresign_version }}.tar.gz"
dest: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz"
checksum: "sha256:{{ libresign_sha256 }}"
mode: 0644
- name: remove previous libresign app tree
become: true
ansible.builtin.file:
path: "{{ cloud_debyltech_path }}/apps/libresign"
state: absent
- name: unpack pinned libresign release into custom_apps
become: true
ansible.builtin.unarchive:
src: "{{ cloud_debyltech_path }}/scripts/libresign-v{{ libresign_version }}.tar.gz"
dest: "{{ cloud_debyltech_path }}/apps/"
remote_src: true
# Unpacked as root the files keep the tarball's owners, which lie
# outside the podman user's subuid range, so the unshare chown below
# is refused. Same two-step as the debyltechmail copy.
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_subuid.stdout }}"
notify: restorecon podman
- name: unshare chown the libresign app tree
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: >
podman unshare chown -R 33:33 {{ cloud_debyltech_path }}/apps/libresign
- name: flush handlers
ansible.builtin.meta: flush_handlers
# Only an in-place upgrade needs this; a first install is handled by the
# app:enable below.
- name: run libresign migrations in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud php occ upgrade
when: libresign_installed.rc == 0
- name: ensure libresign app is enabled in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ app:enable libresign
register: libresign_enable
changed_when: "'already enabled' not in libresign_enable.stdout"
# 14.2.x's downloader does not create its own target directories: on a fresh
# appdata every java/pdftk download fails with "Directory ... does not exist
# for sink value". Creating them first is harmless once they exist.
- name: pre-create libresign binary directories in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
changed_when: false
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud sh -c
'd=$(ls -d /var/www/html/data/appdata_*/libresign) &&
mkdir -p "$d/x86_64/linux/java" "$d/x86_64/pdftk"'
# "Finished with success" is printed even when every download failed, so this
# check only catches the command itself falling over. The real gate is the
# configure:check verify task below, which hashes each binary against the
# release's signed metadata.
- name: install libresign java/pdftk/jsignpdf binaries in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-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 debyltech-cloud
php occ libresign:configure:check --certificate
register: libresign_cert_check
changed_when: false
failed_when: false
# Guarded: re-running would mint a new root CA and orphan every certificate
# already issued. No --ou -- see skudak/cloud.yml.
- name: generate libresign root certificate for debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ libresign:configure:openssl
--cn="{{ libresign_debyltech_cert_cn }}"
-o "{{ libresign_debyltech_cert_o }}"
-c "{{ libresign_debyltech_cert_c }}"
-s "{{ libresign_debyltech_cert_st }}"
-l "{{ libresign_debyltech_cert_l }}"
when: "'error' in libresign_cert_check.stdout"
changed_when: true
# Signers are mostly customers WITHOUT an account, reached by emailed
# invitation; the ID-document gate would leave them unable to sign at all.
- name: relax libresign identification-document gate in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set libresign identification_documents --value=0
register: libresign_ident
changed_when: "'is now set to' in libresign_ident.stdout"
# Must be exactly GRAPHIC_ONLY -- see skudak/cloud.yml.
- name: use signature-only stamp in debyltech-cloud libresign
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-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"
# Lets account-owned emails be added as signers. NEVER set the _email variant
# of this key to 'no' -- see skudak/cloud.yml.
- name: allow account-owned emails as libresign signers in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set core
shareapi_restrict_user_enumeration_full_match --value=no
register: debyltech_enum_fullmatch
changed_when: "'is now set to' in debyltech_enum_fullmatch.stdout"
- name: drop libresign validation QR code from signed-PDF footer in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-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"
- name: verify libresign configuration in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ libresign:configure:check
register: libresign_verify
changed_when: false
# Double backslashes: Jinja unescapes string literals, so a single '\b'
# becomes a BACKSPACE character and this could never match -- which is
# how a check reporting three errors passed clean on 2026-09-28.
failed_when: libresign_verify.stdout is search('\\berror\\b')
# ---------------------------------------------------------------------------
# Background jobs. A fresh install defaults to AJAX mode, which only runs jobs
# while someone has the web UI open -- LibreSign's queued signature mail and
# every cleanup job would stall. The cloud-cron timer included below drives
# cron.php; this tells Nextcloud to expect it.
- name: set debyltech-cloud background jobs to cron
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set core backgroundjobs_mode --value=cron
register: debyltech_bgjobs
changed_when: "'is now set to' in debyltech_bgjobs.stdout"
- name: disable nextcloud signup link in debyltech-cloud config
become: true
ansible.builtin.lineinfile:
path: "{{ cloud_debyltech_path }}/config/config.php"
regexp: "^\\s*'simpleSignUpLink\\.shown'\\s*=>"
line: " 'simpleSignUpLink.shown' => false,"
insertbefore: '^\);'
create: false
# ---------------------------------------------------------------------------
# Branding: debyltechmail, cloned from skudakmail. custom_apps is a persisted
# bind mount, so only enabling and config need reasserting.
# Owned directly by the HOST uid that rootless podman maps www-data (33) to --
# subuid start + 32, since container uid 1 is the first subuid. Skudak copies
# as the subuid and then `podman unshare chown`s, which flips ownership back
# and forth so the copy reports changed on every run; here that would also
# re-import the theming logos below every time.
- name: deploy debyltechmail email-template app to debyltech-cloud
become: true
ansible.builtin.copy:
src: debyltechmail/
dest: "{{ cloud_debyltech_path }}/apps/debyltechmail/"
owner: "{{ podman_subuid.stdout | int + 32 }}"
group: "{{ podman_subuid.stdout | int + 32 }}"
mode: 0644
directory_mode: 0755
register: debyltechmail_copy
notify: restorecon podman
- name: enable debyltechmail app in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud php occ app:enable debyltechmail
register: debyltechmail_enable
changed_when: "'already enabled' not in debyltechmail_enable.stdout"
# System config, set only when it differs so a clean re-deploy reports no
# changes (Skudak's equivalents report changed on every run). Values are
# single-quoted into the shell, so the backslashes in mail_template_class pass
# through literally. overwrite.cli.url is what LibreSign invitation links and
# mail asset URLs are built from when sent by a background job.
- name: set debyltech-cloud system config
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
cur=$(occ config:system:get {{ item.k }} || true)
if [ "$cur" != {{ item.v | quote }} ]; then
occ config:system:set {{ item.k }} --value={{ item.v | quote }} --type={{ item.t | default('string') }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_sysconfig
changed_when: "'CHANGED' in debyltech_sysconfig.stdout"
loop:
- {k: overwrite.cli.url, v: "https://{{ cloud_debyltech_server_name }}"}
- {k: overwriteprotocol, v: https}
- {k: loglevel, v: "2", t: integer}
- {k: log_rotate_size, v: "10485760", t: integer}
- {k: mail_template_class, v: "OCA\\Debyltechmail\\Mail\\DebyltechEMailTemplate"}
- {k: mail_smtpmode, v: smtp}
- {k: mail_smtphost, v: "{{ cloud_debyltech_smtp_host }}"}
- {k: mail_smtpport, v: "{{ cloud_debyltech_smtp_port }}", t: integer}
- {k: mail_smtpsecure, v: ssl}
- {k: mail_smtpauth, v: "true", t: boolean}
- {k: mail_from_address, v: noreply}
- {k: mail_domain, v: debyltech.com}
loop_control:
label: "{{ item.k }}"
- name: set debyltech-cloud SES SMTP credentials
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
cur=$(occ config:system:get {{ item.k }} || true)
if [ "$cur" != {{ item.v | quote }} ]; then
occ config:system:set {{ item.k }} --value={{ item.v | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_smtp_creds
changed_when: "'CHANGED' in debyltech_smtp_creds.stdout"
loop:
- {k: mail_smtpname, v: "{{ cloud_debyltech_smtp_user }}"}
- {k: mail_smtppassword, v: "{{ cloud_debyltech_smtp_pass }}"}
loop_control:
label: "{{ item.k }}"
no_log: true
# Compared first: theming:config prints "Updated" even when nothing changed.
- name: set debyltech-cloud theming
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
cur=$(occ config:app:get theming {{ item.k }} || true)
if [ "$cur" != {{ item.v | quote }} ]; then
occ theming:config {{ item.k }} {{ item.v | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
loop:
- {k: name, v: "de Byl Technologies"}
- {k: slogan, v: "Hardware, firmware and design services"}
- {k: url, v: "https://debyltech.com"}
- {k: primary_color, v: "{{ theming_debyltech_primary }}"}
- {k: background_color, v: "{{ theming_debyltech_background }}"}
register: debyltech_theming
changed_when: "'CHANGED' in debyltech_theming.stdout"
loop_control:
label: "{{ item.k }}"
# The web UI logos ship inside the debyltechmail app (the white variants; the
# ink wordmark is the mail one). theming:config re-imports the file on every
# call, so it runs only when the app's files changed or no logo is set yet.
# `logo` is the wide wordmark on the login page; `logoheader` is the square
# mark in the top bar, where a wordmark would shrink to illegibility.
- name: check debyltech-cloud theming logos
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:get theming {{ item }}Mime
loop: [logo, logoheader]
register: debyltech_logo_mime
changed_when: false
failed_when: false
- name: set debyltech-cloud theming logos
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud php occ theming:config {{ item.item }}
/var/www/html/custom_apps/debyltechmail/img/{{ logo_files[item.item] }}
loop: "{{ debyltech_logo_mime.results }}"
when: debyltechmail_copy is changed or item.rc != 0 or item.stdout == ''
vars:
logo_files:
logo: debyltech-wordmark-white.png
logoheader: debyltech-mark-white.png
loop_control:
label: "{{ item.item }}"
# Fails the play if branding, the LibreSign settings above, or Redis locking
# have silently regressed -- see skudak/cloud.yml.
- name: template debyltechmail verification script
become: true
ansible.builtin.template:
src: nextcloud/debyltechmail-verify.php.j2
dest: "{{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_subuid.stdout }}"
mode: 0644
notify: restorecon podman
- name: verify debyltech mail branding is live
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: >
set -o pipefail;
podman exec -i -u www-data debyltech-cloud php
< {{ cloud_debyltech_path }}/scripts/debyltechmail-verify.php
args:
executable: /bin/bash
register: debyltechmail_verify
changed_when: false
- include_tasks: containers/cloud-cron.yml
vars:
cron_name: debyltech-cloud
cron_container: debyltech-cloud
cron_script_path: /usr/local/bin/debyltech-cloud-cron.sh
# BUSINESS data that DELIBERATELY reaches personal storage -- the opposite of
# Skudak, and on purpose: de Byl Technologies LLC is the owner's own company.
#
# Chain: this rsync -> TrueNAS /mnt/glacier/debyltechcloud (05:00 ZFS
# snapshot) -> the personal "iDrive E2 Backup" cloud-sync task, which pushes
# /mnt/glacier to the personal iDrive e2 bucket. Unlike /skudakcloud/**,
# /skudakapps/** and /skudakgit/**, there is NO exclude for /debyltechcloud/**
# on that task, and there must not be one -- that inclusion IS the offsite
# copy. If that ever changes, give it its own cloud-sync task first.
- include_tasks: containers/cloud-backup.yml
vars:
backup_name: debyltech-cloud
data_path: "{{ cloud_debyltech_path }}/data"
config_path: "{{ cloud_debyltech_path }}/config"
db_container: debyltech-cloud-db
ssh_key_path: /etc/ssh/backup_keys/debyltech-cloud
ssh_key_content: "{{ cloud_debyltech_backup_ssh_key }}"
ssh_user: debyltechcloud
remote_path: /mnt/glacier/debyltechcloud
script_path: /usr/local/bin/debyltech-cloud-backup.sh
# data/ is mode 770 here too; see skudak/cloud.yml.
backup_rsync_extra_args: "--chmod=Du=rwx,Dgo=rx"
# Between the 04:00 personal and 04:30 Skudak runs, before the 05:00
# TrueNAS snapshot.
backup_oncalendar: "*-*-* 04:15:00"
@@ -405,7 +405,10 @@
php occ libresign:configure:check php occ libresign:configure:check
register: libresign_verify register: libresign_verify
changed_when: false changed_when: false
failed_when: libresign_verify.stdout is search('\berror\b') # Double backslashes: Jinja unescapes string literals, so a single '\b'
# becomes a BACKSPACE character and this could never match -- which is
# how a check reporting three errors passed clean on 2026-09-28.
failed_when: libresign_verify.stdout is search('\\berror\\b')
- name: disable nextcloud signup link in config - name: disable nextcloud signup link in config
become: true become: true
+16
View File
@@ -79,6 +79,22 @@
image: docker.io/library/nextcloud:34.0.3-apache image: docker.io/library/nextcloud:34.0.3-apache
tags: skudak, skudak-cloud tags: skudak, skudak-cloud
# cloud.debyltech.com -- cloned from the Skudak instance above; keep the two
# image pins in step. DNS is a terraform-managed ALIAS (see defaults).
- import_tasks: containers/debyltech/cloud.yml
vars:
db_image: docker.io/library/mariadb:10.6
# Fully qualified on purpose: podman records `docker.io/library/redis`, and
# podman-check compares names literally, so the short `docker.io/redis`
# form (as in the Skudak block above) recreates redis on every deploy.
redis_image: docker.io/library/redis:8.2-alpine
image: docker.io/library/nextcloud:34.0.3-apache
# GitHub release asset + its sha256 -- see the pinned-install comment in
# the task file for why this is not left to the app store.
libresign_version: "14.2.2"
libresign_sha256: 8655a4c89f52ca7eaf542d0764d07a7732cb23b39906e7246b37e569ec7a6579
tags: debyltech, debyltech-cloud
- import_tasks: containers/debyltech/fulfillr.yml - import_tasks: containers/debyltech/fulfillr.yml
vars: vars:
image: git.debyl.io/debyltech/fulfillr:20260915.0011 image: git.debyl.io/debyltech/fulfillr:20260915.0011
@@ -457,6 +457,38 @@
} }
} }
# de Byl Tech Nextcloud - {{ cloud_debyltech_server_name }}
{{ cloud_debyltech_server_name }} {
request_body {
max_size {{ caddy_max_request_body_mb }}MB
}
reverse_proxy localhost:8091 {
header_up Host {host}
header_up X-Real-IP {remote}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "same-origin"
-X-Powered-By
}
# Nextcloud specific redirects
redir /.well-known/carddav /remote.php/dav 301
redir /.well-known/caldav /remote.php/dav 301
log {
output file /var/log/caddy/cloud-debyltech.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
# Gitea - {{ gitea_debyl_server_name }} # Gitea - {{ gitea_debyl_server_name }}
{{ gitea_debyl_server_name }} { {{ gitea_debyl_server_name }} {
import common_headers import common_headers
@@ -0,0 +1,186 @@
<?php
/**
* {{ ansible_managed }}
*
* Post-deploy assertion that de Byl Tech mail branding is actually live.
*
* WHY THIS EXISTS: DebyltechEMailTemplate extends OC\Mail\EMailTemplate, which is
* Nextcloud's PRIVATE namespace -- no API stability guarantee. Two things can
* silently switch the branding off:
*
* 1. A Nextcloud major upgrade. appinfo/info.xml pins max-version, so the app
* is auto-disabled as incompatible; Mailer::createEMailTemplate() then
* fails its class_exists() check and falls back to the stock template.
* Mail still sends -- unbranded. That is the right failure mode, but it is
* invisible without this check.
* 2. An upstream change to the private base class breaking an override.
*
* Renders through Message::useTemplate() -- the REAL path -- rather than
* calling renderHtml() directly. That distinction is not academic: renderText()
* runs first and flips the parent's footerAdded flag, and a renderHtml()-only
* test once passed green while live mail shipped with no footer at all.
*
* Exits non-zero with a diagnostic on any failure, so the Ansible task fails
* the play rather than reporting a clean deploy over broken branding.
*/
require_once '/var/www/html/lib/base.php';
$mailer = \OC::$server->get(\OCP\Mail\IMailer::class);
$dispatcher = \OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class);
// Mirrors MailService::notifyUnsignedUser() (custom_apps/libresign/lib/Service/MailService.php:85-116).
$template = $mailer->createEMailTemplate('settings.TestEmail');
$template->setSubject('LibreSign: There is a file for you to sign');
$template->addHeader();
$template->addHeading('File to sign', false);
$template->addBodyText('There is a document for you to sign. Access the link below:');
$template->addBodyButton('Sign »verify.pdf«', 'https://{{ cloud_debyltech_server_name }}/verify');
$message = $mailer->createMessage();
$message->setTo(['verify@example.invalid' => 'Verify']);
$message->useTemplate($template);
// What Mailer::send() does at lib/private/Mail/Mailer.php:186. Nothing is sent.
$dispatcher->dispatchTyped(new \OCP\Mail\Events\BeforeMessageSent($message));
$html = $message->getSymfonyEmail()->getHtmlBody() ?? '';
$text = $message->getPlainBody();
$subject = $message->getSubject();
$inlineNames = [];
foreach ($message->getSymfonyEmail()->getAttachments() as $part) {
$inlineNames[] = (string)$part->getFilename();
}
$failures = [];
if (!$template instanceof \OCA\Debyltechmail\Mail\DebyltechEMailTemplate) {
$failures[] = 'template class is ' . get_class($template)
. ' -- expected DebyltechEMailTemplate. Is the debyltechmail app enabled, and does '
. 'appinfo/info.xml still allow this Nextcloud major?';
}
if (!str_starts_with($subject, 'de Byl Technologies LLC')) {
$failures[] = 'subject not rewritten: ' . $subject;
}
if (!str_contains($html, 'official document-signing request')) {
$failures[] = 'HTML footer missing (renderText/renderHtml ordering regression?)';
}
if (!str_contains($text, 'official document-signing request')) {
$failures[] = 'plain-text footer missing';
}
if (!str_contains($html, 'debyltech.com/legal/privacy') || !str_contains($html, 'debyltech.com/legal/tos')) {
$failures[] = 'privacy/terms links missing from footer';
}
if (preg_match('/[»«]/u', $html)) {
$failures[] = 'German guillemets survived into the body';
}
if (!str_contains($html, 'Review document')) {
$failures[] = 'button label not normalised to "Review document"';
}
if (!str_contains($html, 'cid:debyltech-wordmark.png')) {
$failures[] = 'logo is not a cid: reference -- BeforeMessageSent listener did not fire';
}
if (!in_array('debyltech-wordmark.png', $inlineNames, true)) {
$failures[] = 'inline logo MIME part absent (found: ' . (implode(', ', $inlineNames) ?: 'none') . ')';
}
// ---------------------------------------------------------------------------
// LibreSign signing settings. These live in oc_appconfig (the database), not on
// disk, so they survive container recreation -- but they are re-assertable and
// a stray click in the admin UI can change them silently. GRAPHIC in particular
// matters: any other mode makes SignatureTextService::getSignatureWidth()
// return $current / 2 and stamp a name/date block that duplicates -- and
// collides with -- the one our documents already typeset.
$appConfig = \OC::$server->get(\OCP\IAppConfig::class);
// Must be exactly GRAPHIC_ONLY -- SignerElementsService::RENDER_MODE_GRAPHIC_ONLY.
// The valid set is DESCRIPTION_ONLY / SIGNAME_AND_DESCRIPTION /
// GRAPHIC_AND_DESCRIPTION / GRAPHIC_ONLY. Anything outside it (a bare 'GRAPHIC',
// say) is accepted by occ but matches no radio in the admin UI and falls
// through to default behaviour, so this asserts membership, not just non-empty.
$renderMode = $appConfig->getValueString('libresign', 'signature_render_mode', '');
if ($renderMode !== 'GRAPHIC_ONLY') {
$failures[] = 'libresign signature_render_mode is "' . $renderMode
. '" -- expected GRAPHIC_ONLY (signature only). Any other mode halves the '
. 'stamp width and overlays a duplicate name/date block.';
}
// Read with getValueBool, exactly as FooterHandler:158 does -- asserting the
// string form would pass on a value the app itself reads as true.
if ($appConfig->getValueBool('libresign', 'write_qrcode_on_footer', true) !== false) {
$failures[] = 'libresign write_qrcode_on_footer is not false -- the validation '
. 'QR block will be stamped on every page and overlaps the document footer. '
. '(Was it written without --type=boolean?)';
}
// Signer search for account-owned emails. Both keys are asserted because the
// two failure modes are opposite and the second is the more dangerous:
// full_match = yes -> account-owned emails silently unselectable
// full_match_email = no -> email signer search disabled ENTIRELY
// Defaults are 'yes' for both (MailPlugin.php:50-55), so an unset
// full_match_email is correct and only an explicit 'no' is a problem.
if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') !== 'no') {
$failures[] = 'core shareapi_restrict_user_enumeration_full_match is not "no" -- '
. 'emails belonging to an existing Nextcloud account cannot be added as '
. 'LibreSign signers (MailPlugin.php:163 aborts the search).';
}
if ($appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'no') {
$failures[] = 'core shareapi_restrict_user_enumeration_full_match_email is "no" -- '
. 'this disables email signer search ENTIRELY (MailPlugin.php:67). It must be '
. 'unset or "yes"; it is NOT the knob for the account-owned-email problem.';
}
$identDocs = $appConfig->getValueString('libresign', 'identification_documents', '');
if ($identDocs !== '0') {
$failures[] = 'libresign identification_documents is "' . $identDocs
. '" -- expected 0. A non-zero value gates signing behind an ID upload '
. 'plus admin approval, and signers see no way to sign.';
}
// ---------------------------------------------------------------------------
// Redis: distributed cache + transactional file locking.
//
// These come from the image's config/redis.config.php drop-in, which only
// activates when REDIS_HOST is set on the container. If the env var is lost
// (a container recreated from a stale spec, say), Nextcloud silently reverts
// to DBLockingProvider and every file lock goes back to being a MariaDB write
// -- functional, but the stalls come back with no error anywhere.
$sysConfig = \OC::$server->get(\OCP\IConfig::class);
foreach (['memcache.locking', 'memcache.distributed'] as $key) {
$value = $sysConfig->getSystemValueString($key, '');
if ($value !== '\OC\Memcache\Redis') {
$failures[] = $key . ' is "' . $value . '" -- expected \\OC\\Memcache\\Redis. '
. 'Is REDIS_HOST still set on the debyltech-cloud container?';
}
}
// Prove Redis is actually reachable and authenticating, not merely configured.
// A wrong password leaves the config looking perfect while every cache and
// lock operation fails at runtime.
try {
$cacheFactory = \OC::$server->get(\OCP\ICacheFactory::class);
if (!$cacheFactory->isAvailable()) {
$failures[] = 'distributed cache reports unavailable -- redis unreachable or auth failed';
} else {
$probe = $cacheFactory->createDistributed('debyltechmail-verify');
$probe->set('probe', 'ok', 30);
if ($probe->get('probe') !== 'ok') {
$failures[] = 'distributed cache round-trip failed (set/get mismatch)';
}
$probe->remove('probe');
}
} catch (\Throwable $e) {
$failures[] = 'distributed cache threw: ' . $e->getMessage();
}
if ($failures !== []) {
fwrite(STDERR, "debyltechmail branding verification FAILED:\n");
foreach ($failures as $f) {
fwrite(STDERR, " - $f\n");
}
exit(1);
}
echo "debyltechmail branding OK (subject: $subject)\n";
@@ -0,0 +1,40 @@
# {{ ansible_managed }}
#
# Redis for debyltech-cloud: Nextcloud distributed cache + transactional file
# locking. Reachable only by container name on the `shared` podman network --
# no host port is published.
#
# The password lives HERE rather than on the command line as
# `redis-server --requirepass <pass>`. That is the existing house idiom (see
# the deleted container-nosql.yml in git history), but it leaks the secret into
# `podman inspect`, into the generated systemd unit under
# ~/.config/systemd/user/, and into `ps` for every user on the host. A 0640
# config file mounted read-only keeps it out of all three.
requirepass {{ cloud_debyltech_redis_pass }}
# Bind to all interfaces WITHIN the container's network namespace. The
# container publishes no port, so this is reachable only from the `shared`
# podman network -- not from the host and not from the LAN.
bind 0.0.0.0
port 6379
protected-mode yes
# NO maxmemory / eviction policy, deliberately.
#
# Nextcloud puts BOTH the distributed cache and the transactional file locks in
# this instance. Cache entries are safely evictable; LOCKS ARE NOT. An
# `allkeys-lru` policy under memory pressure can evict a lock that a live
# request still believes it holds, which permits concurrent writers to the same
# file -- silent corruption rather than a visible error. With no maxmemory,
# Redis never evicts. The host has ~14 GiB free of 31 GiB and this instance
# holds a few hundred keys, so a cap buys nothing.
#
# If a cap is ever genuinely needed, use `maxmemory-policy noeviction` so Redis
# returns an error instead of silently discarding a lock.
# No persistence. Locks are ephemeral and TTL-bounded, and the cache is
# rebuildable -- there is nothing here worth surviving a restart. Persisting
# would be actively worse: a restored RDB could reinstate locks whose owning
# request died, blocking files until the TTL expired.
save ""
appendonly no
Binary file not shown.