Compare commits
3
Commits
master
..
f61105c990
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61105c990 | ||
|
|
7b35d0806d | ||
|
|
fba78f99b5 |
@@ -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
|
||||
@@ -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 TARGET=specific-host` - Deploy to specific host instead of all
|
||||
- `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 list-tags` - List all available Ansible tags
|
||||
- `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
|
||||
- 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
|
||||
|
||||
### Directory Structure
|
||||
@@ -40,17 +33,13 @@ The project uses Python virtualenv for dependency management:
|
||||
ansible/
|
||||
├── deploy.yml # Main playbook entry point (imports deploy_home.yml)
|
||||
├── deploy_home.yml # Core playbook with role definitions
|
||||
├── deploy_labelprint.yml # Label print proxy Pi (separate host, Debian)
|
||||
├── inventories/home/ # Inventory configuration
|
||||
├── inventories/labelprint/ # Label print proxy inventory
|
||||
├── roles/ # Ansible roles organized by function
|
||||
│ ├── common/ # Base system configuration
|
||||
│ ├── git/ # Git repository management
|
||||
│ ├── podman/ # Container orchestration
|
||||
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
|
||||
│ ├── 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
|
||||
└── vars/
|
||||
└── vault.yml # Encrypted secrets
|
||||
@@ -60,7 +49,7 @@ ansible/
|
||||
Containers are organized in `ansible/roles/podman/tasks/containers/`:
|
||||
- `base/` - Core infrastructure containers (Caddy web server, AWS DDNS)
|
||||
- `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)
|
||||
|
||||
### Security Model
|
||||
@@ -96,7 +85,6 @@ Tasks are tagged by service/component for selective deployment:
|
||||
- `ddns` - Dynamic DNS tasks
|
||||
- ~~`drone` - CI/CD tasks (decommissioned)~~
|
||||
- `hass` - Home Assistant tasks
|
||||
- `gitea-actions` - Gitea Actions runners and their CI job images
|
||||
- Common infrastructure tags like `common`, `ssl`
|
||||
|
||||
## Configuration Files
|
||||
@@ -108,38 +96,13 @@ Tasks are tagged by service/component for selective deployment:
|
||||
|
||||
## Target Environment
|
||||
|
||||
- Primary target host: `home.debyl.io`
|
||||
- Single target host: `home.debyl.io`
|
||||
- OS: Fedora (ansible_user: fedora)
|
||||
- Container runtime: Podman
|
||||
- Web server: Caddy with automatic HTTPS and built-in security (replaced nginx + ModSecurity)
|
||||
- All services accessible via HTTPS with automatic certificate renewal
|
||||
- ~~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
|
||||
|
||||
The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
|
||||
|
||||
@@ -21,9 +21,6 @@ VAULT_FILE=ansible/vars/vault.yml
|
||||
|
||||
# Variables
|
||||
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
|
||||
|
||||
# Default to all ansible tags to run (passed via 'make deploy TAGS=sometag')
|
||||
@@ -32,9 +29,6 @@ SKIP_TAGS?=none
|
||||
TARGET?=all
|
||||
EXTRA_VARS?=
|
||||
|
||||
# Mounted boot partition of the label print proxy's SD card (see `make bootfs`)
|
||||
BOOTFS?=/Volumes/bootfs
|
||||
|
||||
${VENV}:
|
||||
python3 -m venv ${VENV}
|
||||
${VENV_BIN}/python3 -m pip install --upgrade pip
|
||||
@@ -54,28 +48,13 @@ ${VAULT_FILE}: ${VAULT_PASS_FILE}
|
||||
touch $@
|
||||
|
||||
# Linting
|
||||
# .gitea/workflows is linted too: the CI-image workflow is as much part of the
|
||||
# deployment as the roles it publishes for.
|
||||
YAML_FILES=$(shell find ansible/ .gitea/ -name '*.yml' -not -name '*vault*')
|
||||
YAML_FILES=$(shell find ansible/ -name '*.yml' -not -name '*vault*')
|
||||
SKIP_FILE=./.lint-vars.sh
|
||||
|
||||
# Targets
|
||||
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
|
||||
|
||||
# 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}
|
||||
${ANSIBLE} --list-tags -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} ansible/deploy.yml
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,12 +8,8 @@
|
||||
- role: podman
|
||||
# SSL certificates are now handled automatically by Caddy
|
||||
# - role: ssl # REMOVED - Caddy handles all certificate management
|
||||
- role: ollama
|
||||
tags: ollama
|
||||
- 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
|
||||
when: graylog_enabled | bool
|
||||
tags: graylog-config
|
||||
- role: ups
|
||||
tags: ups
|
||||
|
||||
@@ -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
|
||||
@@ -1,26 +1,5 @@
|
||||
---
|
||||
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:
|
||||
home.debyl.io:
|
||||
ansible_user: fedora
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -21,12 +21,3 @@ services:
|
||||
- podman
|
||||
- fail2ban
|
||||
- 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"
|
||||
|
||||
@@ -11,28 +11,8 @@
|
||||
name: fail2ban
|
||||
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
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: fluent-bit
|
||||
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
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
---
|
||||
# Fluent Bit - Log forwarder from journald to Graylog GELF
|
||||
# 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
|
||||
become: true
|
||||
@@ -43,9 +37,9 @@
|
||||
mode: '0644'
|
||||
notify: restart fluent-bit
|
||||
|
||||
- name: set fluent-bit service state to match graylog_enabled
|
||||
- name: enable and start fluent-bit service
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: fluent-bit
|
||||
enabled: "{{ graylog_enabled | bool }}"
|
||||
state: "{{ 'started' if (graylog_enabled | bool) else 'stopped' }}"
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
@@ -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,9 +3,6 @@
|
||||
- import_tasks: security.yml
|
||||
- import_tasks: service.yml
|
||||
|
||||
- import_tasks: mail.yml
|
||||
tags: mail
|
||||
|
||||
- import_tasks: fluent-bit.yml
|
||||
tags: fluent-bit, graylog
|
||||
|
||||
|
||||
@@ -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
|
||||
become: true
|
||||
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
|
||||
@@ -4,11 +4,9 @@ git_home: "/srv/{{ git_user }}"
|
||||
|
||||
# Gitea configuration
|
||||
gitea_debyl_server_name: git.debyl.io
|
||||
# Pinned per instance so one can be upgraded (and verified) before the other.
|
||||
gitea_debyl_image: docker.gitea.com/gitea:1.27.3
|
||||
gitea_image: docker.gitea.com/gitea:1.26.1
|
||||
gitea_db_image: docker.io/library/postgres:14-alpine
|
||||
|
||||
# Skudak Gitea configuration
|
||||
gitea_skudak_server_name: git.skudak.com
|
||||
gitea_skudak_ssh_port: 2222
|
||||
gitea_skudak_image: docker.gitea.com/gitea:1.27.3
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
become_user: "{{ git_user }}"
|
||||
containers.podman.podman_container:
|
||||
name: gitea-skudak
|
||||
image: "{{ gitea_skudak_image }}"
|
||||
image: "{{ gitea_image }}"
|
||||
pod: gitea-skudak-pod
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
state: started
|
||||
ports:
|
||||
- "3100:3000"
|
||||
tags: gitea, gitea-debyl
|
||||
tags: gitea
|
||||
|
||||
# PostgreSQL container in pod
|
||||
- name: create gitea-debyl-postgres container
|
||||
@@ -28,7 +28,7 @@
|
||||
POSTGRES_PASSWORD: "{{ gitea_debyl_db_pass }}"
|
||||
volumes:
|
||||
- "{{ git_home }}/volumes/gitea/psql:/var/lib/postgresql/data"
|
||||
tags: gitea, gitea-debyl
|
||||
tags: gitea
|
||||
|
||||
# Gitea container in pod
|
||||
- name: create gitea-debyl container
|
||||
@@ -36,7 +36,7 @@
|
||||
become_user: "{{ git_user }}"
|
||||
containers.podman.podman_container:
|
||||
name: gitea-debyl
|
||||
image: "{{ gitea_debyl_image }}"
|
||||
image: "{{ gitea_image }}"
|
||||
pod: gitea-debyl-pod
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
@@ -66,7 +66,7 @@
|
||||
volumes:
|
||||
- "{{ git_home }}/volumes/gitea/data:/data"
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
tags: gitea, gitea-debyl
|
||||
tags: gitea
|
||||
|
||||
# Generate systemd service for the pod
|
||||
- name: create systemd job for gitea-debyl-pod
|
||||
@@ -80,7 +80,7 @@
|
||||
args:
|
||||
chdir: "{{ git_home }}"
|
||||
changed_when: false
|
||||
tags: gitea, gitea-debyl
|
||||
tags: gitea
|
||||
|
||||
- name: enable gitea-debyl-pod service
|
||||
become: true
|
||||
@@ -91,4 +91,4 @@
|
||||
enabled: true
|
||||
state: started
|
||||
scope: user
|
||||
tags: gitea, gitea-debyl
|
||||
tags: gitea
|
||||
|
||||
@@ -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.
|
||||
@@ -22,70 +22,14 @@ act_runner_bin: /usr/local/bin/act_runner
|
||||
act_runner_config_dir: /etc/act_runner
|
||||
act_runner_work_dir: /var/lib/act_runner
|
||||
|
||||
# Job container images, served from the Gitea container registry.
|
||||
#
|
||||
# They used to live only under localhost/, built by this role. The nightly
|
||||
# podman prune (roles/podman: podman_prune_ci_until) deletes any CI-user image
|
||||
# older than 48h that no container is using, so every idle weekend CI failed in
|
||||
# 0-1s on `docker pull localhost/gitea-ci:latest` until someone re-ran the role
|
||||
# and waited out a full rebuild.
|
||||
#
|
||||
# Now .gitea/workflows/ci-images.yml builds them from files/Containerfile.* and
|
||||
# pushes them here, and this role only pulls. A pruned image is re-pulled by the
|
||||
# next job on its own (force_pull stays false, which means "pull only when
|
||||
# missing", so a present image is never re-fetched).
|
||||
#
|
||||
# Workflows that pin `container: image:` must use these registry paths too
|
||||
# (esp-mg-tpms, skudak/esp32-stm32-vcu, skudak/esp32-web-interface).
|
||||
gitea_ci_registry: git.debyl.io
|
||||
# Namespace = the owner of gitea_registry_username / gitea_registry_token (vault).
|
||||
gitea_ci_registry_namespace: gitbot
|
||||
gitea_ci_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci:latest"
|
||||
# Job container images (built locally into the gitea-runner rootless image
|
||||
# store by tasks/images.yml; never pulled — force_pull is false).
|
||||
gitea_ci_image: localhost/gitea-ci:latest
|
||||
# ESP-IDF firmware image tag tracks the upstream espressif/idf release we build from.
|
||||
esp_idf_version: v5.5.1
|
||||
gitea_ci_espidf_image: "{{ gitea_ci_registry }}/{{ gitea_ci_registry_namespace }}/gitea-ci-espidf:{{ esp_idf_version }}"
|
||||
# PlatformIO image for Arduino-framework ESP32 builds (esp32-web-interface).
|
||||
# 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 }}"
|
||||
esp_idf_version: v5.4.1
|
||||
gitea_ci_espidf_image: "localhost/gitea-ci-espidf:{{ esp_idf_version }}"
|
||||
|
||||
# Registry credentials for the gitea-runner user. The Docker-format path is read
|
||||
# by both act_runner (to authenticate job image pulls) and podman (as its
|
||||
# fallback auth file), so one login covers the runner and this role.
|
||||
gitea_ci_registry_authfile: "{{ gitea_runner_home }}/.docker/config.json"
|
||||
|
||||
# The images this role keeps present on the runner. `build_args` is a literal
|
||||
# podman-build argument string (podman_image has no structured build-arg
|
||||
# option) and is only used by the gitea_ci_build_local fallback below -- the
|
||||
# workflow passes the same --build-arg values, read out of the version vars
|
||||
# above, so there is one source of truth for the pins.
|
||||
gitea_ci_images:
|
||||
- image: "{{ gitea_ci_image }}"
|
||||
containerfile: Containerfile.ci
|
||||
build_args: ""
|
||||
- image: "{{ gitea_ci_espidf_image }}"
|
||||
containerfile: Containerfile.espidf
|
||||
build_args: "--build-arg ESP_IDF_VERSION={{ esp_idf_version }}"
|
||||
- image: "{{ gitea_ci_platformio_image }}"
|
||||
containerfile: Containerfile.platformio
|
||||
build_args: >-
|
||||
--build-arg PLATFORMIO_CORE_VERSION={{ platformio_core_version }}
|
||||
--build-arg PIO_ESPRESSIF32_VERSION={{ pio_espressif32_version }}
|
||||
|
||||
# Escape hatch: build the images on the host and push them, instead of pulling
|
||||
# what CI published. Needed to seed a brand-new registry namespace, and when CI
|
||||
# itself is down -- the workflow that builds gitea-ci runs *in* gitea-ci, so a
|
||||
# registry that has never held it cannot bootstrap itself.
|
||||
#
|
||||
# make deploy TAGS=gitea-actions EXTRA_VARS="gitea_ci_build_local=true"
|
||||
#
|
||||
# Off by default: a plain deploy should never sit through a 15-minute ESP-IDF
|
||||
# rebuild, and two publishers racing on the same tag is worth avoiding.
|
||||
gitea_ci_build_local: false
|
||||
|
||||
# Default labels for every runner — map runs-on values to the registry CI image.
|
||||
# Default labels for every runner — map runs-on values to the local CI image.
|
||||
# Firmware jobs opt into the ESP-IDF image per-job via `container:` in their workflow.
|
||||
gitea_runner_labels:
|
||||
- "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}
|
||||
@@ -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
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_user }}"
|
||||
@@ -53,41 +6,50 @@
|
||||
path: "{{ gitea_runner_home }}/ci-images"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: gitea_ci_build_local | bool
|
||||
tags: gitea-actions
|
||||
|
||||
# copy, not template: these are plain Containerfiles that CI builds verbatim.
|
||||
# Versions come in as --build-arg from the same defaults/main.yml the workflow
|
||||
# reads, so neither builder can drift from the other.
|
||||
- name: stage CI Containerfiles
|
||||
- name: stage default CI Containerfile
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_user }}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.containerfile }}"
|
||||
dest: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
|
||||
ansible.builtin.template:
|
||||
src: Containerfile.ci
|
||||
dest: "{{ gitea_runner_home }}/ci-images/Containerfile.ci"
|
||||
mode: "0644"
|
||||
loop: "{{ gitea_ci_images }}"
|
||||
loop_control:
|
||||
label: "{{ item.containerfile }}"
|
||||
when: gitea_ci_build_local | bool
|
||||
register: ci_containerfile
|
||||
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_user: "{{ gitea_runner_user }}"
|
||||
containers.podman.podman_image:
|
||||
name: "{{ item.image }}"
|
||||
name: "{{ gitea_ci_image }}"
|
||||
path: "{{ gitea_runner_home }}/ci-images"
|
||||
build:
|
||||
file: "{{ gitea_runner_home }}/ci-images/{{ item.containerfile }}"
|
||||
extra_args: "{{ item.build_args }}"
|
||||
force: true
|
||||
push: true
|
||||
auth_file: "{{ gitea_ci_registry_authfile }}"
|
||||
file: "{{ gitea_runner_home }}/ci-images/Containerfile.ci"
|
||||
force: "{{ ci_containerfile is changed }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
tags: gitea-actions
|
||||
|
||||
- name: build ESP-IDF CI image ({{ gitea_ci_espidf_image }})
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_user }}"
|
||||
containers.podman.podman_image:
|
||||
name: "{{ gitea_ci_espidf_image }}"
|
||||
path: "{{ gitea_runner_home }}/ci-images"
|
||||
build:
|
||||
file: "{{ gitea_runner_home }}/ci-images/Containerfile.espidf"
|
||||
force: "{{ espidf_containerfile is changed }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
loop: "{{ gitea_ci_images }}"
|
||||
loop_control:
|
||||
label: "{{ item.image }}"
|
||||
when: gitea_ci_build_local | bool
|
||||
tags: gitea-actions
|
||||
|
||||
@@ -69,50 +69,3 @@
|
||||
group: "{{ gitea_runner_user }}"
|
||||
mode: "0644"
|
||||
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
-12
@@ -1,24 +1,13 @@
|
||||
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git openssh-client make build-essential \
|
||||
python3 python3-pip python3-yaml python3-jinja2 jq zip unzip \
|
||||
gcc-arm-none-eabi binutils-arm-none-eabi libnewlib-arm-none-eabi \
|
||||
python3 python3-pip jq zip unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 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.
|
||||
network: host
|
||||
privileged: false
|
||||
# Bind-mount the runner's SSH material (key + known_hosts) read-only into
|
||||
# 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
|
||||
options:
|
||||
workdir_parent:
|
||||
# act_runner gates host bind-mounts against this allowlist; the ci-ssh source
|
||||
# path must be listed or the -v above is silently stripped from the job container.
|
||||
valid_volumes:
|
||||
- {{ gitea_runner_home }}/ci-ssh
|
||||
valid_volumes: []
|
||||
# Point act at the real rootless socket so it mounts the correct path into
|
||||
# job containers (the documented rootless-podman gotcha).
|
||||
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/podman/podman.sock"
|
||||
|
||||
@@ -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) |
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
- import_tasks: base.yml
|
||||
- import_tasks: driver.yml
|
||||
- import_tasks: cups.yml
|
||||
- import_tasks: wifi.yml
|
||||
- import_tasks: firewall.yml
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
ollama_models:
|
||||
- dolphin-phi
|
||||
- dolphin-mistral
|
||||
ollama_host: "127.0.0.1"
|
||||
ollama_port: 11434
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
- name: restart ollama
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: ollama
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
tags: ollama
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
dependencies:
|
||||
- role: common
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
- import_tasks: install.yml
|
||||
tags: ollama
|
||||
|
||||
- import_tasks: service.yml
|
||||
tags: ollama
|
||||
|
||||
- import_tasks: models.yml
|
||||
tags: ollama
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
deps: [
|
||||
php-sqlite,
|
||||
php-fpm
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
dependencies:
|
||||
- role: http
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
- import_tasks: deps.yml
|
||||
- import_tasks: php.yml
|
||||
@@ -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
|
||||
@@ -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"> </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>
|
||||
 · 
|
||||
<a href="{$site}/legal/tos" style="color:{$muted};text-decoration:underline">Terms of Use</a>
|
||||
 · 
|
||||
<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">
|
||||
© {$year} {$entity}. All rights reserved.<br/>
|
||||
Automated message — 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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
alias: Driveway String Lights Off
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: time
|
||||
trigger:
|
||||
- platform: time
|
||||
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: []
|
||||
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:
|
||||
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
|
||||
- id: '1739912161794'
|
||||
alias: Lights - 00 - Morning
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: '09:00:00'
|
||||
at: 09:00:00
|
||||
conditions: []
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
data:
|
||||
- type: turn_on
|
||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
target:
|
||||
entity_id: light.bathroom_hallway
|
||||
mode: single
|
||||
- id: '1762116115638'
|
||||
alias: Light - TV On
|
||||
description: TV mode on; once it's dark, dim the living room and turn off the
|
||||
lights that glare on the TV
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: remote.samsung_tv
|
||||
to: 'on'
|
||||
not_from:
|
||||
- unavailable
|
||||
- unknown
|
||||
- type: turned_on
|
||||
device_id: 18a9bb7a2a32be4371da447767ef50a9
|
||||
entity_id: c05688f2610e27e2d86380e2945ceae5
|
||||
domain: remote
|
||||
trigger: device
|
||||
conditions: []
|
||||
actions:
|
||||
- action: input_boolean.turn_on
|
||||
target:
|
||||
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
|
||||
metadata: {}
|
||||
data:
|
||||
brightness_pct: 5
|
||||
target:
|
||||
area_id: living_room
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- light.kitchen_wall_light
|
||||
- light.dining_hall
|
||||
- light.bathroom_hallway
|
||||
- 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: 3f7f65571d9bb0833433996f1f6725bd
|
||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
||||
domain: light
|
||||
mode: single
|
||||
- id: new_tv_off
|
||||
alias: Light - TV Off - Restore
|
||||
description: Evening (sunset -1h to 23:30) brings the lights back to the
|
||||
scheduled level; late night (23:30 to sunrise) only turns off the TV glow;
|
||||
daytime leaves the lights alone
|
||||
description: Restores appropriate lighting level when TV turns off based on time
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: remote.samsung_tv
|
||||
to: 'off'
|
||||
not_from:
|
||||
- unavailable
|
||||
- unknown
|
||||
- type: turned_off
|
||||
device_id: 18a9bb7a2a32be4371da447767ef50a9
|
||||
entity_id: c05688f2610e27e2d86380e2945ceae5
|
||||
domain: remote
|
||||
trigger: device
|
||||
conditions: []
|
||||
actions:
|
||||
- action: input_boolean.turn_off
|
||||
@@ -80,179 +239,409 @@
|
||||
entity_id: input_boolean.tv_mode
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: sun
|
||||
after: sunset
|
||||
after_offset: "-01:00:00"
|
||||
- condition: time
|
||||
before: '23:30:00'
|
||||
after: '22:30:00'
|
||||
before: '23:45:00'
|
||||
sequence:
|
||||
- action: script.evening_lights_apply
|
||||
data:
|
||||
apply: force
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.desk_lamp
|
||||
# Late dim levels
|
||||
- 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
|
||||
- conditions:
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: time
|
||||
after: '23:30:00'
|
||||
- condition: sun
|
||||
before: sunrise
|
||||
after: '21:30:00'
|
||||
before: '22:30:00'
|
||||
sequence:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
area_id: living_room
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.desk_lamp
|
||||
mode: single
|
||||
- id: driveway_lights_on
|
||||
alias: Driveway String Lights On
|
||||
description: On 1 hour before sunset whether or not the TV is on. Also catches
|
||||
up if Home Assistant restarts before the 23:00 off
|
||||
triggers:
|
||||
- trigger: sun
|
||||
event: sunset
|
||||
offset: "-01:00:00"
|
||||
id: sunset
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: switch.driveway_string_lights
|
||||
state: 'off'
|
||||
- condition: or
|
||||
conditions:
|
||||
- condition: trigger
|
||||
id: sunset
|
||||
- condition: and
|
||||
conditions:
|
||||
- condition: sun
|
||||
after: sunset
|
||||
after_offset: "-01:00:00"
|
||||
# Mid dim levels
|
||||
- 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
|
||||
- conditions:
|
||||
- condition: time
|
||||
before: '23:00:00'
|
||||
actions:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.driveway_string_lights
|
||||
after: '21:00:00'
|
||||
before: '21:30:00'
|
||||
sequence:
|
||||
# Early dim levels
|
||||
- type: turn_on
|
||||
device_id: 03a12d2360d9954aed19c2449070725a
|
||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
||||
domain: light
|
||||
brightness_pct: 50
|
||||
- type: turn_on
|
||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
||||
domain: light
|
||||
brightness_pct: 50
|
||||
- type: turn_on
|
||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
||||
domain: light
|
||||
brightness_pct: 50
|
||||
- type: turn_on
|
||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
||||
domain: light
|
||||
brightness_pct: 50
|
||||
default:
|
||||
# Full brightness (before 21:00 after sunset)
|
||||
- type: turn_on
|
||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
||||
domain: light
|
||||
brightness_pct: 25
|
||||
- type: turn_on
|
||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
- type: turn_on
|
||||
device_id: 03a12d2360d9954aed19c2449070725a
|
||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
- type: turn_on
|
||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
||||
domain: light
|
||||
brightness_pct: 75
|
||||
- type: turn_on
|
||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
mode: single
|
||||
- id: 'sunset_lights_on'
|
||||
alias: Lights - Sunset On
|
||||
description: Turn on lights 1 hour before sunset. If the TV is on, the living
|
||||
room gets the TV glow and the lights that glare on the TV stay off
|
||||
description: Turn on lights 1 hour before sunset
|
||||
triggers:
|
||||
- trigger: sun
|
||||
event: sunset
|
||||
offset: "-01:00:00"
|
||||
conditions: []
|
||||
actions:
|
||||
- action: script.evening_lights_apply
|
||||
data:
|
||||
apply: force
|
||||
- if:
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: input_boolean.tv_mode
|
||||
state: 'on'
|
||||
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'
|
||||
state: 'off'
|
||||
actions:
|
||||
- action: script.evening_lights_apply
|
||||
data:
|
||||
apply: ramp
|
||||
- type: turn_on
|
||||
device_id: 1fa1aca8f90daf94a2a7baf8a3abc158
|
||||
entity_id: 58d101e63456fd8e088d3a3b63f3a0f9
|
||||
domain: switch
|
||||
- type: turn_on
|
||||
device_id: 800eddbeeda071225f181a14cb9527e0
|
||||
entity_id: 521a92ddd8be76c7eddfc544f81f6020
|
||||
domain: light
|
||||
brightness_pct: 25
|
||||
- type: turn_on
|
||||
device_id: f31e4f9bf8fa3687a07aeb4430eaef38
|
||||
entity_id: b79934d97f3bb9d8a3da47c76d03ded4
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
- type: turn_on
|
||||
device_id: 03a12d2360d9954aed19c2449070725a
|
||||
entity_id: 7c1e7db73799cc3f90948b5118596985
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
- type: turn_on
|
||||
device_id: 3f7f65571d9bb0833433996f1f6725bd
|
||||
entity_id: 7407afe14783543252c666d5ff7c5d5c
|
||||
domain: light
|
||||
brightness_pct: 75
|
||||
- type: turn_on
|
||||
device_id: 21eb2bd28aba2ee361a22af92e8b2d16
|
||||
entity_id: 81c486d682afcc94e98e377475cc92fc
|
||||
domain: light
|
||||
brightness_pct: 100
|
||||
mode: single
|
||||
- id: 'evening_dim_2100'
|
||||
alias: Lights - Evening Dim (21:00)
|
||||
description: Dim lights at 21:00 - only affects lights that are ON
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: "21:00:00"
|
||||
conditions:
|
||||
- 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
|
||||
- id: 'lights_out_2345'
|
||||
alias: Lights - Out (23:30)
|
||||
description: Turn off all lights at 23:30. While the TV is on the living room
|
||||
is left as it is
|
||||
alias: Lights - Out (23:45)
|
||||
description: Turn off all lights at 23:45
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: "23:30:00"
|
||||
at: "23:45:00"
|
||||
conditions: []
|
||||
actions:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- light.kitchen_lights
|
||||
- light.kitchen_wall_light
|
||||
- light.dining_hall
|
||||
- light.dining_room
|
||||
- light.bathroom_hallway
|
||||
- if:
|
||||
- condition: state
|
||||
entity_id: input_boolean.tv_mode
|
||||
state: 'off'
|
||||
then:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.desk_lamp
|
||||
mode: single
|
||||
- id: lights_sweep_0100
|
||||
alias: Lights - Sweep (01:00)
|
||||
description: Catch anything turned back on after lights-out. While the TV is
|
||||
on the living room is left as it is
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: "01:00:00"
|
||||
conditions: []
|
||||
actions:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id:
|
||||
- light.kitchen_lights
|
||||
- light.kitchen_wall_light
|
||||
- light.dining_hall
|
||||
- light.dining_room
|
||||
- light.bathroom_hallway
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.driveway_string_lights
|
||||
- if:
|
||||
- condition: state
|
||||
entity_id: input_boolean.tv_mode
|
||||
state: 'off'
|
||||
then:
|
||||
- action: light.turn_off
|
||||
target:
|
||||
entity_id: light.living_room
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.desk_lamp
|
||||
- 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
|
||||
- id: '1768862300896'
|
||||
alias: Bedroom On
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: switch.bedroom_light
|
||||
to: 'on'
|
||||
not_from:
|
||||
- unavailable
|
||||
- unknown
|
||||
- type: turned_on
|
||||
device_id: afb9734fe9b187ab6881a64d24e1c2f5
|
||||
entity_id: 27efa149b9ebb388e7c21ba89e671b42
|
||||
domain: switch
|
||||
trigger: device
|
||||
conditions: []
|
||||
actions:
|
||||
- action: light.turn_on
|
||||
@@ -266,12 +655,11 @@
|
||||
alias: Bedroom Off
|
||||
description: ''
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: switch.bedroom_light
|
||||
to: 'off'
|
||||
not_from:
|
||||
- unavailable
|
||||
- unknown
|
||||
- type: turned_off
|
||||
device_id: afb9734fe9b187ab6881a64d24e1c2f5
|
||||
entity_id: 27efa149b9ebb388e7c21ba89e671b42
|
||||
domain: switch
|
||||
trigger: device
|
||||
conditions: []
|
||||
actions:
|
||||
- action: light.turn_off
|
||||
|
||||
@@ -9,11 +9,6 @@ http:
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 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
|
||||
- 192.168.1.0/24
|
||||
|
||||
@@ -23,7 +18,6 @@ homeassistant:
|
||||
media: /share
|
||||
|
||||
automation: !include automations.yaml
|
||||
script: !include scripts.yaml
|
||||
|
||||
input_boolean:
|
||||
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"> </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>
|
||||
 · 
|
||||
<a href="{$site}/terms" style="color:{$muted};text-decoration:underline">Terms of Use</a>
|
||||
 · 
|
||||
<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">
|
||||
© {$year} {$entity}. All rights reserved.<br/>
|
||||
Automated message — 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
|
||||
@@ -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
|
||||
become: true
|
||||
ansible.builtin.command: |
|
||||
restorecon -Frxv {{ podman_home }}/.local/share/volumes
|
||||
restorecon -Frv {{ podman_home }}/.local/share/volumes
|
||||
tags:
|
||||
- podman
|
||||
- selinux
|
||||
@@ -37,21 +25,11 @@
|
||||
tags:
|
||||
- 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
|
||||
become: true
|
||||
become_user: "{{ podman_user }}"
|
||||
ansible.builtin.command: |
|
||||
podman exec caddy caddy reload --config /config/Caddyfile --adapter caddyfile
|
||||
podman exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
tags:
|
||||
- caddy
|
||||
- caddy-config
|
||||
@@ -64,12 +42,3 @@
|
||||
scope: user
|
||||
tags:
|
||||
- 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
|
||||
vars:
|
||||
container_name: awsddns
|
||||
@@ -23,7 +13,6 @@
|
||||
image: "{{ image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
init: true
|
||||
env:
|
||||
AWS_ZONE_TTL: 60
|
||||
AWS_ZONE_ID: "{{ aws_zone_id }}"
|
||||
@@ -51,7 +40,6 @@
|
||||
image: "{{ image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
init: true
|
||||
env:
|
||||
AWS_ZONE_TTL: 60
|
||||
AWS_ZONE_ID: "{{ aws_skudak_zone_id }}"
|
||||
@@ -79,7 +67,6 @@
|
||||
image: "{{ image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
init: true
|
||||
env:
|
||||
AWS_ZONE_TTL: 60
|
||||
AWS_ZONE_ID: "{{ fulfillr_zone_id }}"
|
||||
@@ -109,7 +96,6 @@
|
||||
image: "{{ image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
init: true
|
||||
env:
|
||||
AWS_ZONE_TTL: 60
|
||||
AWS_ZONE_ID: "{{ fulfillr_zone_id }}"
|
||||
@@ -137,7 +123,6 @@
|
||||
image: "{{ image }}"
|
||||
restart_policy: on-failure:3
|
||||
log_driver: journald
|
||||
init: true
|
||||
env:
|
||||
AWS_ZONE_TTL: 60
|
||||
AWS_ZONE_ID: "Z07501202A6AYMHCVP50A"
|
||||
|
||||
@@ -18,18 +18,6 @@
|
||||
mode: 0600
|
||||
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
|
||||
become: true
|
||||
ansible.builtin.template:
|
||||
@@ -40,27 +28,6 @@
|
||||
mode: 0755
|
||||
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
|
||||
become: true
|
||||
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
|
||||
command: --config /config/dev.json
|
||||
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
|
||||
volumes:
|
||||
- "{{ fulfillr_dev_path }}:/config"
|
||||
|
||||
@@ -52,17 +52,6 @@
|
||||
image_strict: true
|
||||
command: --config /config/production.json
|
||||
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
|
||||
volumes:
|
||||
- "{{ 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:
|
||||
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
|
||||
vars:
|
||||
backup_name: cloud
|
||||
data_path: "{{ cloud_path }}/data"
|
||||
config_path: "{{ cloud_path }}/config"
|
||||
db_container: cloud-db
|
||||
ssh_key_path: /etc/ssh/backup_keys/cloud
|
||||
ssh_key_content: "{{ cloud_backup_ssh_key }}"
|
||||
ssh_user: cloud
|
||||
|
||||
@@ -40,8 +40,14 @@
|
||||
- host
|
||||
env:
|
||||
TZ: America/New_York
|
||||
# xAI Grok API — the bot's sole AI backend
|
||||
XAI_API_KEY: "{{ xai_api_key }}"
|
||||
# Ollama + SearXNG for FISTO AI responses
|
||||
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_HOST: "127.0.0.1"
|
||||
ZOMBOID_RCON_PORT: "{{ zomboid_rcon_port }}"
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
loop:
|
||||
- configuration.yaml
|
||||
- automations.yaml
|
||||
- scripts.yaml
|
||||
|
||||
- name: 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
Reference in New Issue
Block a user