Compare commits

..
60 Commits
Author SHA1 Message Date
Bastian de BylandClaude Fable 5.1 bc423a1c7c chore(gitea-actions): bump ESP-IDF CI image to v5.5.1
CI Images / Plan (push) Successful in 35s
CI Images / Build ci (push) Successful in 7m53s
CI Images / Build espidf (push) Successful in 18m27s
CI Images / Build platformio (push) Successful in 25m48s
esp32-vi-can-obd2 needs the IDF 5.5 TWAI node driver. ci-images.yml publishes
gitbot/gitea-ci-espidf:v5.5.1 from this pin; the v5.4.1 tag stays in the
registry for esp-mg-tpms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:08:58 -04:00
Bastian de BylandClaude Opus 5.5 4b826a0008 chore(gregtime): bump to 3.21.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:22 -04:00
Bastian de BylandClaude Opus 5.5 4220a2190d chore(gregtime): bump to 3.21.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:42:30 -04:00
Bastian de BylandClaude Opus 5.5 867b3038c8 chore(gregtime): bump to 3.20.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:09:39 -04:00
Bastian de BylandClaude Opus 5.5 ecb85e96d0 chore(gregtime): bump to 3.20.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:42:43 -04:00
Bastian de BylandClaude Opus 5.5 135c26369f chore(gregtime): bump to 3.19.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:27:07 -04:00
Bastian de BylandClaude Opus 5.5 bcebdc19cc chore(gregtime): bump to 3.19.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:36:55 -04:00
Bastian de BylandClaude Opus 5.5 a38cb080da feat(hass): hook the living room desk lamp into the light schedule
switch.desk_lamp (EP10) is an on/off switch, so the area-targeted
light.* actions skip it. It now follows light.living_room explicitly:
on at sunset unless TV mode is on, on when the TV goes off in the
evening, and off with the living room at 23:30, the 01:00 sweep and
when the TV goes off late at night.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:21:09 -04:00
Bastian de BylandClaude Opus 5.5 3d03332aaa fix(hass): drop dead device-based automations, use entity ids
The five "Lights - 01/02/03/04/10" automations had no triggers left and
were superseded by the sunset/evening-ramp/lights-out/sweep automations;
nothing references them. "Driveway String Lights Off" and "Lights - 00 -
Morning" referenced device ids that no longer exist in the device
registry (they only worked because HA resolved the entity registry id),
so they now target switch.driveway_string_lights and
light.bathroom_hallway directly. Ids and aliases are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:58:01 -04:00
Bastian de BylandClaude Opus 5.5 f575f8f62d chore(hass): add one-off playbook to repoint tplink entries at IoT VLAN
The IoT WiFi moved from Default (192.168.1.x) to the IoT VLAN
(192.168.2.x) with fixed IPs reserved in UniFi. HA 2026.9.3's tplink
reconfigure flow ignores the entered host and reconnects to the stored
one, so this edits data.host in .storage/core.config_entries with HA
stopped, matching entries by MAC and refusing to write unless all 9
match. Backs up core.config_entries first and always restarts hass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:45:59 -04:00
Bastian de BylandClaude Opus 5.5 339d4b150c chore(gregtime): bump to 3.19.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:46:04 -04:00
Bastian de BylandClaude Fable 5.1 0bd6c6e3fe chore(fulfillr): bump prod and dev image to 20260929.1624 (SCRUM-208 activity feed)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:40:08 -04:00
Bastian de BylandClaude Fable 5.1 4759ee5909 chore(fulfillr): bump prod and dev image to 20260929.1553 (SCRUM-207, SCRUM-208)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:04:00 -04:00
Bastian de BylandClaude Fable 5.1 a55e7b7c3d chore(fulfillr): bump prod image to 20260929.1412 (nav badges, SCRUM-206)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:23:53 -04:00
Bastian de BylandClaude Fable 5.1 554d3acb6e chore(fulfillr-dev): bump image to 20260929.1412 (nav badges, SCRUM-206)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 10:22:05 -04:00
Bastian de BylandClaude Fable 5.1 69d2c2de92 chore(fulfillr): bump prod and dev image to 20260929.0203 (project invoicing, SCRUM-205)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 09:39:39 -04:00
Bastian de BylandClaude Fable 5.1 72c1006b3e chore(fulfillr-dev): bump image to 20260929.0015 (project invoicing, SCRUM-202)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 20:23:29 -04:00
Bastian de BylandClaude Opus 5.5 1f399cee91 chore(vault): rotate gitea registry token to write:package
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:53:46 -04:00
Bastian de BylandClaude Opus 5.5 0cc4c1460e feat(debyltech-cloud): limit customers to Files, Activity and signing
Nothing was group-restricted, so customers saw Dashboard, Photos, Office
and the rest, plus Nextcloud's first-run and promo apps.

