Compare commits

..
3 Commits
Author SHA1 Message Date
Bastian de BylandClaude Opus 4.8 f61105c990 SCRUM-45: Caddy carve-out for the EasyPost return webhook
The Fulfillr host is IP-restricted, so EasyPost's servers can't reach it. Add a
narrow `handle /webhooks/easypost` before the IP restriction (handle blocks are
mutually exclusive, first match wins) for prod (:9054) and dev (:9055) so the
HMAC-verified tracker webhook is reachable while the rest of the host stays locked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:23:52 -04:00
Bastian de Byl 7b35d0806d fulfillr prod: bump to 20260607.0217 (Snipcart decommission — go-snipcart dropped) 2026-06-06 22:20:58 -04:00
Bastian de Byl fba78f99b5 fulfillr prod: bump to 20260607.0055 (idle-conn fix [orders-500] + outreach->Turso) 2026-06-06 21:06:24 -04:00
167 changed files with 1252 additions and 13152 deletions
-273
View File
@@ -1,273 +0,0 @@
---
# 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
+2 -39
View File
@@ -14,8 +14,6 @@ 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
@@ -28,11 +26,6 @@ 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
@@ -40,17 +33,13 @@ 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
@@ -60,7 +49,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, Nextcloud at cloud.debyltech.com - cloned from the Skudak instance, backs up to personal iDrive via TrueNAS) - `debyltech/` - Personal/business services (Fulfillr)
- `skudak/` - Additional services (BookStack wiki, Nextcloud) - `skudak/` - Additional services (BookStack wiki, Nextcloud)
### Security Model ### Security Model
@@ -96,7 +85,6 @@ 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
@@ -108,38 +96,13 @@ Tasks are tagged by service/component for selective deployment:
## Target Environment ## Target Environment
- Primary target host: `home.debyl.io` - Single 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:
+1 -22
View File
@@ -21,9 +21,6 @@ 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')
@@ -32,9 +29,6 @@ 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
@@ -54,28 +48,13 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE}
touch $@ touch $@
# Linting # Linting
# .gitea/workflows is linted too: the CI-image workflow is as much part of the YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*')
# 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
@@ -1,106 +0,0 @@
---
# 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
+2 -6
View File
@@ -8,12 +8,8 @@
- role: podman - role: podman
# SSL certificates are now handled automatically by Caddy # SSL certificates are now handled automatically by Caddy
# - role: ssl # REMOVED - Caddy handles all certificate management # - role: ssl # REMOVED - Caddy handles all certificate management
- role: ollama
tags: ollama
- role: github-actions - role: github-actions
# Provisions streams/pipelines/lookup tables over the Graylog REST API, so
# it can only run when the stack is up -- see graylog_enabled in
# inventories/home/hosts.yml.
- role: graylog-config - role: graylog-config
when: graylog_enabled | bool
tags: graylog-config tags: graylog-config
- role: ups
tags: ups
-14
View File
@@ -1,14 +0,0 @@
---
# 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
-21
View File
@@ -1,26 +1,5 @@
--- ---
all: all:
vars:
# Master switch for the Graylog logging stack (graylog + graylog-opensearch
# + graylog-mongo), the fluent-bit journal shipper that feeds it, the GeoIP
# database download it enriches with, and the graylog-config role that
# provisions its streams/pipelines over the REST API.
#
# Lives in inventory rather than a role default because it is read by three
# separate roles (common, podman, graylog-config) and role defaults are
# scoped to their own role.
#
# Off because the stack is the worst cost/benefit tenant on this 4-core box:
# two JVMs plus MongoDB hold ~1.6 GB resident and burn ~3% of the CPU around
# the clock to store ~3k messages a day -- about 28 MB of actual log data
# across its four live indices. journald already retains ~25 days of the
# same logs at its 500M cap, so turning this off costs searchability, not
# the logs themselves.
#
# Set true (here, or -e graylog_enabled=true) to bring it back. All data
# under {{ graylog_path }} is left in place, so re-enabling resumes with the
# existing indices, streams and pipelines intact.
graylog_enabled: false
hosts: hosts:
home.debyl.io: home.debyl.io:
ansible_user: fedora ansible_user: fedora
-16
View File
@@ -1,16 +0,0 @@
---
# 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
@@ -1,50 +0,0 @@
"""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
@@ -1,72 +0,0 @@
---
# 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,12 +21,3 @@ 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"
-20
View File
@@ -11,28 +11,8 @@
name: fail2ban name: fail2ban
state: restarted state: restarted
# Guarded: a config change still notifies this handler when graylog_enabled is
# false, and an unconditional restart would start the service the fluent-bit
# task just stopped.
- name: restart fluent-bit - name: restart fluent-bit
become: true become: true
ansible.builtin.systemd: ansible.builtin.systemd:
name: fluent-bit name: fluent-bit
state: restarted state: restarted
when: graylog_enabled | bool
- name: restart_journald
become: true
ansible.builtin.systemd:
name: systemd-journald
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
+3 -9
View File
@@ -1,12 +1,6 @@
--- ---
# Fluent Bit - Log forwarder from journald to Graylog GELF # Fluent Bit - Log forwarder from journald to Graylog GELF
# Deployed as systemd service (not container) for direct journal access # Deployed as systemd service (not container) for direct journal access
#
# Graylog's GELF input is fluent-bit's only output, so the two share a switch:
# with the stack down fluent-bit would just spin retrying a dead 127.0.0.1:12202
# and filling the journal it is meant to be draining. The package and config
# stay installed either way -- only the service follows graylog_enabled (see
# inventories/home/hosts.yml) -- so re-enabling is a restart, not a reinstall.
- name: install fluent-bit package - name: install fluent-bit package
become: true become: true
@@ -43,9 +37,9 @@
mode: '0644' mode: '0644'
notify: restart fluent-bit notify: restart fluent-bit
- name: set fluent-bit service state to match graylog_enabled - name: enable and start fluent-bit service
become: true become: true
ansible.builtin.systemd: ansible.builtin.systemd:
name: fluent-bit name: fluent-bit
enabled: "{{ graylog_enabled | bool }}" enabled: true
state: "{{ 'started' if (graylog_enabled | bool) else 'stopped' }}" state: started
-34
View File
@@ -1,34 +0,0 @@
---
# Outbound mail for system notifications (backup failures, cron output).
#
# msmtp rather than the esmtp already on the box: the OpenSRS relay uses port
# 465, which is IMPLICIT TLS, and libesmtp/esmtp only speaks STARTTLS. msmtp
# handles implicit TLS via `tls_starttls off` and mirrors the settings the
# TrueNAS box already uses.
# On Fedora the single `msmtp` package already ships /usr/bin/sendmail and
# /usr/lib/sendmail; there is no separate msmtp-sendmail package (Debian split).
- name: install msmtp
become: true
ansible.builtin.package:
name: msmtp
state: present
tags: mail
- name: configure msmtp
become: true
ansible.builtin.template:
src: msmtp/msmtprc.j2
dest: /etc/msmtprc
owner: root
group: root
mode: 0600
no_log: true
tags: mail
- name: point the mta alternative at msmtp
become: true
community.general.alternatives:
name: mta
path: /usr/bin/msmtp-sendmail
failed_when: false
tags: mail
-3
View File
@@ -3,9 +3,6 @@
- import_tasks: security.yml - import_tasks: security.yml
- import_tasks: service.yml - import_tasks: service.yml
- import_tasks: mail.yml
tags: mail
- import_tasks: fluent-bit.yml - import_tasks: fluent-bit.yml
tags: fluent-bit, graylog tags: fluent-bit, graylog
-95
View File
@@ -1,99 +1,4 @@
--- ---
# Bound the journal. Container stdout all lands here (log_driver=journald) and
# is shipped to Graylog by fluent-bit, so the local journal only needs to be a
# buffer -- see the template for the reasoning behind the size.
- name: ensure journald drop-in directory exists
become: true
ansible.builtin.file:
path: /etc/systemd/journald.conf.d
state: directory
owner: root
group: root
mode: 0755
tags: security, service, journald
- name: cap journald disk usage
become: true
ansible.builtin.template:
src: journald-size.conf.j2
dest: /etc/systemd/journald.conf.d/99-size.conf
owner: root
group: root
mode: 0644
notify: restart_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:
@@ -1,18 +0,0 @@
# {{ 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 }}"
@@ -1,28 +0,0 @@
### {{ ansible_managed }}
# Without a cap journald sizes itself at 10% of the filesystem, which on this
# 1.9 TB root means it will happily grow into the tens of gigabytes; it had
# reached 4 GB before this was set.
#
# Container stdout lands here: every container runs with log_driver=journald
# except zomboid, which is chatty enough to evict everything else and so gets
# its own rotating k8s-file log instead.
{% if graylog_enabled | bool %}
# fluent-bit drains the journal into Graylog continuously (systemd input,
# _COMM=conmon -- see templates/fluent-bit/fluent-bit.conf.j2), so Graylog is
# the system of record and what stays here is only the buffer that covers
# fluent-bit being down.
{% else %}
# Graylog is disabled (see graylog_enabled in inventories/home/hosts.yml), so
# the journal is now the only log store. No increase was needed for that: the
# cap is a size limit, not a time limit, and fluent-bit only ever read the
# journal rather than rotating it, so retention is unchanged at roughly 25 days
# at the current rate -- longer than Graylog's own four live indices covered.
{% endif %}
[Journal]
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
@@ -1,17 +0,0 @@
# {{ ansible_managed }}
defaults
auth on
tls on
tls_trust_file /etc/pki/tls/certs/ca-bundle.crt
logfile /var/log/msmtp.log
account home
host {{ system_smtp_host | default('mail.b.hostedemail.com') }}
port {{ system_smtp_port | default(465) }}
# Port 465 is implicit TLS (SMTPS), not STARTTLS.
tls_starttls off
from {{ system_smtp_from | default('home@bdebyl.net') }}
user {{ system_smtp_user | default('home@bdebyl.net') }}
password {{ home_smtp }}
account default : home
+1 -3
View File
@@ -4,11 +4,9 @@ git_home: "/srv/{{ git_user }}"
# Gitea configuration # Gitea configuration
gitea_debyl_server_name: git.debyl.io gitea_debyl_server_name: git.debyl.io
# Pinned per instance so one can be upgraded (and verified) before the other. gitea_image: docker.gitea.com/gitea:1.26.1
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_skudak_image }}" image: "{{ gitea_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, gitea-debyl tags: gitea
# 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, gitea-debyl tags: gitea
# 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_debyl_image }}" image: "{{ gitea_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, gitea-debyl tags: gitea
# 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, gitea-debyl tags: gitea
- 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, gitea-debyl tags: gitea
-96
View File
@@ -1,96 +0,0 @@
# 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.
+6 -62
View File
@@ -22,70 +22,14 @@ 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, served from the Gitea container registry. # Job container images (built locally into the gitea-runner rootless image
# # store by tasks/images.yml; never pulled — force_pull is false).
# They used to live only under localhost/, built by this role. The nightly gitea_ci_image: localhost/gitea-ci:latest
# 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.5.1 esp_idf_version: v5.4.1
gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}" gitea_ci_espidf_image: "localhost/gitea-ci-espidf:{{ esp_idf_version }}"
# PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface).
# Tag tracks the pre-baked espressif32 platform version; both pins match the
# hardware-validated local build.
platformio_core_version: "6.1.19"
pio_espressif32_version: "7.0.1"
gitea_ci_platformio_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-platformio:{{ pio_espressif32_version }}"
# Registry credentials for the gitea-runner user. The Docker-format path is read # Default labels for every runner — map runs-on values to the local CI image.
# 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,40 +0,0 @@
# ESP-IDF firmware job image (managed by ansible: roles/gitea-actions).
# Adds node (required by actions/checkout and other JS actions), the AWS CLI
# (firmware artifacts ship to S3), jq (the release script rewrites the protocol
# manifest with it), and the common-yaml header generator's Python deps on top
# of the official Espressif toolchain.
# IDF lives at /opt/esp/idf — firmware jobs source /opt/esp/idf/export.sh.
# python3-yaml + python3-jinja2 are installed as distro packages so the
# common-yaml generator runs with a plain `python3 generate.py` — no pip at job
# time (the base image's system Python is PEP 668 externally-managed) and no
# need to source the IDF venv just to generate headers.
#
# jq is required by esp32-stm32-vcu scripts/release.sh, which publishes the
# protocol manifest and BLE_API.md to S3 after the firmware upload. Without it
# 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
# are never written. Keep it installed.
#
# 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 \
curl ca-certificates unzip jq python3-yaml python3-jinja2 \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
@@ -1,48 +0,0 @@
# PlatformIO firmware job image (managed by ansible: roles/gitea-actions).
# For Arduino-framework ESP32 projects (e.g. Skudak/esp32-web-interface) that
# build with PlatformIO rather than raw ESP-IDF. Node is required by
# actions/checkout and other JS actions.
# The espressif32 platform, xtensa toolchain and Arduino framework are
# pre-baked into PLATFORMIO_CORE_DIR via a throwaway seed project so ephemeral
# job containers download nothing at build time. Versions are pinned to what
# was validated on hardware — bump pio_espressif32_version /
# platformio_core_version in defaults/main.yml to upgrade (the image tag
# 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
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
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl ca-certificates unzip \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION}
# Seed project mirroring the real projects' platformio.ini so `pio pkg install`
# 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 \
&& 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 --tool platformio/tool-mkspiffs \
&& rm -rf /tmp/seed \
&& chmod -R a+rX ${PLATFORMIO_CORE_DIR}
+32 -70
View File
@@ -1,51 +1,4 @@
--- ---
# 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 }}"
@@ -53,41 +6,50 @@
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
# copy, not template: these are plain Containerfiles that CI builds verbatim. - name: stage default CI Containerfile
# 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.copy: ansible.builtin.template:
src: "{{ item.containerfile }}" src: Containerfile.ci
dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}" dest: "{{ gitea_runner_home }}/ci-images/Containerfile.ci"
mode: "0644" mode: "0644"
loop: "{{ gitea_ci_images }}" register: ci_containerfile
loop_control:
label: "{{ item.containerfile }}"
when: gitea_ci_build_local | bool
tags: gitea-actions tags: gitea-actions
- name: build and push CI images - name: stage ESP-IDF CI Containerfile
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: "{{ item.image }}" name: "{{ gitea_ci_image }}"
path: "{{ gitea_runner_home }}/ci-images" path: "{{ gitea_runner_home }}/ci-images"
build: build:
file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}" file: "{{ gitea_runner_home }}/ci-images/Containerfile.ci"
extra_args: "{{ item.build_args }}" force: "{{ ci_containerfile is changed }}"
force: true environment:
push: true XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
auth_file: "{{ gitea_ci_registry_authfile }}" 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: 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
@@ -69,50 +69,3 @@
group: "{{ gitea_runner_user }}" group: "{{ gitea_runner_user }}"
mode: "0644" mode: "0644"
tags: gitea-actions tags: gitea-actions
# CI jobs run in ephemeral rootless-podman containers that don't inherit the
# gitea-runner user's ~/.ssh. Stage a dedicated, SELinux-labelled copy of the
# runner's key + known_hosts and bind-mount it read-only into every job
# container at /root/.ssh (see config.yaml.j2) so submodule clones over
# ssh://git@git.skudak.com:2222 work. Kept separate from ~/.ssh so the real
# directory's label is never touched.
- name: create ci-ssh dir for job-container mount
become: true
ansible.builtin.file:
path: "{{ gitea_runner_home }}/ci-ssh"
state: directory
owner: "{{ gitea_runner_user }}"
group: "{{ gitea_runner_user }}"
mode: "0700"
tags: gitea-actions
- name: stage runner ssh material into ci-ssh
become: true
ansible.builtin.copy:
src: "{{ gitea_runner_home }}/.ssh/{{ item.name }}"
dest: "{{ gitea_runner_home }}/ci-ssh/{{ item.name }}"
remote_src: true
owner: "{{ gitea_runner_user }}"
group: "{{ gitea_runner_user }}"
mode: "{{ item.mode }}"
loop:
- { name: id_ed25519, mode: "0600" }
- { name: known_hosts, mode: "0644" }
notify: restart act_runner services
tags: gitea-actions
- name: label ci-ssh as container_file_t so job containers can read it
become: true
community.general.sefcontext:
target: "{{ gitea_runner_home }}/ci-ssh(/.*)?"
setype: container_file_t
state: present
register: ci_ssh_sefcontext
tags: gitea-actions
- name: apply selinux label to ci-ssh
become: true
ansible.builtin.command: restorecon -RF {{ gitea_runner_home }}/ci-ssh
when: ci_ssh_sefcontext is changed
changed_when: true
tags: gitea-actions
@@ -1,24 +1,13 @@
# 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 \
ca-certificates curl git openssh-client make build-essential \ ca-certificates curl git openssh-client make build-essential \
python3 python3-pip python3-yaml python3-jinja2 jq zip unzip \ python3 python3-pip jq zip unzip \
gcc-arm-none-eabi binutils-arm-none-eabi libnewlib-arm-none-eabi \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Static docker client (no daemon) for jobs that run `docker build` against the # Static docker client (no daemon) for jobs that run `docker build` against the
@@ -0,0 +1,16 @@
# ESP-IDF firmware job image (managed by ansible: roles/gitea-actions).
# Adds node (required by actions/checkout and other JS actions) and the AWS CLI
# (firmware artifacts ship to S3) on top of the official Espressif toolchain.
# IDF lives at /opt/esp/idf — firmware jobs source /opt/esp/idf/export.sh.
FROM espressif/idf:{{ esp_idf_version }}
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates unzip \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
@@ -23,16 +23,9 @@ container:
# per-job Go module/build caches and fixes cross-repo cache poisoning. # per-job Go module/build caches and fixes cross-repo cache poisoning.
network: host network: host
privileged: false privileged: false
# Bind-mount the runner's SSH material (key + known_hosts) read-only into options:
# every job container at /root/.ssh (CI image runs as root) so git submodule
# clones over ssh://git@git.skudak.com:2222 succeed. ci-ssh is a dedicated
# container_file_t-labelled copy staged in tasks/user.yml.
options: -v {{ gitea_runner_home }}/ci-ssh:/root/.ssh:ro
workdir_parent: workdir_parent:
# act_runner gates host bind-mounts against this allowlist; the ci-ssh source valid_volumes: []
# path must be listed or the -v above is silently stripped from the job container.
valid_volumes:
- {{ gitea_runner_home }}/ci-ssh
# Point act at the real rootless socket so it mounts the correct path into # Point act at the real rootless socket so it mounts the correct path into
# job containers (the documented rootless-podman gotcha). # job containers (the documented rootless-podman gotcha).
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/podman/podman.sock" docker_host: "unix:///run/user/{{ gitea_runner_uid }}/podman/podman.sock"
-162
View File
@@ -1,162 +0,0 @@
# 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
@@ -1,118 +0,0 @@
---
# ---------------------------------------------------------------------------
# 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
@@ -1,25 +0,0 @@
# 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 }
@@ -1,41 +0,0 @@
---
- 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
@@ -1,92 +0,0 @@
---
- 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
@@ -1,136 +0,0 @@
---
- 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
@@ -1,147 +0,0 @@
---
# 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]
@@ -1,20 +0,0 @@
---
- 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
@@ -1,6 +0,0 @@
---
- import_tasks: base.yml
- import_tasks: driver.yml
- import_tasks: cups.yml
- import_tasks: wifi.yml
- import_tasks: firewall.yml
-133
View File
@@ -1,133 +0,0 @@
---
# 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]
@@ -1,11 +0,0 @@
# {{ 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 }}
@@ -1,18 +0,0 @@
# {{ 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
@@ -1,125 +0,0 @@
#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]
@@ -1,97 +0,0 @@
# {{ 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>
@@ -1,32 +0,0 @@
# {{ 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
@@ -1,63 +0,0 @@
#!/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;
}
}
@@ -1,38 +0,0 @@
# {{ 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
@@ -1,9 +0,0 @@
# {{ 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
@@ -1,132 +0,0 @@
#!/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
@@ -1,13 +0,0 @@
# {{ 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
+6
View File
@@ -0,0 +1,6 @@
---
ollama_models:
- dolphin-phi
- dolphin-mistral
ollama_host: "127.0.0.1"
ollama_port: 11434
+8
View File
@@ -0,0 +1,8 @@
---
- name: restart ollama
become: true
ansible.builtin.systemd:
name: ollama
state: restarted
daemon_reload: true
tags: ollama
+3
View File
@@ -0,0 +1,3 @@
---
dependencies:
- role: common
+11
View File
@@ -0,0 +1,11 @@
---
- name: check if ollama is already installed
ansible.builtin.stat:
path: /usr/local/bin/ollama
register: ollama_binary
- name: install ollama via official install script
become: true
ansible.builtin.shell: |
curl -fsSL https://ollama.com/install.sh | sh
when: not ollama_binary.stat.exists
+9
View File
@@ -0,0 +1,9 @@
---
- import_tasks: install.yml
tags: ollama
- import_tasks: service.yml
tags: ollama
- import_tasks: models.yml
tags: ollama
+10
View File
@@ -0,0 +1,10 @@
---
- name: pull ollama models
become: true
ansible.builtin.command: ollama pull {{ item }}
loop: "{{ ollama_models }}"
register: result
retries: 3
delay: 10
until: result is not failed
changed_when: "'pulling' in result.stderr or 'pulling' in result.stdout"
+23
View File
@@ -0,0 +1,23 @@
---
- name: create ollama systemd override directory
become: true
ansible.builtin.file:
path: /etc/systemd/system/ollama.service.d
state: directory
mode: 0755
- name: template ollama environment override
become: true
ansible.builtin.template:
src: ollama.env.j2
dest: /etc/systemd/system/ollama.service.d/override.conf
mode: 0644
notify: restart ollama
- name: enable and start ollama service
become: true
ansible.builtin.systemd:
name: ollama
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,4 @@
[Service]
Environment="OLLAMA_HOST={{ ollama_host }}:{{ ollama_port }}"
Environment="OLLAMA_NUM_PARALLEL=1"
Environment="OLLAMA_MAX_LOADED_MODELS=1"
+5
View File
@@ -0,0 +1,5 @@
---
deps: [
php-sqlite,
php-fpm
]
+3
View File
@@ -0,0 +1,3 @@
---
dependencies:
- role: http
+11
View File
@@ -0,0 +1,11 @@
---
- name: install pi-hole-server
ansible.builtin.command: yay -S --noconfirm pi-hole-server
args:
creates: /bin/pihole
- name: install pi-hole-server dependencies
become: true
ansible.builtin.package:
name: "{{ deps }}"
state: present
+3
View File
@@ -0,0 +1,3 @@
---
- import_tasks: deps.yml
- import_tasks: php.yml
+12
View File
@@ -0,0 +1,12 @@
---
- name: replace pi.hole hostname
become: true
ansible.builtin.replace:
path: "{{ item }}"
regexp: "pi\\.hole"
replace: "pi.bdebyl.net"
loop:
- /srv/http/pihole/admin/scripts/pi-hole/php/auth.php
- /srv/http/pihole/pihole/index.php
tags:
- pihole
-246
View File
@@ -1,246 +0,0 @@
# podman role
Container orchestration for the home server. Containers are defined under
`tasks/containers/{base,home,skudak,debyltech}/` and wired from
`tasks/main.yml`, which is where every image tag is pinned.
## Backups
Every Nextcloud-style instance (Nextcloud, Gitea, BookStack, partsy) shares one
backup engine: `tasks/containers/cloud-backup.yml` plus
`templates/nextcloud/cloud-backup.sh.j2`. Each instance includes it with its own
vars, producing `/usr/local/bin/<name>-backup.sh` and a systemd timer.
Stages, in order — the ordering is deliberate, see the comments in the template:
1. **Database dump** inside the container (mariadb / mysql / postgres branches),
gzipped to `/var/backups/nextcloud/<name>/db/`. Promoted over yesterday's dump
only after passing `gzip -t` **and** a completion-trailer grep.
2. **SQLite snapshots** (`.backup`, then `pragma integrity_check`) where used.
3. **rsync** of the data tree, config, and db dumps to TrueNAS.
Failures raise `status=failed` on the `nextcloud-backup` syslog tag. The mail
itself is sent by the unit's own `OnFailure=` handler
(`templates/nextcloud/nextcloud-backup-alert.sh.j2`) straight through
`sendmail`, so alerting does **not** depend on Graylog and is unaffected by
`graylog_enabled` being off. A Graylog rule matching the same tag is a
secondary, dashboard-side copy of that signal.
Do not rename that tag: it is shared by every instance including Gitea, and
renaming it here silently stops the Graylog-side alerting for all of them.
## Restore
**Untested backups are not a control.** Rehearse this into a scratch location
before you need it, and record the date you last did.
### 1. Database
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
each dataset offsite, so a third copy exists there — but restoring from it
means going through the TrueNAS console, 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:
```bash
gzip -t <name>-YYYYMMDD.sql.gz
gunzip -c <name>-YYYYMMDD.sql.gz | tail -c 512 # expect the completion trailer
```
Replay into the running database container. MariaDB/MySQL:
```bash
sudo -H -u podman bash -c 'cd; gunzip -c /path/to/dump.sql.gz \
| podman exec -i <db_container> sh -c \
"exec env MYSQL_PWD=\$MYSQL_ROOT_PASSWORD mariadb -u root \$MYSQL_DATABASE"'
```
Postgres dumps are taken with `--clean --if-exists --no-owner`, so they replay
into an existing database:
```bash
sudo -H -u podman bash -c 'cd; gunzip -c /path/to/dump.sql.gz \
| podman exec -i <db_container> sh -c \
"exec env PGPASSWORD=\$POSTGRES_PASSWORD psql -U \$POSTGRES_USER \$POSTGRES_DB"'
```
### 2. Data tree
```bash
# from TrueNAS
rsync -az -e "ssh -i /etc/ssh/backup_keys/<name>" \
<ssh_user>@truenas.localdomain:<remote_path>/ <volumes>/<instance>/data/
```
Then fix ownership — the containers run as uid 33 inside a rootless userns:
```bash
sudo -H -u podman bash -c 'cd; podman unshare chown -R 33:33 <volumes>/<instance>/data'
```
### 3. Reconcile
```bash
sudo -H -u podman bash -c 'cd; podman exec -u www-data <container> php occ maintenance:mode --on'
sudo -H -u podman bash -c 'cd; podman exec -u www-data <container> php occ files:scan --all'
sudo -H -u podman bash -c 'cd; podman exec -u www-data <container> php occ maintenance:mode --off'
```
A DB snapshot slightly **older** than the files degrades to "files the app has
not indexed yet" and is repaired by `files:scan`. A DB snapshot **newer** than
the files references blobs that were never backed up, which surfaces as broken
shares and dead file entries — this is why the dump runs first.
### 4. LibreSign-specific
The signing CA lives in the data tree at
`data/appdata_*/libresign/pki/<instance>_<n>_openssl/`, so a data-tree restore
brings it back with everything else. After restoring, confirm it:
```bash
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
`certificate_engine` / `config_path` app config is pointing somewhere without a
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
without understanding why: it mints a *new* root CA and invalidates the trust
chain on every document already signed under the old one.
## LibreSign
Deployed on `skudak-cloud` and `debyltech-cloud`. LibreSign 14.1.0 requires
Nextcloud server `>=34.0.0,<35.0.0`, which the pinned `nextcloud:34.0.3-apache`
satisfies. If the Nextcloud tag is bumped to 35, LibreSign must be held or
upgraded in step — each instance is pinned independently in `tasks/main.yml`,
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:
| Component | Location | Survives recreate? |
|---|---|---|
| Java (JRE 21), PDFtk, jSignPdf | `data/appdata_*/libresign/` | **Yes** — persisted volume |
| Root CA / PKI | `data/appdata_*/libresign/pki/` | **Yes** — persisted volume |
| poppler-utils, ghostscript | `/usr` in the image | **No** — reinstalled by Ansible each run |
Certificate engine is **OpenSSL**, not CFSSL. CFSSL is the more common source of
LibreSign setup failures and buys nothing at this scale.
### Gotchas
- **Do not pass `--ou`** to `libresign:configure:openssl`. LibreSign appends its
own `libresign-ca-id:...` entry to the OU field, and the combined value
overruns the 64-character ASN.1 limit for `organizationalUnitName`, failing
with `string too long`.
- **A disabled app has no `occ` commands.** If `occ list | grep libresign`
returns nothing, the app is disabled, not missing — `occ app:list` will still
show it under `Disabled:`. This is what a Nextcloud major upgrade does to an
app it thinks is incompatible.
- `PHP_MEMORY_LIMIT` must be raised above the 512M image default; signing fails
opaquely mid-operation otherwise.
- `LC_ALL` / `LANG` must be set or the JVM comes up as `ANSI_X3.4-1968` and
LibreSign warns that accented characters in signer names will be mangled
(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
Every container runs with `log_driver=journald`, so container stdout lands in
the host journal, capped at 500M by `roles/common/templates/journald-size.conf.j2`
(about 25 days at the current rate). `journalctl CONTAINER_NAME=<name>` is the
day-to-day way to read it.
On top of that sits an optional Graylog stack — `graylog`, `graylog-opensearch`,
`graylog-mongo`, fed by a host `fluent-bit` service that tails the journal and
ships GELF to `127.0.0.1:12202`, enriched with the MaxMind GeoIP database, and
configured over the REST API by the separate `graylog-config` role.
**It is off.** The switch is `graylog_enabled` in
`inventories/home/hosts.yml`, and it gates all five of those pieces at once:
| `graylog_enabled` | effect |
| --- | --- |
| `true` | stack deployed, fluent-bit shipping, GeoIP downloaded, `graylog-config` runs, `logs.debyl.io` proxies to the UI and `/gelf` |
| `false` | containers removed and their systemd user units disabled and deleted, fluent-bit stopped and disabled, GeoIP and `graylog-config` skipped, `logs.debyl.io` answers 503 |
It was turned off because the cost/benefit is bad on a 4-core box: two JVMs plus
MongoDB held ~1.6 GB resident and ~3% of the CPU continuously to store roughly
3k messages a day — about 28 MB of real log data across four live indices — all
of which journald already keeps for longer.
Turning it back on is `graylog_enabled: true` plus `make deploy TAGS=graylog`.
Nothing is destroyed by the off path: the volumes under `{{ graylog_path }}`
keep the indices, the Mongo database holding streams/pipelines/dashboards, and
the node-id file, so the stack comes back with its configuration intact.
Two things genuinely stop while it is off, both by design:
- **Search and dashboards.** The logs still exist in journald; the query
interface over them does not.
- **External GELF ingest.** The AWS Lambda that POSTs to `logs.debyl.io/gelf`
for the `debyltech-api` stream gets a 503. Those events are dropped, not
queued — nothing else records them.
### Caddy config reloads
The `reload caddy` handler deliberately reads `/config/Caddyfile`, not the
`/etc/caddy/Caddyfile` the container starts from, even though both are the same
host file. `/etc/caddy/Caddyfile` is a **single-file** bind mount, which podman
binds by inode, and Ansible's `template` module writes a temp file and renames
it into place — so every deploy gives the host file a new inode while the
container keeps seeing the one it was created with. Reloading from that path
silently re-applied the previous config; changes only landed when something
recreated the container. `{{ caddy_path }}/config` is also bind-mounted as a
*directory* at `/config`, and directory mounts resolve names at `open()` time,
so `/config/Caddyfile` is always the file Ansible just wrote.
File diff suppressed because one or more lines are too long
@@ -1,44 +0,0 @@
<?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>
@@ -1,40 +0,0 @@
/*
* 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.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

@@ -1,41 +0,0 @@
<?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 {
}
}
@@ -1,121 +0,0 @@
<?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;
}
}
@@ -1,51 +0,0 @@
<?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.
}
}
}
@@ -1,436 +0,0 @@
<?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);
}
}
+581 -193
View File
@@ -1,78 +1,237 @@
- 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: ''
triggers: trigger:
- trigger: time - platform: 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:
- action: switch.turn_off - 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
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:
entity_id: switch.driveway_string_lights area_id: bedroom
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:
- action: light.turn_on - type: turn_on
data: device_id: 3f7f65571d9bb0833433996f1f6725bd
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: TV mode on; once it's dark, dim the living room and turn off the description: ''
lights that glare on the TV
triggers: triggers:
- trigger: state - type: turned_on
entity_id: remote.samsung_tv device_id: 18a9bb7a2a32be4371da447767ef50a9
to: 'on' entity_id: c05688f2610e27e2d86380e2945ceae5
not_from: domain: remote
- unavailable trigger: device
- 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
- action: light.turn_off - type: turn_off
target: device_id: 03a12d2360d9954aed19c2449070725a
entity_id: entity_id: 7c1e7db73799cc3f90948b5118596985
- light.kitchen_wall_light domain: light
- light.dining_hall - type: turn_off
- light.bathroom_hallway device_id: 800eddbeeda071225f181a14cb9527e0
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: Evening (sunset -1h to 23:30) brings the lights back to the description: Restores appropriate lighting level when TV turns off based on time
scheduled level; late night (23:30 to sunrise) only turns off the TV glow;
daytime leaves the lights alone
triggers: triggers:
- trigger: state - type: turned_off
entity_id: remote.samsung_tv device_id: 18a9bb7a2a32be4371da447767ef50a9
to: 'off' entity_id: c05688f2610e27e2d86380e2945ceae5
not_from: domain: remote
- unavailable trigger: device
- unknown
conditions: [] conditions: []
actions: actions:
- action: input_boolean.turn_off - action: input_boolean.turn_off
@@ -80,179 +239,409 @@
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
before: '23:30:00' after: '22:30:00'
before: '23:45:00'
sequence: sequence:
- action: script.evening_lights_apply # Late dim levels
data: - type: turn_on
apply: force device_id: f31e4f9bf8fa3687a07aeb4430eaef38
- action: switch.turn_on entity_id: b79934d97f3bb9d8a3da47c76d03ded4
target: domain: light
entity_id: switch.desk_lamp 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
- conditions: - conditions:
- condition: or
conditions:
- condition: time - condition: time
after: '23:30:00' after: '21:30:00'
- condition: sun before: '22:30:00'
before: sunrise
sequence: sequence:
- action: light.turn_off # Mid dim levels
target: - type: turn_on
area_id: living_room device_id: f31e4f9bf8fa3687a07aeb4430eaef38
- action: switch.turn_off entity_id: b79934d97f3bb9d8a3da47c76d03ded4
target: domain: light
entity_id: switch.desk_lamp brightness_pct: 25
mode: single - type: turn_on
- id: driveway_lights_on device_id: 03a12d2360d9954aed19c2449070725a
alias: Driveway String Lights On entity_id: 7c1e7db73799cc3f90948b5118596985
description: On 1 hour before sunset whether or not the TV is on. Also catches domain: light
up if Home Assistant restarts before the 23:00 off brightness_pct: 25
triggers: - type: turn_on
- trigger: sun device_id: 800eddbeeda071225f181a14cb9527e0
event: sunset entity_id: 521a92ddd8be76c7eddfc544f81f6020
offset: "-01:00:00" domain: light
id: sunset brightness_pct: 50
- trigger: homeassistant - type: turn_on
event: start device_id: 3f7f65571d9bb0833433996f1f6725bd
conditions: entity_id: 7407afe14783543252c666d5ff7c5d5c
- condition: state domain: light
entity_id: switch.driveway_string_lights brightness_pct: 25
state: 'off' - type: turn_on
- condition: or device_id: 21eb2bd28aba2ee361a22af92e8b2d16
conditions: entity_id: 81c486d682afcc94e98e377475cc92fc
- condition: trigger domain: light
id: sunset brightness_pct: 25
- condition: and - conditions:
conditions:
- condition: sun
after: sunset
after_offset: "-01:00:00"
- condition: time - condition: time
before: '23:00:00' after: '21:00:00'
actions: before: '21:30:00'
- action: switch.turn_on sequence:
target: # Early dim levels
entity_id: switch.driveway_string_lights - 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: 'sunset_lights_on'
alias: Lights - Sunset On alias: Lights - Sunset On
description: Turn on lights 1 hour before sunset. If the TV is on, the living description: Turn on lights 1 hour before sunset
room gets the TV glow and the lights that glare on the TV stay off
triggers: triggers:
- trigger: sun - trigger: sun
event: sunset event: sunset
offset: "-01:00:00" offset: "-01:00:00"
conditions: [] conditions:
actions:
- action: script.evening_lights_apply
data:
apply: force
- if:
- condition: state - condition: state
entity_id: input_boolean.tv_mode entity_id: input_boolean.tv_mode
state: 'on' state: 'off'
then:
- action: light.turn_on
data:
brightness_pct: 5
target:
area_id: living_room
else:
- action: switch.turn_on
target:
entity_id: switch.desk_lamp
mode: single
- id: evening_dim_ramp
alias: Lights - Evening Dim Ramp
description: Every 5 minutes from 20:30 to 23:30, ease the lights that are on
toward the schedule in script.evening_lights_apply
triggers:
- trigger: time_pattern
minutes: /5
conditions:
- condition: time
after: '20:30:00'
before: '23:30:00'
actions: actions:
- action: script.evening_lights_apply - type: turn_on
data: device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
apply: ramp 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:
- condition: state
entity_id: input_boolean.tv_mode
state: 'off'
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:
- 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: 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
- id: 'late_dim_2230'
alias: Lights - Late Dim (22:30)
description: Dim lights at 22:30 - only affects lights that are ON
triggers:
- trigger: time
at: "22:30:00"
conditions:
- 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 mode: single
- id: 'lights_out_2345' - id: 'lights_out_2345'
alias: Lights - Out (23:30) alias: Lights - Out (23:45)
description: Turn off all lights at 23:30. While the TV is on the living room description: Turn off all lights at 23:45
is left as it is
triggers: triggers:
- trigger: time - trigger: time
at: "23:30:00" at: "23:45:00"
conditions: [] conditions: []
actions: actions:
- action: light.turn_off - type: turn_off
target: device_id: 3f7f65571d9bb0833433996f1f6725bd
entity_id: entity_id: 7407afe14783543252c666d5ff7c5d5c
- light.kitchen_lights domain: light
- light.kitchen_wall_light - type: turn_off
- light.dining_hall device_id: f31e4f9bf8fa3687a07aeb4430eaef38
- light.dining_room entity_id: b79934d97f3bb9d8a3da47c76d03ded4
- light.bathroom_hallway domain: light
- if: - type: turn_off
- condition: state device_id: 03a12d2360d9954aed19c2449070725a
entity_id: input_boolean.tv_mode entity_id: 7c1e7db73799cc3f90948b5118596985
state: 'off' domain: light
then: - type: turn_off
- action: light.turn_off device_id: 800eddbeeda071225f181a14cb9527e0
target: entity_id: 521a92ddd8be76c7eddfc544f81f6020
entity_id: light.living_room domain: light
- action: switch.turn_off - type: turn_off
target: device_id: 03eb359bf2344a58bebfe1e9c5bcfadd
entity_id: switch.desk_lamp entity_id: a30b2da3cd80a5b4c927e1608b91eb65
mode: single domain: light
- id: lights_sweep_0100 - type: turn_off
alias: Lights - Sweep (01:00) device_id: 21eb2bd28aba2ee361a22af92e8b2d16
description: Catch anything turned back on after lights-out. While the TV is entity_id: 81c486d682afcc94e98e377475cc92fc
on the living room is left as it is domain: light
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:
- trigger: state - type: turned_on
entity_id: switch.bedroom_light device_id: afb9734fe9b187ab6881a64d24e1c2f5
to: 'on' entity_id: 27efa149b9ebb388e7c21ba89e671b42
not_from: domain: switch
- unavailable trigger: device
- unknown
conditions: [] conditions: []
actions: actions:
- action: light.turn_on - action: light.turn_on
@@ -266,12 +655,11 @@
alias: Bedroom Off alias: Bedroom Off
description: '' description: ''
triggers: triggers:
- trigger: state - type: turned_off
entity_id: switch.bedroom_light device_id: afb9734fe9b187ab6881a64d24e1c2f5
to: 'off' entity_id: 27efa149b9ebb388e7c21ba89e671b42
not_from: domain: switch
- unavailable trigger: device
- unknown
conditions: [] conditions: []
actions: actions:
- action: light.turn_off - action: light.turn_off
@@ -9,11 +9,6 @@ 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
@@ -23,7 +18,6 @@ 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:
@@ -1,76 +0,0 @@
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
@@ -1,44 +0,0 @@
<?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>skudakmail</id>
<name>Skudak Customisations</name>
<summary>Skudak-branded email templates and UI overrides for Nextcloud and LibreSign</summary>
<description><![CDATA[
Restyles outgoing Nextcloud and LibreSign mail to match the Skudak design
system at ~/src/skudak/skudak-site. Two supported extension points, no core
patch and no LibreSign fork:
1. `OCA\Skudakmail\Mail\SkudakEMailTemplate` 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\Skudakmail\Listener\SkudakMailListener` 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\Skudakmail\Listener\SkudakStyleListener` 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
remains `skudakmail` for historical reasons -- it is referenced by the
`mail_template_class` system config -- but its scope is Skudak-wide
customisation, not mail alone.
]]></description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>Skudak LLP</author>
<namespace>Skudakmail</namespace>
<category>customization</category>
<dependencies>
<nextcloud min-version="34" max-version="34"/>
</dependencies>
</info>
@@ -1,40 +0,0 @@
/*
* 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.

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace OCA\Skudakmail\AppInfo;
use OCA\Skudakmail\Listener\SkudakMailListener;
use OCA\Skudakmail\Listener\SkudakStyleListener;
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 = 'skudakmail';
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, SkudakMailListener::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, SkudakStyleListener::class);
}
public function boot(IBootContext $context): void {
}
}
@@ -1,121 +0,0 @@
<?php
declare(strict_types=1);
/**
* Embeds the Skudak 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\Skudakmail\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 SkudakMailListener implements IEventListener {
/** Must match SkudakEMailTemplate::LOGO_PATH. */
private const LOGO_PATH_FRAGMENT = '/custom_apps/skudakmail/img/skudak-wordmark.png';
/** Content-ID. Symfony emits this as <skudak-wordmark.png>. */
private const CID = 'skudak-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('skudakmail: 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/skudak-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/skudak-wordmark.png';
return is_readable($path) ? $path : null;
}
}
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
/**
* Injects Skudak'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\Skudakmail\Listener;
use OCA\Skudakmail\AppInfo\Application;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class SkudakStyleListener 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.
}
}
}
@@ -1,417 +0,0 @@
<?php
declare(strict_types=1);
/**
* Skudak-branded email template.
*
* 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 partnership instruments to signers, so it needs to read as
* an official Skudak LLP communication.
*
* DESIGN INTENT (mirrors ~/src/skudak/skudak-site/src/styles/variables.css):
* - Light ground, near-black text, NO coloured header band. skudak.com is
* --color-white #FAFAFA with --color-gray-900 #0A0A0A text; transactional
* mail from Stripe/Linear/DocuSign is likewise restrained. A band also
* leaves an ugly empty slab when the logo is blocked (see LOGO note).
* - --color-accent #2563EB on the CTA button only. That is the one place the
* site spends colour, so it is the one place this template does.
* - Inter, matching --font-sans, 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\Skudakmail\Mail;
use OC\Mail\EMailTemplate;
class SkudakEMailTemplate 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";
/** --font-sans, with the stock stack retained as fallback. */
private const SKUDAK_FONTS = "Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif";
private const ACCENT = '#2563EB'; // --color-accent
private const ON_ACCENT = '#FAFAFA'; // --color-white
private const INK = '#0A0A0A'; // --color-gray-900
private const MUTED = '#525252'; // --color-gray-500
private const FAINT = '#A3A3A3'; // --color-gray-300
private const RULE = '#E5E5E5'; // --color-gray-100
private const ENTITY = 'Skudak LLP';
private const SITE = 'https://skudak.com';
private const LOGO_PATH = '/custom_apps/skudakmail/img/skudak-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 -> Skudak 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 -> Skudak 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 -> Skudak 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:'
=> 'Skudak LLP 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 Skudak LLP. 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 $skudakFooterInjected = false;
public function __construct(
\OCP\Defaults $themingDefaults,
\OCP\IURLGenerator $urlGenerator,
\OCP\L10N\IFactory $l10nFactory,
?int $logoWidth,
?int $logoHeight,
string $emailId,
array $data,
) {
$this->applySkudakStyling();
// 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 applySkudakStyling(): void {
foreach (self::STYLED_PARTS as $part) {
if (!property_exists($this, $part)) {
continue;
}
$this->$part = str_replace(self::STOCK_FONTS, self::SKUDAK_FONTS, $this->$part);
}
// Site headings are --font-weight-light with tightened tracking.
$this->heading = str_replace(
'font-size:24px;font-weight:400',
'font-size:26px;font-weight:300;letter-spacing:-0.02em',
$this->heading,
);
}
/**
* Rewrites LibreSign's subjects. Called by LibreSign on the TEMPLATE
* (MailService.php:51 etc.), not on the message, which is what makes this
* interceptable at all -- Message::useTemplate() later pulls the result via
* renderSubject(). Prefixed with the entity so the sender is unambiguous in
* an inbox list.
*/
public function setSubject(string $subject): void {
$mapped = self::SUBJECT_MAP[$subject] ?? null;
parent::setSubject(
$mapped === null ? $subject : self::ENTITY . ' — ' . $mapped,
);
}
/**
* Replaces the stock header wholesale: no coloured band, wordmark centred
* on white.
*
* Does NOT use the parent's $header property or its placeholder order --
* this is independent markup, so upstream changes to $header cannot break
* it (and equally cannot improve it). $logoWidth/$logoHeight from the
* Mailer are ignored on purpose: they are clamped to MAX_LOGO_SIZE = 105
* (lib/private/Mail/Mailer.php:60), which is too small for a wordmark to
* be legible.
*/
public function addHeader(): void {
if ($this->headerAdded) {
return;
}
$this->headerAdded = true;
$logoUrl = $this->urlGenerator->getAbsoluteURL(self::LOGO_PATH);
$alt = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$w = self::LOGO_DISPLAY_WIDTH;
$fonts = self::SKUDAK_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 $skudakFooterInjected.
*
* 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 Skudak block goes in before
* $tail.
*/
public function renderHtml(): string {
$this->injectSkudakFooter();
return parent::renderHtml();
}
public function renderText(): string {
$this->injectSkudakFooter();
return parent::renderText();
}
private function injectSkudakFooter(): void {
if ($this->skudakFooterInjected || $this->footerAdded) {
return;
}
$this->skudakFooterInjected = 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->skudakFooterHtml();
$this->plainBody .= $this->skudakFooterText();
}
private function skudakFooterHtml(): string {
$year = date('Y');
$entity = htmlspecialchars(self::ENTITY, ENT_QUOTES, 'UTF-8');
$fonts = self::SKUDAK_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}/privacy" style="color:{$muted};text-decoration:underline">Privacy Policy</a>
&#160;&#183;&#160;
<a href="{$site}/terms" style="color:{$muted};text-decoration:underline">Terms of Use</a>
&#160;&#183;&#160;
<a href="{$site}" style="color:{$muted};text-decoration:underline">skudak.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 skudakFooterText(): 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}/privacy
Terms of Use: {$site}/terms
© {$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
* amendment-001-partner-compensation, 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
-- 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()
return {
unemployed = {
{ worldX = 40, worldY = 22, posX = 67, posY = 201 }
}
}
end
@@ -1,43 +0,0 @@
-- 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()
return {
{ name = "Brandenburg, KY", file = "media/maps/Brandenburg, 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 = "Fallas Lake, KY", file = "media/maps/Fallas Lake, KY/spawnpoints.lua" },
{ name = "Irvington, KY", file = "media/maps/Irvington, KY/spawnpoints.lua" },
{ name = "March Ridge, KY", file = "media/maps/March Ridge, KY/spawnpoints.lua" },
{ name = "Muldraugh, KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" },
{ name = "Riverside, KY", file = "media/maps/Riverside, KY/spawnpoints.lua" },
{ name = "Rosewood, KY", file = "media/maps/Rosewood, KY/spawnpoints.lua" },
{ name = "Valley Station, KY", file = "media/maps/Valley Station, KY/spawnpoints.lua" },
{ name = "West Point, KY", file = "media/maps/West Point, KY/spawnpoints.lua" },
}
end
+2 -33
View File
@@ -1,20 +1,8 @@
--- ---
# -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 -Frxv {{ podman_home }}/.local/share/volumes restorecon -Frv {{ podman_home }}/.local/share/volumes
tags: tags:
- podman - podman
- selinux - selinux
@@ -37,21 +25,11 @@
tags: tags:
- caddy - caddy
# Reads /config/Caddyfile, NOT the /etc/caddy/Caddyfile the container starts
# from, even though both are the same host file. /etc/caddy/Caddyfile is a
# single-file bind mount, which podman binds by inode; the template module
# writes a temp file and renames it into place, so every deploy gives the host
# file a new inode and the container keeps seeing the one it was created with.
# Reloading from that path silently re-applied the old config -- the change only
# ever landed when something recreated the container. {{ caddy_path }}/config is
# also bind-mounted as a *directory* at /config, and a directory mount resolves
# names at open() time, so /config/Caddyfile is always the file Ansible just
# wrote.
- name: reload caddy - name: reload caddy
become: true become: true
become_user: "{{ podman_user }}" become_user: "{{ podman_user }}"
ansible.builtin.command: | ansible.builtin.command: |
podman exec caddy caddy reload --config /config/Caddyfile --adapter caddyfile podman exec caddy caddy reload --config /etc/caddy/Caddyfile
tags: tags:
- caddy - caddy
- caddy-config - caddy-config
@@ -64,12 +42,3 @@
scope: user scope: user
tags: tags:
- zomboid - zomboid
- name: reload podman systemd
become: true
become_user: "{{ podman_user }}"
ansible.builtin.systemd:
daemon_reload: true
scope: user
tags:
- podman
@@ -1,14 +1,4 @@
--- ---
# The image's PID 1 is busybox crond (ENTRYPOINT crond -f -d 8), which runs the
# /etc/periodic/15min/awsddns script every quarter hour. busybox crond only
# waitpid()s the job PIDs it is tracking -- it does no generic orphan reaping --
# so whenever the run-parts/sh layer between crond and the script exits first,
# the script is reparented to PID 1 and stays <defunct> forever. That had left
# 12 zombies across these five containers after 30 days of uptime.
#
# init: true makes podman inject catatonit as PID 1 with crond as its child, and
# catatonit reaps every orphan it inherits. Changing this recreates the
# containers, which also clears the zombies already accumulated.
- import_tasks: podman/podman-check.yml - import_tasks: podman/podman-check.yml
vars: vars:
container_name: awsddns container_name: awsddns
@@ -23,7 +13,6 @@
image: "{{ image }}" image: "{{ image }}"
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
init: true
env: env:
AWS_ZONE_TTL: 60 AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ aws_zone_id }}" AWS_ZONE_ID: "{{ aws_zone_id }}"
@@ -51,7 +40,6 @@
image: "{{ image }}" image: "{{ image }}"
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
init: true
env: env:
AWS_ZONE_TTL: 60 AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ aws_skudak_zone_id }}" AWS_ZONE_ID: "{{ aws_skudak_zone_id }}"
@@ -79,7 +67,6 @@
image: "{{ image }}" image: "{{ image }}"
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
init: true
env: env:
AWS_ZONE_TTL: 60 AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ fulfillr_zone_id }}" AWS_ZONE_ID: "{{ fulfillr_zone_id }}"
@@ -109,7 +96,6 @@
image: "{{ image }}" image: "{{ image }}"
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
init: true
env: env:
AWS_ZONE_TTL: 60 AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ fulfillr_zone_id }}" AWS_ZONE_ID: "{{ fulfillr_zone_id }}"
@@ -137,7 +123,6 @@
image: "{{ image }}" image: "{{ image }}"
restart_policy: on-failure:3 restart_policy: on-failure:3
log_driver: journald log_driver: journald
init: true
env: env:
AWS_ZONE_TTL: 60 AWS_ZONE_TTL: 60
AWS_ZONE_ID: "Z07501202A6AYMHCVP50A" AWS_ZONE_ID: "Z07501202A6AYMHCVP50A"
@@ -18,18 +18,6 @@
mode: 0600 mode: 0600
setype: ssh_home_t setype: ssh_home_t
# A direct host-to-S3 stage was added here and then removed. Offsite already
# happens: the TrueNAS rsync below feeds /mnt/glacier/skudakcloud, and a
# TrueNAS cloud-sync task pushes that to Skudak's own iDrive e2 bucket. A
# second, direct push would have written the same data into the same bucket
# twice. If offsite is ever moved onto this host, it should REPLACE the rsync
# rather than run alongside it.
- name: remove obsolete backup S3 credentials
become: true
ansible.builtin.file:
path: "/etc/backup_s3/{{ backup_name }}"
state: absent
- name: template {{ backup_name }} backup script - name: template {{ backup_name }} backup script
become: true become: true
ansible.builtin.template: ansible.builtin.template:
@@ -40,27 +28,6 @@
mode: 0755 mode: 0755
setype: bin_t setype: bin_t
# Shared by every backup instance. Rendered once per include; the second and
# later renders are no-ops.
- name: template nextcloud backup alert script
become: true
ansible.builtin.template:
src: nextcloud/nextcloud-backup-alert.sh.j2
dest: /usr/local/bin/nextcloud-backup-alert.sh
owner: root
group: root
mode: 0755
setype: bin_t
- name: template nextcloud backup failure handler unit
become: true
ansible.builtin.template:
src: nextcloud/nextcloud-backup-failed@.service.j2
dest: /etc/systemd/system/nextcloud-backup-failed@.service
owner: root
group: root
mode: 0644
- name: template {{ backup_name }} backup systemd service - name: template {{ backup_name }} backup systemd service
become: true become: true
ansible.builtin.template: ansible.builtin.template:
@@ -1,40 +0,0 @@
---
- name: template {{ cron_name }} cron script
become: true
ansible.builtin.template:
src: nextcloud/cloud-cron.sh.j2
dest: "{{ cron_script_path }}"
owner: root
group: root
mode: 0755
setype: bin_t
- name: template {{ cron_name }} cron systemd service
become: true
ansible.builtin.template:
src: nextcloud/cloud-cron.service.j2
dest: "/etc/systemd/system/{{ cron_name }}-cron.service"
owner: root
group: root
mode: 0644
vars:
instance_name: "{{ cron_name }}"
- name: template {{ cron_name }} cron systemd timer
become: true
ansible.builtin.template:
src: nextcloud/cloud-cron.timer.j2
dest: "/etc/systemd/system/{{ cron_name }}-cron.timer"
owner: root
group: root
mode: 0644
vars:
instance_name: "{{ cron_name }}"
- name: enable and start {{ cron_name }} cron timer
become: true
ansible.builtin.systemd:
name: "{{ cron_name }}-cron.timer"
enabled: true
state: started
daemon_reload: true
@@ -1,855 +0,0 @@
---
# 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"
@@ -55,17 +55,6 @@
image_strict: true image_strict: true
command: --config /config/dev.json command: --config /config/dev.json
restart_policy: on-failure:3 restart_policy: on-failure:3
# Self-heal the SCRUM-97 boot race: if a transient Secrets-Manager blip left the
# gated outreach/newsletter/cases routes unregistered, /health/startup returns 503
# and podman restarts the container in place (busybox wget ships in the alpine
# image; start_period covers the ~14s HMAC retry backoff so a healthy boot is never
# flagged).
healthcheck: "wget -q -O /dev/null http://localhost:8080/api/v1/health/startup || exit 1"
healthcheck_interval: 30s
healthcheck_timeout: 5s
healthcheck_retries: 3
healthcheck_start_period: 30s
healthcheck_failure_action: restart
log_driver: journald log_driver: journald
volumes: volumes:
- "{{ fulfillr_dev_path }}:/config" - "{{ fulfillr_dev_path }}:/config"
@@ -52,17 +52,6 @@
image_strict: true image_strict: true
command: --config /config/production.json command: --config /config/production.json
restart_policy: on-failure:3 restart_policy: on-failure:3
# Self-heal the SCRUM-97 boot race: if a transient Secrets-Manager blip left the
# gated outreach/newsletter/cases routes unregistered, /health/startup returns 503
# and podman restarts the container in place (busybox wget ships in the alpine
# image; start_period covers the ~14s HMAC retry backoff so a healthy boot is never
# flagged).
healthcheck: "wget -q -O /dev/null http://localhost:8080/api/v1/health/startup || exit 1"
healthcheck_interval: 30s
healthcheck_timeout: 5s
healthcheck_retries: 3
healthcheck_start_period: 30s
healthcheck_failure_action: restart
log_driver: journald log_driver: journald
volumes: volumes:
- "{{ fulfillr_path }}:/config" - "{{ fulfillr_path }}:/config"
@@ -1,59 +0,0 @@
---
# Runs in place of graylog.yml when graylog_enabled is false.
#
# A bare `when:` on the import would only stop Ansible from *creating* the
# stack; containers already on the host would keep running and their systemd
# user units would keep starting them at boot. So the disabled path has to be an
# active teardown: disable the units, then remove the containers.
#
# Volumes under {{ graylog_path }} are deliberately untouched -- indices, the
# Mongo database holding streams/pipelines/dashboards, and the node-id file all
# survive, so flipping graylog_enabled back to true resumes where this left off.
# graylog is stopped first: its unit `requires` the other two, and tearing down
# a dependency out from under it makes the shutdown noisy for no reason.
- name: stop and disable graylog stack systemd units
become: true
become_user: "{{ podman_user }}"
ansible.builtin.systemd:
name: "{{ item }}.service"
enabled: false
state: stopped
daemon_reload: true
scope: user
loop:
- graylog
- graylog-opensearch
- graylog-mongo
# Left over from the Elasticsearch-to-OpenSearch migration; the container is
# long gone but the enabled unit still tries to start it every boot.
- graylog-elastic
register: graylog_units
failed_when: false
tags: graylog
- name: remove graylog stack containers
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: "{{ item }}"
state: absent
loop:
- graylog
- graylog-opensearch
- graylog-mongo
tags: graylog
- name: remove graylog stack systemd unit files
become: true
become_user: "{{ podman_user }}"
ansible.builtin.file:
path: "{{ podman_home }}/.config/systemd/user/{{ item }}.service"
state: absent
loop:
- graylog
- graylog-opensearch
- graylog-mongo
- graylog-elastic
notify: reload podman systemd
tags: graylog
@@ -84,44 +84,10 @@
vars: vars:
container_name: cloud container_name: cloud
# Unbounded by default: nextcloud.log.1 had reached 1.12 GB and was being
# rsynced to TrueNAS and pushed to S3 on every run. Cap at 10 MiB.
- name: cap nextcloud log rotation size for cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data cloud
php occ config:system:set log_rotate_size --value 10485760 --type integer
register: cloud_log_rotate
changed_when: "'System config value log_rotate_size' in cloud_log_rotate.stdout"
failed_when: false
# Nextcloud's default ('auto') only expires trash when disk space is needed,
# so 66 GB of >30-day deletions sat untouched on a host with 1.3 TB free --
# the retention was effectively unbounded. 'auto, 30' makes the 30-day
# expiry unconditional while still purging early under space pressure.
- name: set nextcloud trashbin retention for cloud
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: >
podman exec -u www-data cloud
php occ config:system:set trashbin_retention_obligation --value "auto, 30"
register: cloud_trashbin_retention
changed_when: "'System config value trashbin_retention_obligation' in cloud_trashbin_retention.stdout"
failed_when: false
- include_tasks: containers/cloud-cron.yml
vars:
cron_name: cloud
cron_container: cloud
cron_script_path: /usr/local/bin/cloud-cron.sh
- include_tasks: containers/cloud-backup.yml - include_tasks: containers/cloud-backup.yml
vars: vars:
backup_name: cloud backup_name: cloud
data_path: "{{ cloud_path }}/data" data_path: "{{ cloud_path }}/data"
config_path: "{{ cloud_path }}/config"
db_container: cloud-db
ssh_key_path: /etc/ssh/backup_keys/cloud ssh_key_path: /etc/ssh/backup_keys/cloud
ssh_key_content: "{{ cloud_backup_ssh_key }}" ssh_key_content: "{{ cloud_backup_ssh_key }}"
ssh_user: cloud ssh_user: cloud
@@ -40,8 +40,14 @@
- host - host
env: env:
TZ: America/New_York TZ: America/New_York
# xAI Grok API — the bot's sole AI backend # Ollama + SearXNG for FISTO AI responses
XAI_API_KEY: "{{ xai_api_key }}" OLLAMA_HOST: "http://127.0.0.1:11434"
OLLAMA_MODEL: "dolphin-mistral"
OLLAMA_FALLBACK_MODEL: "dolphin-phi"
OLLAMA_NUM_PREDICT: "300"
SEARXNG_URL: "http://127.0.0.1:8080"
# Gemini API for @bot gemini command
GEMINI_API_KEY: "{{ gemini_api_key }}"
# Zomboid RCON configuration for Discord restart command # Zomboid RCON configuration for Discord restart command
ZOMBOID_RCON_HOST: "127.0.0.1" ZOMBOID_RCON_HOST: "127.0.0.1"
ZOMBOID_RCON_PORT: "{{ zomboid_rcon_port }}" ZOMBOID_RCON_PORT: "{{ zomboid_rcon_port }}"
@@ -25,7 +25,6 @@
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
@@ -0,0 +1,59 @@
---
# PartKeepr has been replaced by Partsy
# This playbook removes PartKeepr containers and services
# Keeping MySQL data volume for historical reference only
- name: stop and remove partkeepr container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: partkeepr
state: absent
- name: stop and remove partkeepr-db container
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: partkeepr-db
state: absent
- name: remove systemd service for partkeepr
become: true
ansible.builtin.systemd:
name: "podman-partkeepr.service"
state: stopped
enabled: false
daemon_reload: true
ignore_errors: true
- name: remove systemd service for partkeepr-db
become: true
ansible.builtin.systemd:
name: "podman-partkeepr-db.service"
state: stopped
enabled: false
daemon_reload: true
ignore_errors: true
- name: remove systemd service files for partkeepr
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "/etc/systemd/system/podman-partkeepr.service"
- "/etc/systemd/system/podman-partkeepr-db.service"
notify: systemd daemon-reload
- name: preserve partkeepr mysql data volume for history
become: true
ansible.builtin.file:
path: "{{ partkeepr_path }}/mysql"
state: directory
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0755
notify: restorecon podman
- name: flush handlers
ansible.builtin.meta: flush_handlers

Some files were not shown because too many files have changed in this diff Show More