- Disable for everyone: firstrunwizard, recommendations, related_resources,
  weather_status, survey_client, support, app_api, contactsinteraction,
  photos. A refused disable now fails the play (occ exits 0 on "can't be
  disabled").
- Restrict dashboard and office to staff. defaultapp=dashboard,files so
  staff land on the dashboard and customers fall through to Files.
- libresign groups_request_sign pinned to staff and asserted in verify.
  LibreSign itself is deliberately NOT group-restricted: that also blocks
  anonymous requests and would break public signing links.
- profile.enabled=false; lookup_server="" (lookup_server_connector
  can't be disabled).
- README: what customers can open.

Checked as a probe customer: apps=files,activity,libresign,text,viewer,
lands in Files, can't request signatures; staff land on Dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:48:39 -04:00
Bastian de BylandClaude Opus 5.5 c4fe506860 feat(debyltech-cloud): lock customers to read-only, no discovery
Customers are admin-created accounts in per-customer groups. They should
read what staff share with them and nothing else. Checked against a test
customer in the UI, and by running the sharee and contacts-menu search
services as that user.

- shareapi_exclude_groups=allow, list [admin]: only staff can share. Exclude
  mode ("yes") only disables sharing for users whose groups are ALL
  excluded, so it never catches a customer in their own group.
- User/group autocomplete off. By default a customer typing "bas" found the
  owner's account. Staff share by exact group name; LibreSign signers are
  found by email.
- New shares default to View only (shareapi_default_permissions=1).
- files default_quota 0 B, so customers get no personal storage. Staff in
  cloud_debyltech_staff_users are exempted (skipped if not yet created).
- No "Leon Green" sample contact in new address books.
- The verify script fails the deploy if any isolation setting drifts.
- README: staff and customer onboarding checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:47 -04:00
Bastian de BylandClaude Opus 5.5 be50798096 fix(ci-images): static build matrix for Gitea
CI Images / Plan (push) Successful in 29s
CI Images / Build ci (push) Failing after 1m8s
CI Images / Build espidf (push) Failing after 1m16s
CI Images / Build platformio (push) Failing after 1m30s
Gitea expands a job's matrix when the run is created, before plan has any
outputs, so fromJSON(needs.plan.outputs.matrix) collapsed to a single empty
"Build ${{ matrix.key }}" job and nothing was ever built. The matrix is now
the fixed list of image keys; plan emits every image's spec with a build flag
and each matrix job looks its own entry up, no-opping when it wasn't picked.

Also document that both registry tokens need write:package -- the vault token
was read-only, so the gitea_ci_build_local bootstrap failed its push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:00:35 -04:00
Bastian de BylandClaude Opus 5.5 8701ada7e2 feat(debyltech-cloud): plain login background, empty homes for new accounts
- theming background -> backgroundColor, dropping the stock image for the
  navy theming colour.
- skeletondirectory/templatedirectory set to "" so customer accounts start
  empty instead of getting Nextcloud's sample Manual, intro video and
  Templates folder.
- The system-config compare now tells an unset key apart from an empty
  value. Both print nothing, so an intentionally empty setting was
  silently skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:38:52 -04:00
Bastian de BylandClaude Opus 5.5 9ce9610965 fix(debyltech-cloud): external signers, proxy trust, light-only mail
- Enable LibreSign's email identify method (click-to-sign, no account
  creation), remove the stamp background and collect signer metadata.
  On Skudak these were only ever set in the admin UI. Without the email
  method a fresh instance answers "No signers." for any outside address.
  The verify script now asserts it.
- Store add_footer=true explicitly. The code already defaults to it, but
  the 14.2 admin page shows unset as unchecked.
- trusted_proxies = the container's own address, read per deploy. Behind
  rootless port forwarding every request arrives from it, so without this
  X-Forwarded-For was ignored and every client shared one IP for
  brute-force throttling.
- maintenance_window_start and default_phone_region, which clears the setup
  warnings.
- Mail declares color-scheme "light only" so Apple Mail's dark mode doesn't
  repaint the white ground and bury the black wordmark (asserted in verify).
- Idempotency: redis image fully qualified (docker.io/library/...), the
  debyltechmail copy owned by the mapped www-data uid, and theming
  compared before setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:08:41 -04:00
Bastian de BylandClaude Opus 5.5 fa5bbf8e54 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>
2026-09-28 17:04:04 -04:00
bastian 0eca63d4b7 Merge pull request 'fix(gitea-actions): serve CI images from the Gitea registry' (#12) from feat/ci-images-registry into master
CI Images / Plan (push) Failing after 1s
CI Images / Build ${{ matrix.key }} (push) Skipped
2026-09-28 12:19:57 -04:00
Bastian de BylandClaude Opus 5.5 73c552303b docs(claude): work directly on master, no branches or PRs
CI Images / Plan (pull_request) Failing after 2s
CI Images / Build ${{ matrix.key }} (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:53:14 -04:00
Bastian de BylandClaude Opus 5.5 5d6aa187cc chore(vault): update secrets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:51:45 -04:00
Bastian de BylandClaude Opus 5.5 1852af5fc9 chore: bump gregtime to 3.19.0, rsvp to 1.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:51:45 -04:00
Bastian de BylandClaude Opus 5.5 f674f61b8d fix(hass): don't fire on-off automations when a device reconnects
CI Images / Plan (pull_request) Failing after 2s
CI Images / Build ${{ matrix.key }} (pull_request) Skipped
The Bedroom Light HS200 dropped off Wi-Fi for 5 s at 03:01 and came back
reporting "on"; the Bedroom On device trigger treated unavailable -> on as
someone flipping the switch and lit the bedroom Hue lamps at 100%.

Bedroom On/Off and TV On/Off now use state triggers with not_from
unavailable/unknown, so reconnects and HA restarts no longer count as a
flip. The driveway's switch-reconnect catch-up is dropped for the same
reason (it would undo a manual off); the HA-restart catch-up stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:32:24 -04:00
Bastian de BylandClaude Opus 5.5 fd985e014c fix(hass): driveway lights ignore TV mode, ramped evening dimming, bump to 2026.9.3
CI Images / Plan (pull_request) Failing after 2s
CI Images / Build ${{ matrix.key }} (pull_request) Skipped
The sunset automation required TV mode off, so an afternoon of TV skipped
the driveway string lights entirely (Sep 22 and 23) - not an outage. The
driveway now has its own sunset -1h automation, with catch-up on restart
or switch reconnect before 23:00.

Evening brightness lives in one script (evening_lights_apply) that blends
between the old step levels; a 5-minute ramp from 20:30 eases lights that
are on and leaves alone any a person has changed by hand. The Dining Hall
no longer bumps to 50% at 21:30.

TV off after 23:30 now only turns off the living room glow instead of
bringing the whole house back to full brightness, and TV on/off leave the
lights alone in daylight. Lights-out moves to 23:30, and a 01:00 sweep
catches anything switched back on at the wall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:42:33 -04:00
Bastian de BylandClaude Fable 5.1 15a8ec693e chore(gitea): bump git.skudak.com to 1.27.3
CI Images / Plan (pull_request) Successful in 37s
CI Images / Build ${{ matrix.key }} (pull_request) Failing after 58s
Same security fixes as git.debyl.io, deployed and verified after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:10:07 -04:00
Bastian de BylandClaude Fable 5.1 64850995ec chore(gitea): bump git.debyl.io to 1.27.3, pin the two instances separately
1.26.1 -> 1.27.3 picks up the security fixes in 1.27.0 through 1.27.3.
The image was one shared variable, so the two instances could only move
together; split it into gitea_debyl_image / gitea_skudak_image and tag
the debyl tasks gitea-debyl so each can be upgraded and verified on its
own. Skudak stays on 1.26.1 in this commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:06:31 -04:00
Bastian de BylandClaude Opus 5 ba0936bc8d chore(gregtime): bump to 3.18.4
The daily quote keeps a ledger of what it has posted and re-rolls ZenQuotes
until it finds something the channel has not read, with the header framing and
the offline fallback pool drawn against that same ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:30:07 -04:00
Bastian de BylandClaude Opus 5 d0e76bd6cf feat(gitea-actions): build the CI job images in Gitea CI
The runner's job images were built by ansible into localhost/ only, so the
nightly CI prune deleted them and every idle stretch ended with CI failing in
under a second on `docker pull localhost/gitea-ci:latest` until someone re-ran
the role and waited out a rebuild. The previous commit moved them to the Gitea
registry; this moves the *build* off the deploy path entirely.

- .gitea/workflows/ci-images.yml builds files/Containerfile.* and pushes to
  git.debyl.io/gitbot/. Per-image change detection, so an ESP-IDF pin bump does
  not rebuild the other two; weekly schedule for base-image updates; a
  workflow_dispatch selector. PRs build under a throwaway :pr-<n> tag and drop
  it -- the build lands in the live runner's store, and act_runner will not
  re-pull a tag it already has, so a PR using the real tag would hand every
  later job on this host an unmerged image.
- The Containerfiles stop being ansible templates: their version vars are now
  --build-arg, read by the workflow out of the same defaults/main.yml the role
  interpolates, so CI and ansible build the same bytes from one set of pins.
- LABEL io.debyl.ci-base moves into each Containerfile so neither builder can
  forget the prune exemption; the workflow re-checks it before pushing.
- roles/gitea-actions pulls instead of building. gitea_ci_build_local=true
  restores the local build+push for seeding a cold registry or when CI is
  down -- the workflow that builds gitea-ci runs in gitea-ci.
- Lint .gitea/ alongside ansible/, and document the flow in the role README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:10:43 -04:00
Bastian de Byl a52209ff6a Merge branch 'master' into feat/ci-images-registry 2026-09-21 11:02:55 -04:00
Bastian de BylandClaude Opus 5 56d74660b8 chore(rsvp): bump to 1.0.5
Nights are checkboxes again: a household ticks every night that works, which
also says which nights do not. At least one tick (or "Any of these works")
is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:42:58 -04:00
bastian 306e43e700 Merge pull request 'SCRUM-196: fulfillr 20260915.0011 (GA4 users over the exact window)' (#13) from scrum-196/fulfillr-users-window into master 2026-09-14 20:34:12 -04:00
Bastian de BylandClaude Opus 5 355a0739ff SCRUM-196: fulfillr 20260915.0011 (GA4 users over the exact window)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:26:02 -04:00
Bastian de Byl f7f4d903a0 Merge remote-tracking branch 'origin/master' into feat/ci-images-registry 2026-09-14 19:57:33 -04:00
bastian f0e2fae900 Merge pull request 'SCRUM-196: GA4 analytics config for fulfillr Traffic & funnel tab' (#11) from scrum-196/fulfillr-ga4-analytics into master 2026-09-14 17:59:21 -04:00
Bastian de BylandClaude Opus 5 75e257077e SCRUM-196: fulfillr 20260914.2149 (embedded tzdata for GA4)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:55:15 -04:00
Bastian de BylandClaude Opus 5 80edc84586 SCRUM-196: fulfillr 20260914.2103 (funnel + GA4 traffic endpoints)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:10:08 -04:00
Bastian de BylandClaude Opus 5 42e6f5271d fix(gitea-actions): serve CI images from the Gitea registry
The CI job images only existed under localhost/ in the gitea-runner store,
and the nightly CI prune deletes any image older than 48h that no container
holds. After every idle stretch CI failed in 0-1s pulling
localhost/gitea-ci:latest until the role was re-run and the images rebuilt.

- Build under git.debyl.io/gitbot/..., push after every run, and pull from the
  registry instead of rebuilding when the Containerfile is unchanged.
- Log gitea-runner in via ~/.docker/config.json, which both act_runner (job
  image pulls) and podman read.
- Label the base images io.debyl.ci-base and skip that label in the CI prune;
  its `until` counts from build time, so a re-pulled image would otherwise be
  deleted again the next night.

Workflows pinning `container: image: localhost/gitea-ci-*` must move to the
registry paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:40:57 -04:00
Bastian de BylandClaude Opus 5 e174e259eb SCRUM-196: Read GA4 key from fulfillr_ga4_credentials_json
Match the vault variable name holding the service-account key file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:21:04 -04:00
Bastian de BylandClaude Opus 5 e681a46b78 SCRUM-196: GA4 analytics config for fulfillr Traffic & funnel tab
Render an analytics block (property 353859448 + service-account key) into the
fulfillr dev and prod configs once fulfillr_ga4_credentials is in the vault.
Without the vault var the block is omitted and the portal reports GA as not
connected. Remember to restart the container after deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:02:38 -04:00
Bastian de BylandClaude Opus 5 d9ab55f05d chore(rsvp): bump to 1.0.4
Compacts the admin invite table so it fits without clipping: short token link
with Copy/Msg, status as an emoji, Qty, allergens/notes behind popovers, and
relative update times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 10:16:39 -04:00
Bastian de BylandClaude Opus 5 1c5d3b020a chore(rsvp): bump to 1.0.3
Fixes the admin invite table clipping its Edit/Revoke column, and adds
default-headcount people estimates to the invited/awaiting summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:36:08 -04:00
Bastian de BylandClaude Opus 5 1b9fccd779 chore(rsvp): bump to 1.0.2
Adds the anonymized "Who's coming" card for guests who have answered, and a
message-template copy button on the admin invite table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:20:57 -04:00
Bastian de BylandClaude Opus 5 f59ade748b feat(rsvp): deploy rsvp.debyl.io, the invite-only party RSVP app
A single Go binary with SQLite, built and loaded as localhost/rsvpd:<VERSION>
by make deploy-remote in ~/src/rsvp-debylio. Both of its listeners are
published on 127.0.0.1 only: Caddy proxies the public one to everyone and the
admin one (/admin, no login) only to caddy_local_networks. A Caddyfile mistake
alone cannot expose admin, and neither can a port mistake alone.

Guests' invite links are the credential and they sit in the URL path, which
shapes the vhost:

- It does not import common_headers. That snippet sets Referrer-Policy
  same-origin, which would replace the app's no-referrer and let a token leak
  in a Referer header.
- Its access log rewrites request>uri to /i/REDACTED and drops the Location
  response header, since every POST 303s back to /i/<token>.
- Caddy's error logger is separate from the site's and wrote the raw URI to
  caddy.log when the upstream was down. The global log now excludes
  http.log.error.rsvp and a filtered rsvp-errors logger takes it instead.
  Verified with zero token occurrences in both logs, locally and live.

The data directory is owned directly by the host uid of the container's uid
10001 (subuid + 10000). Setting it to the podman user and chowning back each run
flipped ownership on every deploy and briefly locked the app out of its
database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:14:46 -04:00
Bastian de BylandClaude Opus 5 a9f51b77e1 fix(podman): pull a new image before removing the running container
podman-check deleted the old container as soon as the pinned image differed,
and the create task pulled afterwards. A tag that did not exist, or a registry
that was down, therefore left the service with no container at all. The pull
now happens first, so that failure stops the play with the old container still
running. localhost/ images are built and loaded by hand and are never pulled.

The pull is skipped when the container does not exist yet: there is nothing to
protect, and containers[0] is not there to compare against. Without that guard
the first deploy of any new service failed on the conditional -- rsvp was the
first to hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:14:46 -04:00
Bastian de BylandClaude Opus 5 73e50bb19d chore(gregtime): bump to 3.17.3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:14:45 -04:00
Bastian de BylandClaude Opus 5 6cd4d56de1 feat(labelprint): 4x6 label print proxy on a Raspberry Pi
A Pi 3B+ (stickah.local) shares a Phomemo PM246 to the LAN as a plain CUPS
queue, so any machine can print 4x6 labels -- fulfillr-site's shipping labels
in particular -- without installing the vendor driver, which is x86-64 only.
The role builds the TSPL CUPS driver from source instead.

It is Debian, not Fedora, so it lives in its own inventory and playbook
(make deploy-labelprint / check-labelprint) and the home.debyl.io roles can
never run against it. make bootfs renders its cloud-init first-boot files onto
a freshly imaged SD card from the same templates the role uses. The Wi-Fi
credentials for the home and rescue networks are in the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:14:45 -04:00
Bastian de BylandClaude Opus 5 be0d02b938 feat(zomboid): restore the world from one of PZ's own backups
Rolling a world back meant hand-work over ssh: stop the service, move the live
save aside, unzip the right archive, chown into the container's subuid range,
relabel, start. That is the wrong shape of task to do by hand, and it is always
done under time pressure -- by construction, because the archive you want is
being deleted while you work.

PZ keeps BackupsCount=10 per set and writes one every BackupsPeriod=30 minutes,
so a periodic backup is reachable for about five hours and then gone. On
2026-09-05 the snapshot the admins asked for (05:16, four minutes before the
incident) had about 90 minutes of life left when the request came in.

Same shape as the wipe: the Discord bot writes a trigger file into its own rw
volume, zomboid-restore.path notices it, and zomboid-restore.service runs the
script as the podman user. The bot gets no ssh, no systemd, and keeps only its
existing read-only mount of the Zomboid volume.

Two details carry most of the correctness.

Resolution is by mtime, not by index. The rotation renames the files -- today's
backup_7.zip is backup_8.zip half an hour from now, and a new backup_7.zip holds
a different world -- so an index is valid only while the listing is fresh, which
is not long enough to survive a human reading a confirmation prompt. The trigger
names a set and an mtime; the script resolves the path itself, whitelists the
filename, and refuses if nothing matches. It never accepts a path.

Everything that can fail is checked before the server is touched. A rotated-out
target, an archive with no debbzoid world in it, a bad action, a traversal
attempt in the set name: each aborts with the server still running and writes a
result file the bot reports back. The live world is moved aside rather than
deleted, so a restore is undoable and the last three are kept.

One thing PZ does not advertise: its backups do not cover the whole save
directory. blam/, a mod's own state, is in none of them -- not the 05:16 archive
and not the newest one. Restoring only what the archive holds therefore lands
the world slightly *behind* the target rather than on it, so anything present in
the displaced world and absent from the archive is carried across.

The gregtime tag moves to 3.17.0 for the bot half of this -- `backups`,
`restore <n>`, `restore confirm`, `restore undo`, gated to the same two admins
as the wipe. That image is built and running on the host; its source is not
committed yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QdWYhwUtwM2NQGukiRh12
2026-09-05 09:12:09 -04:00
Bastian de BylandClaude Opus 5 ba9c4f2bfe fix(zomboid): re-sync the world settings from the live server
The vendored Sophie preset and the running debbzoid world had drifted, and the
repo only held the preset. That is not a restore point: force-pushing it would
have reverted the admins' in-game tuning rather than recovering it, which is
exactly how a Sophie world quietly became an Apocalypse one once already -- 139
values reverted, loot from 0.35 back to 0.9, CharacterFreePoints 0 to 60.

files/zomboid/sophie/SandboxVars.lua is now a snapshot of the live world taken
2026-08-31, not the preset as shipped. The modlist is untouched and still
upstream, which is why zomboid_preset_version now names the two halves and their
separate dates. server.ini.j2 carries the eight keys that had drifted:

  PlayerSafehouse              false -> true
  SafehouseAllowNonResidential false -> true   (the diner/gas-station case)
  SafehouseAllowRespawn        false -> true
  SafehouseAllowLoot           true  -> false
  SafehouseAllowFire           true  -> false
  TrashDeleteAll               false -> true
  MapRemotePlayerVisibility    1     -> 4
  ResetID                      6953472 -> 826046

ResetID is in that list on purpose, and matters most. It is the world's
soft-reset token: a file value that differs from the one the live world was
created with tells every connected client to roll a new character. Carrying the
live value makes a deliberate force-push a no-op instead of a server-wide wipe
prompt.

Spawn config gets its own switch, zomboid_spawn_force. spawnregions.lua and
spawnpoints.lua are the only config a running world re-reads -- at every server
start, where SandboxVars is read once, when the world is created -- so a spawn
edit is deployable on the live world without a wipe. Sharing zomboid_config_force
between them would have meant force-pushing the whole preset to land a one-line
spawn edit, rewriting the INI (hence the ResetID hazard above) and the world's
SandboxVars along with it.

config-template/ now tracks the repo unconditionally. Nothing on the host writes
that directory and the server cannot see it, so it has no hand edits to protect;
if it does not track the repo it is not a restore point, just an older world's
settings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016QdWYhwUtwM2NQGukiRh12
2026-09-05 09:11:31 -04:00
Bastian de BylandClaude Opus 5 9954d774e7 fix(ups): start upsd only once the network is actually online
After the host rebooted, truenas began mailing NOCOMM continuously. The UPS
driver was fine throughout -- nut-driver@cyberpower stayed running and the
CyberPower is still on USB here. What died was upsd, which publishes its state
to truenas over the network:

  upsd: not listening on 192.168.1.10 port 3493
  upsd: Fatal error: some listening interfaces were not available
  nut-server.service: Start request repeated too quickly

upsd binds an explicit address, but the packaged unit only orders itself
After=network.target, which is satisfied when networking STARTS rather than
when an address exists. It tried to bind 11 seconds into boot, before
NetworkManager had assigned the address, then burned all five default restart
attempts inside one second, tripped the start limit and stayed dead. The
shipped unit carries these two lines commented out, because upstream knows the
case.

network-online.target is the correct ordering; NetworkManager-wait-online is
enabled here so it genuinely waits for addresses. StartLimitIntervalSec=0 and
RestartSec are belt and braces: a slow address can no longer exhaust the
attempts, and retries are spaced instead of hammered.

truenas needs no change -- it is a correctly configured SLAVE that reconnected
on its own, and its NUT config is regenerated from the middleware database
anyway. Verified: upsd listening on both addresses, UPS OL at 100%, and truenas
querying it again with zero failures since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:40:19 -04:00
Bastian de BylandClaude Opus 5 fb09a01c88 fix(podman): stop restorecon walking the TrueNAS CIFS mounts
restorecon -Frv over the podman volumes tree descends into two SMB shares
mounted inside it -- volumes/photos/immich and volumes/photos/storage, both
from truenas -- so it was relabelling the entire remote photo library over the
network, on a filesystem that cannot store SELinux xattrs at all.

On 2026-08-28 that pinned CPU#0 at 100% system time and the kernel logged six
escalating soft lockups:

  watchdog: BUG: soft lockup - CPU#0 stuck for 1423s! [restorecon:132780]

New process creation starved, so sshd accepted connections and then hung during
session setup, and the host needed a hard reboot. An earlier run the same day
had already been SIGKILLed at 49s, which was the same bug surfacing quietly.

-x keeps it on the local filesystem. Measured: 1,279,231 local files walked and
relabelled in 29.2s, against never finishing before. The mounts are also
x-systemd.automount, so merely walking into them triggers a mount -- there was
never anything there to relabel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:40:07 -04:00
Bastian de BylandClaude Opus 5 ae99ba415e fix(immich): keep the ML models resident, and pin the ML URL explicitly
Smart search, face detection and OCR were all failing with

  Machine learning request to "http://immich-machine-learning:3003" failed

while the container still reported Up. Podman only sees PID 1: gunicorn's
master was alive and holding the listening socket, but its worker had died at a
WORKER TIMEOUT and was never respawned -- hence a connect timeout rather than a
refusal. The dead worker was a zombie whose remaining thread was stuck in
uninterruptible sleep in exit_mmap, so it survived SIGKILL, podman rm -f and
rm -f -t 0, and kept the container name and network alias until the host was
rebooted.

MACHINE_LEARNING_MODEL_TTL=0 addresses the cause rather than the symptom. The
default unloads models after 300s idle, so every search following a gap
reloaded four of them (CLIP, buffalo_l detection + recognition, PP-OCRv5) on a
CPU-only 4-core box and then tore those mappings back down -- and that teardown
is what wedged. Keeping them resident costs ~1-2 GB and removes the path.

IMMICH_MACHINE_LEARNING_URL is now set explicitly instead of relying on
immich's implicit default, so the ML container can be renamed without silently
losing search, and the name is a variable so a replacement can be stood up
beside a broken one without editing tasks.

Verified after deploy: zero ML failures, "in-memory cache with unloading
disabled", ping 200 from immich-server, 26/26 containers healthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:39:57 -04:00
Bastian de BylandClaude Opus 5 2a6390c0ad fix(podman-prune): reap the CI stores, which had reached 113 GB
podman_prune_users listed only the podman and git users, so the two stores that
turn over fastest were never touched. gitea-runner had reached 1205 images /
113.1 GB with 100% of it reclaimable, and actions-runner had 137 exited job
containers. That layer count is what makes overlayfs lookups -- and so CI
itself -- slow; the disk was the lesser problem.

Split into two policies, because the stores are not the same kind of thing:

  service users keep the 30-day rollback window, and their containers are
  deliberately NOT pruned. They are the live services, and reaping one that
  merely happens to be stopped would turn a transient crash into a unit that
  cannot start again until the next deploy.

  CI users get 48h and their exited job containers reaped too. Build layers
  carry no rollback value. Containers are reaped BEFORE images on purpose: an
  exited container pins the image it ran from, so pruning images first would
  leave those layers behind for another day.

Timer moved weekly -> daily; a week of CI turnover is what let the store reach
113 GB between runs. Persistent=true is kept so a missed run catches up.

First run reclaimed 134 GB: gitea-runner 113.1 -> 4.2 GB, actions-runner
7.6 GB -> 0, podman 25.7 -> 15.1 GB. Disk 449G -> 315G, all 26 containers up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:39:45 -04:00
Bastian de BylandClaude Opus 5 f21be79452 perf(host): drop the duplicate syslog copy and fix the SSD I/O path
Three findings from investigating sustained I/O pressure on the root SSD.
Grouped because the logging and storage edits land in the same task file.

rsyslog was writing a second complete copy of the journal to
/var/log/messages: 6.7 GB of rotated copies, two weekly files of which were
2.8 GB and 2.5 GB. It loads imjournal, so it reads the journal directly and
ForwardToSyslog=no alone does not stop it -- the unit itself has to go.
Verified nothing consumes those files first: fail2ban runs backend=systemd and
matches on the journal ("No file is currently monitored"), and lsof showed only
rsyslogd holding them. Measured afterwards: writes 46 -> 23 GB/day, /var/log
6.7 GB -> 655 MB, journald still capturing container stdout.

The SSD was on bfq, which fedora's stock 60-block-scheduler.rules picks for any
rotational=0 disk. bfq is built for spinning disks and desktop interactivity:
it costs CPU per request, lets reads queue behind write bursts, and hard-caps
nr_requests at 64. With 26 containers and two CI runners writing at once that
is the wrong trade. mq-deadline rather than none because this is SATA with a
32-deep NCQ queue, not NVMe -- the merging and the read-expiry deadline both
earn their place.

Writeback was at the stock percent-of-RAM ratios, so on 31 GB the kernel would
sit on 3.1 GB before starting writeback and 6.2 GB before blocking writers.
Flushing that to a QLC drive that falls to ~80-160 MB/s once its SLC cache is
spent takes tens of seconds with everything stalled behind it. Capped in
absolute bytes instead: one long stall traded for frequent short ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:39:05 -04:00
Bastian de BylandClaude Opus 5 9e136ce903 fix(hass): trust pasta's link-local address, and bump to 2026.8.3
assistant.debyl.io returned 400 on every request. With use_x_forwarded_for
set, home assistant hard-fails any request carrying X-Forwarded-For from an
address outside trusted_proxies:

  Received X-Forwarded-For header from an untrusted proxy 169.254.2.1

caddy runs --network host and proxies to localhost:8123, so the source address
is whatever pasta presents inside the container's netns. Rootless podman
switched from slirp4netns to pasta in v5 (this host runs 5.7.1), which moved
that address from 10.0.2.x -- covered by the existing 10.0.0.0/8 entry -- to a
link-local tap0 address that nothing in the list matched. The container has no
10.x address at all any more.

Latent since the pasta migration; it only surfaced when the site was next
opened. Reproduced directly:

  curl -H 'X-Forwarded-For: 1.2.3.4' http://localhost:8123/  ->  400

Bumped to 2026.8.3 in the same pass (identical digest to the stable tag).
The config directory was snapshotted first: the 2026.5.1 -> 2026.8.3 database
migration is not reversible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:38:50 -04:00
85 changed files with 5791 additions and 939 deletions
+273
View File
@@ -0,0 +1,273 @@
---
# Builds the Gitea Actions job images and publishes them to the Gitea container
# registry, so the runner can re-pull one the nightly podman prune removed
# instead of waiting for a human to re-run `make deploy TAGS=gitea-actions`.
#
# Source of truth is ansible/roles/gitea-actions: files/Containerfile.* for the
# image contents, defaults/main.yml for the version pins and the registry path.
# This workflow reads those vars rather than repeating them. roles/gitea-actions
# then only pulls what lands here (gitea_ci_build_local is the escape hatch for
# seeding an empty namespace, since the job below runs *in* gitea-ci).
#
# `docker build` here talks to the gitea-runner user's rootless podman socket,
# mounted into every job container by roles/gitea-actions (config.yaml.j2), so
# the build happens in the same image store the runner pulls from and the layer
# cache survives between runs. That also means a build writes tags the live
# runner will use -- which is why pull requests build under a throwaway
# :pr-<n> tag and delete it again.
name: CI Images
on:
push:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
pull_request:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
workflow_dispatch:
inputs:
image:
description: Which image to rebuild
type: choice
options: [all, ci, espidf, platformio]
default: all
schedule:
# Weekly rebuild so base-image security updates land without a commit.
# Sunday 04:00, after the 02:00 podman prune has finished.
- cron: "0 4 * * 0"
env:
DEFAULTS: ansible/roles/gitea-actions/defaults/main.yml
CONTEXT: ansible/roles/gitea-actions/files
REGISTRY: git.debyl.io
# Not a secret: the same namespace is in defaults/main.yml. It must be the
# owner of REGISTRY_TOKEN -- Gitea authorises a package push by the token's
# user, not by the path, so pushing to gitbot/ means logging in as gitbot.
REGISTRY_USER: gitbot
KEEP_LABEL: io.debyl.ci-base
# One publisher at a time. Two runs pushing :latest concurrently would leave the
# registry holding whichever finished last, which need not be the newest commit.
concurrency:
group: ci-images
cancel-in-progress: false
jobs:
plan:
name: Plan
runs-on: fedora
outputs:
images: ${{ steps.plan.outputs.images }}
any: ${{ steps.plan.outputs.any }}
steps:
- uses: actions/checkout@v4
with:
# Full history so the change detection below can diff against the
# pushed-from commit / the PR base.
fetch-depth: 0
- name: Decide which images to build
id: plan
env:
EVENT: ${{ github.event_name }}
SELECTED: ${{ github.event.inputs.image }}
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, subprocess, sys, yaml
defaults = yaml.safe_load(open(os.environ["DEFAULTS"]))
ctx = os.environ["CONTEXT"]
reg, ns = os.environ["REGISTRY"], os.environ["REGISTRY_USER"]
# Mirrors gitea_ci_images in defaults/main.yml. The tags are rebuilt
# from the same version vars the role interpolates, so a pin bump in
# that file moves the image tag here and in ansible together.
images = [
{
"key": "ci",
"containerfile": "Containerfile.ci",
"tag": f"{reg}/{ns}/gitea-ci:latest",
"build_args": "",
},
{
"key": "espidf",
"containerfile": "Containerfile.espidf",
"tag": f"{reg}/{ns}/gitea-ci-espidf:{defaults['esp_idf_version']}",
"build_args": f"ESP_IDF_VERSION={defaults['esp_idf_version']}",
},
{
"key": "platformio",
"containerfile": "Containerfile.platformio",
"tag": f"{reg}/{ns}/gitea-ci-platformio:{defaults['pio_espressif32_version']}",
"build_args": (
f"PLATFORMIO_CORE_VERSION={defaults['platformio_core_version']} "
f"PIO_ESPRESSIF32_VERSION={defaults['pio_espressif32_version']}"
),
},
]
event = os.environ["EVENT"]
def changed_files(base):
"""Paths touched since `base`, or None if the diff is not usable."""
if not base or set(base) == {"0"}:
return None
try:
out = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout
except subprocess.CalledProcessError:
# Force push, shallow clone, first push of a branch: fall back
# to building everything rather than silently skipping a real
# change.
return None
return set(out.split())
if event == "workflow_dispatch":
selected = os.environ.get("SELECTED") or "all"
picked = images if selected == "all" else [i for i in images if i["key"] == selected]
elif event == "schedule":
picked = images
else:
base = os.environ["PR_BASE"] if event == "pull_request" else os.environ["BEFORE"]
touched = changed_files(base)
if touched is None:
picked = images
else:
# defaults/main.yml holds every pin, so a change there could
# retag any image; the workflow file itself changes how all of
# them are built. Either one rebuilds the lot.
wide = {os.environ["DEFAULTS"], ".gitea/workflows/ci-images.yml"}
if touched & wide:
picked = images
else:
picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched]
# Every image, keyed by matrix.key, each flagged build or skip. The
# build job's matrix is static (see there), so it needs the full set
# to look its own entry up in, not just the picked ones.
picked_keys = {i["key"] for i in picked}
specs = {i["key"]: {**i, "build": i["key"] in picked_keys} for i in images}
print(f"images={json.dumps(specs)}")
print(f"any={'true' if picked else 'false'}")
print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr)
PY
build:
name: Build ${{ matrix.key }}
needs: plan
if: needs.plan.outputs.any == 'true'
runs-on: fedora
strategy:
# One image failing must not cancel the others: they are independent, and
# a half-published set is what this whole workflow exists to avoid.
fail-fast: false
# Static on purpose. Gitea expands the matrix when the run is created,
# before plan has produced any outputs, so a
# fromJSON(needs.plan.outputs.*) matrix collapses to one empty job. Each
# entry instead looks its spec up in plan's output and no-ops its steps
# when plan did not pick it. Keys must match `images` in plan.
matrix:
key: [ci, espidf, platformio]
steps:
- name: Look up the ${{ matrix.key }} image spec
id: spec
env:
IMAGES: ${{ needs.plan.outputs.images }}
KEY: ${{ matrix.key }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os
spec = json.loads(os.environ["IMAGES"])[os.environ["KEY"]]
for k in ("containerfile", "tag", "build_args"):
print(f"{k}={spec[k]}")
print(f"build={'true' if spec['build'] else 'false'}")
PY
- uses: actions/checkout@v4
if: steps.spec.outputs.build == 'true'
- name: Log in to the Gitea Container Registry
if: steps.spec.outputs.build == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
# The build lands in the live runner's image store, and act_runner will
# not re-pull a tag it already has locally. Tagging a PR build with the
# real tag would therefore hand every later job on this host an unmerged
# image, so PRs get a throwaway tag that the cleanup step removes.
- name: Resolve build tag
id: tag
if: steps.spec.outputs.build == 'true'
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "image=${{ steps.spec.outputs.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "image=${{ steps.spec.outputs.tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Build ${{ matrix.key }}
if: steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
BUILD_ARGS: ${{ steps.spec.outputs.build_args }}
run: |
set -euo pipefail
args=()
for a in $BUILD_ARGS; do args+=(--build-arg "$a"); done
# --pull so a scheduled run actually picks up a refreshed base image;
# without it an unchanged FROM line just hits the local layer cache.
docker build --pull \
"${args[@]}" \
-t "$IMAGE" \
-f "$CONTEXT/${{ steps.spec.outputs.containerfile }}" \
"$CONTEXT"
- name: Verify the prune-exemption label survived the build
if: steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
# roles/podman's nightly prune keeps an image only if it carries this
# label (podman_prune_ci_keep_label). Publishing one without it would
# quietly restore the nightly-deletion behaviour this replaced, and
# nothing would notice until CI failed on a Monday morning.
got=$(docker inspect -f "{{ index .Config.Labels \"$KEEP_LABEL\" }}" "$IMAGE")
test "$got" = "true" || {
echo "::error::$IMAGE is missing LABEL $KEEP_LABEL=true"
exit 1
}
- name: Push ${{ matrix.key }}
if: github.event_name != 'pull_request' && steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
docker push "$IMAGE"
echo "Pushed: $IMAGE"
# Always, including on failure: the throwaway tag carries the keep label,
# so the nightly prune will not reclaim it and a few skipped cleanups add
# up to gigabytes in the runner's store.
- name: Drop the pull-request image
if: always() && github.event_name == 'pull_request' && steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: docker rmi -f "$IMAGE" || true
+39 -2
View File
@@ -14,6 +14,8 @@ This is a home infrastructure deployment repository using Ansible for automated
- `make deploy TAGS=sometag` - Deploy only specific tagged tasks - `make deploy TAGS=sometag` - Deploy only specific tagged tasks
- `make deploy TARGET=specific-host` - Deploy to specific host instead of all - `make deploy TARGET=specific-host` - Deploy to specific host instead of all
- `make check` - Run deployment in dry-run mode showing potential changes - `make check` - Run deployment in dry-run mode showing potential changes
- `make deploy-labelprint` / `make check-labelprint` - Deploy (or dry-run) the label print proxy Pi. Separate inventory and playbook from the home server - see `ansible/roles/labelprint/README.md`
- `make bootfs BOOTFS=/Volumes/bootfs` - Render the label print proxy's cloud-init first-boot files onto a freshly imaged SD card
- `make vault` - Edit encrypted Ansible vault file - `make vault` - Edit encrypted Ansible vault file
- `make list-tags` - List all available Ansible tags - `make list-tags` - List all available Ansible tags
- `make list-tasks` - List all Ansible tasks - `make list-tasks` - List all Ansible tasks
@@ -26,6 +28,11 @@ The project uses Python virtualenv for dependency management:
- Makefile automatically creates `.venv/` and installs dependencies - Makefile automatically creates `.venv/` and installs dependencies
- Vault password is sourced from password manager via `.pass.sh` - Vault password is sourced from password manager via `.pass.sh`
### Git Workflow
- Work directly on `master` - no feature branches and no pull requests in this repo.
- Commit to `master` and push to `origin/master` when asked; don't create a branch first.
- Pushing to `master` also triggers `.gitea/workflows/ci-images.yml` (see Gitea Actions CI images below), which only rebuilds images whose inputs changed.
## Architecture ## Architecture
### Directory Structure ### Directory Structure
@@ -33,13 +40,17 @@ The project uses Python virtualenv for dependency management:
ansible/ ansible/
├── deploy.yml # Main playbook entry point (imports deploy_home.yml) ├── deploy.yml # Main playbook entry point (imports deploy_home.yml)
├── deploy_home.yml # Core playbook with role definitions ├── deploy_home.yml # Core playbook with role definitions
├── deploy_labelprint.yml # Label print proxy Pi (separate host, Debian)
├── inventories/home/ # Inventory configuration ├── inventories/home/ # Inventory configuration
├── inventories/labelprint/ # Label print proxy inventory
├── roles/ # Ansible roles organized by function ├── roles/ # Ansible roles organized by function
│ ├── common/ # Base system configuration │ ├── common/ # Base system configuration
│ ├── git/ # Git repository management │ ├── git/ # Git repository management
│ ├── podman/ # Container orchestration │ ├── podman/ # Container orchestration
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically) │ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
│ ├── github-actions/# CI/CD runner setup │ ├── github-actions/# CI/CD runner setup
│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL)
│ ├── gitea-actions/ # Gitea Actions runners + CI job images (see its README)
│ └── pihole/ # DNS filtering │ └── pihole/ # DNS filtering
└── vars/ └── vars/
└── vault.yml # Encrypted secrets └── vault.yml # Encrypted secrets
@@ -49,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
@@ -85,6 +96,7 @@ Tasks are tagged by service/component for selective deployment:
- `ddns` - Dynamic DNS tasks - `ddns` - Dynamic DNS tasks
- ~~`drone` - CI/CD tasks (decommissioned)~~ - ~~`drone` - CI/CD tasks (decommissioned)~~
- `hass` - Home Assistant tasks - `hass` - Home Assistant tasks
- `gitea-actions` - Gitea Actions runners and their CI job images
- Common infrastructure tags like `common`, `ssl` - Common infrastructure tags like `common`, `ssl`
## Configuration Files ## Configuration Files
@@ -96,13 +108,38 @@ Tasks are tagged by service/component for selective deployment:
## Target Environment ## Target Environment
- Single target host: `home.debyl.io` - Primary target host: `home.debyl.io`
- OS: Fedora (ansible_user: fedora) - OS: Fedora (ansible_user: fedora)
- Container runtime: Podman - Container runtime: Podman
- Web server: Caddy with automatic HTTPS and built-in security (replaced nginx + ModSecurity) - Web server: Caddy with automatic HTTPS and built-in security (replaced nginx + ModSecurity)
- All services accessible via HTTPS with automatic certificate renewal - All services accessible via HTTPS with automatic certificate renewal
- ~~CI/CD: Drone CI infrastructure completely decommissioned~~ - ~~CI/CD: Drone CI infrastructure completely decommissioned~~
### Label print proxy
- Second target host: `stickah.local` (Raspberry Pi 3B+, Raspberry Pi OS 64-bit,
apt not dnf) with a Phomemo PM246 4x6 label printer on USB
- Deployed only via `make deploy-labelprint` - it is in its own inventory so the
Fedora roles can never run against it
- The SD card image is written by hand with rpi-imager, but its cloud-init files
come from `make bootfs`, rendered from the same templates the role uses
- Login is `stickah` / `stickah` with SSH key auth preferred; password auth is a
deliberate LAN-only fallback so the box is never unreachable
- LAN-only (192.168.1.0/24), discovered over mDNS, no DNS record
- Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is
unreachable
### Gitea Actions CI images
The runner's job images (`gitea-ci`, `gitea-ci-espidf`, `gitea-ci-platformio`)
are built by `.gitea/workflows/ci-images.yml` and published to the Gitea
container registry under `git.debyl.io/gitbot/`. The `gitea-actions` role only
pulls them - do NOT add build steps back to it. To change an image, edit
`ansible/roles/gitea-actions/files/Containerfile.*` (or a version pin in that
role's `defaults/main.yml`) and push to master; CI rebuilds only what changed.
See `ansible/roles/gitea-actions/README.md` for the registry rationale, the
prune-exemption label, and the bootstrap path when CI itself cannot build.
### Remote SSH Commands for Service Users ### Remote SSH Commands for Service Users
The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH: The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
+22 -1
View File
@@ -21,6 +21,9 @@ VAULT_FILE=ansible/vars/vault.yml
# Variables # Variables
ANSIBLE_INVENTORY=ansible/inventories/home/hosts.yml ANSIBLE_INVENTORY=ansible/inventories/home/hosts.yml
# The label print proxy is a separate inventory and playbook: it is Debian, not
# Fedora, and shares none of the roles home.debyl.io runs.
ANSIBLE_INVENTORY_LABELPRINT=ansible/inventories/labelprint/hosts.yml
#SSH_KEY=${HOME}/.ssh/id_rsa_home_ansible #SSH_KEY=${HOME}/.ssh/id_rsa_home_ansible
# Default to all ansible tags to run (passed via 'make deploy TAGS=sometag') # Default to all ansible tags to run (passed via 'make deploy TAGS=sometag')
@@ -29,6 +32,9 @@ SKIP_TAGS?=none
TARGET?=all TARGET?=all
EXTRA_VARS?= EXTRA_VARS?=
# Mounted boot partition of the label print proxy's SD card (see `make bootfs`)
BOOTFS?=/Volumes/bootfs
${VENV}: ${VENV}:
python3 -m venv ${VENV} python3 -m venv ${VENV}
${VENV_BIN}/python3 -m pip install --upgrade pip ${VENV_BIN}/python3 -m pip install --upgrade pip
@@ -48,13 +54,28 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE}
touch $@ touch $@
# Linting # Linting
YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*') # .gitea/workflows is linted too: the CI-image workflow is as much part of the
# deployment as the roles it publishes for.
YAML_FILES=$(shell find ansible/ .gitea/ -name '*.yml' -not -name '*vault*')
SKIP_FILE=./.lint-vars.sh SKIP_FILE=./.lint-vars.sh
# Targets # Targets
deploy: ${ANSIBLE} ${VAULT_FILE} deploy: ${ANSIBLE} ${VAULT_FILE}
${ANSIBLE} --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy.yml ${ANSIBLE} --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy.yml
# Writes the Pi's cloud-init first-boot files onto a freshly imaged SD card.
bootfs: ${ANSIBLE} ${VAULT_FILE}
${ANSIBLE} --diff -i ${ANSIBLE_INVENTORY_LABELPRINT} --vault-password-file ${VAULT_PASS_FILE} -e "bootfs=${BOOTFS}" ansible/bootfs.yml
# Label print proxy (stickah.local). Override the address when the Pi has
# fallen back to its rescue AP:
# make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1"
deploy-labelprint: ${ANSIBLE} ${VAULT_FILE}
${ANSIBLE} --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY_LABELPRINT} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy_labelprint.yml
check-labelprint: ${ANSIBLE} ${VAULT_FILE}
${ANSIBLE} --check --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY_LABELPRINT} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy_labelprint.yml
list-tags: ${ANSIBLE} ${VAULT_FILE} list-tags: ${ANSIBLE} ${VAULT_FILE}
${ANSIBLE} --list-tags -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} ansible/deploy.yml ${ANSIBLE} --list-tags -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} ansible/deploy.yml
+106
View File
@@ -0,0 +1,106 @@
---
# Renders the Raspberry Pi's cloud-init first-boot files onto a freshly written
# SD card's boot partition:
#
# make bootfs BOOTFS=/Volumes/bootfs
#
# The image itself is still built by hand with rpi-imager -- this only writes
# the two cloud-init files onto it. Everything it writes comes from the same
# templates roles/labelprint uses, so the Pi boots with its Wi-Fi profiles and
# rescue access point already in place, and the first deploy has nothing to
# correct.
#
# The rendered files contain the Wi-Fi PSKs in the clear, as any Pi Wi-Fi setup
# does. They land on the SD card, never in this repo.
- hosts: localhost
gather_facts: false
connection: local
vars_files:
- vars/vault.yml
- roles/labelprint/defaults/main.yml
tasks:
- name: check that BOOTFS points at a Raspberry Pi boot partition
ansible.builtin.stat:
path: "{{ bootfs }}/config.txt"
register: labelprint_bootfs_check
tags: bootfs
- name: refuse to write to anything else
ansible.builtin.assert:
that: labelprint_bootfs_check.stat.exists
fail_msg: >-
{{ bootfs }} has no config.txt, so it is not a Raspberry Pi boot
partition. Write the image with rpi-imager first, then re-run with
BOOTFS pointing at the mounted boot volume.
tags: bootfs
- name: look for files rpi-imager already wrote
ansible.builtin.stat:
path: "{{ bootfs }}/{{ item }}"
register: labelprint_bootfs_existing
loop:
- user-data
- network-config
- cmdline.txt
tags: bootfs
# Keeps whatever rpi-imager put there, so a bad render can be undone by hand
# without reflashing. force:false means the first run's backup is the one
# that survives -- a second run must not overwrite it with our own output.
- name: back up the files rpi-imager wrote
ansible.builtin.copy:
src: "{{ bootfs }}/{{ item.item }}"
dest: "{{ bootfs }}/{{ item.item }}.rpi-imager.bak"
mode: "0644"
force: false
loop: "{{ labelprint_bootfs_existing.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
tags: bootfs
- name: render the cloud-init files
ansible.builtin.template:
src: "roles/labelprint/templates/bootfs/{{ item }}.j2"
dest: "{{ bootfs }}/{{ item }}"
mode: "0644"
loop:
- user-data
- network-config
tags: bootfs
- name: hash the rendered user-data
ansible.builtin.stat:
path: "{{ bootfs }}/user-data"
checksum_algorithm: sha1
register: labelprint_user_data_stat
tags: bootfs
- name: stamp the instance id with that hash
ansible.builtin.template:
src: roles/labelprint/templates/bootfs/meta-data.j2
dest: "{{ bootfs }}/meta-data"
mode: "0644"
vars:
labelprint_user_data_id: "{{ labelprint_user_data_stat.stat.checksum[:12] }}"
tags: bootfs
# rpi-imager writes `ds=nocloud;i=<id>` onto the kernel command line, and
# that id outranks the one in meta-data. Leave it alone and a card that has
# booted even once is seen by cloud-init as the same instance forever: it
# skips users, write_files and runcmd, silently, and the only symptom is a
# Pi that came up with none of this applied. Both places have to agree.
- name: pin the same instance id on the kernel command line
ansible.builtin.replace:
path: "{{ bootfs }}/cmdline.txt"
regexp: '(ds=nocloud[^\s]*?);i=[^\s]+'
replace: '\1;i={{ labelprint_hostname }}-{{ labelprint_user_data_stat.stat.checksum[:12] }}'
tags: bootfs
- name: what to do next
ansible.builtin.debug:
msg:
- "Wrote user-data, network-config, meta-data and cmdline.txt to {{ bootfs }}."
- "Instance id is {{ labelprint_hostname }}-{{ labelprint_user_data_stat.stat.checksum[:12] }}; the Pi re-applies this config whenever it changes."
- "Eject the volume, boot the Pi, then: make deploy-labelprint"
tags: bootfs
+14
View File
@@ -0,0 +1,14 @@
---
# Label print proxy (Raspberry Pi 3B+, Raspberry Pi OS trixie).
#
# The Pi image itself is built by hand with rpi-imager and written to the SD
# card outside of Ansible -- see roles/labelprint/README.md for what that image
# has to contain. Everything from first boot onwards lives in the labelprint
# role: the print driver, the CUPS queue, security updates, and the Wi-Fi
# rescue access point.
- hosts: labelprint
vars_files:
- vars/vault.yml
roles:
- role: labelprint
tags: labelprint
+16
View File
@@ -0,0 +1,16 @@
---
# The 4x6 label print proxy: a Raspberry Pi 3B+ with the Phomemo PM246 on USB,
# shared to the LAN over IPP/AirPrint. Deliberately a separate inventory from
# inventories/home: this host is Debian/apt and shares none of the Fedora roles
# that home.debyl.io runs, so `make deploy` can never reach it by accident.
#
# Reached by mDNS (avahi on the Pi, Bonjour/UDM Pro on the LAN) rather than by a
# DNS record. If the Pi has fallen back to its rescue access point, it is not on
# the LAN at all -- join the rescue SSID and deploy against 192.168.4.1 instead:
#
# make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1"
labelprint:
hosts:
stickah.local:
ansible_user: stickah
ansible_python_interpreter: /usr/bin/python3
@@ -0,0 +1,50 @@
"""Point HA's 9 TP-Link (Kasa) config entries at their new IoT VLAN addresses.
Run on galactica as the podman user while HA is STOPPED:
python3 /tmp/fix_tplink.py # dry run, prints what would change
python3 /tmp/fix_tplink.py apply # writes the change (refuses unless all 9 match)
Only data.host is changed; entries are matched by MAC (unique_id).
"""
import json
import os
import re
import sys
path = "/home/podman/.local/share/volumes/hass/config/.storage/core.config_entries"
new = {
"54af970a5d01": "192.168.2.220", # Printer Plug KP115
"788cb56955b4": "192.168.2.146", # Driveway String Lights HS200
"2887ba1bd687": "192.168.2.199", # Kitchen Wall Light KS230
"5ce9319dc5d6": "192.168.2.38", # Kitchen Lights HS220
"5ce9319da5e0": "192.168.2.200", # Dining Hall HS220
"5ce9319dd193": "192.168.2.159", # Dining Room HS220
"5ce931a23a6e": "192.168.2.61", # Bathroom Hallway HS220
"f0a731771227": "192.168.2.55", # Stair Light KS230
"788cb5694e4a": "192.168.2.252", # Bedroom Light HS200
}
apply = len(sys.argv) > 1 and sys.argv[1] == "apply"
with open(path) as f:
d = json.load(f)
n = 0
for e in d["data"]["entries"]:
if e.get("domain") != "tplink":
continue
mac = re.sub(r"[^0-9a-f]", "", (e.get("unique_id") or "").lower())
ip = new.get(mac)
print(f'{e["title"]:<36} host={e["data"].get("host")} -> {ip}')
if ip:
e["data"]["host"] = ip
n += 1
print("matched", n, "of 9")
if apply:
if n != 9:
sys.exit("refusing to write: not all 9 matched")
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(d, f, indent=4, ensure_ascii=False)
os.replace(tmp, path)
print("written")
+72
View File
@@ -0,0 +1,72 @@
---
# One-off: repoint Home Assistant's TP-Link (Kasa) config entries at their new
# addresses after the IoT WiFi moved from the Default network to the IoT VLAN
# (192.168.1.x -> 192.168.2.x, fixed IPs reserved in UniFi).
#
# HA 2026.9.3's tplink "Reconfigure" flow ignores the host entered in the form
# and reconnects to the stored one, so it cannot be used to change the address.
# Instead this edits data.host in .storage/core.config_entries while HA is
# stopped. Entries are matched by MAC (unique_id); nothing else is touched.
#
# Run:
# .venv/bin/ansible-playbook -i ansible/inventories/home/hosts.yml \
# ansible/oneoff/hass-tplink-rehost.yml
- name: Repoint HA tplink entries at the IoT VLAN
hosts: home.debyl.io
gather_facts: false
become: true
become_user: podman
vars:
hass_storage: /home/podman/.local/share/volumes/hass/config/.storage
hass_entries: "{{ hass_storage }}/core.config_entries"
tasks:
- name: Get podman uid for systemctl --user
ansible.builtin.command: id -u
register: podman_uid
changed_when: false
- name: Dry run - all 9 tplink entries must match before HA is stopped
ansible.builtin.script:
cmd: files/hass_tplink_rehost.py
executable: python3
register: dry_run
changed_when: false
failed_when: "'matched 9 of 9' not in dry_run.stdout"
- name: Show dry run
ansible.builtin.debug:
var: dry_run.stdout_lines
- name: Back up core.config_entries
ansible.builtin.copy:
src: "{{ hass_entries }}"
dest: "{{ hass_entries }}.bak-iot-vlan-20261001"
remote_src: true
mode: preserve
force: false
- name: Stop HA, rewrite hosts, start HA
environment:
XDG_RUNTIME_DIR: "/run/user/{{ podman_uid.stdout }}"
block:
- name: Stop hass
ansible.builtin.systemd:
name: hass.service
state: stopped
scope: user
- name: Rewrite tplink hosts
ansible.builtin.script:
cmd: files/hass_tplink_rehost.py apply
executable: python3
register: applied
- name: Show result
ansible.builtin.debug:
var: applied.stdout_lines
always:
- name: Start hass
ansible.builtin.systemd:
name: hass.service
state: started
scope: user
+9
View File
@@ -21,3 +21,12 @@ services:
- podman - podman
- fail2ban - fail2ban
- systemd-timesyncd - systemd-timesyncd
# Storage tuning for the SATA SSD (see tasks/service.yml and the udev template).
ssd_io_scheduler: mq-deadline
ssd_nr_requests: "256"
# 256 MB before background writeback starts, 1 GB before writers block. Absolute
# bytes rather than the default percent-of-RAM ratios, which scale to multi-GB
# stalls on a 31 GB host.
vm_dirty_background_bytes: "268435456"
vm_dirty_bytes: "1073741824"
+10
View File
@@ -26,3 +26,13 @@
ansible.builtin.systemd: ansible.builtin.systemd:
name: systemd-journald name: systemd-journald
state: restarted state: restarted
# --reload-rules alone only affects devices that appear later; the root disk is
# already attached, so trigger a change event to apply the rule now rather than
# at the next reboot.
- name: reload_udev
become: true
ansible.builtin.shell: |
udevadm control --reload-rules
udevadm trigger --subsystem-match=block --action=change
changed_when: true
+71
View File
@@ -23,6 +23,77 @@
notify: restart_journald notify: restart_journald
tags: security, service, journald tags: security, service, journald
# rsyslog wrote a second full copy of the journal to /var/log/messages. It
# loads imjournal, so it reads the journal directly and ForwardToSyslog=no
# alone does not stop it -- the unit itself has to go. Nothing consumes those
# files: fail2ban runs backend=systemd and matches on the journal
# (_SYSTEMD_UNIT=sshd.service, "No file is currently monitored"), and lsof
# showed only rsyslogd itself holding them open. The journal is capped and is
# the system of record, so this was pure write amplification: 6 GB of rotated
# copies, two weekly files of which were 2.8 GB and 2.5 GB.
# Masked rather than merely disabled so a dependency cannot pull it back in.
- name: disable rsyslog, which duplicated the journal to /var/log/messages
become: true
ansible.builtin.systemd:
name: rsyslog
state: stopped
enabled: false
masked: true
tags: security, service, journald
- name: find the syslog copies rsyslog left behind
become: true
ansible.builtin.find:
paths: /var/log
patterns: "messages*,secure*,cron*,maillog*"
register: syslog_leftovers
tags: security, service, journald
- name: reclaim the syslog copies
become: true
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ syslog_leftovers.files }}"
loop_control:
label: "{{ item.path }}"
tags: security, service, journald
# Storage tuning for the SATA SSD. See the template for why bfq is wrong here.
- name: use an SSD-appropriate I/O scheduler and queue depth
become: true
ansible.builtin.template:
src: 60-ssd-scheduler.rules.j2
dest: /etc/udev/rules.d/60-ssd-scheduler.rules
owner: root
group: root
mode: 0644
notify: reload_udev
tags: security, service, storage
# Writeback was left at the defaults, which are ratios of RAM: dirty_ratio=20
# and dirty_background_ratio=10 on 31 GB means the kernel will sit on up to
# 3.1 GB before it starts writing back and 6.2 GB before it blocks writers
# outright. Flushing that much at once to a QLC SATA drive -- which falls to
# roughly 80-160 MB/s once its SLC cache is spent -- takes tens of seconds, and
# everything else stalls behind it. Capping the dirty set in absolute bytes
# instead trades one long stall for frequent short ones, which is what keeps
# CI and the databases responsive.
- name: bound writeback so a flush cannot stall the box
become: true
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_set: true
state: present
reload: true
loop:
- { name: vm.dirty_background_bytes, value: "{{ vm_dirty_background_bytes }}" }
- { name: vm.dirty_bytes, value: "{{ vm_dirty_bytes }}" }
loop_control:
label: "{{ item.name }}={{ item.value }}"
tags: security, service, storage
- name: ensure desired services are started and enabled - name: ensure desired services are started and enabled
become: true become: true
ansible.builtin.service: ansible.builtin.service:
@@ -0,0 +1,18 @@
# {{ ansible_managed }}
# Fedora's /usr/lib/udev/rules.d/60-block-scheduler.rules picks bfq for every
# rotational=0 SATA disk. bfq is a fairness scheduler built for spinning disks
# and desktop interactivity: it costs real CPU per request and, with 26
# containers plus two CI runners all writing at once, it lets reads queue
# behind write bursts. That is the shape of the stalls seen here -- I/O
# pressure spiking to 76% while the CPU sat nearly idle.
#
# mq-deadline instead of none: this is a SATA SSD with a 32-deep NCBQ queue,
# not an NVMe device with its own deep queues, so the request merging and the
# read-expiry deadline are both worth having. The deadline is what stops reads
# starving behind a QLC write burst.
#
# bfq also hard-caps nr_requests at 64; mq-deadline allows a deeper queue,
# which is what lets concurrent container and CI I/O actually overlap.
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="0", \
ATTR{queue/scheduler}="{{ ssd_io_scheduler }}", \
ATTR{queue/nr_requests}="{{ ssd_nr_requests }}"
@@ -20,3 +20,9 @@
{% endif %} {% endif %}
[Journal] [Journal]
SystemMaxUse={{ journald_max_use | default('500M') }} SystemMaxUse={{ journald_max_use | default('500M') }}
# Nothing should be forwarded to syslog: rsyslog is disabled (see
# tasks/service.yml) because it duplicated the whole journal into
# /var/log/messages. This also closes the imuxsock path so anything that
# logs via logger(1) still lands in the journal and nowhere else.
ForwardToSyslog=no
+3 -1
View File
@@ -4,9 +4,11 @@ git_home: "/srv/{{ git_user }}"
# Gitea configuration # Gitea configuration
gitea_debyl_server_name: git.debyl.io gitea_debyl_server_name: git.debyl.io
gitea_image: docker.gitea.com/gitea:1.26.1 # Pinned per instance so one can be upgraded (and verified) before the other.
gitea_debyl_image: docker.gitea.com/gitea:1.27.3
gitea_db_image: docker.io/library/postgres:14-alpine gitea_db_image: docker.io/library/postgres:14-alpine
# Skudak Gitea configuration # Skudak Gitea configuration
gitea_skudak_server_name: git.skudak.com gitea_skudak_server_name: git.skudak.com
gitea_skudak_ssh_port: 2222 gitea_skudak_ssh_port: 2222
gitea_skudak_image: docker.gitea.com/gitea:1.27.3
+1 -1
View File
@@ -43,7 +43,7 @@
become_user: "{{ git_user }}" become_user: "{{ git_user }}"
containers.podman.podman_container: containers.podman.podman_container:
name: gitea-skudak name: gitea-skudak
image: "{{ gitea_image }}" image: "{{ gitea_skudak_image }}"
pod: gitea-skudak-pod pod: gitea-skudak-pod
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
+6 -6
View File
@@ -10,7 +10,7 @@
state: started state: started
ports: ports:
- "3100:3000" - "3100:3000"
tags: gitea tags: gitea, gitea-debyl
# PostgreSQL container in pod # PostgreSQL container in pod
- name: create gitea-debyl-postgres container - name: create gitea-debyl-postgres container
@@ -28,7 +28,7 @@
POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}" POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}"
volumes: volumes:
- "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data" - "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data"
tags: gitea tags: gitea, gitea-debyl
# Gitea container in pod # Gitea container in pod
- name: create gitea-debyl container - name: create gitea-debyl container
@@ -36,7 +36,7 @@
become_user: "{{ git_user }}" become_user: "{{ git_user }}"
containers.podman.podman_container: containers.podman.podman_container:
name: gitea-debyl name: gitea-debyl
image: "{{ gitea_image }}" image: "{{ gitea_debyl_image }}"
pod: gitea-debyl-pod pod: gitea-debyl-pod
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
@@ -66,7 +66,7 @@
volumes: volumes:
- "{{ git_home }}/volumes/gitea/data:/data" - "{{ git_home }}/volumes/gitea/data:/data"
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
tags: gitea tags: gitea, gitea-debyl
# Generate systemd service for the pod # Generate systemd service for the pod
- name: create systemd job for gitea-debyl-pod - name: create systemd job for gitea-debyl-pod
@@ -80,7 +80,7 @@
args: args:
chdir: "{{ git_home }}" chdir: "{{ git_home }}"
changed_when: false changed_when: false
tags: gitea tags: gitea, gitea-debyl
- name: enable gitea-debyl-pod service - name: enable gitea-debyl-pod service
become: true become: true
@@ -91,4 +91,4 @@
enabled: true enabled: true
state: started state: started
scope: user scope: user
tags: gitea tags: gitea, gitea-debyl
+96
View File
@@ -0,0 +1,96 @@
# gitea-actions
Runs the Gitea Actions runners on `home.debyl.io`. One `act_runner` process per
Gitea instance (`git.debyl.io`, `git.skudak.com`), both as the `gitea-runner`
user, both backed by the same rootless podman image store.
## CI job images
Jobs do not run on the host. Each one gets an ephemeral container from one of
three images:
| `runs-on` / `container:` | Image | Used by |
| --- | --- | --- |
| `fedora`, `ubuntu-latest`, `ubuntu-22.04` | `git.debyl.io/gitbot/gitea-ci:latest` | Go / node / web jobs, `docker build` |
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-espidf:<esp_idf_version>` | esp-mg-tpms, skudak/esp32-stm32-vcu |
| `container: image:` | `git.debyl.io/gitbot/gitea-ci-platformio:<pio_espressif32_version>` | skudak/esp32-web-interface |
**This role does not build them.** `.gitea/workflows/ci-images.yml` builds
`files/Containerfile.*` and pushes to the Gitea registry; the role logs
`gitea-runner` in and pulls. Version pins live in `defaults/main.yml` and are
read by both the role and the workflow, so a bump moves the image tag in one
place.
### Why the registry
The images used to exist only as `localhost/gitea-ci*` in the runner's store.
The nightly prune (`roles/podman`, `podman_prune_ci_until: 48h`) deletes any
CI-user image older than that which no container holds, so after an idle
weekend every job failed in under a second on `docker pull
localhost/gitea-ci:latest`, and the only fix was re-running this role and
waiting out a full rebuild.
Two things now keep that from happening:
- **A registry copy.** `force_pull` stays `false`, which in act_runner means
*pull only when missing* — so a present image is never re-fetched, and a
pruned one is restored by the next job without anyone noticing.
- **A prune exemption.** Each Containerfile declares
`LABEL io.debyl.ci-base="true"`, and the prune skips that label
(`podman_prune_ci_keep_label`). Its `until` counts from build time, not pull
time, so without this a re-pulled image would be deleted again the same night
— a 7.8 GB ESP-IDF download every single day.
The label is declared in the Containerfile rather than passed as `--label` so
neither builder can omit it; the workflow re-checks it with `docker inspect`
before pushing.
### Authentication
Both the role and act_runner read `/home/gitea-runner/.docker/config.json`.
act_runner uses it for the job-image pull it performs when a label's image is
missing; podman falls back to the same file. The role writes it from
`gitea_registry_username` / `gitea_registry_token` (vault), so one login covers
both. The `skudak` runner pulls from `git.debyl.io` too — same host, same user,
same file.
The workflow pushes with a `REGISTRY_TOKEN` secret on `bastian/deploy_home`,
belonging to the same `gitbot` user: Gitea authorises a package push by the
token's owner, not by the path, so pushing to `gitbot/` means logging in as
`gitbot`.
Both tokens need the `write:package` scope, not just `read:package`: the
workflow pushes with `REGISTRY_TOKEN`, and the `gitea_ci_build_local` bootstrap
below pushes with the vault token. A read-only token logs in and pulls fine but
fails the push with `authentication required` (Gitea logs `reqPackageAccess`).
### Rebuilding
Normally nothing to do — edit a `files/Containerfile.*` or a version pin, push
to `master`, and the workflow rebuilds only the affected images. It also
rebuilds everything weekly so base-image updates land without a commit, and
takes a `workflow_dispatch` with an image selector.
Pull requests build but do not push, under a throwaway `:pr-<n>` tag that is
deleted afterwards. The build runs in the live runner's image store, so a PR
tagged with the real name would hand every later job on this host an unmerged
image.
### Bootstrap / CI is down
The workflow that builds `gitea-ci` runs *in* `gitea-ci`, so a registry that has
never held it cannot bootstrap itself. Build on the host instead:
```sh
make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
```
That builds all three from the same Containerfiles and pushes them. One run is
enough even on a cold registry: `tasks/main.yml` imports `images.yml` before
`runner.yml`, so the images are published before the runner labels are flipped
to point at them.
The alternative first-time path is to merge the workflow and dispatch it while
the deployed labels still say `localhost/` — the job then builds inside the old
local image and seeds the registry — then run a plain
`make deploy TAGS=gitea-actions` to switch the labels over.
+57 -7
View File
@@ -22,20 +22,70 @@ act_runner_bin: /usr/local/bin/act_runner
act_runner_config_dir: /etc/act_runner act_runner_config_dir: /etc/act_runner
act_runner_work_dir: /var/lib/act_runner act_runner_work_dir: /var/lib/act_runner
# Job container images (built locally into the gitea-runner rootless image # Job container images, served from the Gitea container registry.
# store by tasks/images.yml; never pulled — force_pull is false). #
gitea_ci_image: localhost/gitea-ci:latest # They used to live only under localhost/, built by this role. The nightly
# podman prune (roles/podman: podman_prune_ci_until) deletes any CI-user image
# older than 48h that no container is using, so every idle weekend CI failed in
# 0-1s on `docker pull localhost/gitea-ci:latest` until someone re-ran the role
# and waited out a full rebuild.
#
# Now .gitea/workflows/ci-images.yml builds them from files/Containerfile.* and
# pushes them here, and this role only pulls. A pruned image is re-pulled by the
# next job on its own (force_pull stays false, which means "pull only when
# missing", so a present image is never re-fetched).
#
# Workflows that pin `container: image:` must use these registry paths too
# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface).
gitea_ci_registry: git.debyl.io
# Namespace = the owner of gitea_registry_username / gitea_registry_token (vault).
gitea_ci_registry_namespace: gitbot
gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest"
# ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from. # ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from.
esp_idf_version: v5.4.1 esp_idf_version: v5.5.1
gitea_ci_espidf_image: "localhost/gitea-ci-espidf:{{ esp_idf_version }}" gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}"
# PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface). # PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface).
# Tag tracks the pre-baked espressif32 platform version; both pins match the # Tag tracks the pre-baked espressif32 platform version; both pins match the
# hardware-validated local build. # hardware-validated local build.
platformio_core_version: "6.1.19" platformio_core_version: "6.1.19"
pio_espressif32_version: "7.0.1" pio_espressif32_version: "7.0.1"
gitea_ci_platformio_image: "localhost/gitea-ci-platformio:{{ pio_espressif32_version }}" gitea_ci_platformio_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-platformio:{{ pio_espressif32_version }}"
# Default labels for every runner — map runs-on values to the local CI image. # Registry credentials for the gitea-runner user. The Docker-format path is read
# by both act_runner (to authenticate job image pulls) and podman (as its
# fallback auth file), so one login covers the runner and this role.
gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json"
# The images this role keeps present on the runner. `build_args` is a literal
# podman-build argument string (podman_image has no structured build-arg
# option) and is only used by the gitea_ci_build_local fallback below -- the
# workflow passes the same --build-arg values, read out of the version vars
# above, so there is one source of truth for the pins.
gitea_ci_images:
- image: "{{ gitea_ci_image }}"
containerfile: Containerfile.ci
build_args: ""
- image: "{{ gitea_ci_espidf_image }}"
containerfile: Containerfile.espidf
build_args: "--build-arg ESP_IDF_VERSION={{ esp_idf_version }}"
- image: "{{ gitea_ci_platformio_image }}"
containerfile: Containerfile.platformio
build_args: >-
--build-arg PLATFORMIO_CORE_VERSION={{ platformio_core_version }}
--build-arg PIO_ESPRESSIF32_VERSION={{ pio_espressif32_version }}
# Escape hatch: build the images on the host and push them, instead of pulling
# what CI published. Needed to seed a brand-new registry namespace, and when CI
# itself is down -- the workflow that builds gitea-ci runs *in* gitea-ci, so a
# registry that has never held it cannot bootstrap itself.
#
# make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
#
# Off by default: a plain deploy should never sit through a 15-minute ESP-IDF
# rebuild, and two publishers racing on the same tag is worth avoiding.
gitea_ci_build_local: false
# Default labels for every runner — map runs-on values to the registry CI image.
# Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow. # Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow.
gitea_runner_labels: gitea_runner_labels:
- "fedora:docker://{{ gitea_ci_image }}" - "fedora:docker://{{ gitea_ci_image }}"
@@ -1,8 +1,18 @@
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions). # Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
# Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless # Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless
# podman socket). Go toolchains are provided per-job by actions/setup-go. # podman socket). Go toolchains are provided per-job by actions/setup-go.
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result (see gitea_ci_build_local for the local-build fallback).
# A plain Containerfile, not a template, so CI and ansible build the same bytes.
FROM node:20-bookworm-slim FROM node:20-bookworm-slim
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ARG DOCKER_CLI_VERSION=27.3.1 ARG DOCKER_CLI_VERSION=27.3.1
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -14,7 +14,19 @@
# the release aborts *after* the firmware and version.json are already live — # the release aborts *after* the firmware and version.json are already live —
# clients get the new build while the tag, Gitea release and protocol manifest # clients get the new build while the tag, Gitea release and protocol manifest
# are never written. Keep it installed. # are never written. Keep it installed.
FROM espressif/idf:{{ esp_idf_version }} #
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. ESP_IDF_VERSION is a build arg rather than an ansible
# template var so CI and ansible build the same bytes -- its value is read from
# esp_idf_version in roles/gitea-actions/defaults/main.yml by both.
ARG ESP_IDF_VERSION
FROM espressif/idf:${ESP_IDF_VERSION}
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls 7.8 GB.
LABEL io.debyl.ci-base="true"
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates unzip jq python3-yaml python3-jinja2 \ curl ca-certificates unzip jq python3-yaml python3-jinja2 \
@@ -8,8 +8,23 @@
# was validated on hardware — bump pio_espressif32_version / # was validated on hardware — bump pio_espressif32_version /
# platformio_core_version in defaults/main.yml to upgrade (the image tag # platformio_core_version in defaults/main.yml to upgrade (the image tag
# tracks the platform version). # tracks the platform version).
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. The pins are build args rather than ansible template
# vars so CI and ansible build the same bytes -- their values are read from
# platformio_core_version / pio_espressif32_version in
# roles/gitea-actions/defaults/main.yml by both.
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
ARG PLATFORMIO_CORE_VERSION
ARG PIO_ESPRESSIF32_VERSION
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ENV PLATFORMIO_CORE_DIR=/opt/platformio ENV PLATFORMIO_CORE_DIR=/opt/platformio
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -18,12 +33,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio=={{ platformio_core_version }} RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION}
# Seed project mirroring the real projects' platformio.ini so `pio pkg install` # Seed project mirroring the real projects' platformio.ini so `pio pkg install`
# pulls the platform + toolchain + framework packages into the core dir. # pulls the platform + toolchain + framework packages into the core dir.
# %s + a quoted argument, not ${...} inside the single-quoted format string:
# RUN is `sh -c`, and sh does not expand inside single quotes, so an inlined
# ${PIO_ESPRESSIF32_VERSION} would be written to platformio.ini literally.
RUN mkdir -p /tmp/seed/src \ RUN mkdir -p /tmp/seed/src \
&& printf '[env:seed]\nplatform = espressif32@{{ pio_espressif32_version }}\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' > /tmp/seed/platformio.ini \ && printf '[env:seed]\nplatform = espressif32@%s\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' "${PIO_ESPRESSIF32_VERSION}" > /tmp/seed/platformio.ini \
&& pio pkg install -d /tmp/seed \ && pio pkg install -d /tmp/seed \
&& pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \ && pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \
&& rm -rf /tmp/seed \ && rm -rf /tmp/seed \
+70 -55
View File
@@ -1,4 +1,51 @@
--- ---
# CI job images. .gitea/workflows/ci-images.yml builds files/Containerfile.*
# and pushes them to the Gitea registry; this role only logs the runner in and
# makes sure the images are present, so a plain deploy never waits on a build.
#
# Set gitea_ci_build_local=true to build and push from here instead -- see the
# comment on that variable in defaults/main.yml.
- name: create gitea-runner registry auth directory
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.file:
path: "{{ gitea_ci_registry_authfile | dirname }}"
state: directory
mode: "0700"
tags: gitea-actions
# Docker-format path on purpose: act_runner reads ~/.docker/config.json to
# authenticate the job-image pull it does when a label's image is missing, and
# podman falls back to the same file. One login covers both.
- name: log gitea-runner in to the Gitea container registry
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_login:
registry: "{{ gitea_ci_registry }}"
username: "{{ gitea_registry_username }}"
password: "{{ gitea_registry_token }}"
authfile: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
no_log: true
tags: gitea-actions
- name: pull CI images from the registry
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ item.image }}"
auth_file: "{{ gitea_ci_registry_authfile }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.image }}"
when: not (gitea_ci_build_local | bool)
tags: gitea-actions
# --- local build fallback (gitea_ci_build_local=true) ------------------------
# Only reached when seeding a new namespace or when CI cannot build for us.
- name: create CI image build directory - name: create CI image build directory
become: true become: true
become_user: "{{ gitea_runner_user }}" become_user: "{{ gitea_runner_user }}"
@@ -6,73 +53,41 @@
path: "{{ gitea_runner_home }}/ci-images" path: "{{ gitea_runner_home }}/ci-images"
state: directory state: directory
mode: "0755" mode: "0755"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
- name: stage default CI Containerfile # copy, not template: these are plain Containerfiles that CI builds verbatim.
# Versions come in as --build-arg from the same defaults/main.yml the workflow
# reads, so neither builder can drift from the other.
- name: stage CI Containerfiles
become: true become: true
become_user: "{{ gitea_runner_user }}" become_user: "{{ gitea_runner_user }}"
ansible.builtin.template: ansible.builtin.copy:
src: Containerfile.ci src: "{{ item.containerfile }}"
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.ci" dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
mode: "0644" mode: "0644"
register: ci_containerfile loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.containerfile }}"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
- name: stage ESP-IDF CI Containerfile - name: build and push CI images
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.espidf.j2
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
mode: "0644"
register: espidf_containerfile
tags: gitea-actions
- name: build default CI image ({{ gitea_ci_image }})
become: true become: true
become_user: "{{ gitea_runner_user }}" become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image: containers.podman.podman_image:
name: "{{ gitea_ci_image }}" name: "{{ item.image }}"
path: "{{ gitea_runner_home }}/ci-images" path: "{{ gitea_runner_home }}/ci-images"
build: build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.ci" file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
force: "{{ ci_containerfile is changed }}" extra_args: "{{ item.build_args }}"
environment: force: true
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" push: true
tags: gitea-actions auth_file: "{{ gitea_ci_registry_authfile }}"
- name: stage PlatformIO CI Containerfile
become: true
become_user: "{{ gitea_runner_user }}"
ansible.builtin.template:
src: Containerfile.platformio.j2
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio"
mode: "0644"
register: platformio_containerfile
tags: gitea-actions
- name: build ESP-IDF CI image ({{ gitea_ci_espidf_image }})
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_espidf_image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
force: "{{ espidf_containerfile is changed }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
tags: gitea-actions
- name: build PlatformIO CI image ({{ gitea_ci_platformio_image }})
become: true
become_user: "{{ gitea_runner_user }}"
containers.podman.podman_image:
name: "{{ gitea_ci_platformio_image }}"
path: "{{ gitea_runner_home }}/ci-images"
build:
file: "{{ gitea_runner_home }}/ci-images/Containerfile.platformio"
force: "{{ platformio_containerfile is changed }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
loop: "{{ gitea_ci_images }}"
loop_control:
label: "{{ item.image }}"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
+162
View File
@@ -0,0 +1,162 @@
# labelprint — 4x6 label print proxy
A Raspberry Pi 3B+ (`stickah.local`) with a **Phomemo PM246** on USB, shared to
the LAN so any Mac, Windows or Linux machine can print 4x6 labels without
installing a printer driver. Built for
[`fulfillr-site`](../../../../debyltech/fulfillr-site), which prints shipping
labels straight out of the browser, but the queue is a plain 4x6 label printer
and anything can use it.
Deployed on its own:
```
make deploy-labelprint
make check-labelprint # dry run
make deploy-labelprint TAGS=cups # just the queue
```
## Why not the Phomemo driver
The vendor driver that runs on `yoga` installs
`/usr/lib/cups/filter/rastertolabeltspl`, and that file is an **x86-64 ELF**.
There is no ARM build, so none of it can be reused on the Pi.
The PM246 speaks TSPL over USB, so this role builds
[RunTheWall/tspl-cups-driver](https://github.com/RunTheWall/tspl-cups-driver)
(MIT) from a pinned commit instead: a CUPS raster filter, a backend and a PPD,
about a minute of compiling on the Pi. Pinned to a commit rather than installed
from the project's apt repo so that a version bump is a reviewed change in this
repo, and so the Pi carries no third-party signing key.
The PM246's USB id is not in the driver's auto-detect list. `tspl://auto` is
tried first; if the queue cannot find the printer, read the id off the Pi and
pin it — see `labelprint_device_uri` in [defaults/main.yml](defaults/main.yml).
## The image
The SD card image is built by hand, but the cloud-init files on it are not:
`make bootfs` renders them from the same templates the role uses, so the Pi
boots with its Wi-Fi profiles, its rescue access point and its login already in
place. Nothing here needs the printer driver — that is Ansible's job.
1. **Write the image** with rpi-imager: **Raspberry Pi OS (64-bit)**, Bookworm or
newer, so NetworkManager is the network stack. Verified against the
`2026-06-18` pi-gen build. Skip the customisation screen entirely — anything
set there is about to be overwritten.
2. **Render the boot files** onto the mounted boot partition:
```
make bootfs # defaults to /Volumes/bootfs
make bootfs BOOTFS=/path/to/boot
```
It refuses to write anywhere without a `config.txt`, and keeps whatever
rpi-imager wrote as `*.rpi-imager.bak`.
3. **Eject**, boot the Pi, and give cloud-init a couple of minutes.
4. `make deploy-labelprint`.
`make bootfs` is safe to re-run. The instance id is a hash of the rendered
`user-data`, so an unchanged render leaves the card alone, and a changed one
makes the Pi re-apply it on the next boot.
That id is written in **two** places, and they have to agree: `meta-data`, and
the `ds=nocloud;i=<id>` token rpi-imager puts on the kernel command line in
`cmdline.txt`. The command line wins. Leave rpi-imager's id there and a card
that has booted even once looks like the same instance to cloud-init forever —
it skips `users`, `write_files` and `runcmd` without a word, and the only
symptom is a Pi that came up with none of this applied. `make bootfs` rewrites
both.
To force a re-apply on a Pi that is already running, without pulling the card:
```
sudo sed -i 's/;i=[^ ]*/;i=<new-id>/' /boot/firmware/cmdline.txt
sudo cloud-init clean --logs
sudo reboot
```
### Getting in
- `ssh stickah@stickah.local` — your `~/.ssh/id_ed25519.pub` is installed, and
that is what Ansible uses.
- Password auth is **on**, with the password `stickah`. It is deliberately
trivial and deliberately not hashed: this is the fallback for a Pi that will
not take the key — reflashed card, someone else's laptop, standing at the
bench — and port 22 is reachable only from the LAN or from the rescue AP,
which has a real WPA2 password of its own. If that trade stops being the right
one, `labelprint_user_password` in [defaults/main.yml](defaults/main.yml) is
the only thing to change.
## Wi-Fi and the rescue AP
Normally the Pi is a station on the home SSID. When that network is
unreachable — the password changed, the router died, the Pi moved — the
`wifi-rescue` watchdog brings up an access point so there is still a way in:
- Join the rescue SSID (both it and its password are in the vault).
- The Pi is at **192.168.4.1**: `ssh pi@192.168.4.1`, or print directly to
`ipp://192.168.4.1:631/printers/labels`.
- Deploy to it there with
`make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1"`.
The Pi also keeps the Wi-Fi profile netplan rendered from the image's original
`network-config`, at a lower autoconnect priority than `home-wifi`. It is a
deliberate fallback: if `home-wifi` is ever rendered wrong, the Pi still comes
back on the LAN rather than stranding itself on the rescue AP.
The 3B+ has one radio and cannot hold an AP and a station link at the same
time, so the watchdog cannot listen for the home SSID while the AP is up.
Instead the AP drops every five minutes to scan -- about seven seconds if the
home SSID is plainly gone, up to half a minute if it is worth a join attempt --
and comes back immediately if the home network is still missing. When the home SSID
does return, the Pi rejoins it and shuts the AP down by itself.
If you are working over the rescue AP and do not want your session cut:
```
touch /run/wifi-rescue.hold
```
The hold expires after 30 minutes, so a forgotten hold file cannot strand the
Pi. Deploys take the hold automatically for as long as they run.
Watch it work with `journalctl -t wifi-rescue -f`.
## Adding the printer from a client
Nothing to install anywhere — the Pi renders.
- **macOS** — System Settings → Printers → `+`. It appears under its own name
as an **AirPrint** printer. (`BrowseDNSSDSubTypes _print,_universal` in
cupsd.conf is what makes macOS offer the driverless add instead of guessing at
Generic PostScript.)
- **Windows 10/11** — Add printer; it is discovered as IPP Everywhere / Mopria.
- **Linux** — discovered by `cups-browsed`, or add
`ipp://stickah.local:631/printers/labels` by hand.
## Access
LAN only, `192.168.1.0/24`, plus the rescue AP subnet. Enforced twice on
purpose: at nftables ([templates/nftables.conf.j2](templates/nftables.conf.j2))
and again in cupsd's own `Location` blocks
([templates/cupsd.conf.j2](templates/cupsd.conf.j2)). CUPS is explicitly set
`--no-remote-any`; the upstream driver's `install.sh` turns that on, which would
offer this printer to anything that can route to the Pi.
The Pi patches itself: `unattended-upgrades` is on, with the Raspberry Pi
archives added to the origins allowlist (Debian's default covers only the Debian
security origin, which would leave the kernel and firmware — the packages most
specific to this hardware — unpatched). Kernel updates reboot at 04:00. Nothing
here holds state across a reboot; a queued job is spooled to disk and resumes.
## Secrets
In `ansible/vars/vault.yml` (`make vault`), no `vault_` prefix, per repo
convention:
| Key | What |
| --- | --- |
| `stickah_ssid` | Home SSID |
| `stickah_psk` | Home passphrase, or the 64-hex precomputed PSK |
| `stickah_ssid_rescue` | Rescue AP SSID |
| `stickah_psk_rescue` | Rescue AP passphrase (WPA2, 8+ characters) |
+118
View File
@@ -0,0 +1,118 @@
---
# ---------------------------------------------------------------------------
# Identity
# ---------------------------------------------------------------------------
# Reached as stickah.local. There is no DNS record for it: the UDM Pro passes
# mDNS across the LAN, so avahi on the Pi is the whole of the name service.
labelprint_hostname: stickah
# The login the image creates and Ansible connects as. Password auth stays on
# with a trivial, documented password: this box has to be reachable when the SSH
# key is not an option -- a reflashed card, a different laptop, someone standing
# at the workshop bench -- and it is only reachable from the LAN or from its own
# rescue AP in the first place. The key is what Ansible actually uses.
labelprint_user: stickah
labelprint_user_password: stickah
labelprint_authorized_key_file: ~/.ssh/id_ed25519.pub
# ---------------------------------------------------------------------------
# Printer
# ---------------------------------------------------------------------------
# Phomemo PM246, 4x6 direct thermal, 203 dpi, speaks TSPL over USB.
labelprint_queue: labels
labelprint_queue_info: 4x6 Label Printer (Phomemo PM246)
labelprint_queue_location: stickah
# The tspl backend finds the printer's usblp node by USB id. "auto" matches only
# the ids the driver already knows, and the PM246 is not yet one of them -- so if
# a deploy leaves the queue unable to find the printer, read the id off the Pi:
#
# for n in /dev/usb/lp*; do udevadm info -q property -n "$n" | grep -E 'ID_(VENDOR|MODEL)_ID|ID_SERIAL_SHORT'; done
#
# and pin it here as tspl://<vid>-<pid> (a DASH, not a colon: CUPS parses ":pid"
# as a port number and rejects the URI), or as tspl:///dev/usb/lp0.
labelprint_device_uri: "tspl://auto"
# 203dpi matches the PM246 head. The PPD defaults to 300dpi, which would render
# every label at ~2/3 scale on this printer.
labelprint_resolution: 203dpi
labelprint_media: na_index-4x6_4x6in
# Cut every other media size out of the PPD, so 4x6 is the only paper a client
# can pick. Driverless clients build their own PPD from the IPP media-supported
# list cupsd derives from ours, and there is no lpadmin option that restricts
# that list -- trimming the PPD is the only lever.
#
# The trade is real: the driver's PPD also offers 100x150mm, 4x4, 2.25x1.5, 2x1
# and a custom range, and this takes all of them away. Set false if you ever
# want this queue to run stock other than 4x6; a second queue off the untrimmed
# PPD is the better answer if you want both.
labelprint_media_only_4x6: true
# The PPD page-size keyword kept when the above is on. Pairs with
# labelprint_media, which is the same size under its IPP name.
labelprint_ppd_pagesize: w288h432
# 0-15. 8 is the driver's default and a sane starting point for the cheap
# thermal stock; raise it if barcodes scan poorly, lower it if edges bleed.
labelprint_darkness: 8
# in/sec x10. 40 = 4 in/sec.
labelprint_print_speed: 40
# ---------------------------------------------------------------------------
# Driver: RunTheWall/tspl-cups-driver (MIT)
# ---------------------------------------------------------------------------
# Built from source at a pinned commit rather than installed from the project's
# apt repo: this keeps a third-party signing key and package feed off the Pi,
# and makes the version we run a reviewed, deliberate bump in git history.
#
# The vendor Phomemo driver is not an option here -- its rastertolabeltspl
# filter ships as an x86-64 ELF only, and this host is aarch64.
labelprint_driver_repo: https://github.com/RunTheWall/tspl-cups-driver.git
labelprint_driver_version: f433b7774d80a4f6a901b6b998cb710fd79918a4
labelprint_driver_src: /usr/local/src/tspl-cups-driver
labelprint_ppd_dir: /usr/share/ppd/tspl
# The PPD the queue is actually built from.
labelprint_ppd_active: >-
{{ labelprint_ppd_dir }}/{{
'tspl-label-4x6.ppd' if labelprint_media_only_4x6 else 'tspl-label.ppd'
}}
# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------
# The only subnet allowed to reach CUPS. Everything else is dropped at nftables
# and refused again by cupsd's own access rules.
labelprint_lan_cidr: 192.168.1.0/24
# Rescue access point, brought up when the home SSID is unreachable. The Pi 3B+
# has a single radio and cannot hold an AP and a station link at once, so this
# is strictly a fallback -- see templates/wifi-rescue.sh.j2.
labelprint_ap_addr: 192.168.4.1
labelprint_ap_cidr: 192.168.4.0/24
# How often the watchdog checks, and how long the AP stays up before it drops
# for a few seconds to scan for the home SSID again.
labelprint_watchdog_interval_secs: 60
labelprint_ap_rescan_secs: 300
# How long a hand-placed /run/wifi-rescue.hold pins the radio before the
# watchdog ignores it. Bounded so a forgotten hold file cannot strand the Pi.
labelprint_hold_max_age_secs: 1800
# ---------------------------------------------------------------------------
# Packages
# ---------------------------------------------------------------------------
labelprint_deps:
[
avahi-daemon,
build-essential,
cups,
cups-filters,
dnsmasq-base,
git,
libcups2-dev,
network-manager,
nftables,
unattended-upgrades,
]
# Secrets live in ansible/vars/vault.yml (no vault_ prefix, per repo
# convention): stickah_ssid, stickah_psk,
# stickah_ssid_rescue, stickah_psk_rescue
@@ -0,0 +1,25 @@
# Cuts every media size except one out of a CUPS PPD.
#
# macOS and Windows add this printer driverless: they never see this PPD, they
# see the IPP media-supported list cupsd derives from it. Trimming here is
# therefore the only way to make 4x6 the single choice a client can offer --
# there is no lpadmin option that restricts the media list.
#
# Regenerated from the driver's own PPD on every deploy, so a driver version
# bump carries its new PPD through this filter rather than being pinned to a
# fork.
#
# awk -v keep=w288h432 -f trim-ppd-media.awk tspl-label.ppd
#
# The four families below are keyed by the PPD page-size keyword in field 2,
# which reads as "w288h432/4 x 6 in:" -- hence the split on "/".
/^\*(PageSize|PageRegion|ImageableArea|PaperDimension) / {
split($2, f, "/")
if (f[1] != keep) next
}
# Custom sizes would put "Manage Custom Sizes" back in the client's paper menu
# and let a job arrive at any dimension, which is the thing being prevented.
/^\*(CustomPageSize|ParamCustomPageSize|MaxMediaWidth|MaxMediaHeight)/ { next }
{ print }
@@ -0,0 +1,41 @@
---
- name: restart cups
become: true
ansible.builtin.systemd:
name: cups.service
state: restarted
- name: reload udev rules
become: true
ansible.builtin.command:
argv: [udevadm, control, --reload-rules]
changed_when: true
notify: trigger udev
# --action=add, not the default "change": udev only creates SYMLINK+= entries
# when a device is added, so a change event reloads the rule and leaves
# /dev/usb/tspl-label missing until the printer is next replugged or rebooted.
- name: trigger udev
become: true
ansible.builtin.command:
argv: [udevadm, trigger, --subsystem-match=usbmisc, --action=add]
changed_when: true
- name: reload systemd
become: true
ansible.builtin.systemd:
daemon_reload: true
# NetworkManager only reads new keyfiles from disk on request. This does not
# disturb the live connection.
- name: reload networkmanager connections
become: true
ansible.builtin.command:
argv: [nmcli, connection, reload]
changed_when: true
- name: reload nftables
become: true
ansible.builtin.systemd:
name: nftables.service
state: reloaded
+92
View File
@@ -0,0 +1,92 @@
---
- name: set the hostname
become: true
ansible.builtin.hostname:
name: "{{ labelprint_hostname }}"
tags: [labelprint, base]
# The hostname is also the mDNS name, and avahi publishes whatever is in
# /etc/hosts for 127.0.1.1. cloud-init writes this line on first boot from the
# image's own hostname, so it has to be corrected here too or the Pi answers to
# the wrong .local name.
- name: point 127.0.1.1 at the hostname
become: true
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1\s'
line: "127.0.1.1\t{{ labelprint_hostname }}"
owner: root
group: root
mode: "0644"
tags: [labelprint, base]
- name: install the print proxy packages
become: true
ansible.builtin.apt:
name: "{{ labelprint_deps }}"
state: present
update_cache: true
cache_valid_time: 3600
tags: [labelprint, base]
- name: publish the host over mDNS
become: true
ansible.builtin.systemd:
name: avahi-daemon.service
enabled: true
state: started
tags: [labelprint, base]
# ---------------------------------------------------------------------------
# Unattended security updates
# ---------------------------------------------------------------------------
# This box sits on the LAN with an open IPP port and is not something anyone
# logs into for months at a time, so it patches itself.
- name: enable unattended upgrades
become: true
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
owner: root
group: root
mode: "0644"
tags: [labelprint, base, updates]
# Debian's stock 50unattended-upgrades allowlists the Debian security origin
# only. Raspberry Pi OS serves its own kernel, firmware and userland from the
# Raspberry Pi archives, so without these two extra origins the packages most
# specific to this hardware are exactly the ones that never get patched.
- name: allow the Raspberry Pi origins and reboot for kernel updates
become: true
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/52unattended-upgrades-labelprint
content: |
Unattended-Upgrade::Origins-Pattern {
"origin=Raspbian,codename=${distro_codename},label=Raspbian";
"origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation";
};
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
// Nothing here holds state across a reboot -- a queued job is spooled on
// disk and resumes -- so take the kernel update at 04:00 rather than
// leaving the Pi running an unpatched kernel until someone notices.
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
owner: root
group: root
mode: "0644"
tags: [labelprint, base, updates]
- name: enable the unattended-upgrades timers
become: true
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop:
- apt-daily.timer
- apt-daily-upgrade.timer
tags: [labelprint, base, updates]
+136
View File
@@ -0,0 +1,136 @@
---
- name: configure cupsd
become: true
ansible.builtin.template:
src: cupsd.conf.j2
dest: /etc/cups/cupsd.conf
owner: root
group: lp
mode: "0640"
validate: /usr/sbin/cupsd -t -c %s
notify: restart cups
tags: [labelprint, cups]
- name: enable cups
become: true
ansible.builtin.systemd:
name: cups.service
enabled: true
state: started
tags: [labelprint, cups]
# cupsd.conf has to be in place and cupsd running before lpadmin can talk to it.
- name: apply pending cups changes before touching the queue
ansible.builtin.meta: flush_handlers
tags: [labelprint, cups]
# ---------------------------------------------------------------------------
# The queue
# ---------------------------------------------------------------------------
# lpadmin is not idempotent and has no "show me everything you would set" mode,
# so the desired definition is fingerprinted and the fingerprint compared with
# what was last applied. The marker is written only after lpadmin succeeds.
# Checksummed here rather than taken from the install task's return value, so
# that the fingerprint is the same whether or not this run included the driver
# tasks -- `make deploy-labelprint TAGS=cups` must not look like a change.
- name: checksum the installed PPD
become: true
ansible.builtin.stat:
path: "{{ labelprint_ppd_active }}"
checksum_algorithm: sha1
register: labelprint_ppd_stat
tags: [labelprint, cups]
- name: build the desired queue fingerprint
ansible.builtin.set_fact:
labelprint_queue_want: >-
{{
[
labelprint_device_uri,
labelprint_queue_info,
labelprint_queue_location,
labelprint_resolution,
labelprint_media,
labelprint_darkness | string,
labelprint_print_speed | string,
labelprint_ppd_stat.stat.checksum | default('none'),
] | join('|')
}}
tags: [labelprint, cups]
- name: read the queue fingerprint that was last applied
become: true
ansible.builtin.slurp:
src: "/etc/cups/.{{ labelprint_queue }}.fingerprint"
register: labelprint_queue_have
failed_when: false
tags: [labelprint, cups]
- name: create or update the label queue
become: true
ansible.builtin.command:
argv:
- lpadmin
- -p
- "{{ labelprint_queue }}"
- -E
- -v
- "{{ labelprint_device_uri }}"
- -P
- "{{ labelprint_ppd_active }}"
- -D
- "{{ labelprint_queue_info }}"
- -L
- "{{ labelprint_queue_location }}"
- -o
- printer-is-shared=true
- -o
- "Resolution={{ labelprint_resolution }}"
- -o
- "media={{ labelprint_media }}"
- -o
- "Darkness={{ labelprint_darkness }}"
- -o
- "PrintSpeed={{ labelprint_print_speed }}"
when: >-
(labelprint_queue_have.content | default('') | b64decode | trim)
!= labelprint_queue_want | trim
register: labelprint_lpadmin
changed_when: true
tags: [labelprint, cups]
- name: record the applied queue fingerprint
become: true
ansible.builtin.copy:
dest: "/etc/cups/.{{ labelprint_queue }}.fingerprint"
content: "{{ labelprint_queue_want | trim }}"
owner: root
group: root
mode: "0600"
when: labelprint_lpadmin is changed
tags: [labelprint, cups]
- name: accept and enable the label queue
become: true
ansible.builtin.command:
argv: ["{{ item }}", "{{ labelprint_queue }}"]
loop:
- cupsaccept
- cupsenable
changed_when: false
tags: [labelprint, cups]
# There is deliberately no `cupsctl` here. It is the obvious way to say
# "share on the LAN only", but cupsctl edits cupsd.conf through cupsd itself,
# which rewrites the file from its parsed form and drops every comment. That
# makes the template above differ on the next run, which re-templates and
# restarts cups, which lets cupsctl rewrite it again -- a deploy that reports
# changes forever and never converges.
#
# Nothing is lost. `cupsctl --share-printers` amounts to `Browsing On` plus a
# per-queue shared flag, and both are already set -- the first in the template,
# the second by lpadmin's printer-is-shared=true above. `--no-remote-any` is the
# absence of `Allow from all` in <Location />, which is how the template is
# written. The driver's own install.sh runs `cupsctl --remote-any`, which would
# offer this printer to anything that can route to the Pi; that is exactly what
# we are not doing.
+147
View File
@@ -0,0 +1,147 @@
---
# Builds RunTheWall/tspl-cups-driver (MIT) from a pinned commit. The build is
# three files -- a CUPS raster filter, a backend and a PPD -- so it is cheap to
# do on the Pi itself and avoids trusting a prebuilt binary.
- name: fetch the tspl driver source
become: true
ansible.builtin.git:
repo: "{{ labelprint_driver_repo }}"
dest: "{{ labelprint_driver_src }}"
version: "{{ labelprint_driver_version }}"
force: true
register: labelprint_driver_checkout
tags: [labelprint, driver]
- name: check whether the filter is already built
become: true
ansible.builtin.stat:
path: /usr/lib/cups/filter/rastertotspl
register: labelprint_filter
tags: [labelprint, driver]
# `make` alone is not idempotent enough to report honestly -- it prints a
# recipe line on a rebuild and nothing on a no-op -- so the decision to build is
# made from the checkout state instead.
- name: build the tspl raster filter
become: true
community.general.make:
chdir: "{{ labelprint_driver_src }}"
when: labelprint_driver_checkout.changed or not labelprint_filter.stat.exists
tags: [labelprint, driver]
- name: install the tspl raster filter
become: true
ansible.builtin.copy:
src: "{{ labelprint_driver_src }}/src/rastertotspl"
dest: /usr/lib/cups/filter/rastertotspl
remote_src: true
owner: root
group: root
mode: "0755"
notify: restart cups
tags: [labelprint, driver]
# 0700 and root-owned on purpose: cupsd refuses to run a backend that is group-
# or world-writable, and runs it as an unprivileged user if it is not 0700.
# Writing to the printer's usblp node needs the privileged path.
- name: install the tspl backend
become: true
ansible.builtin.copy:
src: "{{ labelprint_driver_src }}/backend/tspl"
dest: /usr/lib/cups/backend/tspl
remote_src: true
owner: root
group: root
mode: "0700"
notify: restart cups
tags: [labelprint, driver]
- name: create the PPD directory
become: true
ansible.builtin.file:
path: "{{ labelprint_ppd_dir }}"
state: directory
owner: root
group: root
mode: "0755"
tags: [labelprint, driver]
- name: install the tspl PPD
become: true
ansible.builtin.copy:
src: "{{ labelprint_driver_src }}/ppd/tspl-label.ppd"
dest: "{{ labelprint_ppd_dir }}/tspl-label.ppd"
remote_src: true
owner: root
group: root
mode: "0644"
tags: [labelprint, driver]
# ---------------------------------------------------------------------------
# The 4x6-only PPD
# ---------------------------------------------------------------------------
# Regenerated from the driver's PPD every run rather than kept as a fork, so a
# driver bump brings its new PPD through the same filter.
- name: create the helper directory
become: true
ansible.builtin.file:
path: /usr/local/share/labelprint
state: directory
owner: root
group: root
mode: "0755"
when: labelprint_media_only_4x6
tags: [labelprint, driver]
- name: install the PPD media trim filter
become: true
ansible.builtin.copy:
src: trim-ppd-media.awk
dest: /usr/local/share/labelprint/trim-ppd-media.awk
owner: root
group: root
mode: "0644"
when: labelprint_media_only_4x6
tags: [labelprint, driver]
- name: render the 4x6-only PPD
become: true
ansible.builtin.command:
argv:
- awk
- -v
- "keep={{ labelprint_ppd_pagesize }}"
- -f
- /usr/local/share/labelprint/trim-ppd-media.awk
- "{{ labelprint_ppd_dir }}/tspl-label.ppd"
register: labelprint_ppd_trim
changed_when: false
when: labelprint_media_only_4x6
tags: [labelprint, driver]
- name: install the 4x6-only PPD
become: true
ansible.builtin.copy:
content: "{{ labelprint_ppd_trim.stdout }}\n"
dest: "{{ labelprint_ppd_dir }}/tspl-label-4x6.ppd"
owner: root
group: root
mode: "0644"
validate: cupstestppd -q %s
when: labelprint_media_only_4x6
tags: [labelprint, driver]
# Gives the printer a stable /dev/usb/tspl-label symlink across USB
# re-enumeration. Harmless if the PM246's id is not in the shipped rules -- the
# backend still finds it by walking /dev/usb/lp*.
- name: install the tspl udev rules
become: true
ansible.builtin.copy:
src: "{{ labelprint_driver_src }}/udev/99-tspl-label.rules"
dest: /etc/udev/rules.d/99-tspl-label.rules
remote_src: true
owner: root
group: root
mode: "0644"
notify: reload udev rules
tags: [labelprint, driver]
@@ -0,0 +1,20 @@
---
- name: install the nftables ruleset
become: true
ansible.builtin.template:
src: nftables.conf.j2
dest: /etc/nftables.conf
owner: root
group: root
mode: "0755"
validate: /usr/sbin/nft -c -f %s
notify: reload nftables
tags: [labelprint, firewall]
- name: enable nftables
become: true
ansible.builtin.systemd:
name: nftables.service
enabled: true
state: started
tags: [labelprint, firewall]
+6
View File
@@ -0,0 +1,6 @@
---
- import_tasks: base.yml
- import_tasks: driver.yml
- import_tasks: cups.yml
- import_tasks: wifi.yml
- import_tasks: firewall.yml
+133
View File
@@ -0,0 +1,133 @@
---
# WPA2-PSK takes an 8-63 character passphrase, or exactly 64 hex characters as a
# precomputed key. NetworkManager stores a shorter one without complaint --
# psk-flags stays 0 and the value sits in the keyfile -- and then refuses to
# activate with "Secrets were required, but not provided", which reads like a
# missing password rather than an invalid one.
#
# For the rescue AP that failure is invisible until the day the home network is
# down and this is the only way in, so it is checked here instead. Only lengths
# are reported, never the values.
- name: check the Wi-Fi secrets are usable as WPA2-PSK
ansible.builtin.assert:
that:
- (vars[item] | length >= 8 and vars[item] | length <= 63)
or (vars[item] is match('^[0-9a-fA-F]{64}$'))
fail_msg: >-
{{ item }} is {{ vars[item] | length }} characters, which WPA2 will not
accept. Use an 8-63 character passphrase, or a 64-character hex
precomputed key. Fix it with `make vault`.
quiet: true
# The loop carries the variable NAME, never its value: a failed assert prints
# the item it was iterating over, so looping over the secrets themselves would
# dump both passwords to the terminal on any failure.
loop:
- stickah_psk
- stickah_psk_rescue
tags: [labelprint, wifi]
# The watchdog can pull the radio out from under this very play if it decides
# the Pi is offline while we are mid-deploy over the rescue AP. The hold expires
# on its own after {{ labelprint_hold_max_age_secs }}s, so an aborted run cannot
# leave the watchdog disabled.
- name: hold the radio for the duration of this deploy
become: true
ansible.builtin.file:
path: /run/wifi-rescue.hold
state: touch
owner: root
group: root
mode: "0644"
changed_when: false
tags: [labelprint, wifi]
- name: install the home Wi-Fi profile
become: true
ansible.builtin.template:
src: home-wifi.nmconnection.j2
dest: /etc/NetworkManager/system-connections/home-wifi.nmconnection
owner: root
group: root
mode: "0600"
notify: reload networkmanager connections
tags: [labelprint, wifi]
- name: install the rescue access point profile
become: true
ansible.builtin.template:
src: rescue-ap.nmconnection.j2
dest: /etc/NetworkManager/system-connections/rescue-ap.nmconnection
owner: root
group: root
mode: "0600"
notify: reload networkmanager connections
tags: [labelprint, wifi]
# ---------------------------------------------------------------------------
# Coexisting with netplan
# ---------------------------------------------------------------------------
# The hand-built image configures Wi-Fi through cloud-init's network-config, and
# on Raspberry Pi OS trixie netplan's NetworkManager integration turns that into
# a persistent profile of its own at /etc/netplan/90-NM-<uuid>.yaml -- not the
# /etc/netplan/50-cloud-init.yaml you would expect, and not something a
# cloud-init clean removes.
#
# That profile is deliberately left in place. It carries the same SSID as
# home-wifi, and autoconnect-priority decides between them: 100 here against
# netplan's 0, so NetworkManager picks ours every time. What netplan's copy buys
# is a fallback that predates anything in this role -- if home-wifi is ever
# rendered wrong, the Pi still comes back on the LAN instead of stranding itself
# on the rescue AP. Its PSK goes stale when the home password changes; that
# costs nothing, because a stale profile simply fails and ours is tried first.
#
# What is worth stopping is cloud-init rewriting the network on a future
# re-instance, which would put a third opinion in play.
- name: stop cloud-init from rewriting the network config
become: true
ansible.builtin.copy:
dest: /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
content: |
network: {config: disabled}
owner: root
group: root
mode: "0644"
tags: [labelprint, wifi]
# ---------------------------------------------------------------------------
# The watchdog
# ---------------------------------------------------------------------------
- name: install the Wi-Fi rescue watchdog
become: true
ansible.builtin.template:
src: wifi-rescue.sh.j2
dest: /usr/local/sbin/wifi-rescue
owner: root
group: root
mode: "0755"
tags: [labelprint, wifi]
- name: install the Wi-Fi rescue units
become: true
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- wifi-rescue.service
- wifi-rescue.timer
notify: reload systemd
tags: [labelprint, wifi]
- name: apply pending unit changes
ansible.builtin.meta: flush_handlers
tags: [labelprint, wifi]
- name: enable the Wi-Fi rescue watchdog
become: true
ansible.builtin.systemd:
name: wifi-rescue.timer
enabled: true
state: started
tags: [labelprint, wifi]
@@ -0,0 +1,11 @@
# {{ ansible_managed }} -- rendered by `make bootfs`
#
# cloud-init applies user-data once per INSTANCE, not once per boot: rewrite
# user-data on a card that has already booted and nothing happens, because
# cloud-init recognises the instance id and skips straight to per-boot modules.
#
# Deriving the id from a hash of user-data itself fixes that. Re-running
# `make bootfs` with no changes leaves the id alone, so a card keeps its
# identity; change anything in user-data and the id changes with it, and the
# Pi re-applies the new config on its next boot without a reflash.
instance-id: {{ labelprint_hostname }}-{{ labelprint_user_data_id }}
@@ -0,0 +1,18 @@
# {{ ansible_managed }} -- rendered by `make bootfs`
#
# Ethernet only, on purpose. Wi-Fi is NOT configured here: cloud-init renders
# this through netplan into a persistent profile of netplan's own, which is not
# a file this repo manages or can readily update. The Wi-Fi profiles are written
# straight into /etc/NetworkManager/system-connections by user-data instead, so
# the image and Ansible manage the same files.
#
# A card that has already booted with Wi-Fi in network-config keeps netplan's
# profile. That is fine, and useful -- see the "Coexisting with netplan" note in
# roles/labelprint/tasks/wifi.yml.
network:
version: 2
ethernets:
eth0:
dhcp4: true
dhcp6: true
optional: true
@@ -0,0 +1,125 @@
#cloud-config
# {{ ansible_managed }} -- rendered by `make bootfs BOOTFS=...`
#
# First boot of the label print proxy. The job of this file is to make the Pi
# REACHABLE and nothing more: a login that works, a network that works, and a
# rescue access point for when it does not. The printer driver and the CUPS
# queue are Ansible's job -- see roles/labelprint/.
#
# The Wi-Fi profiles and the rescue watchdog below are rendered from the very
# same templates the role uses, so the first `make deploy-labelprint` reports no
# change on any of them. That is the point: the Pi can already rescue itself
# before Ansible has ever run.
#
# Applies on FIRST BOOT ONLY. Rewriting this file on a card that has already
# booted does nothing -- reflash the image.
hostname: {{ labelprint_hostname }}
manage_etc_hosts: true
manage_resolv_conf: false
timezone: America/New_York
keyboard:
model: pc105
layout: "us"
apt:
preserve_sources_list: true
users:
- name: {{ labelprint_user }}
shell: /bin/bash
lock_passwd: false
# Deliberately trivial, and deliberately not hashed: this password is
# documented in roles/labelprint/README.md, so a hash of it would protect
# nothing while pretending otherwise. It exists so that a Pi which will not
# take the SSH key -- wrong key, reflashed card, someone else's laptop -- is
# still reachable from the LAN or the rescue AP, which is the only place it
# can be reached from at all (see templates/nftables.conf.j2).
plain_text_passwd: {{ labelprint_user_password }}
sudo: "ALL=(ALL) NOPASSWD:ALL"
groups: [sudo, adm, lpadmin, plugdev, dialout, netdev, users]
ssh_authorized_keys:
- {{ lookup('file', labelprint_authorized_key_file) }}
# The key is what Ansible actually uses; the password is the fallback.
ssh_pwauth: true
disable_root: true
chpasswd:
expire: false
# Pre-installing what the role needs makes the first deploy quick, and means a
# Pi that comes up on the rescue AP with no internet still has cups and
# NetworkManager. Ansible installs the same list, so nothing here is load-bearing.
package_update: true
packages:
{% for pkg in labelprint_deps %}
- {{ pkg }}
{% endfor %}
write_files:
# Stop cloud-init rewriting the network on a future re-instance.
- path: /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
owner: root:root
permissions: '0644'
content: |
network: {config: disabled}
# Belt and braces. netplan can hand NetworkManager an "only manage what I
# listed" policy, and what this image lists is eth0; a stock Raspberry Pi OS
# leaves that policy empty, but an unmanaged wlan0 would take the rescue AP
# down with it, so it is not worth depending on.
- path: /etc/NetworkManager/conf.d/10-labelprint.conf
owner: root:root
permissions: '0644'
content: |
[keyfile]
unmanaged-devices=none
[device]
# A randomised MAC would give the Pi a different DHCP lease on every
# association, which makes it hard to find on the UDM Pro's client list.
wifi.scan-rand-mac-address=no
- path: /etc/NetworkManager/system-connections/home-wifi.nmconnection
owner: root:root
permissions: '0600'
content: |
{{ lookup('template', 'roles/labelprint/templates/home-wifi.nmconnection.j2') | indent(6) }}
- path: /etc/NetworkManager/system-connections/rescue-ap.nmconnection
owner: root:root
permissions: '0600'
content: |
{{ lookup('template', 'roles/labelprint/templates/rescue-ap.nmconnection.j2') | indent(6) }}
- path: /usr/local/sbin/wifi-rescue
owner: root:root
permissions: '0755'
content: |
{{ lookup('template', 'roles/labelprint/templates/wifi-rescue.sh.j2') | indent(6) }}
- path: /etc/systemd/system/wifi-rescue.service
owner: root:root
permissions: '0644'
content: |
{{ lookup('template', 'roles/labelprint/templates/wifi-rescue.service.j2') | indent(6) }}
- path: /etc/systemd/system/wifi-rescue.timer
owner: root:root
permissions: '0644'
content: |
{{ lookup('template', 'roles/labelprint/templates/wifi-rescue.timer.j2') | indent(6) }}
runcmd:
# RPi OS soft-blocks the radio until a regulatory domain is known. cmdline.txt
# carries cfg80211.ieee80211_regdom=US, but unblock anyway -- a blocked radio
# is the one failure that takes the rescue AP down with it.
- [rfkill, unblock, wifi]
- [systemctl, enable, --now, ssh]
- [systemctl, enable, --now, avahi-daemon]
# Let NetworkManager pick up the keyfiles written above. The profile netplan
# renders from network-config is left alone on purpose -- see the "Coexisting
# with netplan" note in roles/labelprint/tasks/wifi.yml.
- [nmcli, connection, reload]
- [systemctl, daemon-reload]
- [systemctl, enable, --now, wifi-rescue.timer]
@@ -0,0 +1,97 @@
# {{ ansible_managed }}
#
# CUPS on the label print proxy. The Pi does all the rendering, so macOS,
# Windows and Linux clients add the queue driverless over IPP Everywhere /
# AirPrint and never install a Phomemo driver.
#
# Reachable from the LAN and from the rescue access point only. Everything else
# is refused here and dropped again at nftables.
LogLevel warn
PageLogFormat
MaxLogSize 1m
# A label job is worth retrying: the printer is often powered off or out of
# stock when the job is submitted, and the default is to bin the job outright.
ErrorPolicy retry-job
# Only trusted, local networks reach this port -- see the Location blocks below
# and roles/labelprint/templates/nftables.conf.j2. Listening on all interfaces
# rather than a fixed address so the queue is still reachable at
# {{ labelprint_ap_addr }} when the Pi has fallen back to its rescue AP.
Listen 631
Listen /run/cups/cups.sock
# Advertise over Bonjour/mDNS so clients discover the queue by themselves.
Browsing On
BrowseLocalProtocols dnssd
# Dropping the _cups subtype is what makes macOS and iOS offer a driverless
# "AirPrint" add instead of guessing at a Generic PostScript driver.
BrowseDNSSDSubTypes _print,_universal
DefaultAuthType Basic
WebInterface Yes
<Location />
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
Allow from localhost
</Location>
<Location /admin>
AuthType Default
Require user @SYSTEM
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
</Location>
<Location /admin/conf>
AuthType Default
Require user @SYSTEM
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
</Location>
<Location /admin/log>
AuthType Default
Require user @SYSTEM
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
</Location>
<Policy default>
JobPrivateAccess default
JobPrivateValues default
SubscriptionPrivateAccess default
SubscriptionPrivateValues default
# Anyone on the LAN may print and manage their own jobs -- this is a label
# printer in a workshop, not a shared office device with quotas.
<Limit Create-Job Print-Job Print-URI Validate-Job>
Order deny,allow
</Limit>
<Limit Send-Document Send-URI Hold-Job Release-Job Restart-Job Purge-Jobs Set-Job-Attributes Create-Job-Subscription Renew-Subscription Cancel-Subscription Get-Notifications Reprocess-Job Cancel-Current-Job Suspend-Current-Job Resume-Job Cancel-Jobs CUPS-Authenticate-Job Close-Job CUPS-Move-Job Cancel-My-Jobs CUPS-Get-Document>
Order deny,allow
</Limit>
# Changing the printer itself needs a local admin.
<Limit Pause-Printer Resume-Printer Enable-Printer Disable-Printer Pause-Printer-After-Current-Job Hold-New-Jobs Release-Held-New-Jobs Deactivate-Printer Activate-Printer Restart-Printer Shutdown-Printer Startup-Printer Promote-Job Schedule-Job-After Cancel-Job CUPS-Accept-Jobs CUPS-Reject-Jobs>
AuthType Default
Require user @SYSTEM
Order deny,allow
</Limit>
<Limit CUPS-Add-Modify-Printer CUPS-Delete-Printer CUPS-Add-Modify-Class CUPS-Delete-Class CUPS-Set-Default>
AuthType Default
Require user @SYSTEM
Order deny,allow
</Limit>
<Limit All>
Order deny,allow
</Limit>
</Policy>
@@ -0,0 +1,32 @@
# {{ ansible_managed }}
#
# The normal, everyday Wi-Fi link. autoconnect-priority outranks anything
# cloud-init/netplan rendered for the same SSID, so this is the profile
# NetworkManager picks. autoconnect-retries=0 means retry forever rather than
# giving up after four attempts and leaving the Pi off the network until
# someone power-cycles it -- the rescue AP is the fallback, not the
# destination.
[connection]
id=home-wifi
uuid={{ (stickah_ssid ~ '-home-wifi') | to_uuid }}
type=wifi
interface-name=wlan0
autoconnect=true
autoconnect-priority=100
autoconnect-retries=0
[wifi]
mode=infrastructure
ssid={{ stickah_ssid }}
[wifi-security]
key-mgmt=wpa-psk
# Accepts either a passphrase or the 64-hex precomputed PSK.
psk={{ stickah_psk }}
[ipv4]
method=auto
[ipv6]
method=auto
addr-gen-mode=default
@@ -0,0 +1,63 @@
#!/usr/sbin/nft -f
# {{ ansible_managed }}
#
# The print proxy answers to the LAN and to its own rescue access point, and to
# nothing else. This is the outer half of the same rule that cupsd enforces in
# its Location blocks -- both are here on purpose, so a mistake in one is not
# the only thing standing between the printer and the rest of the world.
# Declare-then-delete rather than `flush ruleset`: NetworkManager's shared mode
# keeps its own table for the rescue AP's dnsmasq, and a global flush would take
# that with it every time this file is reloaded.
table inet labelprint
delete table inet labelprint
table inet labelprint {
set trusted {
type ipv4_addr
flags interval
elements = { {{ labelprint_lan_cidr }}, {{ labelprint_ap_cidr }} }
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
# DHCP replies to our own client. Broadcast, so conntrack does not see
# them as related to the request we sent.
udp dport 68 accept
# ssh and IPP, from the LAN or from a machine on the rescue AP.
ip saddr @trusted tcp dport { 22, 631 } accept
ip saddr @trusted udp dport 631 accept
# mDNS: how every client finds this printer, since it has no DNS record.
ip saddr @trusted udp dport 5353 accept
# DHCP for whoever joins the rescue AP. Deliberately not restricted by
# source address: a client asking for its first lease has no address
# yet and sends DHCPDISCOVER from 0.0.0.0, so a source-matched rule
# would mean the rescue network never hands out a lease at all. Only a
# machine already associated to our own AP can reach this port.
iifname "wlan0" udp dport 67 accept
# DNS, once they have an address.
iifname "wlan0" ip saddr {{ labelprint_ap_cidr }} udp dport 53 accept
iifname "wlan0" ip saddr {{ labelprint_ap_cidr }} tcp dport 53 accept
}
# The rescue AP is a way in to this Pi, not a route to anywhere else.
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
@@ -0,0 +1,38 @@
# {{ ansible_managed }}
#
# Rescue access point. Never autoconnects -- wifi-rescue brings it up only when
# the home SSID is unreachable, so that a changed password or a dead router
# leaves a way back in to reconfigure the Pi.
#
# Join this SSID and the Pi is at {{ labelprint_ap_addr }}: ssh pi@{{ labelprint_ap_addr }},
# or print to it directly at ipp://{{ labelprint_ap_addr }}:631/printers/{{ labelprint_queue }}.
[connection]
id=rescue-ap
uuid={{ (stickah_ssid_rescue ~ '-rescue-ap') | to_uuid }}
type=wifi
interface-name=wlan0
autoconnect=false
[wifi]
mode=ap
ssid={{ stickah_ssid_rescue }}
# The 3B+ radio does 5 GHz, but 2.4 GHz AP mode is what brcmfmac is reliable
# at, and a rescue network only has to carry an SSH session.
band=bg
channel=6
[wifi-security]
key-mgmt=wpa-psk
proto=rsn
pairwise=ccmp
group=ccmp
psk={{ stickah_psk_rescue }}
# "shared" makes NetworkManager run a dnsmasq for DHCP and DNS on this
# interface, so a laptop that joins gets an address without any further setup.
[ipv4]
method=shared
address1={{ labelprint_ap_addr }}/24
[ipv6]
method=ignore
@@ -0,0 +1,9 @@
# {{ ansible_managed }}
[Unit]
Description=Wi-Fi rescue access point watchdog
After=NetworkManager.service
Requires=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/wifi-rescue
@@ -0,0 +1,132 @@
#!/bin/sh
# {{ ansible_managed }}
#
# Keeps the label print proxy reachable.
#
# Normally the Pi is a station on the home SSID. If that network is gone -- the
# password changed, the AP died, the Pi was carried somewhere else -- it brings
# up its own rescue access point so there is still a way in to reconfigure it.
# It keeps checking, and hands the radio back the moment the home SSID returns.
#
# The 3B+ has one radio and brcmfmac will not hold an AP and a station link at
# the same time, so this cannot listen for the home SSID while the AP is up.
# Instead the AP drops for a few seconds every {{ labelprint_ap_rescan_secs }}s
# to scan, and comes straight back if the home network is still missing.
#
# If you are working over the rescue AP and do not want the radio pulled out
# from under your SSH session:
#
# touch /run/wifi-rescue.hold
#
# The hold expires by itself after {{ (labelprint_hold_max_age_secs / 60) | int }} minutes, so a forgotten hold file
# cannot strand the Pi permanently.
set -eu
HOME_CON=home-wifi
AP_CON=rescue-ap
SSID='{{ stickah_ssid }}'
AP_SSID='{{ stickah_ssid_rescue }}'
IFACE=wlan0
HOLD=/run/wifi-rescue.hold
STAMP=/run/wifi-rescue.ap-since
RESCAN_SECS={{ labelprint_ap_rescan_secs }}
HOLD_MAX_AGE={{ labelprint_hold_max_age_secs }}
log() { logger -t wifi-rescue -- "$@"; }
con_active() { nmcli -t -f NAME connection show --active | grep -qxF "$1"; }
# A default IPv4 route is the honest test for "on a real network". The rescue
# AP uses NetworkManager's shared mode, which hands out addresses but installs
# no default route, so the AP can never make this look true.
online() { [ -n "$(ip -4 route show default 2>/dev/null)" ]; }
held() {
[ -e "$HOLD" ] || return 1
age=$(( $(date +%s) - $(stat -c %Y "$HOLD" 2>/dev/null || echo 0) ))
if [ "$age" -lt "$HOLD_MAX_AGE" ]; then
return 0
fi
log "hold file is ${age}s old; expiring it"
rm -f "$HOLD"
return 1
}
start_ap() {
con_active "$AP_CON" && return 0
log "starting rescue AP '$AP_SSID' on {{ labelprint_ap_addr }}"
if nmcli --wait 20 connection up "$AP_CON" >/dev/null 2>&1; then
date +%s > "$STAMP"
else
log "ERROR: rescue AP failed to start"
fi
}
join_home() {
# Bounded: the default 90s wait outlives the watchdog interval, and an
# SSID that is not there is not going to appear in the next minute.
#
# --wait is a GLOBAL nmcli option and has to precede the subcommand. Written
# as `connection up <id> --wait 20` it is rejected outright with "invalid
# extra argument" -- and only for a connection that exists, so it looks fine
# against a typo'd name. That failure mode is silent and total: every join
# returns failure and the rescue AP never starts.
nmcli --wait 20 connection up "$HOME_CON" >/dev/null 2>&1 || return 1
online
}
held && exit 0
if online; then
if con_active "$AP_CON"; then
log "back on the network; shutting the rescue AP down"
nmcli connection down "$AP_CON" >/dev/null 2>&1 || true
rm -f "$STAMP"
fi
exit 0
fi
if con_active "$AP_CON"; then
since=$(cat "$STAMP" 2>/dev/null || echo 0)
[ $(( $(date +%s) - since )) -lt "$RESCAN_SECS" ] && exit 0
log "rescue AP up for ${RESCAN_SECS}s; dropping it to scan for '$SSID'"
nmcli connection down "$AP_CON" >/dev/null 2>&1 || true
sleep 2
nmcli device wifi rescan ifname "$IFACE" >/dev/null 2>&1 || true
sleep 5
scan=$(nmcli -t -f SSID device wifi list ifname "$IFACE" 2>/dev/null || true)
if printf '%s\n' "$scan" | grep -qxF "$SSID"; then
log "'$SSID' is back; rejoining"
try=yes
elif [ -z "$(printf '%s' "$scan" | tr -d '[:space:]')" ]; then
# Nothing at all came back. Either the radio has not settled after
# dropping the AP, or the home SSID is hidden and will never show up in
# a scan. Worth 20 seconds to find out.
log "scan came back empty; trying '$SSID' anyway"
try=yes
else
# Other networks are visible and ours is not, so it really is gone.
# Straight back to the AP -- no point spending the join timeout.
try=no
fi
if [ "$try" = yes ]; then
if join_home; then
log "rejoined '$SSID'"
rm -f "$STAMP"
exit 0
fi
log "join failed; returning to the rescue AP"
fi
start_ap
exit 0
fi
log "offline and no rescue AP; trying '$SSID'"
if join_home; then
log "joined '$SSID'"
exit 0
fi
start_ap
@@ -0,0 +1,13 @@
# {{ ansible_managed }}
[Unit]
Description=Run the Wi-Fi rescue watchdog every {{ labelprint_watchdog_interval_secs }}s
[Timer]
# Waits for NetworkManager to have had a fair go at the home SSID before the
# first check, so a slow DHCP lease at boot does not trip the rescue AP.
OnBootSec=90
OnUnitActiveSec={{ labelprint_watchdog_interval_secs }}
AccuracySec=5
[Install]
WantedBy=timers.target
+56 -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:
@@ -148,6 +154,46 @@ LibreSign setup failures and buys nothing at this scale.
(LibreSign issue #4872). (LibreSign issue #4872).
## cloud.debyltech.com accounts
Registration is off, so every account is created by an admin in
Settings → Accounts. The isolation policy in
`tasks/containers/debyltech/cloud.yml` is enforced on every deploy and checked
by the verify script.
**Staff**: add to the `admin` group (the only group allowed to share) and to
`cloud_debyltech_staff_users` in `defaults/main.yml`, so the next deploy
exempts them from the 0 B default quota. Until then, set the account's quota
to *Unlimited* by hand.
**Customer**:
1. Create a group per customer, e.g. `Customer - Acme`. Use a consistent
prefix: autocomplete is off, so you share by typing the **exact** group
name.
2. Create the account with only their email filled in and no password, in
that group. Nextcloud sends a branded welcome email with a set-password
link.
3. Share a folder (for example `Customers/Acme`) with the group. It defaults
to **View only**; choose *Allow editing* only if they should upload.
What a customer gets:
- read-only access to what's shared with them
- no personal storage (0 B quota)
- no sharing or public links
- no view of other accounts or groups: the share search and contacts menu
return nothing
What they can open: Files, Activity, and LibreSign for signing only.
Dashboard and Office are staff-only. Promotional or directory-style apps
(first-run wizard, recommendations, weather, Photos, contacts interaction,
lookup server, ...) are disabled for everyone. Only staff can *request*
signatures.
Signature requests to customers need no account: use LibreSign with their
email address. LibreSign stays enabled for all accounts on purpose.
Restricting an app to a group also blocks visitors who aren't logged in,
which would break the public signing links.
## Logging ## Logging
Every container runs with `log_driver=journald`, so container stdout lands in Every container runs with `log_driver=journald`, so container stdout lands in
+91 -2
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"
@@ -27,6 +28,20 @@ hass_path: "{{ podman_volumes }}/hass"
partsy_path: "{{ podman_volumes }}/partsy" partsy_path: "{{ podman_volumes }}/partsy"
partsy_skudak_path: "{{ podman_volumes }}/partsy-skudak" partsy_skudak_path: "{{ podman_volumes }}/partsy-skudak"
photos_path: "{{ podman_volumes }}/photos" photos_path: "{{ podman_volumes }}/photos"
# rsvp.debyl.io -- invite-only RSVP app (~/src/rsvp-debylio). Both listeners are
# published on loopback only: Caddy proxies the public one, and the admin one
# only for LAN clients. Caddy runs with network: host, so 127.0.0.1 reaches them.
rsvp_path: "{{ podman_volumes }}/rsvp"
rsvp_public_port: 9080
rsvp_admin_port: 9081
# Named rather than hardcoded so the ML service can be stood up beside a broken
# one without editing tasks. On 2026-08-28 a worker thread wedged in
# uninterruptible sleep (D state) in exit_mmap, which made the container
# unremovable -- it survived SIGKILL and `podman rm -f` and kept its name and
# network alias until the host was rebooted. The reboot cleared it, so this
# stays on the canonical name; see MACHINE_LEARNING_MODEL_TTL in photos.yml for
# the change that stops it recurring.
immich_ml_container: immich-machine-learning
uptime_kuma_path: "{{ podman_volumes }}/uptime-kuma" uptime_kuma_path: "{{ podman_volumes }}/uptime-kuma"
uptime_kuma_personal_path: "{{ podman_volumes }}/uptime-kuma-personal" uptime_kuma_personal_path: "{{ podman_volumes }}/uptime-kuma-personal"
zomboid_path: "{{ podman_volumes }}/zomboid" zomboid_path: "{{ podman_volumes }}/zomboid"
@@ -72,7 +87,12 @@ zomboid_max_ram: 24g
# The modlist itself lives in vars/zomboid_sophie_mods.yml -- 283 mod IDs and # The modlist itself lives in vars/zomboid_sophie_mods.yml -- 283 mod IDs and
# 256 workshop items vendored from the upstream Sophie 42 preset. # 256 workshop items vendored from the upstream Sophie 42 preset.
zomboid_preset_version: "sophie-42 @ 2026-07-31" #
# The modlist is still the upstream preset. The world settings no longer are:
# files/zomboid/sophie/SandboxVars.lua is a snapshot of the running debbzoid
# world taken 2026-08-31, not the preset as shipped. See the seed tasks in
# containers/home/zomboid.yml.
zomboid_preset_version: "sophie-42 mods @ 2026-07-31 / live world settings @ 2026-08-31"
# Mods subtracted from the vendored Sophie lists before they reach the INI. # Mods subtracted from the vendored Sophie lists before they reach the INI.
# Explicit and reasoned so that re-vendoring a newer preset cannot silently # Explicit and reasoned so that re-vendoring a newer preset cannot silently
@@ -131,6 +151,19 @@ zomboid_mods_renamed:
# between world regenerations. Set true to overwrite it from the templates. # between world regenerations. Set true to overwrite it from the templates.
zomboid_config_force: false zomboid_config_force: false
# Same switch, narrowed to spawnregions.lua and spawnpoints.lua. They are the
# only config a running world re-reads (at every server start, unlike
# SandboxVars, which is read only when the world is created), so a spawn edit
# can be deployed to the live world without a wipe. It is separate from
# zomboid_config_force so that landing one is not an excuse to force the INI and
# SandboxVars over the top of whatever the admins have set in-game.
#
# make deploy TAGS=zomboid-conf -e zomboid_spawn_force=true
#
# The server still has to be restarted, with players offline, before the new
# spawn config is read.
zomboid_spawn_force: false
sshpass_cron_path: "{{ podman_volumes }}/sshpass_cron" sshpass_cron_path: "{{ podman_volumes }}/sshpass_cron"
caddy_path: "{{ podman_volumes }}/caddy" caddy_path: "{{ podman_volumes }}/caddy"
@@ -159,6 +192,7 @@ home_server_name_io: home.debyl.io
parts_server_name_io: parts.debyl.io parts_server_name_io: parts.debyl.io
photos_server_name_io: photos.debyl.io photos_server_name_io: photos.debyl.io
gitea_debyl_server_name: git.debyl.io gitea_debyl_server_name: git.debyl.io
rsvp_server_name: rsvp.debyl.io
# skudak.com domains (migration from skudakrennsport.com) # skudak.com domains (migration from skudakrennsport.com)
parts_skudak_server_name: parts.skudak.com parts_skudak_server_name: parts.skudak.com
@@ -185,6 +219,37 @@ 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
# Staff vs customers (see "customer isolation" in containers/debyltech/cloud.yml).
# Only this group may share; everyone else -- customers -- can only read what
# is shared with them. Staff accounts are exempted from the 0 B default quota.
# Accounts listed here that do not exist yet are skipped, not created.
cloud_debyltech_staff_group: admin
cloud_debyltech_staff_users:
- admin
- bastian@debyltech.com
# Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security # Legacy nginx/ModSecurity configuration removed - Caddy provides built-in security
@@ -251,6 +316,7 @@ caddy_log_names:
- graylog - graylog
- cloud - cloud
- cloud-skudak - cloud-skudak
- cloud-debyltech
- gitea-debyl - gitea-debyl
- gitea-skudak - gitea-skudak
- fulfillr - fulfillr
@@ -269,7 +335,27 @@ podman_prune_users:
- "{{ git_user }}" - "{{ git_user }}"
# Keep 30 days of unused images so a rollback needs no rebuild or re-pull. # Keep 30 days of unused images so a rollback needs no rebuild or re-pull.
podman_prune_until: 720h podman_prune_until: 720h
podman_prune_oncalendar: "Sun *-*-* 02:00:00"
# The CI runners were never pruned and had run away: gitea-runner reached 1205
# images / 113 GB with 100% reclaimable, actions-runner 137 exited job
# containers. Build layers carry no rollback value, so they keep a far shorter
# window than the service stores and their exited containers are reaped too.
podman_prune_ci_users:
- gitea-runner
- actions-runner
podman_prune_ci_until: 48h
# The CI base images declare LABEL io.debyl.ci-base="true" in
# roles/gitea-actions/files/Containerfile.* (and .gitea/workflows/ci-images.yml
# verifies it before publishing -- keep all three in sync). Images carrying it
# are skipped below: `until` counts from build time and not pull time, so
# without the exemption a re-pulled image would be deleted again the next
# night, a 7.8 GB ESP-IDF re-download after every idle day. Superseded tags
# (e.g. after an esp_idf_version bump) survive too; remove those by hand.
podman_prune_ci_keep_label: io.debyl.ci-base
# Daily rather than weekly: CI turns over many images a day, and a week of that
# is what let the store reach 113 GB between runs.
podman_prune_oncalendar: "*-*-* 02:00:00"
# Hardened CIFS options for the TrueNAS shares (see containers/home/photos.yml). # Hardened CIFS options for the TrueNAS shares (see containers/home/photos.yml).
# x-systemd.automount is what makes a failed mount recoverable without a human. # x-systemd.automount is what makes a failed mount recoverable without a human.
@@ -281,3 +367,6 @@ cifs_watchdog_mounts:
- "{{ photos_path }}/storage" - "{{ photos_path }}/storage"
- "{{ photos_path }}/immich" - "{{ photos_path }}/immich"
# GA4 property for the fulfillr portal Traffic & funnel tab (SCRUM-196, non-secret).
# Shared by dev and prod. The service-account key `fulfillr_ga4_credentials_json` lives in the vault.
fulfillr_ga4_property_id: "353859448"
@@ -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,436 @@
<?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,
);
// Opt out of mail-client dark mode. Without this Apple Mail repaints
// the white ground charcoal on its own, and the black wordmark all but
// disappears. Swapping to a white logo under prefers-color-scheme is
// NOT a fix: with Apple Mail's "Use dark backgrounds for messages" off,
// the query still matches while the ground stays white, leaving a
// white-on-white logo. This mail is designed light; render it light.
$this->head = str_replace(
'</head>',
'<meta name="color-scheme" content="light only">'
. '<meta name="supported-color-schemes" content="light only">'
. '<style type="text/css">:root{color-scheme:light only;supported-color-schemes:light only}</style>'
. '</head>',
$this->head,
);
}
/**
* 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);
}
}
+195 -583
View File
@@ -1,237 +1,78 @@
- id: '1649042328061'
alias: Lights - 01 - On
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single
- id: '1707432903086' - id: '1707432903086'
alias: Driveway String Lights Off alias: Driveway String Lights Off
description: '' description: ''
trigger: triggers:
- platform: time - trigger: time
at: '23:00:00' at: '23:00:00'
condition: []
action:
- type: turn_off
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
mode: single
- id: '1707433130493'
alias: Lights - 02 - Early Dim
description: ''
triggers: []
conditions: [] conditions: []
actions: actions:
- type: turn_on - action: switch.turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
mode: single
- id: '1707433185560'
alias: Lights - 03 - Mid Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
mode: single
- id: '1707433226166'
alias: Lights - 04 - Late Dim
description: ''
triggers: []
conditions: []
actions:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
- action: light.turn_on
metadata: {}
data:
brightness_pct: 1
target: target:
area_id: bedroom entity_id: switch.driveway_string_lights
enabled: false
mode: single
- id: '1711218890065'
alias: Lights - 10 - Off
description: ''
triggers: []
conditions: []
actions:
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
- type: turn_off
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
- type: turn_off
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
- type: turn_off
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd
entity_id: a30b2da3cd80a5b4c927e1608b91eb65
domain: light
- type: turn_off
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
mode: single mode: single
- id: '1739912161794' - id: '1739912161794'
alias: Lights - 00 - Morning alias: Lights - 00 - Morning
description: '' description: ''
triggers: triggers:
- trigger: time - trigger: time
at: 09:00:00 at: '09:00:00'
conditions: [] conditions: []
actions: actions:
- type: turn_on - action: light.turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd data:
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 100 brightness_pct: 100
target:
entity_id: light.bathroom_hallway
mode: single mode: single
- id: '1762116115638' - id: '1762116115638'
alias: Light - TV On alias: Light - TV On
description: '' description: TV mode on; once it's dark, dim the living room and turn off the
lights that glare on the TV
triggers: triggers:
- type: turned_on - trigger: state
device_id: 18a9bb7a2a32be4371da447767ef50a9 entity_id: remote.samsung_tv
entity_id: c05688f2610e27e2d86380e2945ceae5 to: 'on'
domain: remote not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: input_boolean.turn_on - action: input_boolean.turn_on
target: target:
entity_id: input_boolean.tv_mode entity_id: input_boolean.tv_mode
- if:
- condition: or
conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: sun
before: sunrise
then:
- action: light.turn_on - action: light.turn_on
metadata: {}
data: data:
brightness_pct: 5 brightness_pct: 5
target: target:
area_id: living_room area_id: living_room
- type: turn_off - action: light.turn_off
device_id: 03a12d2360d9954aed19c2449070725a target:
entity_id: 7c1e7db73799cc3f90948b5118596985 entity_id:
domain: light - light.kitchen_wall_light
- type: turn_off - light.dining_hall
device_id: 800eddbeeda071225f181a14cb9527e0 - light.bathroom_hallway
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
- type: turn_off
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
mode: single mode: single
- id: new_tv_off - id: new_tv_off
alias: Light - TV Off - Restore alias: Light - TV Off - Restore
description: Restores appropriate lighting level when TV turns off based on time description: Evening (sunset -1h to 23:30) brings the lights back to the
scheduled level; late night (23:30 to sunrise) only turns off the TV glow;
daytime leaves the lights alone
triggers: triggers:
- type: turned_off - trigger: state
device_id: 18a9bb7a2a32be4371da447767ef50a9 entity_id: remote.samsung_tv
entity_id: c05688f2610e27e2d86380e2945ceae5 to: 'off'
domain: remote not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: input_boolean.turn_off - action: input_boolean.turn_off
@@ -239,409 +80,179 @@
entity_id: input_boolean.tv_mode entity_id: input_boolean.tv_mode
- choose: - choose:
- conditions: - conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: time - condition: time
after: '22:30:00' before: '23:30:00'
before: '23:45:00'
sequence: sequence:
# Late dim levels - action: script.evening_lights_apply
- type: turn_on data:
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 apply: force
entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - action: switch.turn_on
domain: light target:
brightness_pct: 1 entity_id: switch.desk_lamp
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
- conditions: - conditions:
- condition: or
conditions:
- condition: time - condition: time
after: '21:30:00' after: '23:30:00'
before: '22:30:00' - condition: sun
before: sunrise
sequence: sequence:
# Mid dim levels - action: light.turn_off
- type: turn_on target:
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 area_id: living_room
entity_id: b79934d97f3bb9d8a3da47c76d03ded4 - action: switch.turn_off
domain: light target:
brightness_pct: 25 entity_id: switch.desk_lamp
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
- conditions:
- condition: time
after: '21:00:00'
before: '21:30:00'
sequence:
# Early dim levels
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
default:
# Full brightness (before 21:00 after sunset)
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single mode: single
- id: 'sunset_lights_on' - id: driveway_lights_on
alias: Lights - Sunset On alias: Driveway String Lights On
description: Turn on lights 1 hour before sunset description: On 1 hour before sunset whether or not the TV is on. Also catches
up if Home Assistant restarts before the 23:00 off
triggers: triggers:
- trigger: sun - trigger: sun
event: sunset event: sunset
offset: "-01:00:00" offset: "-01:00:00"
id: sunset
- trigger: homeassistant
event: start
conditions: conditions:
- condition: state - condition: state
entity_id: input_boolean.tv_mode entity_id: switch.driveway_string_lights
state: 'off' state: 'off'
actions: - condition: or
- type: turn_on
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
domain: switch
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 100
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 100
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 75
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 100
mode: single
- id: 'evening_dim_2100'
alias: Lights - Evening Dim (21:00)
description: Dim lights at 21:00 - only affects lights that are ON
triggers:
- trigger: time
at: "21:00:00"
conditions: conditions:
- condition: state - condition: trigger
entity_id: input_boolean.tv_mode id: sunset
state: 'off' - condition: and
actions:
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 50
mode: single
- id: 'mid_dim_2130'
alias: Lights - Mid Dim (21:30)
description: Dim lights at 21:30 - only affects lights that are ON
triggers:
- trigger: time
at: "21:30:00"
conditions: conditions:
- condition: state - condition: sun
entity_id: input_boolean.tv_mode after: sunset
state: 'off' after_offset: "-01:00:00"
- condition: time
before: '23:00:00'
actions: actions:
- if: - action: switch.turn_on
- condition: device target:
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 entity_id: switch.driveway_string_lights
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 800eddbeeda071225f181a14cb9527e0
domain: light
entity_id: 521a92ddd8be76c7eddfc544f81f6020
type: is_on
then:
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 50
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 25
mode: single mode: single
- id: 'late_dim_2230' - id: 'sunset_lights_on'
alias: Lights - Late Dim (22:30) alias: Lights - Sunset On
description: Dim lights at 22:30 - only affects lights that are ON description: Turn on lights 1 hour before sunset. If the TV is on, the living
room gets the TV glow and the lights that glare on the TV stay off
triggers: triggers:
- trigger: time - trigger: sun
at: "22:30:00" event: sunset
conditions: offset: "-01:00:00"
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
actions:
- if:
- condition: device
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
domain: light
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
type: is_on
then:
- type: turn_on
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
domain: light
brightness_pct: 1
- if:
- condition: device
device_id: 03a12d2360d9954aed19c2449070725a
domain: light
entity_id: 7c1e7db73799cc3f90948b5118596985
type: is_on
then:
- type: turn_on
device_id: 03a12d2360d9954aed19c2449070725a
entity_id: 7c1e7db73799cc3f90948b5118596985
domain: light
brightness_pct: 1
- if:
- condition: device
device_id: 800eddbeeda071225f181a14cb9527e0
domain: light
entity_id: 521a92ddd8be76c7eddfc544f81f6020
type: is_on
then:
- type: turn_on
device_id: 800eddbeeda071225f181a14cb9527e0
entity_id: 521a92ddd8be76c7eddfc544f81f6020
domain: light
brightness_pct: 25
- if:
- condition: device
device_id: 3f7f65571d9bb0833433996f1f6725bd
domain: light
entity_id: 7407afe14783543252c666d5ff7c5d5c
type: is_on
then:
- type: turn_on
device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: 7407afe14783543252c666d5ff7c5d5c
domain: light
brightness_pct: 10
- if:
- condition: device
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
domain: light
entity_id: 81c486d682afcc94e98e377475cc92fc
type: is_on
then:
- type: turn_on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
entity_id: 81c486d682afcc94e98e377475cc92fc
domain: light
brightness_pct: 10
mode: single
- id: 'lights_out_2345'
alias: Lights - Out (23:45)
description: Turn off all lights at 23:45
triggers:
- trigger: time
at: "23:45:00"
conditions: [] conditions: []
actions: actions:
- type: turn_off - action: script.evening_lights_apply
device_id: 3f7f65571d9bb0833433996f1f6725bd data:
entity_id: 7407afe14783543252c666d5ff7c5d5c apply: force
domain: light - if:
- type: turn_off - condition: state
device_id: f31e4f9bf8fa3687a07aeb4430eaef38 entity_id: input_boolean.tv_mode
entity_id: b79934d97f3bb9d8a3da47c76d03ded4 state: 'on'
domain: light then:
- type: turn_off - action: light.turn_on
device_id: 03a12d2360d9954aed19c2449070725a data:
entity_id: 7c1e7db73799cc3f90948b5118596985 brightness_pct: 5
domain: light target:
- type: turn_off area_id: living_room
device_id: 800eddbeeda071225f181a14cb9527e0 else:
entity_id: 521a92ddd8be76c7eddfc544f81f6020 - action: switch.turn_on
domain: light target:
- type: turn_off entity_id: switch.desk_lamp
device_id: 03eb359bf2344a58bebfe1e9c5bcfadd mode: single
entity_id: a30b2da3cd80a5b4c927e1608b91eb65 - id: evening_dim_ramp
domain: light alias: Lights - Evening Dim Ramp
- type: turn_off description: Every 5 minutes from 20:30 to 23:30, ease the lights that are on
device_id: 21eb2bd28aba2ee361a22af92e8b2d16 toward the schedule in script.evening_lights_apply
entity_id: 81c486d682afcc94e98e377475cc92fc triggers:
domain: light - trigger: time_pattern
minutes: /5
conditions:
- condition: time
after: '20:30:00'
before: '23:30:00'
actions:
- action: script.evening_lights_apply
data:
apply: ramp
mode: single
- id: 'lights_out_2345'
alias: Lights - Out (23:30)
description: Turn off all lights at 23:30. While the TV is on the living room
is left as it is
triggers:
- trigger: time
at: "23:30:00"
conditions: []
actions:
- action: light.turn_off
target:
entity_id:
- light.kitchen_lights
- light.kitchen_wall_light
- light.dining_hall
- light.dining_room
- light.bathroom_hallway
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
then:
- action: light.turn_off
target:
entity_id: light.living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single
- id: lights_sweep_0100
alias: Lights - Sweep (01:00)
description: Catch anything turned back on after lights-out. While the TV is
on the living room is left as it is
triggers:
- trigger: time
at: "01:00:00"
conditions: []
actions:
- action: light.turn_off
target:
entity_id:
- light.kitchen_lights
- light.kitchen_wall_light
- light.dining_hall
- light.dining_room
- light.bathroom_hallway
- action: switch.turn_off
target:
entity_id: switch.driveway_string_lights
- if:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
then:
- action: light.turn_off
target:
entity_id: light.living_room
- action: switch.turn_off
target:
entity_id: switch.desk_lamp
mode: single mode: single
- id: '1768862300896' - id: '1768862300896'
alias: Bedroom On alias: Bedroom On
description: '' description: ''
triggers: triggers:
- type: turned_on - trigger: state
device_id: afb9734fe9b187ab6881a64d24e1c2f5 entity_id: switch.bedroom_light
entity_id: 27efa149b9ebb388e7c21ba89e671b42 to: 'on'
domain: switch not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: light.turn_on - action: light.turn_on
@@ -655,11 +266,12 @@
alias: Bedroom Off alias: Bedroom Off
description: '' description: ''
triggers: triggers:
- type: turned_off - trigger: state
device_id: afb9734fe9b187ab6881a64d24e1c2f5 entity_id: switch.bedroom_light
entity_id: 27efa149b9ebb388e7c21ba89e671b42 to: 'off'
domain: switch not_from:
trigger: device - unavailable
- unknown
conditions: [] conditions: []
actions: actions:
- action: light.turn_off - action: light.turn_off
@@ -9,6 +9,11 @@ http:
use_x_forwarded_for: true use_x_forwarded_for: true
trusted_proxies: trusted_proxies:
- 127.0.0.1 - 127.0.0.1
# Caddy runs on the host network and reaches us through the published
# port, so pasta (rootless podman's default since v5) rewrites the source
# to the container's own link-local tap0 address, not 10.0.2.x as
# slirp4netns used to.
- 169.254.0.0/16
- 10.0.0.0/8 - 10.0.0.0/8
- 192.168.1.0/24 - 192.168.1.0/24
@@ -18,6 +23,7 @@ homeassistant:
media: /share media: /share
automation: !include automations.yaml automation: !include automations.yaml
script: !include scripts.yaml
input_boolean: input_boolean:
tv_mode: tv_mode:
@@ -0,0 +1,76 @@
evening_lights_apply:
alias: Evening Lights - Apply Schedule
description: >-
Sets each evening light to its scheduled brightness for the current time,
blending linearly between the points in `schedule`. apply=force turns the
lights on (sunset, TV off); apply=ramp only eases lights that are already
on, and leaves alone any light someone has changed by hand. While TV mode
is on the living room and the lights that glare on the TV are left alone.
mode: queued
fields:
apply:
description: "force: turn lights on at the target. ramp: only adjust lights that are already on."
example: ramp
selector:
select:
options:
- force
- ramp
variables:
# [minute of day, brightness %] - 1230 = 20:30, 1260 = 21:00,
# 1290 = 21:30, 1350 = 22:30. Before the first point a light sits at the
# first value; after the last it holds the last value until lights-out.
schedule:
light.kitchen_lights: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
light.kitchen_wall_light: [[1230, 100], [1260, 50], [1290, 25], [1350, 1]]
light.bathroom_hallway: [[1230, 75], [1260, 50], [1290, 25], [1350, 10]]
light.living_room: [[1230, 100], [1260, 50], [1290, 25], [1350, 10]]
light.dining_hall: [[1290, 25], [1350, 15]]
tv_mode_lights:
- light.living_room
- light.kitchen_wall_light
- light.dining_hall
- light.bathroom_hallway
apply_mode: "{{ apply | default('ramp') }}"
sequence:
- repeat:
for_each: "{{ schedule.keys() | list }}"
sequence:
- variables:
light: "{{ repeat.item }}"
# [target now, target 5 minutes ago - what the last ramp tick set]
levels: >-
{%- macro at(pts, t) -%}
{%- if t <= pts[0][0] -%}{{ pts[0][1] }}
{%- elif t >= pts[-1][0] -%}{{ pts[-1][1] }}
{%- else -%}
{%- for i in range(pts | length - 1) if pts[i][0] <= t < pts[i + 1][0] -%}
{{ (pts[i][1] + (pts[i + 1][1] - pts[i][1]) * (t - pts[i][0]) / (pts[i + 1][0] - pts[i][0])) | round(0) | int }}
{%- endfor -%}
{%- endif -%}
{%- endmacro -%}
{%- set t = now().hour * 60 + now().minute -%}
{{ [at(schedule[repeat.item], t) | int, at(schedule[repeat.item], t - 5) | int] }}
current: "{{ ((state_attr(repeat.item, 'brightness') or 0) / 2.55) | round(0) | int }}"
skip: "{{ is_state('input_boolean.tv_mode', 'on') and repeat.item in tv_mode_lights }}"
- choose:
- conditions: "{{ not skip and apply_mode == 'force' }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ light }}"
data:
brightness_pct: "{{ levels[0] }}"
transition: 2
# A light more than 10 points off the last tick was set by hand; the
# biggest scheduled change in 5 minutes is ~8
- conditions: >-
{{ not skip and apply_mode == 'ramp' and is_state(light, 'on')
and (current - levels[1]) | abs <= 10 and current != levels[0] }}
sequence:
- action: light.turn_on
target:
entity_id: "{{ light }}"
data:
brightness_pct: "{{ levels[0] }}"
transition: 60
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,28 @@
-- Deployed as Server/debbzoid_spawnpoints.lua, and currently unused: nothing
-- reads it, because no entry in spawnregions.lua names it with
-- serverfile = "debbzoid_spawnpoints.lua". The contents below are the stock
-- sample and mean nothing until such an entry exists.
--
-- This is the file to use if a spawn point has to move rather than disappear.
-- Point a region at it by serverfile instead of file, and put that region's
-- whole spawn table here -- the serverfile replaces the map's list, it does not
-- merge with it, so anything left out is gone.
--
-- Keeping "Echo Creek, KY" as a spawn choice while freeing the diner would look
-- like this, with an outdoor square -- the parking lot or the road, not another
-- building, or that building becomes the unclaimable one instead:
--
-- unemployed = {
-- { posX = <outdoor X>, posY = <outdoor Y>, posZ = 0 },
-- }
--
-- Coordinates are absolute world tiles when worldX/worldY are omitted; with
-- them, the world position is worldX * 300 + posX (same for Y).
--
-- The filename is not free-form: serverfile resolves inside the server's
-- Server/ directory, and the deploy names this copy after zomboid_server_name.
-- Rename the server and the serverfile reference has to follow, or the region
-- loses its points and drops out of the list.
function SpawnPoints() function SpawnPoints()
return { return {
unemployed = { unemployed = {
@@ -1,7 +1,35 @@
-- Spawn regions offered to new characters, deployed as
-- Server/debbzoid_spawnregions.lua. PZ reads it at every server start, so a
-- change here takes effect on the live world after a restart -- no wipe.
--
-- Each entry either points at a map's own list (file = ...) or at a file in the
-- server's Server/ directory (serverfile = ...). Both are loaded by
-- media/lua/shared/SpawnRegions.lua; a region whose file is missing logs
-- "spawn points may be broken" to the server console and is dropped from the
-- list, taking its spawn choices with it.
--
-- This list is also what makes buildings unclaimable. SafeHouse.canBeSafehouse
-- walks every point of every region here, resolves each to a grid square, and
-- refuses the claim with "Spawn location. Cannot be claimed." if the square
-- being claimed is inside that square's building. It does not care which
-- profession the point belongs to, whether the region is reachable, or whether
-- the player is an admin. A point on an outdoor square blocks nothing, because
-- an outdoor square has no building.
--
-- 2026-08-31: "Echo Creek, KY" is commented out below. Its spawn list holds
-- exactly one point -- posX 3573, posY 10899, unemployed only -- and that point
-- sits inside the Echo Creek gas station/diner, which is why the diner cannot
-- be claimed as a safehouse. There is no server option that lifts the
-- restriction, so removing the region is the only way to free the building
-- short of moving the point somewhere else (see spawnpoints.lua).
--
-- The cost of removing it: unemployed characters lose Echo Creek as a spawn
-- choice. Nothing else changes -- the town, its loot and its buildings stay in
-- the world, and no existing character is affected.
function SpawnRegions() function SpawnRegions()
return { return {
{ name = "Brandenburg, KY", file = "media/maps/Brandenburg, KY/spawnpoints.lua" }, { name = "Brandenburg, KY", file = "media/maps/Brandenburg, KY/spawnpoints.lua" },
{ name = "Echo Creek, KY", file = "media/maps/Echo Creek, KY/spawnpoints.lua" }, -- { name = "Echo Creek, KY", file = "media/maps/Echo Creek, KY/spawnpoints.lua" },
{ name = "Ekron, KY", file = "media/maps/Ekron, KY/spawnpoints.lua" }, { name = "Ekron, KY", file = "media/maps/Ekron, KY/spawnpoints.lua" },
{ name = "Fallas Lake, KY", file = "media/maps/Fallas Lake, KY/spawnpoints.lua" }, { name = "Fallas Lake, KY", file = "media/maps/Fallas Lake, KY/spawnpoints.lua" },
{ name = "Irvington, KY", file = "media/maps/Irvington, KY/spawnpoints.lua" }, { name = "Irvington, KY", file = "media/maps/Irvington, KY/spawnpoints.lua" },
+13 -1
View File
@@ -1,8 +1,20 @@
--- ---
# -x keeps this on the local filesystem. Two TrueNAS CIFS shares are mounted
# INSIDE this tree -- volumes/photos/immich and volumes/photos/storage -- and
# without -x restorecon walked the entire remote photo library over SMB,
# relabelling a filesystem that cannot even store SELinux xattrs. On 2026-08-28
# that pinned CPU#0 at 100% system time and the kernel logged escalating soft
# lockups ("BUG: soft lockup - CPU#0 stuck for 1423s! [restorecon]") until the
# host could no longer create login sessions and needed a hard reboot. An
# earlier run the same day had already been SIGKILLed at 49s, which was the
# same problem surfacing quietly.
#
# The mounts are also x-systemd.automount, so merely walking into them triggers
# a mount -- there is nothing to relabel there and never was.
- name: restorecon podman - name: restorecon podman
become: true become: true
ansible.builtin.command: | ansible.builtin.command: |
restorecon -Frv {{ podman_home }}/.local/share/volumes restorecon -Frxv {{ podman_home }}/.local/share/volumes
tags: tags:
- podman - podman
- selinux - selinux
@@ -0,0 +1,855 @@
---
# 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"
# Settings Skudak only ever had from clicks in the LibreSign admin page, never
# in git -- a fresh instance without them cannot invite anyone by address:
#
# identify_methods The EMAIL identification method. Without it the signer
# search only lists accounts, so an outside address
# returns a bare "No signers." -- the whole point of this
# instance. clickToSign (no emailed code) and
# can_create_account=false, as on Skudak: the emailed link
# is the identity check, and customers never get accounts.
# signature_background_type=deleted
# Drops the LibreSign logo watermark from behind the
# stamp, so with GRAPHIC_ONLY the stamp is the drawn mark
# and nothing else.
# collect_metadata Records signer IP/user agent alongside each signature.
- name: set libresign signer identification and stamp settings 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 {{ item.k }} --value={{ item.v | quote }}
register: debyltech_libresign_settings
changed_when: "'is now set to' in debyltech_libresign_settings.stdout"
loop:
- k: identify_methods
v: >-
{{ [{'name': 'email', 'friendly_name': 'Email', 'enabled': true,
'mandatory': true,
'signatureMethods': {
'clickToSign': {'name': 'clickToSign', 'enabled': true},
'emailToken': {'name': 'emailToken', 'enabled': false}},
'can_create_account': false,
'test_url': '/index.php/settings/admin/mailtest',
'signatureMethodEnabled': 'clickToSign'}] | to_json }}
- {k: signature_background_type, v: deleted}
- {k: collect_metadata, v: "1"}
loop_control:
label: "{{ item.k }}"
# The validation footer ("Digitally signed by ... Validate in <url>") is WANTED
# -- only its QR code goes, below. FooterHandler defaults add_footer to true
# when unset, but the 14.2 admin page renders unset as UNCHECKED, inviting
# someone to "correct" it into actually turning the footer off. Stored
# explicitly so the page shows what the code does.
- name: keep libresign validation footer text 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 add_footer --value=1 --type=boolean
register: libresign_footer
changed_when: "'is now set to' in libresign_footer.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
# ---------------------------------------------------------------------------
# Customer isolation. Customers are admin-created accounts in a per-customer
# group, and must only READ what staff share with them -- not upload, not
# share onward, and not discover that other customers exist. Verified
# 2026-09-28 against a test customer, both in the UI and with the sharee and
# contacts-menu search services run as that user.
#
# shareapi_exclude_groups=allow + list=[staff]
# Only staff may share. NOT "yes" (exclude mode): that only disables
# sharing for users whose groups are ALL excluded, so a customer in
# their own per-customer group would never be caught by it.
# shareapi_allow_share_dialog_user_enumeration=no
# No partial-match browsing of accounts or groups, for anyone. By
# default a customer typing "bas" found the owner's account. Staff
# share to a customer group by typing its exact name; LibreSign signers
# are found by email and are unaffected.
# shareapi_default_permissions=1
# New shares default to View only; tick "Allow editing" per share to
# let a customer upload.
# files default_quota=0 B
# No personal storage, so no "+ New" in a customer's own home. Uploads
# into a share granted editing count against the OWNER's quota and still
# work. Staff are exempted by the next task.
# dav enableDefaultContact=false
# No "Leon Green" sample contact in new address books.
- name: set debyltech-cloud customer isolation policy
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data debyltech-cloud
php occ config:app:set {{ item.app }} {{ item.k }} --value={{ item.v | quote }}
{{ ('--type=' ~ item.t) if item.t is defined else '' }}
register: debyltech_isolation
changed_when: "'is now set to' in debyltech_isolation.stdout"
loop:
- {app: core, k: shareapi_exclude_groups, v: allow}
- {app: core, k: shareapi_exclude_groups_list, v: "{{ [cloud_debyltech_staff_group] | to_json }}"}
- {app: core, k: shareapi_allow_share_dialog_user_enumeration, v: "no"}
- {app: core, k: shareapi_default_permissions, v: "1"}
- {app: files, k: default_quota, v: "0 B"}
- {app: dav, k: enableDefaultContact, v: "0", t: boolean}
loop_control:
label: "{{ item.app }}.{{ item.k }}"
- name: exempt debyltech-cloud staff from the zero default quota
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
occ user:info {{ item | quote }} >/dev/null 2>&1 || exit 0
cur=$(occ user:setting {{ item | quote }} files quota) || cur='<unset>'
if [ "$cur" != none ]; then
occ user:setting {{ item | quote }} files quota none
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_staff_quota
changed_when: "'CHANGED' in debyltech_staff_quota.stdout"
loop: "{{ cloud_debyltech_staff_users }}"
# What a customer can reach. Nextcloud had nothing group-restricted, so every
# customer saw Dashboard, Photos, Office and the rest in the app menu.
#
# Disabled outright -- promos, prompts, or directory-ish features a business
# file-and-signing portal has no use for (contactsinteraction silently adds
# whoever shares with you to your address book; app_api only produces a
# setup warning here). lookup_server_connector cannot be disabled (occ refuses)
# -- the empty `lookup_server` system value below switches it off instead.
#
# Restricted to staff: dashboard and office. `defaultapp` below lists
# dashboard first so staff land there and customers fall through to Files.
#
# NOT restricted: libresign. A group restriction is enforced for anonymous
# requests too (AppManager::checkAppForUser returns false for no user), so it
# would break the public signing links sent to outside signers and to any
# customer already logged in. Who may AUTHOR requests is LibreSign's own
# groups_request_sign, pinned to staff below.
- name: disable unneeded apps in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
if occ app:list --output=json | python3 -c 'import json,sys; sys.exit(0 if sys.argv[1] in json.load(sys.stdin)["enabled"] else 1)' {{ item | quote }}; then
occ app:disable {{ item | quote }}
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_app_disable
changed_when: "'CHANGED' in debyltech_app_disable.stdout"
# occ exits 0 even when it refuses ("can't be disabled").
failed_when: debyltech_app_disable.rc != 0 or "can't be disabled" in debyltech_app_disable.stdout
loop:
- firstrunwizard
- recommendations
- related_resources
- weather_status
- survey_client
- support
- app_api
- contactsinteraction
- photos
- name: restrict staff-only apps in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
want={{ [cloud_debyltech_staff_group] | to_json | quote }}
if [ "$(occ config:app:get {{ item | quote }} enabled || true)" != "$want" ]; then
occ app:enable --groups {{ cloud_debyltech_staff_group | quote }} {{ item | quote }} >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_app_restrict
changed_when: "'CHANGED' in debyltech_app_restrict.stdout"
loop:
- dashboard
- office
- name: pin who may request libresign signatures 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 groups_request_sign
--value={{ [cloud_debyltech_staff_group] | to_json | quote }}
register: debyltech_request_sign
changed_when: "'is now set to' in debyltech_request_sign.stdout"
# ---------------------------------------------------------------------------
# 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"
# Behind rootless podman's port forwarder, every request -- Caddy's included --
# reaches Apache FROM THE CONTAINER'S OWN ADDRESS on `shared`, not from the
# host. Unless exactly that address is a trusted proxy, Nextcloud ignores the
# X-Forwarded-For header Caddy sends, so every client looks like one IP:
# brute-force throttling then penalises everyone at once. Read per deploy
# because the address is assigned at container creation, and deploys are the
# only thing that recreate it.
- name: read debyltech-cloud container address
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman inspect debyltech-cloud
--format "{{ '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' }}"
register: debyltech_cloud_ip
changed_when: false
failed_when: debyltech_cloud_ip.stdout is not match('^[0-9.]+$')
# 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 "$@"; }
# An UNSET key prints nothing and exits 1 -- indistinguishable from ""
# by output alone, which would skip setting an intentionally empty value.
cur=$(occ config:system:get {{ item.k }}) || cur='<unset>'
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: trusted_proxies 0, v: "{{ debyltech_cloud_ip.stdout }}"}
# Hour in UTC: 05:00 UTC is 01:00/00:00 Eastern, ahead of the 04:15 backup.
- {k: maintenance_window_start, v: "5", t: integer}
- {k: default_phone_region, v: US}
# New accounts -- customers above all -- start with an empty home rather
# than Nextcloud's sample Manual/intro video/Readme and Templates folder.
- {k: skeletondirectory, v: ""}
- {k: templatedirectory, v: ""}
# First ENABLED app wins: staff get the dashboard, and customers -- who
# cannot open it (restricted below) -- fall through to Files.
- {k: defaultapp, v: "dashboard,files"}
# No per-account profile pages.
- {k: profile.enabled, v: "false", t: boolean}
# Never query or publish to the global lookup server (lookup.nextcloud.com).
- {k: lookup_server, v: ""}
- {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.
# Plain theming_debyltech_background instead of Nextcloud's stock blue-shapes
# image. `background backgroundColor` is a special case in UpdateConfig.php
# (absent from --help) that drops the image and sets backgroundMime, exactly
# what the admin UI's "remove background image" does.
- name: use a plain colour login background in debyltech-cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.shell: |
set -o pipefail
occ() { podman exec -u www-data debyltech-cloud php occ "$@"; }
if [ "$(occ config:app:get theming backgroundMime || true)" != backgroundColor ]; then
occ theming:config background backgroundColor >/dev/null
echo CHANGED
fi
args:
executable: /bin/bash
register: debyltech_background
changed_when: "'CHANGED' in debyltech_background.stdout"
- 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"
@@ -25,6 +25,7 @@
loop: loop:
- configuration.yaml - configuration.yaml
- automations.yaml - automations.yaml
- scripts.yaml
- name: flush handlers - name: flush handlers
ansible.builtin.meta: flush_handlers ansible.builtin.meta: flush_handlers
@@ -94,26 +94,35 @@
- import_tasks: podman/podman-check.yml - import_tasks: podman/podman-check.yml
vars: vars:
container_name: immich-machine-learning container_name: "{{ immich_ml_container }}"
container_image: "{{ ml_image }}" container_image: "{{ ml_image }}"
# MACHINE_LEARNING_MODEL_TTL=0 keeps the models resident instead of unloading
# them after 300s idle. The default made every search following an idle gap
# reload four models (CLIP, buffalo_l detection + recognition, PP-OCRv5) on a
# CPU-only 4-core box, then tear those mappings down again -- and it is that
# teardown, in exit_mmap, that wedged a worker thread in uninterruptible sleep
# on 2026-08-27 and took smart search, face detection and OCR down with it.
# Costs ~1-2 GB resident to remove the code path entirely.
- name: create immich-ml container - name: create immich-ml container
become: true become: true
become_user: "{{ podman_user }}" become_user: "{{ podman_user }}"
containers.podman.podman_container: containers.podman.podman_container:
name: immich-machine-learning name: "{{ immich_ml_container }}"
image: "{{ ml_image }}" image: "{{ ml_image }}"
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
network: network:
- shared - shared
env:
MACHINE_LEARNING_MODEL_TTL: "0"
volumes: volumes:
- "{{ photos_path }}/mlcache:/cache" - "{{ photos_path }}/mlcache:/cache"
- name: create systemd startup job for immich-machine-learning - name: create systemd startup job for immich-machine-learning
include_tasks: podman/systemd-generate.yml include_tasks: podman/systemd-generate.yml
vars: vars:
container_name: immich-machine-learning container_name: "{{ immich_ml_container }}"
- import_tasks: podman/podman-check.yml - import_tasks: podman/podman-check.yml
vars: vars:
@@ -186,6 +195,10 @@
DB_USERNAME: photos DB_USERNAME: photos
DB_PASSWORD: "{{ photos_db_pass }}" DB_PASSWORD: "{{ photos_db_pass }}"
IMMICH_PORT: 8088 IMMICH_PORT: 8088
# Set explicitly rather than relying on immich's default of
# http://immich-machine-learning:3003, so the ML container can be renamed
# without silently losing search, faces and OCR.
IMMICH_MACHINE_LEARNING_URL: "http://{{ immich_ml_container }}:3003"
volumes: volumes:
- "{{ photos_path }}/storage:/mnt/media/originals" - "{{ photos_path }}/storage:/mnt/media/originals"
- "{{ photos_path }}/immich:/usr/src/app/upload" - "{{ photos_path }}/immich:/usr/src/app/upload"
@@ -0,0 +1,51 @@
---
# The image runs as uid 10001 (scratch, no passwd file). Rootless podman maps
# container uid N to host uid subuid_start + N - 1, so own the directory as
# that host uid directly. Setting the podman user here and chowning back
# afterwards would flip ownership on every deploy and briefly lock the running
# app out of its own database directory.
- name: create rsvp host directory volumes
become: true
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ podman_subuid.stdout | int + 10000 }}"
group: "{{ podman_subuid.stdout | int + 10000 }}"
mode: 0750
notify: restorecon podman
loop:
- "{{ rsvp_path }}/data"
- name: flush handlers
ansible.builtin.meta: flush_handlers
- import_tasks: podman/podman-check.yml
vars:
container_name: rsvp
container_image: "{{ image }}"
- name: create rsvp container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: rsvp
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
env:
RSVP_DB_PATH: /data/rsvp.db
RSVP_BASE_URL: "https://{{ rsvp_server_name }}"
RSVP_TZ: America/New_York
# Client IPs for the invite-link miss limiter come from Caddy's
# X-Forwarded-For. Safe only because both ports are loopback-only.
RSVP_TRUST_FORWARDED: "1"
volumes:
- "{{ rsvp_path }}/data:/data"
ports:
- "127.0.0.1:{{ rsvp_public_port }}:8080"
- "127.0.0.1:{{ rsvp_admin_port }}:8081"
- name: create systemd startup job for rsvp
include_tasks: podman/systemd-generate.yml
vars:
container_name: rsvp
@@ -34,6 +34,10 @@
loop: loop:
- config-template - config-template
- config-backup - config-backup
# Holds the world each restore displaces, so a restore is always undoable.
# Deliberately outside data/ -- that is the container's bind mount, and a
# stray world directory inside Saves/Multiplayer/ is something PZ would see.
- restore-backup
- name: create zomboid host-side log directory - name: create zomboid host-side log directory
become: true become: true
@@ -82,6 +86,35 @@
mode: '0644' mode: '0644'
notify: reload zomboid systemd notify: reload zomboid systemd
- name: deploy zomboid restore script
become: true
ansible.builtin.template:
src: zomboid/zomboid-restore.sh.j2
dest: "{{ podman_home }}/bin/zomboid-restore.sh"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: deploy zomboid restore path unit
become: true
ansible.builtin.template:
src: zomboid/zomboid-restore.path.j2
dest: "{{ podman_home }}/.config/systemd/user/zomboid-restore.path"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: reload zomboid systemd
- name: deploy zomboid restore service unit
become: true
ansible.builtin.template:
src: zomboid/zomboid-restore.service.j2
dest: "{{ podman_home }}/.config/systemd/user/zomboid-restore.service"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: reload zomboid systemd
- name: deploy zomboid stats script - name: deploy zomboid stats script
become: true become: true
ansible.builtin.template: ansible.builtin.template:
@@ -152,8 +185,12 @@
mode: 0644 mode: 0644
notify: restorecon podman notify: restorecon podman
# Server config is seeded from the vendored Sophie 42 preset *before* first # Server config is seeded *before* first boot, so the server never generates a
# boot, so the server never generates a vanilla INI we then have to patch. # vanilla INI we then have to patch.
#
# The INI and SandboxVars started as the vendored Sophie 42 preset and were
# re-synced from the running debbzoid world on 2026-08-31 -- see the header of
# templates/zomboid/server.ini.j2 for the INI keys that moved and why.
# #
# force is off by design: these files are a starting point, not a managed # force is off by design: these files are a starting point, not a managed
# state. Stop the server, hand-edit them, regenerate the world -- Ansible will # state. Stop the server, hand-edit them, regenerate the world -- Ansible will
@@ -170,7 +207,7 @@
notify: restorecon podman notify: restorecon podman
tags: zomboid-conf tags: zomboid-conf
- name: seed zomboid server ini from sophie preset - name: seed zomboid server ini
become: true become: true
ansible.builtin.template: ansible.builtin.template:
src: zomboid/server.ini.j2 src: zomboid/server.ini.j2
@@ -183,17 +220,42 @@
tags: zomboid-conf tags: zomboid-conf
# Copied, not templated: these are Lua and must not go through Jinja. # Copied, not templated: these are Lua and must not go through Jinja.
- name: seed zomboid sandbox and spawn config from sophie preset - name: seed zomboid sandbox settings
become: true become: true
ansible.builtin.copy: ansible.builtin.copy:
src: "zomboid/sophie/{{ item }}.lua" src: zomboid/sophie/SandboxVars.lua
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_{{ item }}.lua" dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_SandboxVars.lua"
force: "{{ zomboid_config_force | bool }}" force: "{{ zomboid_config_force | bool }}"
owner: "{{ podman_subuid.stdout }}" owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}" group: "{{ podman_user }}"
mode: 0644 mode: 0644
notify: restorecon podman
tags: zomboid-conf
# Spawn config gets its own force switch, and it is not pedantry: these two
# files are the only part of the server config that a *running* world will pick
# up. PZ reads <name>_spawnregions.lua (and any serverfile it names) at every
# server start; SandboxVars is read once, when the world is created. So a spawn
# change is deployable on the live world -- push, restart, done -- while a
# SandboxVars change is not, and needs a wipe to mean anything.
#
# Sharing zomboid_config_force between them would mean force-pushing the whole
# preset to land a one-line spawnregions edit, which also rewrites the INI (a
# ResetID mismatch tells every client to reroll) and the world's SandboxVars.
#
# make deploy TAGS=zomboid-conf -e zomboid_spawn_force=true
#
# then restart the server -- with players offline -- for it to take effect.
- name: seed zomboid spawn config
become: true
ansible.builtin.copy:
src: "zomboid/sophie/{{ item }}.lua"
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_{{ item }}.lua"
force: "{{ zomboid_spawn_force | bool }}"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0644
loop: loop:
- SandboxVars
- spawnregions - spawnregions
- spawnpoints - spawnpoints
notify: restorecon podman notify: restorecon podman
@@ -231,7 +293,7 @@
state: started state: started
daemon_reload: true daemon_reload: true
# A pristine copy of the Sophie preset, kept where the server cannot overwrite it. # The settings this repo would deploy, kept where the server cannot overwrite them.
# #
# Reference only -- nothing applies this automatically. A wipe deliberately leaves # Reference only -- nothing applies this automatically. A wipe deliberately leaves
# the live settings alone so hand tuning survives it. # the live settings alone so hand tuning survives it.
@@ -242,12 +304,21 @@
# much as an input, and that is how a Sophie world quietly became an Apocalypse # much as an input, and that is how a Sophie world quietly became an Apocalypse
# one -- 139 values reverted, loot from 0.35 back to 0.9, CharacterFreePoints # one -- 139 values reverted, loot from 0.35 back to 0.9, CharacterFreePoints
# from 0 to 60. When that happens again, this is what to copy back from. # from 0 to 60. When that happens again, this is what to copy back from.
#
# What it holds changed on 2026-08-31. It was the pristine Sophie preset; it is
# now a snapshot of the live debbzoid world, because the admins' tuning had by
# then diverged from the preset in the same 139 values and re-seeding from the
# preset would have thrown that tuning away rather than restored it.
#
# force is on here, and only here. Nothing on the host writes this directory --
# the server cannot see it -- so it has no hand edits to protect, and if it does
# not track the repo it is not a restore point, just an older world's settings.
- name: seed canonical zomboid world settings - name: seed canonical zomboid world settings
become: true become: true
ansible.builtin.copy: ansible.builtin.copy:
src: "zomboid/sophie/{{ item }}.lua" src: "zomboid/sophie/{{ item }}.lua"
dest: "{{ zomboid_path }}/config-template/{{ item }}.lua" dest: "{{ zomboid_path }}/config-template/{{ item }}.lua"
force: "{{ zomboid_config_force | bool }}" force: true
owner: "{{ podman_user }}" owner: "{{ podman_user }}"
group: "{{ podman_user }}" group: "{{ podman_user }}"
mode: 0644 mode: 0644
@@ -723,3 +794,15 @@
enabled: true enabled: true
state: started state: started
daemon_reload: true daemon_reload: true
# Restore is triggered the same way -- Discord bot -> trigger file -> path unit.
# See zomboid-restore.path and zomboid-restore.service.
- name: enable zomboid restore path unit
become: true
become_user: "{{ podman_user }}"
ansible.builtin.systemd:
name: zomboid-restore.path
scope: user
enabled: true
state: started
daemon_reload: true
@@ -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
+27 -4
View File
@@ -39,7 +39,7 @@
- import_tasks: containers/home/hass.yml - import_tasks: containers/home/hass.yml
vars: vars:
image: ghcr.io/home-assistant/home-assistant:2026.5.1 image: ghcr.io/home-assistant/home-assistant:2026.9.3
tags: hass tags: hass
- import_tasks: containers/home/partsy.yml - import_tasks: containers/home/partsy.yml
@@ -79,15 +79,31 @@
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:20260827.1453 image: git.debyl.io/debyltech/fulfillr:20260929.1624
tags: debyltech, fulfillr tags: debyltech, fulfillr
# Staging back-office (fulfillr-dev.debyltech.com) — same image, staging Turso config. # Staging back-office (fulfillr-dev.debyltech.com) — same image, staging Turso config.
- import_tasks: containers/debyltech/fulfillr-dev.yml - import_tasks: containers/debyltech/fulfillr-dev.yml
vars: vars:
image: git.debyl.io/debyltech/fulfillr:20260825.1909 image: git.debyl.io/debyltech/fulfillr:20260929.1624
tags: debyltech, fulfillr-dev tags: debyltech, fulfillr-dev
- import_tasks: containers/debyltech/uptime-kuma.yml - import_tasks: containers/debyltech/uptime-kuma.yml
@@ -123,9 +139,16 @@
- import_tasks: containers/home/gregtime.yml - import_tasks: containers/home/gregtime.yml
vars: vars:
image: localhost/greg-time-bot:3.16.5 image: localhost/greg-time-bot:3.21.1
tags: gregtime tags: gregtime
# Built and loaded by `make deploy-remote` in ~/src/rsvp-debylio; bump this to
# the VERSION it loaded. The Caddy vhost ships with the caddy-config tag.
- import_tasks: containers/home/rsvp.yml
vars:
image: localhost/rsvpd:1.0.7
tags: rsvp
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken # Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
# down for a CI-heavy stretch without losing the world. # down for a CI-heavy stretch without losing the world.
- import_tasks: containers/home/zomboid.yml - import_tasks: containers/home/zomboid.yml
@@ -12,6 +12,23 @@
when: container.containers[0]["ImageName"] != container_image when: container.containers[0]["ImageName"] != container_image
ignore_errors: true ignore_errors: true
# Pull the new image BEFORE the old container is removed, so a tag that does
# not exist (or a registry that is down) fails the play here and leaves the
# running container untouched. Locally built images (localhost/...) are never
# pulled - they must already be in the podman user's storage. A container that
# does not exist yet has nothing to protect (and no containers[0] to compare),
# so the length check comes first; `when` list items stop at the first false.
- name: pull new image before replacing container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_image:
name: "{{ container_image }}"
state: present
when:
- container.containers | length > 0
- container.containers[0]["ImageName"] != container_image
- not container_image.startswith("localhost/")
- name: delete container if necessary - name: delete container if necessary
become: true become: true
become_user: "{{ podman_user }}" become_user: "{{ podman_user }}"
@@ -23,6 +23,26 @@
} }
format {{ caddy_log_format }} format {{ caddy_log_format }}
level {{ caddy_log_level }} level {{ caddy_log_level }}
# See rsvp-errors below.
exclude http.log.error.rsvp
}
# {{ rsvp_server_name }}: when a proxied request fails, Caddy's error log
# records the raw request URI -- which for /i/<token> is a working invite
# link. Send those through the same redaction as the site's access log
# instead of the default log above.
log rsvp-errors {
output file /var/log/caddy/rsvp.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format filter {
wrap json
request>uri regexp ^/i/[^/?#]+ /i/REDACTED
request>headers>Referer delete
}
include http.log.error.rsvp
} }
} }
@@ -193,6 +213,56 @@
} }
} }
# RSVP - {{ rsvp_server_name }} (public invite links, LAN-only /admin)
#
# Does NOT import common_headers: its Referrer-Policy "same-origin" would
# replace the app's "no-referrer", and invite tokens live in the URL path.
{{ rsvp_server_name }} {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Robots-Tag "noindex, nofollow"
Referrer-Policy "no-referrer"
Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
-Server
}
# Admin is a second listener with no login. It is only published on
# loopback, and only proxied here for LAN clients -- two independent controls.
@admin_local {
path /admin /admin/*
remote_ip {{ caddy_local_networks | join(' ') }}
}
handle @admin_local {
reverse_proxy 127.0.0.1:{{ rsvp_admin_port }}
}
handle /admin* {
respond "Not found" 404
}
handle {
reverse_proxy 127.0.0.1:{{ rsvp_public_port }}
}
# The invite token is the credential and it is in the URL path, so redact
# it from the request URI and drop the redirect Location (POSTs 303 back to
# /i/<token>) before anything is written. Named "rsvp" so its error logger
# (http.log.error.rsvp) can be redirected in the global options.
log rsvp {
output file /var/log/caddy/rsvp.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format filter {
wrap json
request>uri regexp ^/i/[^/?#]+ /i/REDACTED
request>headers>Referer delete
resp_headers>Location delete
}
}
}
# Uptime Kuma (Debyltech) - {{ uptime_kuma_server_name }} # Uptime Kuma (Debyltech) - {{ uptime_kuma_server_name }}
{{ uptime_kuma_server_name }} { {{ uptime_kuma_server_name }} {
{{ ip_restricted_site() }} {{ ip_restricted_site() }}
@@ -387,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
@@ -53,5 +53,13 @@
"recovery": { "recovery": {
"schedule_name": "cart-recovery-dev", "schedule_name": "cart-recovery-dev",
"schedule_group": "default" "schedule_group": "default"
}{%- if fulfillr_ga4_credentials_json is defined %},
{# GA4 Data API for the portal Traffic & funnel tab (SCRUM-196). Renders only once the
service-account key `fulfillr_ga4_credentials_json` (the key JSON, as a mapping or string)
is in the vault; without it the traffic endpoint reports configured:false. #}
"analytics": {
"ga4_property_id": "{{ fulfillr_ga4_property_id }}",
"ga4_credentials": {{ (fulfillr_ga4_credentials_json if fulfillr_ga4_credentials_json is mapping else (fulfillr_ga4_credentials_json | from_json)) | to_json }}
} }
{%- endif %}
} }
@@ -53,5 +53,13 @@
"recovery": { "recovery": {
"schedule_name": "cart-recovery-prod", "schedule_name": "cart-recovery-prod",
"schedule_group": "default" "schedule_group": "default"
}{%- if fulfillr_ga4_credentials_json is defined %},
{# GA4 Data API for the portal Traffic & funnel tab (SCRUM-196). Renders only once the
service-account key `fulfillr_ga4_credentials_json` (the key JSON, as a mapping or string)
is in the vault; without it the traffic endpoint reports configured:false. #}
"analytics": {
"ga4_property_id": "{{ fulfillr_ga4_property_id }}",
"ga4_credentials": {{ (fulfillr_ga4_credentials_json if fulfillr_ga4_credentials_json is mapping else (fulfillr_ga4_credentials_json | from_json)) | to_json }}
} }
{%- endif %}
} }
@@ -0,0 +1,228 @@
<?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 (!str_contains($html, 'content="light only"')) {
$failures[] = 'color-scheme "light only" meta missing -- dark-mode mail clients will repaint '
. 'the ground and bury the black wordmark (did upstream rename </head> in $head?)';
}
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.';
}
// Outside signers are invited by address; without an enabled email identify
// method the signer search returns "No signers." for any non-account email.
$methods = json_decode($appConfig->getValueString('libresign', 'identify_methods', '[]'), true) ?: [];
$emailOn = false;
foreach ($methods as $m) {
if (($m['name'] ?? '') === 'email' && !empty($m['enabled'])) {
$emailOn = true;
}
}
if (!$emailOn) {
$failures[] = 'libresign email identify method is not enabled -- outside addresses '
. 'cannot be added as signers ("No signers.")';
}
// Customer isolation (see the policy task in containers/debyltech/cloud.yml).
// A stray click in Settings > Sharing can undo any of these, and each one
// quietly re-exposes customers to one another or lets them share onward.
$isolation = [
'shareapi_exclude_groups' => 'allow',
'shareapi_exclude_groups_list' => json_encode(['{{ cloud_debyltech_staff_group }}']),
'shareapi_allow_share_dialog_user_enumeration' => 'no',
'shareapi_default_permissions' => '1',
];
foreach ($isolation as $key => $want) {
$have = \OC::$server->get(\OCP\IConfig::class)->getAppValue('core', $key, '<unset>');
if ($have !== $want) {
$failures[] = "core $key is \"$have\" -- expected \"$want\" (customer isolation)";
}
}
// Only staff may AUTHOR signature requests (LibreSign cannot be group-
// restricted without breaking its public signing links).
$requesters = json_decode($appConfig->getValueString('libresign', 'groups_request_sign', ''), true);
if ($requesters !== ['{{ cloud_debyltech_staff_group }}']) {
$failures[] = 'libresign groups_request_sign is ' . json_encode($requesters)
. ' -- expected only the staff group, or customers could send signature requests';
}
$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
@@ -1,13 +1,26 @@
#!/bin/bash #!/bin/bash
# {{ ansible_managed }} # {{ ansible_managed }}
# Weekly reclaim of unused podman images and volumes. # Daily reclaim of unused podman images, volumes and exited job containers.
# #
# Every image bump leaves the previous tag behind and nothing ever removed # Every image bump leaves the previous tag behind and nothing ever removed
# them: this was written after finding 896 images totalling 59.6 GB, 75% of it # them: this was written after finding 896 images totalling 59.6 GB, 75% of it
# unused -- 94 tags of greg-time-bot and 73 of fulfillr, one per deploy. # unused -- 94 tags of greg-time-bot and 73 of fulfillr, one per deploy.
# #
# --filter until={{ podman_prune_until }} keeps recent images so a rollback # Two policies, because the stores serve different purposes:
# does not require a rebuild or re-pull. Anything older is unused AND stale. #
# service users ({{ podman_prune_users | join(', ') }})
# until={{ podman_prune_until }} keeps recent images so a rollback does not
# require a rebuild or re-pull. Containers are deliberately NOT pruned here:
# they are the live services, and reaping one that merely happens to be
# stopped would turn a transient crash into a unit that cannot start again
# until the next deploy.
#
# CI users ({{ podman_prune_ci_users | join(', ') }})
# Build layers are throwaway and there is no rollback to protect, so these
# get a much shorter window ({{ podman_prune_ci_until }}) and their exited
# job containers are reaped too. They were never covered before: gitea-
# runner had reached 1205 images / 113 GB, 100% of it reclaimable, and it
# is the layer count that makes overlayfs lookups -- and so CI itself -- slow.
# #
# Volumes pruned here are podman's ANONYMOUS volumes, not the bind mounts # Volumes pruned here are podman's ANONYMOUS volumes, not the bind mounts
# under {{ podman_volumes }} that hold real service data -- those are # under {{ podman_volumes }} that hold real service data -- those are
@@ -20,34 +33,67 @@ TAG=podman-prune
log() { logger -t "$TAG" -p daemon.info -- "$*"; echo "$TAG: $*"; } log() { logger -t "$TAG" -p daemon.info -- "$*"; echo "$TAG: $*"; }
total_before=0 # Rootless podman: -H so HOME points at the user's store, and the `cd;`
total_after=0 # preamble is required (see CLAUDE.md) or podman cannot find its graph root.
run() {
for u in {{ podman_prune_users | join(' ') }}; do local u=$1
# Rootless podman: -H so HOME points at the user's store, and the `cd;` shift
# preamble is required (see CLAUDE.md) or podman cannot find its graph root.
run() {
sudo -H -u "$u" bash -c \ sudo -H -u "$u" bash -c \
'cd; d=/run/user/$(id -u); [ -d "$d" ] && export XDG_RUNTIME_DIR="$d" 'cd; d=/run/user/$(id -u); [ -d "$d" ] && export XDG_RUNTIME_DIR="$d"
exec podman "$@"' _ "$@" exec podman "$@"' _ "$@"
} }
# prune_user <user> <until> <prune_containers: yes|no> [image prune filter...]
prune_user() {
local u=$1 keep=$2 do_containers=$3
shift 3
local before after img vol con
if ! id "$u" >/dev/null 2>&1; then if ! id "$u" >/dev/null 2>&1; then
log "user=$u status=skipped reason=no-such-user" log "user=$u status=skipped reason=no-such-user"
continue return
fi fi
before=$(run system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1) before=$(run "$u" system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1)
# Deliberately NOT `set -e`: a prune failing for one user must not stop the # Deliberately NOT `set -e`: a prune failing for one user must not stop the
# other, and a busy image is a normal, non-fatal outcome. # others, and a busy image is a normal, non-fatal outcome.
img=$(run image prune -af --filter "until={{ podman_prune_until }}" 2>&1 | tail -1) #
vol=$(run volume prune -f 2>&1 | tail -1) # Containers are reaped BEFORE images on purpose -- an exited container pins
# the image it ran from, so pruning images first would leave those layers
# behind for another day.
con=none
if [ "$do_containers" = yes ]; then
con=$(run "$u" container prune -f --filter "until=$keep" 2>&1 | tail -1)
fi
after=$(run system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1) img=$(run "$u" image prune -af --filter "until=$keep" "$@" 2>&1 | tail -1)
vol=$(run "$u" volume prune -f 2>&1 | tail -1)
log "user=$u images_before=$before images_after=$after" after=$(run "$u" system df --format '{{ '{{' }}.Size{{ '}}' }}' 2>/dev/null | head -1)
log "user=$u image_prune=${img:-none} volume_prune=${vol:-none}"
log "user=$u keep=$keep size_before=$before size_after=$after"
log "user=$u image_prune=${img:-none} volume_prune=${vol:-none} container_prune=${con:-none}"
}
for u in {{ podman_prune_users | join(' ') }}; do
prune_user "$u" "{{ podman_prune_until }}" no
done
for u in {{ podman_prune_ci_users | join(' ') }}; do
# CI base images (gitea-ci, -espidf, -platformio) carry the keep label: they
# are rebuilt or re-pulled from the registry only when missing, so pruning
# them just forces a multi-GB re-download on the next job.
prune_user "$u" "{{ podman_prune_ci_until }}" yes --filter "label!={{ podman_prune_ci_keep_label }}"
# ...but the label is inherited by every build of those images, including the
# one a rebuild supersedes. That copy loses its tag and becomes dangling, and
# the label filter above would keep it forever -- a multi-GB leak per weekly
# rebuild, ESP-IDF alone being several GB. Without -a, `image prune` removes
# only dangling images, so it can ignore the label without touching the live
# tagged ones.
dangling=$(run "$u" image prune -f --filter "until={{ podman_prune_ci_until }}" 2>&1 | tail -1)
log "user=$u dangling_prune=${dangling:-none}"
done done
log "status=ok" log "status=ok"
@@ -1,11 +1,11 @@
[Unit] [Unit]
Description=Weekly podman image and volume prune Description=Daily podman image, volume and CI container prune
[Timer] [Timer]
OnCalendar={{ podman_prune_oncalendar | default('Sun *-*-* 02:00:00') }} OnCalendar={{ podman_prune_oncalendar | default('Sun *-*-* 02:00:00') }}
RandomizedDelaySec=15m RandomizedDelaySec=15m
# Weekly, and growth is one tag per deploy, so a missed run is worth catching # Growth is one tag per deploy plus every CI build, so a missed run is worth
# up on rather than skipping. # catching up on rather than skipping.
Persistent=true Persistent=true
[Install] [Install]
@@ -8,11 +8,30 @@
Only the keys this deployment actually owns are templated -- credentials, Only the keys this deployment actually owns are templated -- credentials,
RCON, Discord, backup cadence, and the mod lists. Every gameplay setting RCON, Discord, backup cadence, and the mod lists. Every gameplay setting
(PVP, safety system, safehouses, sleep, MaxPlayers, PauseEmpty, ResetID, (PVP, safety system, safehouses, sleep, MaxPlayers, PauseEmpty, ResetID,
the welcome message) is left exactly as the preset author shipped it. the welcome message) is a literal, and was the preset author's value until
the live-config sync below.
Seeded once, then left alone: the deploy task uses force=false so the Seeded once, then left alone: the deploy task uses force=false so the
server and admins can hand-edit this file between world regenerations. server and admins can hand-edit this file between world regenerations.
Re-push it deliberately with -e zomboid_config_force=true. Re-push it deliberately with -e zomboid_config_force=true.
Synced from the running debbzoid server on 2026-08-31, so a deliberate
force-push cannot silently revert what the admins set in-game. Eight keys
had drifted from the preset and now carry the live values:
PlayerSafehouse false -> true
SafehouseAllowNonResidential false -> true (the diner/gas-station case)
SafehouseAllowRespawn false -> true
SafehouseAllowLoot true -> false
SafehouseAllowFire true -> false
TrashDeleteAll false -> true
MapRemotePlayerVisibility 1 -> 4
ResetID 6953472 -> 826046
ResetID is in that list on purpose. It is the world's soft-reset token: if
the file's value ever differs from the one the live world was created with,
every connected client is told to roll a new character. Carrying the live
value means a force-push is a no-op instead of a server-wide wipe prompt.
-#} -#}
{% set rename_map = {} %} {% set rename_map = {} %}
{% for r in zomboid_mods_renamed %}{% set _ = rename_map.update({r.old: r.new}) %}{% endfor %} {% for r in zomboid_mods_renamed %}{% set _ = rename_map.update({r.old: r.new}) %}{% endfor %}
@@ -83,7 +102,7 @@ DefaultPort=16261
UDPPort=16262 UDPPort=16262
# Reset ID determines if the server has undergone a soft-reset. If this number does match the client, the client must create a new character. Used in conjunction with PlayerServerID. It is strongly advised that you backup these IDs somewhere Min: 0 Max: 2147483647 Default: 985847558 # Reset ID determines if the server has undergone a soft-reset. If this number does match the client, the client must create a new character. Used in conjunction with PlayerServerID. It is strongly advised that you backup these IDs somewhere Min: 0 Max: 2147483647 Default: 985847558
ResetID=6953472 ResetID=826046
# Enter the mod loading ID here. It can be found in \Steam\steamapps\workshop\modID\mods\modName\info.txt # Enter the mod loading ID here. It can be found in \Steam\steamapps\workshop\modID\mods\modName\info.txt
Mods={{ resolved_mod_ids | reject('in', excluded_mod_ids) | join(';') }} Mods={{ resolved_mod_ids | reject('in', excluded_mod_ids) | join(';') }}
@@ -131,7 +150,7 @@ AnnounceAnimalDeath=false
SaveWorldEveryMinutes=10 SaveWorldEveryMinutes=10
# Both admins and players can claim safehouses # Both admins and players can claim safehouses
PlayerSafehouse=false PlayerSafehouse=true
# Only admins can claim safehouses # Only admins can claim safehouses
AdminSafehouse=false AdminSafehouse=false
@@ -140,13 +159,13 @@ AdminSafehouse=false
SafehouseAllowTrepass=true SafehouseAllowTrepass=true
# Allow fire to damage safehouses # Allow fire to damage safehouses
SafehouseAllowFire=true SafehouseAllowFire=false
# Allow non-members to take items from safehouses # Allow non-members to take items from safehouses
SafehouseAllowLoot=true SafehouseAllowLoot=false
# Players will respawn in a safehouse that they were a member of before they died # Players will respawn in a safehouse that they were a member of before they died
SafehouseAllowRespawn=false SafehouseAllowRespawn=true
# Players must have survived this number of in-game days before they are allowed to claim a safehouse Min: 0 Max: 2147483647 Default: 0 # Players must have survived this number of in-game days before they are allowed to claim a safehouse Min: 0 Max: 2147483647 Default: 0
SafehouseDaySurvivedToClaim=0 SafehouseDaySurvivedToClaim=0
@@ -155,7 +174,7 @@ SafehouseDaySurvivedToClaim=0
SafeHouseRemovalTime=144 SafeHouseRemovalTime=144
# Governs whether players can claim non-residential buildings. # Governs whether players can claim non-residential buildings.
SafehouseAllowNonResidential=false SafehouseAllowNonResidential=true
SafehouseDisableDisguises=true SafehouseDisableDisguises=true
@@ -328,7 +347,7 @@ BanKickGlobalSound=true
RemovePlayerCorpsesOnCorpseRemoval=false RemovePlayerCorpsesOnCorpseRemoval=false
# If true, player can use the "delete all" button on bins. # If true, player can use the "delete all" button on bins.
TrashDeleteAll=false TrashDeleteAll=true
# If true, player can hit again when struck by another player. # If true, player can hit again when struck by another player.
PVPMeleeWhileHitReaction=false PVPMeleeWhileHitReaction=false
@@ -352,7 +371,7 @@ CarEngineAttractionModifier=0.5
PlayerBumpPlayer=false PlayerBumpPlayer=false
# Controls display of remote players on the in-game map. 1=Hidden 2=Friends 3=Friends and nearby players 4=Everyone Min: 1 Max: 4 Default: 1 # Controls display of remote players on the in-game map. 1=Hidden 2=Friends 3=Friends and nearby players 4=Everyone Min: 1 Max: 4 Default: 1
MapRemotePlayerVisibility=1 MapRemotePlayerVisibility=4
# Min: 1 Max: 300 Default: 5 # Min: 1 Max: 300 Default: 5
BackupsCount=10 BackupsCount=10
@@ -86,7 +86,8 @@ fi
# every later wipe regenerates from them -- which is how a Sophie world became an # every later wipe regenerates from them -- which is how a Sophie world became an
# Apocalypse one. Nothing here corrects that automatically any more, so the # Apocalypse one. Nothing here corrects that automatically any more, so the
# snapshot above is the way back: config-backup/ holds the settings as they were # snapshot above is the way back: config-backup/ holds the settings as they were
# before each wipe, and config-template/ holds the pristine Sophie preset. # before each wipe, and config-template/ holds what this repo deploys -- since
# 2026-08-31 a snapshot of Greg's live tuning, not the pristine Sophie preset.
# Start server # Start server
log "Starting zomboid service..." log "Starting zomboid service..."
@@ -0,0 +1,9 @@
[Unit]
Description=Watch for Zomboid backup restore trigger
[Path]
PathExists={{ podman_home }}/.local/share/volumes/gregtime/data/zomboid-restore.trigger
Unit=zomboid-restore.service
[Install]
WantedBy=default.target
@@ -0,0 +1,8 @@
[Unit]
Description=Zomboid Backup Restore Service
[Service]
Type=oneshot
ExecStart={{ podman_home }}/bin/zomboid-restore.sh
StandardOutput=journal
StandardError=journal
@@ -0,0 +1,222 @@
#!/bin/bash
# Zomboid Backup Restore Script
# Triggered by systemd path unit when the discord bot requests a restore.
#
# Sibling of world-reset.sh: same trigger-file -> path-unit -> oneshot shape, same
# podman unshare discipline. The difference is that a reset throws the world away
# and this puts an older one back, so it is a good deal more careful:
#
# - it resolves the target itself and never accepts a path from the trigger
# - it refuses and exits BEFORE stopping the server if anything is wrong
# - it moves the live world aside instead of deleting it, so every restore
# is undoable
set -e
VOL="{{ podman_home }}/.local/share/volumes"
LOGFILE="${VOL}/zomboid/logs/restore.log"
TRIGGER_FILE="${VOL}/gregtime/data/zomboid-restore.trigger"
RESULT_FILE="${VOL}/gregtime/data/zomboid-restore.result"
SERVER_NAME="{{ zomboid_server_name }}"
DATA="${VOL}/zomboid/data"
SAVES_PATH="${DATA}/Saves/Multiplayer/${SERVER_NAME}"
DB_PATH="${DATA}/db/${SERVER_NAME}.db"
BACKUPS="${DATA}/backups"
SNAP_ROOT="${VOL}/zomboid/restore-backup"
STAGING="${VOL}/zomboid/restore-staging"
KEEP_SNAPSHOTS=3
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
echo "$msg"
# Same reasoning as world-reset.sh: stdout is already in the journal, and an
# unwritable log file must never be what aborts a restore under set -e.
echo "$msg" >> "$LOGFILE" 2>/dev/null || true
}
# The bot reads this back to report into #zomboid. Written as the container uid
# so the bot (uid 1000 inside its own container) can actually open it.
result() {
local ok="$1" detail="$2"
printf '{"ok":%s,"detail":%s,"at":"%s"}\n' \
"$ok" "$(printf '%s' "$detail" | sed 's/\\/\\\\/g; s/"/\\"/g; s/^/"/; s/$/"/')" \
"$(date -Is)" > /tmp/zomboid-restore.result.$$ 2>/dev/null || return 0
podman unshare cp /tmp/zomboid-restore.result.$$ "$RESULT_FILE" 2>/dev/null || true
podman unshare chown 1000:1000 "$RESULT_FILE" 2>/dev/null || true
rm -f /tmp/zomboid-restore.result.$$ 2>/dev/null || true
}
die() {
log "ABORT: $1"
result false "$1"
exit 1
}
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
log "Restore triggered"
# ---------------------------------------------------------------------------
# 1. Read and validate the trigger
# ---------------------------------------------------------------------------
podman unshare test -f "$TRIGGER_FILE" || die "no trigger file"
TRIGGER_BODY="$(podman unshare cat "$TRIGGER_FILE")"
podman unshare rm -f "$TRIGGER_FILE"
field() { printf '%s\n' "$TRIGGER_BODY" | sed -n "s/^$1=//p" | head -1 | tr -d '\r'; }
ACTION="$(field action)"
SET="$(field set)"
INDEX="$(field index)"
MTIME="$(field mtime)"
REQUESTER="$(field requester)"
log "Requested by: ${REQUESTER:-unknown} (action=${ACTION:-restore})"
[[ "$ACTION" == "restore" || "$ACTION" == "undo" ]] || die "bad action '${ACTION}'"
# ---------------------------------------------------------------------------
# 2. Resolve the source -- entirely from our own filesystem, never from the
# trigger. The trigger only ever gets to *describe* a target.
# ---------------------------------------------------------------------------
SOURCE_DESC=""
SRC_ZIP=""
UNDO_DIR=""
if [[ "$ACTION" == "restore" ]]; then
[[ "$SET" == "period" || "$SET" == "startup" || "$SET" == "version" ]] \
|| die "bad backup set '${SET}'"
[[ "$MTIME" =~ ^[0-9]+$ ]] || die "bad mtime '${MTIME}'"
# Resolve by mtime, NOT by index. PZ rotates these every BackupsPeriod
# minutes -- backup_7.zip becomes backup_8.zip and so on -- so the index the
# bot showed a human 90 seconds ago may already point at a different world.
# The mtime is the only stable identity a PZ backup has.
for f in "${BACKUPS}/period"/backup_*.zip "${BACKUPS}/startup"/backup_*.zip "${BACKUPS}/version"/backup_*.zip; do
podman unshare test -f "$f" || continue
base="$(basename "$f")"
[[ "$base" =~ ^backup_[0-9]+\.zip$ ]] || continue
m="$(podman unshare stat -c %Y "$f" 2>/dev/null || echo 0)"
if [[ "$m" == "$MTIME" ]]; then
SRC_ZIP="$f"
break
fi
done
[[ -n "$SRC_ZIP" ]] || die "backup from $(date -d "@${MTIME}" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$MTIME") has rotated out -- run the list again"
podman unshare unzip -l "$SRC_ZIP" "Saves/Multiplayer/${SERVER_NAME}/*" >/dev/null 2>&1 \
|| die "archive $(basename "$SRC_ZIP") has no ${SERVER_NAME} world in it"
SOURCE_DESC="$(basename "$SRC_ZIP") (${SET}, $(date -d "@${MTIME}" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$MTIME"))"
log "Resolved target: $SRC_ZIP -> $SOURCE_DESC"
else
# Newest snapshot that actually holds a world.
for d in $(ls -1dt "${SNAP_ROOT}"/*/ 2>/dev/null); do
if podman unshare test -d "${d}${SERVER_NAME}"; then
UNDO_DIR="${d%/}"
break
fi
done
[[ -n "$UNDO_DIR" ]] || die "no snapshot to undo to"
SOURCE_DESC="snapshot $(basename "$UNDO_DIR")"
log "Resolved undo target: $UNDO_DIR"
fi
# ---------------------------------------------------------------------------
# 3. Stop the server. Nothing above this line touches it, so every failure mode
# up to here leaves a running world completely alone.
# ---------------------------------------------------------------------------
# Disarm the wipe trigger for the duration. A reset landing midway through a
# restore would delete the half-restored world and leave nothing coherent.
log "Disarming world-reset path unit"
systemctl --user stop zomboid-world-reset.path || true
log "Stopping zomboid service..."
systemctl --user stop zomboid.service || true
sleep 5
# ---------------------------------------------------------------------------
# 4. Move the live world aside. Never delete -- this is the undo point.
# ---------------------------------------------------------------------------
SNAP_DIR="${SNAP_ROOT}/$(date '+%Y-%m-%d_%H-%M-%S')"
mkdir -p "$SNAP_DIR" 2>/dev/null || die "could not create $SNAP_DIR"
if podman unshare test -d "$SAVES_PATH"; then
podman unshare mv "$SAVES_PATH" "${SNAP_DIR}/${SERVER_NAME}"
log "Live world moved to $(basename "$SNAP_DIR")"
fi
if podman unshare test -f "$DB_PATH"; then
podman unshare cp "$DB_PATH" "${SNAP_DIR}/${SERVER_NAME}.db"
fi
# ---------------------------------------------------------------------------
# 5. Put the target in place
# ---------------------------------------------------------------------------
podman unshare rm -rf "$STAGING"
podman unshare mkdir -p "$STAGING"
if [[ "$ACTION" == "restore" ]]; then
log "Extracting ${SOURCE_DESC}..."
podman unshare unzip -q "$SRC_ZIP" \
"Saves/Multiplayer/${SERVER_NAME}/*" "db/${SERVER_NAME}.db" -d "$STAGING" \
|| die "extract failed"
podman unshare mv "${STAGING}/Saves/Multiplayer/${SERVER_NAME}" "$SAVES_PATH"
if podman unshare test -f "${STAGING}/db/${SERVER_NAME}.db"; then
podman unshare mv "${STAGING}/db/${SERVER_NAME}.db" "$DB_PATH"
fi
# PZ's own backups do not capture everything under the save directory --
# mod state such as blam/ is excluded from every archive it writes. Those
# files were equally present at the moment we are restoring to, so dropping
# them would land the world slightly *behind* the target rather than on it.
# Carry across anything the snapshot had that the archive did not.
for entry in $(podman unshare ls -1 "${SNAP_DIR}/${SERVER_NAME}" 2>/dev/null); do
if ! podman unshare test -e "${SAVES_PATH}/${entry}"; then
podman unshare cp -a "${SNAP_DIR}/${SERVER_NAME}/${entry}" "${SAVES_PATH}/${entry}" 2>/dev/null || true
log "Carried forward un-backed-up entry: ${entry}"
fi
done
else
log "Restoring ${SOURCE_DESC}..."
podman unshare cp -a "${UNDO_DIR}/${SERVER_NAME}" "$SAVES_PATH"
if podman unshare test -f "${UNDO_DIR}/${SERVER_NAME}.db"; then
podman unshare cp -a "${UNDO_DIR}/${SERVER_NAME}.db" "$DB_PATH"
fi
fi
podman unshare rm -rf "$STAGING"
# ---------------------------------------------------------------------------
# 6. Ownership, permissions, labels
# ---------------------------------------------------------------------------
# Container uid 1000; on the host that lands on the subuid base + 999. Same call
# zomboid.yml makes after seeding config.
log "Fixing ownership and permissions..."
podman unshare chown -R 1000:1000 "$SAVES_PATH" "$DB_PATH"
podman unshare chmod -R u+rwX,g+rwX "$SAVES_PATH"
podman unshare chmod 0664 "$DB_PATH"
# Targeted, and -x so it can never wander off this filesystem. The role-wide
# restorecon handler exists in -x form for a reason; see roles/podman/handlers.
if command -v restorecon >/dev/null 2>&1; then
restorecon -Frx "$SAVES_PATH" "$DB_PATH" 2>/dev/null || true
fi
# ---------------------------------------------------------------------------
# 7. Prune old snapshots
# ---------------------------------------------------------------------------
for old in $(ls -1dt "${SNAP_ROOT}"/*/ 2>/dev/null | tail -n +$((KEEP_SNAPSHOTS + 1))); do
podman unshare rm -rf "$old" || true
log "Pruned old snapshot $(basename "${old%/}")"
done
# ---------------------------------------------------------------------------
# 8. Back up
# ---------------------------------------------------------------------------
log "Starting zomboid service..."
systemctl --user start zomboid.service
systemctl --user start zomboid-world-reset.path || true
log "Restore complete: ${SOURCE_DESC}"
result true "Restored ${SOURCE_DESC}. Previous world kept as $(basename "$SNAP_DIR")."
+5
View File
@@ -34,3 +34,8 @@ ups_deps:
# Secrets live in ansible/vars/vault.yml (no vault_ prefix, per repo # Secrets live in ansible/vars/vault.yml (no vault_ prefix, per repo
# convention): nut_upsmon_password, nut_truenas_password, idrac_password # convention): nut_upsmon_password, nut_truenas_password, idrac_password
# Seconds between upsd restart attempts. Paired with StartLimitIntervalSec=0 in
# the nut-server drop-in, so a late network address retries patiently instead of
# exhausting the default five-tries-per-interval and giving up for good.
ups_server_restart_sec: 10
+5
View File
@@ -16,11 +16,16 @@
name: "nut-driver@{{ ups_name }}.service" name: "nut-driver@{{ ups_name }}.service"
state: restarted state: restarted
# daemon_reload so the drop-in under nut-server.service.d is picked up. The
# reload also clears the start-limit counter, which matters because a unit that
# has latched into "start request repeated too quickly" refuses a plain restart
# until that state is reset.
- name: restart nut server - name: restart nut server
become: true become: true
ansible.builtin.systemd: ansible.builtin.systemd:
name: nut-server.service name: nut-server.service
state: restarted state: restarted
daemon_reload: true
- name: restart nut monitor - name: restart nut monitor
become: true become: true
+23
View File
@@ -42,6 +42,29 @@
notify: restart nut server notify: restart nut server
tags: ups tags: ups
# upsd binds an explicit address, so it must not start before that address
# exists. See the template for the boot race this fixes.
- name: ensure nut-server drop-in directory exists
become: true
ansible.builtin.file:
path: /etc/systemd/system/nut-server.service.d
state: directory
owner: root
group: root
mode: '0755'
tags: ups
- name: order upsd after the network is actually online
become: true
ansible.builtin.template:
src: nut-server-network-online.conf.j2
dest: /etc/systemd/system/nut-server.service.d/10-network-online.conf
owner: root
group: root
mode: '0644'
notify: restart nut server
tags: ups
- name: deploy upsd users - name: deploy upsd users
become: true become: true
ansible.builtin.template: ansible.builtin.template:
@@ -0,0 +1,30 @@
# {{ ansible_managed }}
# upsd binds explicit addresses (see upsd.conf), but the packaged unit only
# orders itself After=network.target -- which is satisfied when networking
# STARTS, not when an address actually exists. The shipped unit even carries
# these two lines commented out, because upstream knows the case.
#
# On 2026-08-28 the host came up and upsd tried to bind 11 seconds into boot,
# before NetworkManager had assigned the address:
# upsd: not listening on {{ ups_listen_addr }} port {{ ups_listen_port }}
# upsd: Fatal error: some listening interfaces were not available
# It then burned all five of the default restart attempts inside one second,
# tripped the start limit, and stayed dead. truenas monitors this host as a
# SLAVE (MONITOR cyberpower@{{ ups_listen_addr }}:{{ ups_listen_port }}), so
# it alarmed NOCOMM continuously until someone noticed. The UPS driver itself
# was fine throughout -- only the server that publishes its state was gone.
#
# NetworkManager-wait-online is enabled on this host, so network-online.target
# genuinely waits for addresses rather than just for the service to start.
#
# The restart settings are belt and braces: StartLimitIntervalSec=0 removes the
# rate limit so a slow address can never exhaust the attempts, and RestartSec
# spaces the retries out instead of hammering five times in a second.
[Unit]
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=0
[Service]
Restart=on-failure
RestartSec={{ ups_server_restart_sec }}
Binary file not shown.