Compare commits

...

313 commits

Author SHA1 Message Date
19d33b56a6
left4me: wire LOG_LISTENER_{ADDR,BIND} into web.env
Derives LOG_LISTENER_ADDR from the node's external IPv4 (stripping the
CIDR suffix). The destination MUST be non-loopback — Source silently
drops logaddress UDP destinations in 127.0.0.0/8 (registration
succeeds but no sendto ever happens). LOG_LISTENER_BIND is pinned
to 0.0.0.0:28000 so the listener accepts on every interface,
including the lo-routed packets the kernel uses for same-host
destinations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:13:38 +02:00
14e055a5e0
wol: share the wakeonlan command via wol-sleeper/waker_command
Sleeper now exposes the full `/usr/bin/wakeonlan -i <broadcast> <mac>`
invocation as wol-sleeper/waker_command; waker reads that instead of
rebuilding the command line itself. Adds the `-i <subnet-broadcast>`
flag so magic packets reach sleepers on a different L2 segment than
the waker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:00:30 +02:00
f3122f3d0a
refactor(zsh): replace oh-my-zsh with a hand-rolled zprofile
Drops both git_deploy clones (ohmyzsh, zsh-autosuggestions), the chown
action, the bw.zsh-theme, and the plugin directories. /etc/zsh/zprofile
now embeds a compact prompt mirroring the old theme's colors (no git
segment), sources zsh-autosuggestions from the new apt package, and
binds prefix-history search on the arrow keys.

Per-user ~/.zshrc is kept as a one-line marker so zsh-newuser-install
doesn't fire when ckn logs in.

Leftover /etc/zsh/oh-my-zsh/ trees aren't removed automatically — clean
once with `bw run linux 'rm -rf /etc/zsh/oh-my-zsh'` after the next
fleet-wide apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:00:07 +02:00
9d59d65fa0
fix(.envrc): use uv -> layout uv
Direnv has no built-in `use_uv`; the `use uv` line in 219f045 silently
errored out (`use_uv: command not found`) and the shell ended up with
an unactivated `.venv`. The matching upstream proposal is `layout_uv`
in direnv/direnv#1329 (open, maintainer-approved). Install that
function (verbatim, plus a small auto-sync-on-pyproject-change block)
in ~/.config/direnv/direnvrc on the local machine and switch the
.envrc verb here to match. When #1329 ships in a direnv release, the
custom direnvrc function can stay (strict superset) or be removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:31:54 +02:00
219f045399
refactor: collapse venv chain into uv sync
Replaces requirements.txt + `python -m venv` + periodic `pip install`
with a single pyproject.toml driven by `uv sync`. .envrc shrinks to
`use uv` (plus the BW_*_WORKERS helper). The CroneKorkN/bundlewrap
fork is now pinned via [tool.uv.sources] (git, non-editable) instead
of `pip install -e git+...`; pinned rev lives in uv.lock and bumps
via `uv sync --upgrade-package bundlewrap`. Mirrors left4me 77b5e01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:16:39 +02:00
77b5e01198
refactor(left4me): collapse venv chain into uv sync
Replace left4me_create_venv + left4me_pip_upgrade + left4me_pip_install
(the tempdir-copy dance) with a single left4me_uv_sync action driven by
left4me's committed uv.lock. Deterministic dep versions, no source-tree
mutation during build (hatchling PEP 660 editable installs don't write
to source), one action instead of three.

uv is not in Trixie's apt archive (experimental/sid only), so a new
left4me_install_uv action downloads a pinned 0.11.8 binary tarball
from astral-sh/uv releases, SHA256-verifies against the published
.sha256 sibling, and installs into /usr/local/bin. Idempotent via
`unless` on the version string — only re-runs when the pin is bumped.
Pattern matches left4me_install_steamcmd elsewhere in this bundle.

apt.packages: drop python3-pip and python3-venv (uv replaces both;
no other consumer in the bundle). Keep python3 and python3-dev — uv
shells out to the system Python interpreter.

PYTHONPATH=/opt/left4me/src removed from left4me_alembic_upgrade and
left4me_seed_overlays — was a workaround for the previous layout's
package-dir indirection; with the new standard layout + editable
install, the venv resolves both members natively.

Per left4me/docs/superpowers/plans/2026-05-15-uv-workspace-execution.md.
Requires the matching commit on left4me's master.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:07:47 +02:00
a95a7e20e2
left4me/README: describe symlink delivery + reactor scope after the reshape
Rewrite "What this bundle does" to reflect the post-migration model:
- Target-side symlinks table for the 6 static artifacts (sudoers, sysctl,
  2 hardening drop-ins, 4 libexec helpers, sbin wrapper)
- Reactor-emitted units section (per-host shape: web/server@ units, slices,
  cpuset drop-ins)
- bw files{} for the templated env files
- Action chains section covering the full deploy lifecycle
- Reference to the design doc for rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:49:11 +02:00
ae4bfc8db3
left4me: symlink privileged helpers to the checkout
/usr/local/libexec/left4me/* and /usr/local/sbin/left4me are now
target-side symlinks into /opt/left4me/src/deploy/scripts/...
Replaces the install_left4me_scripts copy-action.

Part of 2026-05-15-deployment-responsibility-design.md migration
step 4. Verified on ovh.left4me: helpers run via sudo from the web
app context; gameserver lifecycle helper invocation succeeds.
2026-05-15 19:42:12 +02:00
05ec7c9bee
left4me: symlink /etc/sudoers.d/left4me to the checkout
Sudoers drop-in lives in left4me/deploy/files/etc/sudoers.d/left4me
(single source of truth). Deleted the verbatim mirror in this bundle's
files/ tree. Added an idempotent chmod action so the in-checkout file
is 0440 root:root — required for sudo to accept it through the symlink.

Syntax check on the source file is now a left4me-side pytest
(deploy/tests/test_sudoers.py) running visudo -cf.

Part of 2026-05-15-deployment-responsibility-design.md migration step 3.
2026-05-15 19:30:23 +02:00
4820b7193f
left4me: add bw action verifying hardening drop-ins load on every apply
Post-daemon-reload self-test that asserts both
  /etc/systemd/system/left4me-{web,server@}.service.d/10-hardening.conf
appear in `systemctl show -p DropInPaths` for the unit. Catches drift
where the symlink lands but daemon-reload didn't take, or someone
manually unlinked the drop-in.

For the gameserver template we query `left4me-server@verify.service` —
systemd resolves drop-ins for a template instance against
`name@.service.d/` regardless of whether the instance has ever started.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:21:50 +02:00
d175c56e6c
left4me: hardening lives in drop-ins owned by left4me; deliver via symlink
Reactor stops emitting hardening directives in the unit bodies. The
HARDENING_COMMON / HARDENING_SERVER / HARDENING_WEB constants are gone.
Effective hardening on the live units now comes from drop-in files
shipped by left4me at:
  /etc/systemd/system/left4me-web.service.d/10-hardening.conf
  /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
Both are target-side symlinks into /opt/left4me/src/deploy/files/...
(safe because /opt/left4me/src is root-owned post-relocation refactor).

Verified on ovh.left4me: systemctl show reports the same directives as
the pre-refactor baseline; relevant hardening test-plan checks pass.
2026-05-15 19:19:17 +02:00
b10c4d22fd
left4me: symlink /etc/sysctl.d/99-left4me.conf to the checkout
Sysctl drop-in lives in left4me/deploy/files/etc/sysctl.d/99-left4me.conf
(absorbed kernel.yama.ptrace_scope from the metadata entry). Deliver
via target-side symlink instead of a verbatim copy.

Canary for the deployment-responsibility reshape (left4me design doc
2026-05-15-deployment-responsibility-design.md, step 1). Validated
end-to-end on ovh.left4me: symlink resolves to the checkout,
sysctl --system fires on apply, kernel target value matches, idempotent.
One-shot cleanup of stale /etc/sysctl.d/99-left4me-ptrace.conf
(orphan from earlier apply; bundles/sysctl does not auto-purge unmanaged
files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:10:23 +02:00
6fae2fd324
refactor(left4me): non-editable install + relocate runtime state to /var/lib/left4me
Two related changes landed together:

1. /opt/left4me/ becomes a root-owned deploy-artifact root. Only
   /opt/left4me/src lives there. Source tree is no longer mutated
   by pip at runtime; left4me only needs read access.
2. Runtime mutable state moved to /var/lib/left4me/: the venv (was
   /opt/left4me/.venv) and steamcmd (was /opt/left4me/steam).

The non-editable install copies the (root-owned) source to a
left4me-owned tempdir before `pip install --force-reinstall`.
setuptools.build_meta writes <pkg>.egg-info/ into the source dir
during get_requires_for_build_wheel, so a direct `pip install
/opt/left4me/src/l4d2*` fails on a root-owned source. The
temp-copy is what `python -m build` ought to do but doesn't (its
build isolation only sandboxes deps).

Prereq for the deployment-responsibility reshape: target-side
symlinks from /etc/... into /opt/left4me/src/deploy/files/... are
now safe by construction (left4me cannot rewrite its own hardening
profile).

Design + verification record: left4me/docs/superpowers/specs/
2026-05-15-runtime-state-relocation-design.md

Verified on ovh.left4me: bw apply idempotent on second pass (0
fixed, 0 failed); pip show reports site-packages location, no
Editable project location; web + gameserver units run clean;
alembic current returns head.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:56:08 +02:00
f3fe49c60e
fix(left4me): bind /var/lib/left4me/workshop_cache into server unit
Same class of leak as the .steam bind: workshop VPKs in overlays are
symlinks pointing to /var/lib/left4me/workshop_cache/<id>.vpk. With
TemporaryFileSystem=/var/lib in HARDENING_SERVER and workshop_cache
not in BindReadOnlyPaths, the targets are invisible inside the unit's
mount namespace. Source silently fails to load the addons — no log
message, the addon just doesn't appear in-game (saw the ions vocalizer
workshop VPK dangling on server@2).

Add workshop_cache to the bind list. Read-only is fine; srcds reads
the VPKs, doesn't write them (web app populates the cache as left4me).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:11:17 +02:00
9a4e184378
left4me: drop +sv_lan 0 from srcds ExecStart
The original LAN-mode rejection turned out to be a downstream cascade
from Steam SDK init failure (.steam dir invisible + cpu.cpp assert
on hidden /proc/cpuinfo) — fixed in subsequent commits by binding
/var/lib/left4me/.steam + /opt/left4me/steam back through
TemporaryFileSystem and dropping ProcSubset=pid. With Steam master-
server registration now succeeding ("Connection to Steam servers
successful. VAC secure mode is activated."), the server defaults to
internet mode (sv_lan=0) on its own.

Drop the explicit override. If LAN-mode rejection ever recurs the
fix is to identify the upstream Steam-registration issue, not to
re-set this cvar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:56:51 +02:00
4339289bad
fix(left4me): drop ProcSubset=pid from server unit too
Same pattern as the web-unit fix (commit b3f...): ProcSubset=pid hides
/proc/cpuinfo and /proc/sys/*. Source's tier0/cpu.cpp asserts on
cpuinfo read failure; SteamAPI_Init then fails with "create pipe
failed" as a downstream cascade, and srcds registers as LAN (rejecting
external clients with "LAN servers are restricted to local clients").

PrivatePIDs=true (private PID namespace) remains the load-bearing
peer-process isolation: no foreign PIDs visible to srcds in its own
namespace. ProtectProc=invisible is the foreign-uid /proc hide.
ProcSubset=pid was a defense-in-depth layer hiding kernel-introspection
files (cpuinfo, meminfo, sysctls); losing it only exposes host kernel
info, which is not sensitive in this threat model and is the same
information any user on the host already sees.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:44:22 +02:00
caf2332051
fix(left4me): bind /var/lib/left4me/.steam + /opt/left4me/steam into server unit
Server@.service has TemporaryFileSystem virtualizing /var/lib and /opt;
the .steam home dir (which holds symlinks to /opt/left4me/steam/linux{32,64})
wasn't bound back into the unit's filesystem view. srcds dlopen's
~/.steam/sdk32/steamclient.so for Steam master-server registration —
under the unit it returned ENOENT, SteamAPI_Init failed, and the server
fell back to LAN-only mode regardless of +sv_lan 0. Clients then got
"LAN servers are restricted to local clients (class C)" on connect.

Bind both /var/lib/left4me/.steam (the symlinks) and /opt/left4me/steam
(the symlink targets) read-only into the unit. The Steam SDK file is
written by steamcmd as part of the install flow, so RO is fine — srcds
doesn't write back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:42:17 +02:00
6bba2b04f7
fix(left4me): force +sv_lan 0 alongside +ip 0.0.0.0
With +ip 0.0.0.0 (added in previous commit to make TCP RCON reachable
via loopback), Source engine can't auto-determine whether the server
is public-facing and defaults to LAN mode (sv_lan=1). Clients
connecting from public IPs get rejected with "LAN servers are
restricted to local clients (class C)".

Force sv_lan=0 explicitly so public clients can connect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:35:47 +02:00
f5bce30a4a
fix(left4me): srcds binds RCON to all interfaces + close external TCP
Two changes to fix web's "No data / Connection refused" symptoms:

1. ExecStart adds +ip 0.0.0.0 so srcds_linux binds both UDP and TCP
   to all interfaces (incl. loopback). Default Source auto-IP picks
   the primary host IP (141.95.32.8 here), so TCP RCON was only on
   the public IP; web's 127.0.0.1 connect got ECONNREFUSED.

2. nftables/input drops the TCP accept on the game port range. UDP
   stays open for players. Loopback (127.0.0.1) bypasses the input
   chain via the existing 'iifname lo accept' rule in
   bundles/nftables/files/nftables.conf, so web→RCON via loopback
   still works.

Net effect: web's live-state polling + console RCON work via
loopback; RCON is no longer reachable from the public internet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:30:00 +02:00
656be1cf69
fix(left4me): move ProcSubset=pid from COMMON to SERVER-only
Discovered post-deploy: ProcSubset=pid hides /proc/sys/kernel/random/boot_id
which journalctl reads at startup. The web app invokes
`sudo -n left4me-journalctl` to stream live server logs into the UI;
journalctl bails with "Failed to get boot ID" before producing any
output. Web log streaming was silently broken.

Server unit keeps ProcSubset=pid (srcds doesn't invoke journalctl);
web unit drops it. ProtectProc=invisible remains in COMMON — that's
the load-bearing D4 defense (foreign-uid /proc hidden).

Reproducer that confirms the diagnosis:
  systemd-run --pipe --uid=left4me --gid=left4me \
    -p ProcSubset=pid -p ProtectProc=invisible \
    -p ProtectSystem=strict -p PrivateTmp=true \
    [...rest of web hardening...] \
    -- sh -c 'sudo -n left4me-journalctl 2 --lines 3 --follow >/var/lib/left4me/tmp/out 2>&1'
  # cat /var/lib/left4me/tmp/out → "Failed to get boot ID: No such file or directory"
  # rc → 1
With ProcSubset=all: timeout 124 (helper running), 3 lines streamed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:14:33 +02:00
3ce1ee486e
bundles/left4me: drop l4d2-sandbox user; tighten /var/lib/left4me to 0755
Companion to the uid-collapse refactor on the left4me side
(docs/superpowers/plans/2026-05-15-uid-collapse.md). The script-
sandbox now runs as left4me too, defended by the hardening profile
that landed earlier today rather than a kernel uid boundary.

users + groups dicts: remove the l4d2-sandbox entry (uid/gid 981).
/var/lib/left4me mode: 0711 → 0755. The 0711 was specifically a
traverse-only loosening for the sandbox uid; with one user, the
natural mode is back.
2026-05-15 15:51:21 +02:00
130b0b1c9c
bundles/left4me: ship kernel.yama.ptrace_scope=2 sysctl drop-in
Belt-and-braces with the gameserver unit's SystemCallFilter=~@debug +
PrivateUsers=true. Currently applied by hand on left4.me (left over
from the hardening test plan's Test 9); landing in the bundle so it
survives bw apply and is reproducible on any future host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:26 +02:00
c6721e7545
bundles/left4me: spread HARDENING_WEB into left4me-web.service
Adds the sudo-compatible hardening subset to the web unit. Tightens
ProtectSystem=full → strict. NoNewPrivileges, PrivateUsers,
RestrictSUIDSGID, empty CapabilityBoundingSet, and ~@privileged in the
syscall filter intentionally absent (sudo-incompatible until a future
refactor replaces the helper sudo with systemctl-managed transient
units).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:49:10 +02:00
640461c87a
bundles/left4me: spread HARDENING_SERVER into left4me-server@.service
Replaces the inline hardening directives on the gameserver unit with
the shared HARDENING_SERVER dict. Removes legacy ReadOnlyPaths /
ReadWritePaths (superseded by TemporaryFileSystem + BindReadOnlyPaths
+ BindPaths in the dict). Brings the unit to the proven Test 7
composition with the i386 amendment (SystemCallArchitectures=native x86)
and PrivatePIDs=true.

Not deployed until bw apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:46:58 +02:00
85b9af0aaa
bundles/left4me: add HARDENING_{COMMON,SERVER,WEB} constants
Three shared dicts capturing the proven hardening composition from the
left4me hardening test plan (left4me commit 461b8d0). HARDENING_COMMON
is the directive set both managed units take verbatim; HARDENING_SERVER
adds the sudo-incompatible flags + filesystem virtualization + i386
amendment + PrivatePIDs + SocketBindAllow; HARDENING_WEB adds the
sudo-compatible syscall filter.

Not yet spread into the unit emission — that's the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:42:26 +02:00
91b7265136
left4me: install_left4me_scripts reads from scripts/{libexec,sbin}/
left4me moved its privileged helpers out of deploy/files/usr/local/
into top-level scripts/{libexec,sbin}/ (left4me commit 5284e28).
deploy/ is now a reference exemplar, not source-of-truth; the helpers
are application-inherent code that lives where the rest of the
application does.

Repoint install_left4me_scripts at the new source paths under
/opt/left4me/src/scripts/{libexec,sbin}/. Install targets unchanged
(/usr/local/{libexec,sbin}/ on the host), so the apply is a no-op
diff on the deployed file content — only the source-of-install path
moves. Verified green by `bw apply ovh.left4me` against the test
server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:08:36 +02:00
3ccaa919ee
left4me: install privileged scripts from git_deploy artifact
Replaces five verbatim file copies (four libexec helpers + the sbin
admin CLI) with a single triggered install action that copies them
from /opt/left4me/src/deploy/files/usr/local/{libexec,sbin}/ after
git_deploy. left4me's repo is now the only source of truth for the
script content; editing the helper here required a separate commit-
and-sync step that masked yesterday's idmap helper update on the
deployed server.

The new action ships everything under deploy/files/usr/local/libexec/
left4me/ to /usr/local/libexec/left4me/, which incidentally includes
the dead-code left4me-apply-cake. Harmless (nothing references it);
the cake migration cleanup can sweep it later.
2026-05-15 00:46:31 +02:00
9fbd84c3b5
left4me: tighten host.env to 0640 root:left4me
Both env files now follow the same pattern: root owns the config so the
service user can't overwrite its own config, group=left4me so the
sudo -u left4me alembic + seed-overlays actions can source the file
(they failed with 'permission denied' when group=root and mode=0640).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:57:21 +02:00
1039e23671
left4me: prefix steam_web_api_key vault value with !decrypt:
Without !decrypt: the encrypt$… string is rendered as a literal into
web.env, which then surfaces as 403 Forbidden from the Steam Web API
(because the URL key parameter contains "encrypt$gAAA…" instead of the
actual API key). Matches the existing pattern used by every other
encrypted secret in this repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:57:21 +02:00
1445aaff0a
left4me: wire STEAM_WEB_API_KEY through to web.env
Adds the metadata key default (None — node must override) and pipes it
into web.env.mako so the live-state poller can resolve Steam IDs to
persona names + avatars via GetPlayerSummaries.

ovh.left4me gets the actual key as an encrypted vault secret.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:42:51 +02:00
7ad9bbcec3
left4me: schedule daily workshop-refresh via systemd-timers
Adds a left4me-workshop-refresh entry to the systemd-timers bundle,
firing nightly at 04:00 and invoking the new flask workshop-refresh
CLI that enqueues a refresh_workshop_items job. Owner of the job is
NULL (system-enqueued). The bw worker picks it up under existing
scheduler rules; idempotent against an already-queued/running refresh.

Also extends bundles/systemd-timers to accept an optional
environment_files key so the new unit can pull DATABASE_URL etc.
from /etc/left4me/{host,web}.env.
2026-05-12 10:29:45 +02:00
508111eb39
AGENTS.md: drop the 6th ccc rule
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:12 +02:00
c6caf2a1cf
left4me: per-node system_cpus set; pin HT siblings on ovh.left4me
Replaces bundle-default system_core_count int with a per-node set of
CPU ids; reactor takes set complement for game cores. ovh.left4me sets
{0, 4} to keep both HT siblings of physical core 0 in system.slice
so games don't share L1/L2 with system work. systemd_units reactor
return inlined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:20:28 +02:00
1b3f3ecf97
left4me: per-slice AllowedCPUs= driven by system_core_count
First N cores pin system/user/build (inline on owned slices, drop-ins
on upstream system.slice and user.slice via the systemd/units
'<parent>.d/<basename>.conf' convention). Remainder pins
l4d2-game.slice. Reactor raises on hosts with <2 threads or
system_core_count that leaves no cores for games.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:04:35 +02:00
1d30830824
left4me: install steamcmd + drop importability gate on pip_install
Two changes from the same debug session, both prerequisites for
`l4d2ctl install` to work end-to-end on a fresh node:

1) Install steamcmd via tarball under /opt/left4me/steam.
   - dpkg --add-architecture i386 + libc6:i386 + lib32z1 (32-bit deps;
     bw pkg_apt translates _ to : at install time, hence libc6_i386)
   - curl|tar one-shot, guarded by `test -x steamcmd.sh`
   - LEFT4ME_STEAMCMD in host.env so l4d2host invokes by absolute path
     (mirrors the old bundles/left4dead2/files/setup approach; avoids
     the dirname-$0 trap that bites when steamcmd is reached via a
     PATH symlink)

2) Drop the `unless` on left4me_pip_install. The gate checked
   importability of l4d2host/l4d2web, which is too weak a proxy for
   install state: adding [project.scripts] to pyproject.toml later
   wouldn't be picked up if the package was already importable from a
   prior `pip install -e`. Cost is ~2s/apply for a no-op pip
   resolution — not enough to keep the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:46:45 +02:00
524ad6e89b
nginx: SSE-friendly proxy_pass + unconditional $connection_upgrade map
Two coupled changes that let every proxy_pass vhost serve both WS and
SSE without per-vhost flags or template conditionals:

1) nginx.conf: $connection_upgrade map is now always defined (drop
   the % if has_websockets: gate), and the '' branch returns "" instead
   of "close". With "" + proxy_http_version 1.1, nginx maintains
   keep-alive to upstream for non-WS clients — which is what SSE
   requires. WS clients still get Connection: upgrade as before.

2) data/nginx/proxy_pass.conf: drop the % if websockets: conditional.
   Always set proxy_http_version 1.1 + Upgrade + Connection via the
   map, plus proxy_buffering off and proxy_read_timeout 1h for SSE.

Effects on existing vhosts:
- home.server's Proxmox WS vhost: unchanged behavior (the WS branch
  was already setting these headers). Gains the ability to also
  serve SSE if ever needed.
- All other proxy_pass vhosts (Nextcloud, Freescout, YOURLS, Gitea,
  etc.): get keep-alive to upstream (minor latency win) and unbuffered
  pass-through (slight throughput cost on huge responses, neutral
  for typical web app traffic).

Dead but harmless: bundles/nginx/metadata.py still defaults
nginx/has_websockets to False, and proxmox-ve/grafana still set it
to True. The flag is now a no-op; clean up in a separate pass.
2026-05-10 22:12:03 +02:00
99d68a5135
AGENTS.md: soften 6th rule — ccc is an option, not a mandate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:36:59 +02:00
852a65a6f6
AGENTS.md: 6th rule — try ccc search before grep for concept queries
The repo is indexed with cocoindex-code; semantic search beats grep for
"where is X / which bundle does Y" questions where you don't know the
exact identifier. Without `--path '**'` ccc scopes to the current
working directory, which is rarely what you want when navigating
ckn-bw — call it out so agents don't get confusing empty results.
2026-05-10 21:32:08 +02:00
09d236ded5
left4me: trigger alembic_upgrade from git_deploy (catch migrations on code updates)
pip_install's `unless` (import l4d2host, l4d2web) skips when both
packages are already installed — so on a code-only apply, pip_install
doesn't fire and alembic_upgrade (which it triggers) never runs.
The new 0008 migration would silently get skipped, leaving the DB
out of sync with the new schema.

Wire git_deploy → alembic_upgrade directly. alembic upgrade head is
idempotent (no-op when at head); seed_overlays + service:restart
cascade off alembic, so editable-install code changes also get picked
up by gunicorn.

Edge case noted (deferred): a migration-only change with no code
change has the same matching git rev, so this won't fire either. In
practice migrations always come with the code change that uses them.
2026-05-10 21:27:40 +02:00
7265c4aab1
letsencrypt: depend on bind9-dnsutils (dnsutils is a trixie transitional)
On Debian 13 trixie `dnsutils` is a transitional package replaced by
`bind9-dnsutils`. Apt installs bind9-dnsutils when you ask for dnsutils,
but `dpkg -s dnsutils` returns 1 because no real package by that name
exists — bw's pkg_apt status check then flags the item as failed every
apply. Switching the dependency to the real package name resolves the
loop.

The bundle just needs `nsupdate` (provided by bind9-dnsutils) for the
DNS-01 challenge hook.
2026-05-10 21:03:16 +02:00
b5662f7ea7
left4me: explicit source for /usr/local/sbin/left4me (basename collides) 2026-05-10 21:01:18 +02:00
b8648cb53f
left4me: ship a /usr/local/sbin/left4me wrapper for the flask CLI
One-liner instead of "ssh + heredoc + sudo + sh -c + double quotes":
  sudo left4me create-user alice --admin
  sudo left4me seed-script-overlays /opt/left4me/src/examples/script-overlays
  sudo left4me routes

The wrapper sources host.env + web.env, drops to the left4me user,
sets JOB_WORKER_ENABLED=false (admin-side ops shouldn't race the
worker) and PYTHONPATH=/opt/left4me/src, then exec's the flask CLI
with whatever args followed `left4me`. No env-var enumeration: the
sh -c trailing 'sh "$@"' forwards positional args without quoting
hell. README updated to drop the verbose recipe.
2026-05-10 21:00:16 +02:00
6f2073847d
nginx/README: how port 80 is served + vm/cores requirement
Two things from the left4me-integration session worth pinning:

- 80.conf was orphaned in sites/ (not sites-enabled/) for an
  unknown amount of time. Commit d49259f moved it; document the
  resulting wiring so it's not re-broken accidentally.
- items.py reads node.metadata.get('vm/cores') with no default
  for worker_processes; bare-metal nodes outside the vm group
  raise at item-build time. Cost the agent ~10 min when
  ovh.left4me first opted into webserver.

Also note the cross-namespace read on letsencrypt/domains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:47:47 +02:00
6cc823613a
bind/README: applying changes needs both master and slave nodes
The slave's bw-managed zone files are rendered from the master's
metadata at slave-apply time. Changing a record on the master only
publishes once both bw apply runs are done. The left4me-integration
session burned ~20 minutes assuming bw apply on htz.mails would
propagate to ovh.secondary via bind's own AXFR; it doesn't, because
bw verify measures the on-disk file, not the running zone.

Frame as the workflow rule rather than the absolute "not AXFR"
claim — the bundle does set type slave; in named.conf.local, but
that's orthogonal to the practical apply-both rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:46:00 +02:00
05abe52221
letsencrypt/README: first-apply, DNS-01 prereqs, negative-cache
Reshapes the existing scratchpad README into operational sections.
Captures three things that took the left4me-integration session
~30 minutes to figure out:

- After bw apply, nginx serves a self-signed cert until the daily
  systemd timer fires; the dehydrated --cron one-liner shortcuts
  the wait.
- DNS-01 needs all NS servers (primary AND secondary) to serve the
  _acme-challenge CNAME, the acme node reachable, and TSIG-key
  reachability via wireguard for off-LAN clients.
- LE's negative-cache + rate-limit combo: stop retrying for ~15
  min after fixing DNS, then make at most one attempt.

Existing nsupdate sample preserved at the bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:43:52 +02:00
7a579f27c5
agents/bundles: file: source defaults to destination basename
Caught during the left4me-integration nginx 80.conf move: the
agent declared a redundant 'source': '80.conf' on a file: item
whose destination already ended in 80.conf. The maintainer
flagged it as noise. Document the rule: only declare source
when the basename differs from the destination (e.g. .mako
template to a non-suffixed destination).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:40:42 +02:00
0e88c4967e
docs/specs: round-2 agents-md refactor design (gaps 7-12)
Continuation of round 1. Five commits: two new bundles/AGENTS.md
Pitfalls (file: source basename, git_deploy gotchas) and three
bundle READMEs (letsencrypt operational, bind apply-both, nginx
new file). Diverges from the handoff on placement: gaps 7-9 go
in bundles/AGENTS.md not items/AGENTS.md, since items/AGENTS.md
is scoped to custom item types only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:39:40 +02:00
69bcac421a
agents/bundles: triggers/triggered:True invariant + self-healing
Two related lessons from the left4me integration:

1. The triggers/triggered:True invariant tripped three times in
   one session. When chown_src was promoted from triggered-only
   to self-healing-every-apply (drop triggered:True + add unless),
   bw rejected because it was still in git_deploy's triggers
   list. Same dance happened for pip_install.

2. Triggered actions can't recover from partial failure: once
   upstream succeeds, it's "in desired state" forever and the
   trigger never re-fires. For pip installs / chowns / migrations
   that must heal on every apply, the right shape is no
   triggered:True + unless:<fast-check>. unless semantics fold
   into the same bullet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:29:10 +02:00
59788f315a
agents/bundles: reactors must read metadata or be defaults
The left4me bundle's first cut had two reactors that returned
static dicts without calling metadata.get(...): systemd_services
(enable/run flags) and nftables_output (two static rule strings).
Both passed bw test (no consumer yet). Once attached to
ovh.left4me, bw raised "did not request any metadata, you might
want to use defaults instead". Fix was to fold both into defaults.

Document the pitfall, with the verbatim error wording and the
note that this applies to cross-namespace contributions too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:31 +02:00
d3068ba8f6
agents: nodes carry only node-specific metadata
When the left4me bundle was first integrated, ovh.left4me's node
file carried ~40 lines of left4me-related metadata (git_url,
secret_key, full nginx vhost, monitoring, backups, nftables
rules). The maintainer pushed back: per-node metadata should be
only what genuinely varies per host. Refactor brought it down to
{'domain': 'left4.me'} with everything else in bundle defaults
or in a reactor deriving from the domain.

Add the rule to bundles/AGENTS.md from the bundle-author angle
(use defaults / vault-keyed-on-node for secrets, cite left4me
and postgresql for the established pattern). Add the reviewer's
form to nodes/AGENTS.md Pitfalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:27:52 +02:00
b5e72a3ac3
agents: bundle validation needs a node attached
bw test (no args) is a parsing gate, not a behaviour gate. A
bundle's reactors only resolve when some node's metadata is
built, so reactor bugs stay dormant until a node opts in. The
left4me-integration session shipped 8 commits that all "passed
bw test" with latent reactor-rejection bugs that surfaced only
once the bundle was attached to ovh.left4me.

Rewrites the verify-list in bundles/AGENTS.md to require attach-
first and uses richer command invocations (bw items --blame,
bw metadata -k <key>). Adds a Bundle-validation workflow section
to commands.md spelling out why step 2 is non-optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:27:13 +02:00
0a9f3dae88
agents/commands: read-only command cheat sheet
Adds a flag-combinations table for bw test (selectors + the
HIJKMSp/IJKMp default-flag split), bw metadata -k/-b/-f (with
the -f sensitive-data warning), bw items --blame/-f, bw verify
-o bundle:, bw hash -m/-d. Also documents the shared target-
selector grammar.

Surfaced by the left4me-integration session, where the agent
relied on bare bw test / bw metadata / bw items invocations and
missed leverage from the available flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:26:27 +02:00
422a275d97
agents: drop bw bundles, add bw verify to read-only allowlist
bw bundles is not a subcommand of the installed fork (the actual
list is apply/debug/diff/groups/hash/ipmi/items/lock/metadata/
nodes/plot/pw/repo/run/stats/test/verify/zen). bw verify is
read-only and was missing from the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:25:43 +02:00
3ed0264be6
docs/specs: round-1 agents-md refactor design (gaps 1-6)
Captures the brainstorm + per-commit wording for the first six
gaps from the left4me-integration handoff, plus a side-quest
read-only command cheat sheet for docs/agents/commands.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:24:03 +02:00
d49259ff07
nginx: move 80.conf to sites-available so it's actually included
The bundle was shipping 80.conf (HTTP-to-HTTPS redirect + acme-challenge
alias) to /etc/nginx/sites/80.conf, but nginx.conf only `include`s
/etc/nginx/sites-enabled/* (which is a symlink to sites-available).
The file was orphaned — no node had a working port-80 listener.

Move the destination to /etc/nginx/sites-available/80.conf so the
existing sites-enabled symlink picks it up. The /etc/nginx purge will
clean up any stale /etc/nginx/sites/80.conf on existing hosts.
2026-05-10 19:59:17 +02:00
ed141a9300
left4me: drop chown_src from git_deploy triggers (self-healing now)
Same constraint pattern: items in a triggers list must be
triggered:True. chown_src dropped triggered:True in the prior commit
to become self-healing every-apply, so it can't stay in git_deploy's
triggers list. Now git_deploy has no triggers at all — chown_src and
pip_install both run every apply, gated by their own `unless` guards.
2026-05-10 18:58:30 +02:00
9d17c69b22
left4me: make chown_src self-healing too
Same problem as pip_install: chown_src was triggered:True and only
fired when git_deploy did. After a partial first-apply where git_deploy
succeeded (extracting root-owned files) but the chown didn't happen
yet, subsequent applies left files root-owned forever — pip_install
fails with "permission denied" trying to write .egg-info/.

Drop triggered:True. Add an unless guard:
  test -z "$(find /opt/left4me/src ! -user left4me -print -quit)"
i.e. skip the chown only when no non-left4me-owned file exists in the
tree.
2026-05-10 18:57:50 +02:00
5bf95cb065
left4me: drop pip_install from pip_upgrade triggers (pip_install now always-runs) 2026-05-10 18:56:30 +02:00
cac04a456b
left4me: make pip_install self-healing on every apply
The previous shape (`triggered: True`, in git_deploy's triggers list)
meant pip_install only ran when something upstream fired. After a
partial first-apply failure (where git_deploy succeeded but pip_install
failed for an unrelated reason), subsequent applies couldn't recover —
git_deploy was already in desired state, nothing fired pip_install.

Drop `triggered: True`. Drop pip_install from git_deploy's triggers
(bw enforces a triggers→triggered:True invariant). Add `unless`:
sudo -u left4me /opt/left4me/.venv/bin/python -c "import l4d2host, l4d2web"
to short-circuit when the venv is already correct. Editable installs
pick up code changes automatically — no need to re-pip on every git
update.

For dep changes (rare), nudge manually:
  bw run ovh.left4me 'sudo -u left4me /opt/left4me/.venv/bin/pip install -e /opt/left4me/src/l4d2host -e /opt/left4me/src/l4d2web'
2026-05-10 18:55:24 +02:00
c2cc3866f3
left4me: chown /opt/left4me/src after git_deploy
bw's git_deploy extracts the git archive as the connecting user (root
after sudo), so files end up root-owned. The subsequent pip install
runs as left4me and needs to write .egg-info/ inside each editable
package, which fails with "permission denied".

Add action:left4me_chown_src triggered by git_deploy and required by
pip_install. Idempotent (chown -R is fine to re-run).
2026-05-10 18:52:37 +02:00
d548235dfe
left4me: declare /opt/left4me/src as a directory: item
bw's git_deploy item assumes the destination directory exists on the
host — its fix path runs `find <dest> -mindepth 1 -delete` to clear
existing contents before unpacking the new archive, which fails on a
fresh box where the directory was never created. Flask follows the
same pattern (bundles/flask/items.py:13).
2026-05-10 18:51:05 +02:00
149ce6c870
left4me: use https git URL so bw clones locally per-apply
bw's git_deploy.py:103 falls into a per-apply temp clone path when the
repo URL contains '://' (HTTPS, ssh://, …). Without that, it requires
a static git_deploy_repos map file pointing at a long-lived local
clone — which is the wrong shape for left4me, where the source of
truth is git.sublimity.de.

Switching the default to the HTTPS URL means anyone with the bundle
gets a working clone-from-source on `bw apply`, no operator-side
mirror map required.

Note: the host will pull whatever is pushed to git.sublimity.de
master. Push local commits before applying.
2026-05-10 18:49:10 +02:00
0479c96ae9
gitignore: add bundlewrap git_deploy_repos map (operator-specific paths) 2026-05-10 18:43:59 +02:00
5d69180466
left4me: terse bundle-membership asserts 2026-05-10 18:34:09 +02:00
7d3554f8a5
left4me: split derived_from_domain into one reactor per consumer
Each reactor now scopes to a single downstream bundle:
  nginx_vhosts    -> nginx/vhosts
  nftables_input  -> nftables/input

Easier to grep "what writes nginx/vhosts" and harder to accidentally
couple unrelated keys together. Same merged metadata.
2026-05-10 18:33:11 +02:00
fc66267656
left4me: reuse nginx bundle's auto-monitoring via check_path
bundles/nginx/metadata.py:91-104 already creates a monitoring/services
entry per nginx/vhost using the vhost's check_protocol/check_path. Set
check_path: '/health' on the left4me vhost so the auto-check hits the
Flask health endpoint, drop the explicit monitoring/services/left4me-web
block from this reactor.

Net effect: same curl command lands in monitoring as before, but the
service name is now 'left4.me' (the hostname, per the nginx reactor's
naming convention) instead of 'left4me-web'.
2026-05-10 18:31:52 +02:00
758660b131
left4me: drop redundant letsencrypt/domains from reactor
bundles/nginx/metadata.py auto-populates letsencrypt/domains from
nginx/vhosts.keys(). Declaring it again in the left4me reactor was a
no-op duplication. Removed; bw metadata still shows the same merged
state (left4.me with reload: [nginx]).
2026-05-10 18:29:15 +02:00
7b291acca1
left4me: refresh README + opt ovh.left4me in via groups
README:
  Updated metadata example to show domain as the only required key.
  Documented the bundle's derived_from_domain reactor as the source of
  nginx/letsencrypt/monitoring/nftables-input wiring, and the
  bundle-defaults source of backup/paths.

nodes/ovh.left4me.py:
  - groups: + backup, + left4me, + webserver
  - bundles: dropped 'left4me' and 'nftables' (come via groups now;
    nftables ships with debian-13).
  - metadata: pinned vm/cores=4, vm/threads=8 (4-core HT box) so the
    nginx bundle's worker_processes resolves; left4me block reduced to
    {'domain': 'left4.me'} — git_url, git_branch, secret_key, and the
    nginx/letsencrypt/monitoring/nftables/backup blocks now come from
    bundle defaults / the derived_from_domain reactor.
2026-05-10 18:24:03 +02:00
90f14b69e4
left4me: pull node-agnostic metadata into the bundle
Nodes should only carry node-specific metadata. Previously each node
running left4me had to declare git_url, git_branch, secret_key, plus
nginx vhost / letsencrypt / monitoring / nftables-input blocks for
every game port. All of those are derivable from one truly node-
specific value: the domain.

Move into the bundle:
  - git_url + git_branch as defaults (override per-node only if needed).
  - secret_key as a per-node vault-derived value
    (random_bytes_as_base64_for f'{node.name} left4me secret_key',
    same convention as postgresql/mosquitto/etc.).
  - backup/paths defaults (set-merged with backup group / node paths).

Add a `derived_from_domain` reactor that reads left4me/domain and
emits:
  - nginx/vhosts/<domain> proxying 127.0.0.1:8000
  - letsencrypt/domains/<domain>
  - monitoring/services/left4me-web (curl /health)
  - nftables/input rules for the configured port range
    (defaults 27015-27115, derived from left4me/port_range_*).

Net effect: a node opting into left4me declares only
  metadata.left4me.domain = 'whatever.tld'
plus the universal node-level stuff (id, vm/cores, network, …).
2026-05-10 18:23:34 +02:00
3bffd7b8f5
bind-acme: guard against letsencrypt clients without internal LAN
The acme_zone reactor's first ACL branch iterates nodes that have
letsencrypt/domains and reads their network/internal/ipv4. Until now
that crashed for any node with letsencrypt but no internal LAN — the
node had to either fake a network/internal/ipv4 or skip TLS.

Add a `metadata.get(..., None)` guard to filter such nodes out of this
branch. The wireguard branch below already covers them (any node with
the wireguard bundle gets its wireguard/my_ip into the ACL), so ACME
DNS-01 reachability still works for cross-Internet nodes that join the
fleet via wireguard.

Surfaced by ovh.left4me: dedicated server with no Hetzner/internal
network, reachable from the bind-acme node only via wireguard.
2026-05-10 18:23:21 +02:00
43f0c57438
groups: add applications/left4me
Single bundle group; pulls in bundles/left4me. Joined by nodes that run
the L4D2 game-server platform. nftables and systemd come in via the
debian-13 group on Debian-13 nodes, so this group needs only the
left4me bundle itself.
2026-05-10 18:08:36 +02:00
d425afad02
left4me: write bundle README 2026-05-10 18:07:58 +02:00
f9bf289ef0
left4me: assert nftables + systemd bundle membership
Catches misconfiguration at bw test time if a node attaches left4me
without those two bundles. Both contribute load-bearing metadata
materializers (nftables/output rules; systemd/units → unit files).
2026-05-10 18:06:35 +02:00
a8fc3f2298
left4me: fix bundle defects surfaced by real-node validation
Three issues caught once `bw test ovh.left4me` ran with the bundle
actually attached (vs. the earlier `bw test` with no node opting in,
which only checks parsing):

1. systemd_services + nftables_output reactors didn't read any metadata.
   bw rejects this with "did not request any metadata, you might want
   to use defaults instead". Both contributions are static, so they
   belong in `defaults` — moved.

2. git_deploy:/opt/left4me/src triggered action:left4me_create_venv,
   but create_venv lacked `triggered: True`. bw enforces that any
   action in a triggers list must be `triggered: True`. Removed
   create_venv from the trigger list — it's gated by `unless` for
   idempotency and doesn't need to refire on git updates anyway
   (the venv persists). pip_install stays in triggers so editable
   installs pick up new code.
2026-05-10 18:05:38 +02:00
c82737b162
left4me: contribute uid-based DSCP/priority marks to nftables/output
Replaces the per-app inet left4me_mark table from
deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft with two rules
in the central bundles/nftables/ inet filter table's output chain.
Same selectors (skuid left4me + l4proto udp), same actions (DSCP EF +
priority 6) for both v4 and v6.
2026-05-10 17:53:17 +02:00
b1edcac3c7
left4me: enable+start left4me-web.service via systemd/services
The server@ template intentionally has no svc_systemd entry — instances
are started on-demand by the web app through the left4me-systemctl
helper. Slices are activated implicitly when units use Slice=.
2026-05-10 17:49:50 +02:00
72da6c0a8d
left4me: pin EnvironmentFile order via tuples (was sets)
Sets in libs/systemd.py:18 are sorted alphabetically. The current
output is correct by accident — host.env < web.env, host.env < /var.
Adding a third path later would silently reorder. Tuples preserve
insertion order; generate_unitfile() iterates them the same way.

Environment (HOME=, PATH=) stays a set: each line is an independent
KEY=VALUE assignment, order is irrelevant.
2026-05-10 17:48:03 +02:00
6965441e9a
left4me: emit server@ template + game/build slice units
Translates the remaining three unit files from left4me/deploy/files/.
Server template carries the full hardening + cgroup/IO/Mem keys
verbatim. Slices need the bundles/systemd .slice support added in
prior commit.
2026-05-10 17:43:25 +02:00
6bf46ce9a4
left4me: emit left4me-web.service via systemd/units reactor
Translates left4me/deploy/files/usr/local/lib/systemd/system/left4me-web.service
into a Python dict consumed by bundles/systemd/. Two changes vs. the
shell-deploy unit:
  - --bind 0.0.0.0:8000 -> 127.0.0.1:8000 (nginx terminates TLS in front)
  - workers/threads are templated from left4me/gunicorn_{workers,threads}
    (defaults: 1 worker + 32 threads — same as the static unit)
2026-05-10 17:38:15 +02:00
def010c976
left4me: git_deploy + venv/pip/alembic/seed action chain
Mirrors deploy-test-server.sh:233-242 + :329-333. Single pip command
installs both editable packages (l4d2host + l4d2web) from the same
checkout. Alembic and seed-overlays run as the left4me user with
JOB_WORKER_ENABLED=false sourced from web.env.
2026-05-10 17:32:19 +02:00
433c403ddc
left4me: validate sudoers file with visudo before install
A malformed /etc/sudoers.d/left4me would lock sudo on the target
(blast radius: every other bundle using sudo at apply time). bw's
file: items support test_with, which runs the supplied command on the
locally-rendered file before transfer. Use it to gate the sudoers
file on visudo -cf — analogous to the visudo -cf check the original
deploy script ran inline (deploy-test-server.sh:186).
2026-05-10 17:29:01 +02:00
80d2a79b97
left4me: declare directories, users, files, sysctl-reload action
Modes/owners match the upstream left4me deploy script:
  helpers          0755 root:root
  sudoers.d/left4me 0440 root:root (validated with visudo -cf)
  sysctl conf      0644 root:root  (triggers sysctl --system)
  sandbox-resolv   0644 root:root
  /etc/left4me/host.env  0644 root:root  (Mako)
  /etc/left4me/web.env   0640 root:left4me (Mako, contains SECRET_KEY)
  /var/lib/left4me 0711 left4me:left4me (l4d2-sandbox traversal)
UIDs/GIDs pinned at 980/981 for deterministic ownership.
2026-05-10 17:23:03 +02:00
e842e7caa6
left4me: wire LEFT4ME_PORT_RANGE_{START,END} into web.env
Bundle metadata declares port_range_start/end in defaults, but the
running app (l4d2web/config.py:34-35) reads them from
LEFT4ME_PORT_RANGE_START/END env vars. Without these in web.env, the
bundle's metadata values were dead code and the app fell back to its
own hardcoded defaults. Wiring them through closes the loop.
2026-05-10 17:19:02 +02:00
3afd4d60cc
left4me: add Mako templates for host.env and web.env
SECRET_KEY pulled from node metadata (set via !32_random_bytes_as_base64_for:
in the node file). SESSION_COOKIE_SECURE flips to true since nginx fronts
gunicorn with TLS.
2026-05-10 17:14:36 +02:00
6db792ce6a
left4me: vendor privileged helpers + sudoers/sysctl/sandbox-resolv
Copied verbatim from left4me/deploy/files/. Helpers are the trust unit
the sudoers rules grant access to; left as static files (not generated)
so the audit trail stays grep-able. Modes/owners are set via items.py
in the next commit.
2026-05-10 17:10:17 +02:00
7547d041a2
left4me: scaffold bundle (items/metadata/README stubs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:05:13 +02:00
cc1c6a5767
systemd: accept .slice extension in unit-file routing
Slices are a standard systemd unit type; the existing routing only
covered timer/service/mount/swap/target and raised on .slice. Same
install path (/usr/local/lib/systemd/system/<name>) and same
systemd-reload trigger as the other unit kinds.
2026-05-10 17:00:45 +02:00
af78e40fda
left4me wireguard 2026-05-10 16:57:52 +02:00
c6bf2e0fc8
spec: banner stale sections so partial readers see the pivot
§0 Revisions notes that §3 and §7 Phase 2 are pre-pivot, but a reader
deep-linking into either section bypasses §0. Add a section-level
banner at the top of each that points back to §0 and to bundles/AGENTS.md
for the current per-bundle convention. Content is preserved as a record
of the original design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:14:12 +02:00
d4dedde0ad
add implementation plan to repo
vendors ~/.claude/plans/btw-are-you-sure-crystalline-balloon.md into
docs/superpowers/plans/2026-05-10-agent-friendliness-plan.md so the
plan lives alongside its spec and handoff. tagged with a top-of-file
note flagging it as a frozen pre-pivot artifact (the per-bundle-doc
section, the AGENTS.template.md reference, and the Phase 2 seed-list
all reflect original intent, not what shipped).

handoff's pointer updated to the in-repo path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:35 +02:00
7b44a8ad3a
spec/handoff: record per-bundle README pivot
- spec §0 gets a new revision bullet noting that per-bundle docs
  are README.md (not AGENTS.md), the rigid template is gone, and
  Phase 2 was dropped. flags §3 and §7 as pre-pivot intent only;
  doesn't back-fit them.
- handoff replaced with a short status note (~50 lines vs the
  original ~390): what landed, where current truth lives, and the
  fact that nothing is planned for a next session.

implementation plan in ~/.claude/plans/ is left as a frozen
pre-pivot artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:06:25 +02:00
9e1bb2ac45
docs: per-bundle docs are README.md, not AGENTS.md
drops the per-bundle AGENTS.md convention and the rigid template
that went with it. each bundle has (or gets) one README.md that
serves humans and agents both.

bundles/AGENTS.md now has a "Per-bundle README" section pointing
at the more substantial existing READMEs (flask, dm-crypt, apt,
nextcloud) for orientation, plus loose guidance on what to cover
and what to skip. no required structure — match the bundle's
actual surface.

removes bundles/AGENTS.template.md; the template was prescriptive
in a way that wouldn't survive contact with this repo's actual
bundles, where READMEs range from one-paragraph balanced docs to
operational scratchpads.

phase-2 seed-bundle work stays deferred and will land as plain
README updates when bundles are materially edited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:02:24 +02:00
04558a9189
docs: scaffold agent-friendly entry points (Phase 1)
introduces a balanced set of agent + human docs:

- root AGENTS.md (with CLAUDE.md symlink) — 5-rule quickstart,
  layout map, mental model, use-case keyed example pointers.
- docs/agents/conventions.md — vault/demagify, eval-loader
  constraints, group inheritance, naming, do-not-touch list,
  suspension idioms, working-style notes.
- docs/agents/commands.md — repo-specific deltas to the fork's
  bw runbook (apt-key offline-verify, *.py_ suspended-node
  visibility, vault-echo rule).
- per-area AGENTS.md for bundles/, nodes/, groups/, libs/,
  hooks/, data/, items/, bin/ — mechanism-focused, no enumeration.
- bundles/AGENTS.template.md — per-bundle doc template with
  optional `## Writes into` section for cross-namespace reactors.

bundlewrap-language reference (item types, dep keywords, reactors,
runbook, three-tier safety envelope) is not duplicated here; we
link out to the fork's AGENTS.md instead.

bw test still green. all internal links resolve. Phase 0 invariants
preserved (libs/hooks docstrings, bin/* # purpose: headers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:44:45 +02:00
730625e36c
libs/hooks/bin: add one-line module docstrings and # purpose: headers
every libs/*.py and hooks/*.py now starts with a one-line module
docstring; every bin/* script starts with a `# purpose:` header.
discovery-by-`ls`-and-read instead of by index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:36:19 +02:00
136313e9c3
add implementation handoff for the next session
Self-contained handover covering: Phase 0 commits already landed,
Phase 0 remainder (docstring/header pass), Phase 1 scaffolding order,
Phase 2 seed bundle list, captured decisions, pitfalls (bw-syntax
corrections, /etc/hosts macOS quirk, sandbox), and verification
criteria. Cross-references the spec, the user-stories validation, the
plan, and the fork's AGENTS.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:28:34 +02:00
1da70970e5
README: drop stale 'install bw fork' instruction
The bundlewrap install is now captured in requirements.txt as an editable
github reference, and the file:/// path in the README pointed at a local
clone with no relation to the actual install method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:19:44 +02:00
3daf70dae7
spec: incorporate fork pivot and bw-syntax corrections
- drop the docs/agents/bundlewrap/ folder; bundlewrap-language docs now
  live in the personal fork's AGENTS.md (canonical reference). ckn-bw
  links out instead of duplicating
- slim commands.md scope from ~80-120 to ~30-50 lines (fork carries the
  generic bw runbook; ckn-bw keeps only repo-specific deltas: apt-key
  verification, *.py_ suspended-node behavior, vault-echo guidance)
- sync bw command syntax against 5.0.3 source (no -p flag; use bare or
  --preview; bw hash takes only literal node/group names; replace
  bw groups -n with bw nodes -a groups)
- rebalance phase 2 seed list: php -> routeros-monitoring (highest-churn
  bundle in 18mo per user-story analysis)
- update fork install pointer to editable github reference
- new section 0 documents revisions inline so a reader sees current shape

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:19:17 +02:00
b804350f17
add user-stories validation doc
21 recurring user stories derived from 1169 commits of git history (with
detailed analysis of the last 222 commits / 18 months). Grounded in
concrete commit evidence; each story carries an "Implications for agent
docs" section that drives content additions in the agent-friendliness
implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:14:38 +02:00
7486c78ae1
switch bundlewrap install to editable from CroneKorkN/bundlewrap@main
Replaces the PyPI 5.0.3 pin with an editable github clone of the personal
fork. The fork tracks upstream main and carries an agent-oriented
AGENTS.md the rest of this repo's docs link to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:14:31 +02:00
c03b033ad9
macbook dummy 2026-05-10 11:57:26 +02:00
186d5039af
migrate to bundlewrap 5
- pin bundlewrap ~=5.0
- rewrite non-reading and KeyError-driven metadata reactors per
  https://docs.bundlewrap.org/guide/migrate_45/ (defaults / metadata.get
  paths / MetadataUnavailable)
- rename custom Download item methods (cdict/sdict/get_auto_deps ->
  expected_state/actual_state/get_auto_attrs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:56:49 +02:00
e99fd4b1a4
add ovh.left4me and update nextcloud 2026-05-10 11:23:49 +02:00
8ec99db7d3
add agent-friendliness design spec
Brainstormed design for making this BundleWrap repo legible to agents:
root AGENTS.md + per-area docs + per-bundle template, with a focused
docs/agents/bundlewrap/ folder covering items.md and metadata.md as
the hard parts. Read-only bw command envelope and an after-change
runbook keyed by what was edited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:04:36 +02:00
7f20c94db8
telegraf deprications 2026-03-09 12:29:24 +01:00
838da64907
home server fan/motherboard sensors 2026-03-09 12:29:16 +01:00
a7c7aaf330
nc preview:pre-generate --no-interaction -vvv 2026-03-09 12:02:56 +01:00
2899cd50c8
nextcloude timer and docs 2026-03-09 12:01:01 +01:00
b62649cae0
nc picsort in python 2026-03-09 11:59:47 +01:00
60c2c42a49
bin/timestamp_icloud_photos_for_nextcloud: introduce 2026-03-09 11:48:35 +01:00
fcd92db125
more swap 2026-03-09 11:48:24 +01:00
afc30a195d
+ 2026-03-07 14:46:56 +01:00
56842dc101
fix debian groups 2026-03-07 14:46:51 +01:00
58007f5121
dowsnt exist 2026-03-07 14:46:39 +01:00
cb19c38376
update home.server to trixie 2026-03-07 14:41:59 +01:00
bf38520b49
comment out slow download workshop maps 2026-03-07 14:41:12 +01:00
98e205a0e0
nc upgrades 2026-03-07 12:28:24 +01:00
326f2aa44d
parallel picsort 2026-03-07 11:37:49 +01:00
4b5e4e1d42
change l4d server setup 2026-02-10 19:38:43 +01:00
a397399e5f
l4d readme 2026-02-10 19:38:35 +01:00
0d35bc2e6c
linux relax icmp ratelimit 2026-02-10 19:38:14 +01:00
969f9af83f
l4d2 rename vanilla to standard 2026-02-10 19:37:49 +01:00
5fab21be13
apt install ca-certificates 2026-02-10 19:37:33 +01:00
ac8e7e2733
delete old l4d bundles 2026-02-10 19:37:27 +01:00
985a15e5c7
wol waker only allow wakeonlan command 2026-01-11 14:52:46 +01:00
59dd4c5877
bundles/telegraf/metadata.py: relax telegraf collection 2026-01-11 14:17:34 +01:00
da2940533c
data/grafana/rows/routeros_*: update names 2026-01-11 14:15:16 +01:00
6ac8118002
bundles/left4dead2/items.py: fix apt deps 2026-01-11 14:11:47 +01:00
a6290244e5
bundles/roundcube/files/config.inc.php: smtp use domain name from cert instead of localhost 2026-01-11 11:32:36 +01:00
7ea760d5eb
hooks/test_ptr_records.py: introduce 2026-01-11 10:18:21 +01:00
a0f5f80a16
bundles/routeros-monitoring/metadata.py: typo 2026-01-11 09:51:24 +01:00
a8b295b551
bundles/routeros-monitoring/metadata.py: use monitoring from isac 2026-01-11 09:49:51 +01:00
49594610d3
bundles/telegraf/items.py: use new bundle from isac 2026-01-11 09:44:16 +01:00
982a27739a
bundles/routeros-monitoring/items.py: dont show mib diff 2026-01-10 11:10:56 +01:00
4652a42346
disable zfs mirror for now 2026-01-10 11:10:35 +01:00
1907c3870e
data/apt/keys/influxdata.asc: update 2026-01-07 16:10:38 +01:00
f5580e14ae
bundles/routeros-monitoring/metadata.py: field zugunsten der table entfernt, schien eh nix zu liefern 2025-12-30 13:34:22 +01:00
e519bdd3ee
bundles/routeros-monitoring/metadata.py: one input for all switches, one agent per switch 2025-12-30 13:19:46 +01:00
178c812a7c
data/grafana/rows/routeros_errors.py: fixes and optimizations 2025-12-30 12:25:23 +01:00
9415167ba5
add interface alias to interface_errors 2025-12-30 12:25:05 +01:00
1f4aaad9ed
improve wireguard config gen 2025-12-16 23:46:25 +01:00
1dc6fab755
routeros better port error monitoring 2025-12-16 17:53:03 +01:00
78a8abc39a
data/routeros-monitoring/files/mikrotik.mib: move to bundle bc why not 2025-12-16 17:14:26 +01:00
53c8615f25
bundles/routeros-monitoring/metadata.py: get interface stats from mikrotik specific mib 2025-12-16 16:46:38 +01:00
b81c9e9763
allow snmp at home 2025-12-16 16:43:39 +01:00
ec2e747b39
add switches to home group 2025-12-16 16:43:31 +01:00
86d9b8b2ed
mikrotik firmware updates 2025-12-13 18:47:55 +01:00
bd639cd6cb
routeros_health 2025-12-13 18:47:43 +01:00
75657d2423
more routeros grafana 2025-12-13 16:29:20 +01:00
4a4167e0b6
routeros grafana discards and errors 2025-12-13 15:31:09 +01:00
c1d9f231b1
l4d2 tickrate 2025-12-13 15:29:36 +01:00
e486aad38c
whitespace 2025-12-13 15:29:29 +01:00
8539f59302
mikrotik snmp monitoring 2025-12-13 15:02:37 +01:00
8066efb923
routeros: also add comment to interface 2025-12-03 22:34:11 +01:00
463cf8783c
mikrotik: more port config 2025-12-03 22:30:42 +01:00
5a8dc7e313
nodes/home.backups.py: use own vlan for wakeonlan to not have 2 ips in same network 2025-12-03 22:06:22 +01:00
7f43efc857
hooks/wake_on_lan.py: dedup 2025-12-03 22:05:15 +01:00
08b8f03661
bundles/routeros/items.py: actually manage ports (pvid was crucial) 2025-12-03 22:04:45 +01:00
fe5e340d6e
bin/script_template: repo -> bw 2025-12-03 22:04:16 +01:00
979c7e1f9d
bin/passwords-for: introduce 2025-12-03 22:03:58 +01:00
a1cdfb57a7
update bw 2025-12-03 22:03:44 +01:00
487fdffd91
update seom apt keys 2025-12-01 22:44:40 +01:00
b1a1038dec
routeros add username 2025-12-01 22:18:48 +01:00
2b873e4cb8
sync routeros logins to 1password 2025-12-01 22:18:43 +01:00
a12edcd360
mroe worksop maps 2025-12-01 20:57:45 +01:00
383b1925ef
more l4d servers 2025-12-01 20:57:34 +01:00
0603a8c7e6
test unique node ids 2025-12-01 20:57:16 +01:00
5620c199a9
l4d refactor workshop downloads 2025-11-04 22:56:51 +01:00
08d99bf714
l4d fixes 2025-11-04 19:35:23 +01:00
ebe76358ce
l4d workshop maps overlay 2025-11-04 19:27:58 +01:00
47b69f0530
l4d items stop script 2025-11-04 19:27:42 +01:00
f46bae2372
fix path 2025-10-30 09:23:46 +01:00
830b3ea114
l4d servers 2025-10-30 09:18:46 +01:00
926e249272
l4d add maps 2025-10-29 17:16:51 +01:00
bcb60def00
l4d purge directories and fix overlay path stuff 2025-10-29 16:51:01 +01:00
03654ef5af
l4d move workshop downlaoder to scripts 2025-10-29 16:45:50 +01:00
2d59c68004
l4d some more options here and there 2025-10-29 16:28:47 +01:00
7a51040ac0
l4d someoptions here and there 2025-10-29 16:26:03 +01:00
c2b177db49
l4d rename zonemod server 2025-10-29 16:14:52 +01:00
7f0aeed88a
l4d some tidyups 2025-10-29 16:00:56 +01:00
8391afdac5
l4d make all underlying server.cfg accessible 2025-10-29 15:57:36 +01:00
d91b205a89
l4d setup bring back workshop downlaoder installation 2025-10-29 15:40:56 +01:00
3311bfbd9f
rmove unnecessary conf 2025-10-29 15:14:11 +01:00
351ce246c5
l4d admin system id got mixed up 2025-10-29 15:13:32 +01:00
9572ac822f
l4d2 dynamic overlays 2025-10-29 14:13:31 +01:00
a59d33ec03
l4d overlay split scripts 2025-10-29 13:36:33 +01:00
a9e4013d86
l4d move some config around 2025-10-29 12:57:15 +01:00
19c1945110
l4d config defaults 2025-10-29 12:39:14 +01:00
fb22a015e5
systemd fix dependency overwrite 2025-10-29 12:27:37 +01:00
e6312a2318
l4d start script refactor 2025-10-29 12:25:05 +01:00
776654970e
l4d extra config folder 2025-10-29 11:05:33 +01:00
22f730d5b5
remove artefact 2025-10-29 10:47:21 +01:00
dc614483b5
zonemod autostart 2025-10-29 10:19:53 +01:00
891e29a362
fix vars 2025-10-28 22:54:32 +01:00
2667553cf2
l4d2 COMPETITIVE REWORK 2025-10-28 22:54:05 +01:00
8467803fdd
server config settings 2025-10-28 15:55:14 +01:00
084cf958a0
l4d2: tickrate enabler 2025-10-28 15:34:19 +01:00
b4cba98564
bootshorn "backup" 2025-10-28 12:38:44 +01:00
39d5fb8d16
git ignore .bw_debug_history 2025-10-28 12:38:27 +01:00
0c74cfd5e9
other tank 2025-08-24 15:27:28 +02:00
841f523f73
bootshorn stuff 2025-08-24 15:23:17 +02:00
6d38d04a1e
bootshonr fixed ip 2025-08-24 13:35:41 +02:00
504089427d
bootshorn records use temp file 2025-08-24 13:34:01 +02:00
60f29aab70
fix hue dhcp 2025-08-24 13:33:44 +02:00
ee94e30004 Merge pull request 'the next l4d2 server iteration, this time more simple and kinda working' (#27) from l4d2_the_next into master
Reviewed-on: #27
2025-08-24 13:33:23 +02:00
3469d98a43
the next l4d2 server iteration, this time more simple and kinda working 2025-08-24 13:33:05 +02:00
5fd775d855 Merge pull request 'htz.mails debian 13' (#26) from htz.mails_debian_13_squash into master
Reviewed-on: #26
2025-08-10 15:40:44 +02:00
725d5292b2
must set number to not screw bw comparison 2025-08-10 15:39:45 +02:00
9161a2501c
vmail set recordsize 2025-08-10 15:34:41 +02:00
9b3f856eb0
mailserver zfs params 2025-08-10 15:33:21 +02:00
9621184bd8
htz.mails debian 13 2025-08-10 15:10:46 +02:00
1f2273d2ab
scale htz.mails down 2025-08-10 09:41:37 +02:00
0514fa0241 Merge pull request 'debian 13' (#25) from debian-13 into master
Reviewed-on: #25
2025-08-10 09:37:49 +02:00
2f263476d3
fix sysctl 2025-08-09 23:31:29 +02:00
e65aa8fdab
openhab no longer exists 2025-08-09 23:08:15 +02:00
70b17657a1
update router 2025-08-09 23:08:06 +02:00
b8389352ec
dont purge sudoers 2025-08-09 22:46:01 +02:00
7586d4ff29
remove unnecessary locales 2025-08-09 22:45:19 +02:00
bc656cdef4
backups debian 13 2025-08-09 22:45:08 +02:00
278f6de6f5
l4d readme updates 2025-08-09 22:26:48 +02:00
2de9fed1fa
besteffort 2025-08-09 22:26:39 +02:00
3bcd2be520
netword remove netplan 2025-08-09 21:33:35 +02:00
7eac09e547
ovh.secondary cake 2025-08-09 21:33:26 +02:00
5fb1ee54b9
less annoying root passwords 2025-08-09 21:32:23 +02:00
ecfd60803f
fix gateway 2025-08-09 21:29:57 +02:00
81b17b389f
ovh.secondary l4d readme 2025-08-09 19:13:00 +02:00
57675c08eb
new ovh.secondary 2025-08-09 14:58:27 +02:00
64f869121b
zones.rfc1918 only affect recursive views 2025-08-09 12:46:05 +02:00
c41e6f8240
debian 13 2025-08-09 12:43:59 +02:00
7483d0c012 Merge pull request 'ipv6_picking' (#24) from ipv6_picking into master
Reviewed-on: #24
2025-08-09 12:43:09 +02:00
f1b26e5933
upgrade debian 2025-08-09 11:54:56 +02:00
f8ddcd7b7c
forward ipv6 2025-08-03 22:39:18 +02:00
962bd06a32
qdisc-ppp0 partof pppoe-isp 2025-08-03 22:38:12 +02:00
3d6d4d5503
IPv6AcceptRA not via dhcp option 2025-08-03 22:35:56 +02:00
4b22705ff7
pyenv install --skip-existing 2025-08-03 22:35:29 +02:00
983ad1b1ae
fix annoying icingaweb redirect to empty page 2025-07-13 14:04:50 +02:00
849c305d7d
remove obsolete homeassistant supervised 2025-07-13 14:04:31 +02:00
ff0d0d2e8b
nodes/home.homeassistant.py: or use nginx addon 2025-07-13 13:12:38 +02:00
c98b8c6f05
homeassistant letsencrypt 2025-07-13 13:10:37 +02:00
4136f819a5
start service instead of duplicating code 2025-07-13 13:10:19 +02:00
78fe5440a8
change leaked password 2025-07-13 12:45:20 +02:00
012325e996
address conflict 2025-07-13 10:57:56 +02:00
951fa63296
bootshorn better temp logging + 2025-07-13 10:13:23 +02:00
6f86abd997
bootshorn better temp logging 2025-07-13 10:08:52 +02:00
c1917d51a0
bootshorn log temp every 15 mins 2025-07-13 09:55:09 +02:00
75017a99df
bootshorn log temperature 2025-07-13 09:53:24 +02:00
980fdc8203
mailman readme 2025-07-12 14:04:44 +02:00
7df21873c1
wip 2025-07-12 14:03:11 +02:00
9bbaeb67d3
mailman poc email sent 2025-07-12 13:53:46 +02:00
a6b557882d
bundles/pppoe/files/isp: ipv6 wip 2025-07-12 12:15:23 +02:00
90c02e58bf
fix rack switch hostname 2025-07-11 23:58:04 +02:00
8829902e0b
fix indent 2025-07-11 23:55:33 +02:00
e7c5fe9213
fix set not dict 2025-07-11 23:55:02 +02:00
5a1ce55086
fix 2025-07-11 23:52:58 +02:00
cca320e2f4
pppoe 2025-07-11 23:49:46 +02:00
e4e3c57f20
pppoe telekom 2025-07-11 20:44:05 +02:00
5274639ca3
bootshorn recording 2025-07-11 19:10:49 +02:00
3e5ed906bc
cake traffic shaping 2025-07-10 20:39:24 +02:00
9a519432b0
nodes/home.switch-rack-poe.py: introduce 2025-07-10 10:34:27 +02:00
6a3424faf4
fix provides 2025-07-10 09:19:20 +02:00
19a8d28a24
homeassistant os is dummy 2025-07-08 20:10:50 +02:00
a52d9b052f
home.backups fiber 2025-07-06 18:23:09 +02:00
db56385513
vlan interface in vlan netwrok, not in seperate list 2025-07-01 12:20:39 +02:00
7ab96e6a47
router as dns relay 2025-07-01 11:43:22 +02:00
c37bca287e Merge pull request 'routeros' (#23) from routeros into master
Reviewed-on: #23
2025-07-01 11:32:27 +02:00
d17f6da77a
tidy up and try home dns server 2025-07-01 11:30:57 +02:00
460f809403
more routeros 2025-07-01 11:30:57 +02:00
0e6a705d3f
routeros switches ok 2025-07-01 11:30:57 +02:00
d54eff344f
routeros wip 2025-07-01 11:30:37 +02:00
79a54578b8
yourls remove temp leftovers 2025-06-30 09:53:31 +02:00
1d8f20ff25
yurlls fix monitoring and use dehydrated certs 2025-06-29 14:46:39 +02:00
d3b8e2e414
mailman 2025-06-29 12:37:09 +02:00
85daf26174
routeros 2025-06-29 12:32:18 +02:00
53933957a4 Merge pull request 'proxmox_mergable' (#22) from proxmox_mergable into master
Reviewed-on: #22
2025-06-29 12:25:34 +02:00
8d941ebef4
open fw for iperf 2025-06-29 12:24:59 +02:00
800bd90778
remove apcupsd 2025-06-29 12:24:59 +02:00
df38fdb99e
new router 2025-06-29 12:24:59 +02:00
23947bd967
mariadb fixed 2025-06-29 12:24:59 +02:00
32ea52c8f4
mariadb use ini parser 2025-06-29 12:24:59 +02:00
d755267dd9
proxmox 2025-06-29 12:24:50 +02:00
53659b4364
yourls enbale wireguard and backup 2025-06-22 10:57:15 +02:00
0035dd1e6f
remove duplicate 2025-06-22 10:56:33 +02:00
c8680b06ac
remove l4d2 server 2025-06-22 10:56:24 +02:00
3f82d0fc57
fix temp dir, its not a file 2025-06-22 10:55:55 +02:00
5d95a33c5a Merge pull request 'mseibert_yourls and many other fixes' (#19) from mseibert_yourls into master
Reviewed-on: #19
2025-06-22 10:09:58 +02:00
aeb0a4fbe7
nodes/mseibert.yourls.py: introduce 2025-06-22 10:07:10 +02:00
9e139fd422
fix remove leftover 2025-06-22 10:03:38 +02:00
9733a55942
svc_systemd:systemd-networkd add .service to name 2025-06-22 09:53:22 +02:00
befdf5ad6e
fixmo mariadb dependency 2025-06-22 09:51:50 +02:00
663116c778
/var/lib/mysql needs mysql user to exist 2025-06-22 09:50:37 +02:00
187b0440c8
nginx use expected dirs and allow websockets in proxy pass 2025-06-22 09:49:27 +02:00
bdb9fa064d
gitea disable registration 2025-06-22 09:40:41 +02:00
d3ba9db0c6
maybe keep etc/kernel/postinst.d/apt-auto-removal? 2025-06-22 09:40:26 +02:00
3dffc05c9d
apt add docs about options 2025-06-22 09:40:13 +02:00
6616ae7417
fix some redis permissions 2025-06-22 09:37:16 +02:00
dc40295dde
print message on parsing group error 2025-06-22 09:36:56 +02:00
1d8361cc5f
cache_to_disk broken 2025-06-22 09:36:21 +02:00
35243fdba6
offsitebackup offline 2025-06-22 09:35:35 +02:00
43e7c1f3e4
fix redis permissions 2025-06-22 09:30:04 +02:00
dcd2ebc49c
dist-upgrade -> full-upgrade 2025-01-16 10:20:34 +01:00
555350eab7
debian update 2025-01-16 10:20:18 +01:00
e117acac04
backup all doesnt stop on first error 2025-01-09 23:41:21 +01:00
16313b9e40
disable tasnomta charge 2025-01-09 22:45:27 +01:00
033a1cf6e5
macbook gnu grep 2025-01-01 13:04:42 +01:00
8befec9769
readme git sign 2024-12-09 09:07:19 +01:00
d22add5bfd
shortcut 2024-12-09 09:03:14 +01:00
69fb93a664
macbook compat 2024-12-09 08:58:14 +01:00
f4b59dc702
stuff 2024-11-23 15:58:10 +01:00
17aa3d7e48
no wg while at home 2024-11-23 14:50:49 +01:00
8bb9dae45c
all via usb interface, internal is broken 2024-11-23 14:50:40 +01:00
c244645020
kea deps 2024-11-23 14:50:22 +01:00
64029d2147
freescout readme 2024-11-23 11:51:31 +01:00
8081f12315
freescout comment 2024-11-23 11:18:11 +01:00
4ec2d5192a
freescout repair? 2024-11-23 11:02:28 +01:00
273 changed files with 15715 additions and 1744 deletions

4
.envrc
View file

@ -2,6 +2,6 @@
PATH_add bin PATH_add bin
source_env ~/.local/share/direnv/pyenv layout uv
source_env ~/.local/share/direnv/venv
source_env ~/.local/share/direnv/bundlewrap source_env ~/.local/share/direnv/bundlewrap

5
.gitignore vendored
View file

@ -2,3 +2,8 @@
.venv .venv
.cache .cache
*.pyc *.pyc
.bw_debug_history
# CocoIndex Code (ccc)
/.cocoindex_code/
# bundlewrap git_deploy local-mirror map (operator-specific paths)
git_deploy_repos

108
AGENTS.md Normal file
View file

@ -0,0 +1,108 @@
# ckn-bw — agent & contributor guide
## What this repo is
A [BundleWrap](https://bundlewrap.org/) configuration-management repo
for ~22 personal/family-infra nodes. Nodes, groups, and bundles are
defined in plain Python; `bw apply` deploys the resulting state to
real machines.
Note: the root `README.md` is the maintainer's personal scratchpad,
not project documentation. Onboarding lives **here**, in `AGENTS.md`.
## Quickstart for agents
Five rules; follow these and you won't break things:
1. **Read-only by default.** Never run `bw apply`, `bw run`, or
`bw lock` without explicit user request — even with `-i`. Stick
to `bw test`, `bw nodes`, `bw groups`, `bw items`,
`bw metadata`, `bw hash`, `bw verify`, `bw debug`. See
[`docs/agents/commands.md`](docs/agents/commands.md) and the
fork's [safety envelope](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md).
2. **Never echo decrypted secrets.** Don't print, paste, or log the
value behind a `!password_for:`, `!decrypt:`, or
`!32_random_bytes_as_base64_for:` magic string — not even from
`bw debug` exploration. See
[`conventions.md#secrets`](docs/agents/conventions.md#secrets).
3. **Don't touch the do-not-modify list.** `.secrets.cfg*`, `.venv`,
`.cache`, `.bw_debug_history`, root `README.md`. Treat
`hooks/` and `items/` (custom item types) with extra care: a
broken hook or item type breaks every `bw` command repo-wide.
4. **Use the fork.** Bundlewrap is pinned to the `main` branch of
[`github.com/CroneKorkN/bundlewrap`](https://github.com/CroneKorkN/bundlewrap)
via `[tool.uv.sources]` in `pyproject.toml`; `uv sync` (run by
direnv on entry) installs it. Behavior tracks the fork's `main`;
the fork's
[`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
is the canonical bundlewrap-language reference. See
[`conventions.md#bundlewrap-version`](docs/agents/conventions.md#bundlewrap-version).
5. **Prefer adding helpers to `libs/`** over duplicating logic across
bundles. Repo-wide helpers go in
[`libs/`](libs/AGENTS.md), reachable as `repo.libs.<x>`.
## Layout
| Dir | What's there |
|---|---|
| [`bundles/`](bundles/AGENTS.md) | 103 bundles. One subdir per bundle (`items.py`, `metadata.py`, `files/`). |
| [`nodes/`](nodes/AGENTS.md) | One file per node (~22). `eval()`-loaded; demagified through `repo.vault`. |
| [`groups/`](groups/AGENTS.md) | Group definitions, organized by axis (`applications/`, `locations/`, `machine/`, `os/`). |
| [`libs/`](libs/AGENTS.md) | Shared Python helpers reachable as `repo.libs.<modulename>`. |
| [`hooks/`](hooks/AGENTS.md) | bw lifecycle hooks (`apply_start`, `test`, `node_apply_start`, …). |
| [`data/`](data/AGENTS.md) | Out-of-bundle data assets (apt keys, grafana dashboards, …). |
| [`items/`](items/AGENTS.md) | Custom item types (currently `download:`). |
| [`bin/`](bin/AGENTS.md) | Operator scripts; not invoked by bundlewrap. |
| [`docs/agents/`](docs/agents/conventions.md) | Repo conventions and command deltas. |
## How nodes, groups, and bundles fit together
- A **node** (`nodes/<location>.<role>.py`) declares the groups it
belongs to and any node-local bundles + metadata overrides.
- A **group** (`groups/<axis>/<x>.py`) attaches bundles and shared
metadata to its members. Groups inherit via `supergroups`.
- A **bundle** (`bundles/<x>/`) is one chunk of configuration:
`items.py` produces the items (files, services, packages),
`metadata.py` declares `defaults` and `@metadata_reactor` functions
that derive metadata from other metadata.
- The repo-root loaders (`nodes.py`, `groups.py`) walk these dirs and
`eval()` each file. `nodes.py` additionally **demagifies** the
result, resolving `!password_for:` etc. through `repo.vault`. See
[`conventions.md#eval-loaded-node-and-group-files`](docs/agents/conventions.md#eval-loaded-node-and-group-files)
for the constraints this places on editors.
- Metadata merges along: `all → location → os → machine →
applications → node`.
## Conventions you must know
| Topic | Where |
|---|---|
| Bundlewrap-language reference (item types, dep keywords, reactors) | Fork's [`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md) — read first if new to bundlewrap |
| Vault / demagify magic strings | [`conventions.md#secrets`](docs/agents/conventions.md#secrets) |
| Bundlewrap install (uv-pinned to the fork's `main`) | [`conventions.md#bundlewrap-version`](docs/agents/conventions.md#bundlewrap-version) |
| Group inheritance order, naming patterns | [`conventions.md#group-inheritance-order`](docs/agents/conventions.md#group-inheritance-order), [`#naming-conventions`](docs/agents/conventions.md#naming-conventions) |
| Repo-specific bw command deltas (apt keys, suspended nodes, vault echo) | [`commands.md`](docs/agents/commands.md) |
| Lib helpers | top-of-file docstrings in `libs/*.py` (`head -1 libs/*.py`) |
| Suspension idioms (`*.py_`, `_old/`, "for now") | [`conventions.md#suspension-and-soft-delete-idioms`](docs/agents/conventions.md#suspension-and-soft-delete-idioms) |
## Where to look for examples
When writing a new bundle, copy patterns from one that already does
the thing you need:
| Pattern | Look at |
|---|---|
| Vault calls inside metadata reactors | `bundles/dm-crypt/metadata.py` (compact, focused) |
| Mako-templated files | `bundles/bind/items.py` (DNS zonefile rendering) |
| Cross-bundle reactor writing | `bundles/nextcloud/metadata.py` (writes into `apt.packages`, `archive.paths`) |
| Custom `download:` items | `bundles/minecraft/items.py` |
| Node file (single-purpose) | `nodes/home.server.py` |
| Group with `supergroups` chain | `groups/os/debian-13.py` |
## Where this doc lives
- This file: `AGENTS.md` at the repo root.
- `CLAUDE.md` is a symlink to this file — both names point to the same
content so different tools can find it.
- The personal TODO scratchpad (`README.md`) is **separate** and not
project documentation.

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -13,10 +13,6 @@ Raspberry pi as soundcard
- OTG g_audio - OTG g_audio
- https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824 - https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824
# install bw fork
pip3 install --editable git+file:///Users/mwiegand/Projekte/bundlewrap-fork@main#egg=bundlewrap
# monitor timers # monitor timers
```sh ```sh
@ -37,3 +33,12 @@ fi
telegraf: execd for daemons telegraf: execd for daemons
TEST TEST
# git signing
git config --global gpg.format ssh
git config --global commit.gpgsign true
git config user.name CroneKorkN
git config user.email i@ckn.li
git config user.signingkey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILMVroYmswD4tLk6iH+2tvQiyaMe42yfONDsPDIdFv6I"

62
bin/AGENTS.md Normal file
View file

@ -0,0 +1,62 @@
# bin/
## What's here
Operator scripts — invoked manually by the maintainer, **not** by
bundlewrap itself. Each is a standalone Python (or shell) script that
opens the repo via `Repository(dirname(dirname(realpath(__file__))))`.
Discovery is by `ls bin/` plus the `# purpose:` header line at the top
of each script:
```sh
head -2 bin/*
```
## Conventions
- **`# purpose:` header.** Every script under `bin/` starts with
`#!/usr/bin/env python3` (or appropriate shebang), then a
`# purpose: <one-line description>` comment. Baseline enforced by
`grep -L '^# purpose' bin/*`.
- **Self-contained.** A script must work when run from anywhere — it
resolves the repo via the script's own path, not `cwd`.
- **Read-only by default.** Most operator scripts query/print state
(`passwords-for`, `wireguard-client-config`). Mutating scripts
(`upgrade_and_restart_all`, `mikrotik-firmware-updater`,
`sync_1password`) are the exception, not the rule, and prompt for
confirmation.
## How to add a script
1. Start from [`bin/script_template`](script_template) — it carries
the canonical shebang + `# purpose:` header + `Repository(...)`
bootstrap.
2. Add the `# purpose:` line; lowercase, terse, include a `usage:`
example if the script takes arguments.
3. `chmod +x bin/<name>`.
4. The script can reach helpers via `bw.libs.<x>` exactly like a
bundle does.
## Pitfalls
- **`bin/` is not on `$PATH` by default.** Invoke as `bin/<name>` from
the repo root, or via `direnv` if `.envrc` exposes it.
- **Mutating scripts can hit Tier-3 territory** (per the fork's
safety envelope). Don't run `upgrade_and_restart_all`,
`mikrotik-firmware-updater`, or anything that does `node.run(...)`
without explicit user instruction. See the fork's
[`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
for the three-tier model.
- **Vault echo.** Scripts like `passwords-for` print decrypted values
by design; that's allowed for the human at the terminal but *not*
for the agent — never paste output into chat, ticket, or PR
description.
## See also
- [`script_template`](script_template) — canonical starter.
- [`docs/agents/conventions.md`](../docs/agents/conventions.md) —
vault rules.
- [`docs/agents/commands.md`](../docs/agents/commands.md) — read-only
bw-command guidance.

149
bin/mikrotik-firmware-updater Executable file
View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
# purpose: upgrade RouterOS and routerboard firmware on `bundle:routeros` (or any selector) — usage: mikrotik-firmware-updater [<selector>...] [--yes].
from argparse import ArgumentParser
from time import sleep
from bundlewrap.exceptions import RemoteException
from bundlewrap.utils.cmdline import get_target_nodes
from bundlewrap.utils.ui import io
from bundlewrap.repo import Repository
from os.path import realpath, dirname
# parse args
parser = ArgumentParser()
parser.add_argument("targets", nargs="*", default=['bundle:routeros'], help="bw nodes selector")
parser.add_argument("--yes", action="store_true", default=False, help="skip confirmation prompts")
args = parser.parse_args()
def wait_up(node):
sleep(5)
while True:
try:
node.run_routeros('/system/resource/print')
except RemoteException:
sleep(2)
continue
else:
io.debug(f"{node.name}: is up")
sleep(10)
return
def upgrade_switch_os(node):
# get versions for comparison
with io.job(f"{node.name}: checking OS version"):
response = node.run_routeros('/system/package/update/check-for-updates').raw[-1]
installed_os = bw.libs.version.Version(response['installed-version'])
latest_os = bw.libs.version.Version(response['latest-version'])
io.debug(f"{node.name}: installed: {installed_os} >= latest: {latest_os}")
# compare versions
if installed_os >= latest_os:
# os is up to date
io.stdout(f"{node.name}: os up to date ({installed_os})")
else:
# confirm os upgrade
if not args.yes and not io.ask(
f"{node.name}: upgrade os from {installed_os} to {latest_os}?", default=True
):
io.stdout(f"{node.name}: skipped by user")
return
# download os
with io.job(f"{node.name}: downloading OS"):
response = node.run_routeros('/system/package/update/download').raw[-1]
io.debug(f"{node.name}: OS upgrade download response: {response['status']}")
# install and wait for reboot
with io.job(f"{node.name}: upgrading OS"):
try:
response = node.run_routeros('/system/package/update/install').raw[-1]
except RemoteException:
pass
wait_up(node)
# verify new os version
with io.job(f"{node.name}: checking new OS version"):
new_os = bw.libs.version.Version(node.run_routeros('/system/package/update/check-for-updates').raw[-1]['installed-version'])
if new_os == latest_os:
io.stdout(f"{node.name}: OS successfully upgraded from {installed_os} to {new_os}")
else:
raise Exception(f"{node.name}: OS upgrade failed, expected {latest_os}, got {new_os}")
def upgrade_switch_firmware(node):
# get versions for comparison
with io.job(f"{node.name}: checking Firmware version"):
response = node.run_routeros('/system/routerboard/print').raw[-1]
current_firmware = bw.libs.version.Version(response['current-firmware'])
upgrade_firmware = bw.libs.version.Version(response['upgrade-firmware'])
io.debug(f"{node.name}: firmware installed: {current_firmware}, upgrade: {upgrade_firmware}")
# compare versions
if current_firmware >= upgrade_firmware:
# firmware is up to date
io.stdout(f"{node.name}: firmware is up to date ({current_firmware})")
else:
# confirm firmware upgrade
if not args.yes and not io.ask(
f"{node.name}: upgrade firmware from {current_firmware} to {upgrade_firmware}?", default=True
):
io.stdout(f"{node.name}: skipped by user")
return
# upgrade firmware
with io.job(f"{node.name}: upgrading Firmware"):
node.run_routeros('/system/routerboard/upgrade')
# reboot and wait
with io.job(f"{node.name}: rebooting"):
try:
node.run_routeros('/system/reboot')
except RemoteException:
pass
wait_up(node)
# verify firmware version
new_firmware = bw.libs.version.Version(node.run_routeros('/system/routerboard/print').raw[-1]['current-firmware'])
if new_firmware == upgrade_firmware:
io.stdout(f"{node.name}: firmware successfully upgraded from {current_firmware} to {new_firmware}")
else:
raise Exception(f"firmware upgrade failed, expected {upgrade_firmware}, got {new_firmware}")
def upgrade_switch(node):
with io.job(f"{node.name}: checking"):
# check if routeros
if node.os != 'routeros':
io.progress_advance(2)
io.stdout(f"{node.name}: skipped, unsupported os {node.os}")
return
# check switch reachability
try:
node.run_routeros('/system/resource/print')
except RemoteException as error:
io.progress_advance(2)
io.stdout(f"{node.name}: skipped, error {error}")
return
upgrade_switch_os(node)
io.progress_advance(1)
upgrade_switch_firmware(node)
io.progress_advance(1)
with io:
bw = Repository(dirname(dirname(realpath(__file__))))
nodes = get_target_nodes(bw, args.targets)
io.progress_set_total(len(nodes) * 2)
io.stdout(f"upgrading {len(nodes)} switches: {', '.join([node.name for node in sorted(nodes)])}")
for node in sorted(nodes):
upgrade_switch(node)

23
bin/passwords-for Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python3
# purpose: print node.password and selected metadata-key passwords for one node — usage: passwords-for <node>.
from bundlewrap.repo import Repository
from os.path import realpath, dirname
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('node', help='Node to generate passwords for')
args = parser.parse_args()
bw = Repository(dirname(dirname(realpath(__file__))))
node = bw.get_node(args.node)
if node.password:
print(f"password: {node.password}")
for metadata_key in sorted([
'users/root/password',
]):
if value := node.metadata.get(metadata_key, None):
print(f"{metadata_key}: {value}")

View file

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: send an RCON command to a left4dead2 server defined in node metadata — usage: rcon (list) | rcon <server> <command>.
from sys import argv from sys import argv
from os.path import realpath, dirname from os.path import realpath, dirname

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: starter template for new operator scripts under bin/.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname
repo = Repository(dirname(dirname(realpath(__file__)))) bw = Repository(dirname(dirname(realpath(__file__))))

133
bin/sync_1password Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
# purpose: upsert one 1Password login per `bundle:routeros` node, keyed on the bw node id.
from bundlewrap.repo import Repository
from os.path import realpath, dirname
import json
import os
import subprocess
from dataclasses import dataclass
from typing import Optional, List
bw = Repository(dirname(dirname(realpath(__file__))))
VAULT=bw.vault.decrypt('encrypt$gAAAAABpLgX_xxb5NmNCl3cgHM0JL65GT6PHVXO5gwly7IkmWoEgkCDSuAcSAkNFB8Tb4RdnTdpzVQEUL1XppTKVto_O7_b11GjATiyQYiSfiQ8KZkTKLvk=').value
BW_TAG = "bw"
BUNDLEWRAP_FIELD_LABEL = "bundlewrap node id"
@dataclass
class OpResult:
stdout: str
stderr: str
returncode: int
def main():
for node in bw.nodes_in_group('routeros'):
upsert_node_item(
node_name=node.name,
node_uuid=node.metadata.get('id'),
username=node.username,
password=node.password,
url=f'http://{node.hostname}',
)
def run_op(args):
proc = subprocess.run(
["op", "--vault", VAULT] + args,
env=os.environ.copy(),
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise RuntimeError(
f"op {' '.join(args)} failed with code {proc.returncode}:\n"
f"STDOUT:\n{proc.stdout}\n\nSTDERR:\n{proc.stderr}"
)
return OpResult(stdout=proc.stdout, stderr=proc.stderr, returncode=proc.returncode)
def op_item_list_bw():
out = run_op([
"item", "list",
"--tags", BW_TAG,
"--format", "json",
])
stdout = out.stdout.strip()
return json.loads(stdout) if stdout else []
def op_item_get(item_id):
args = ["item", "get", item_id, "--format", "json"]
return json.loads(run_op(args).stdout)
def op_item_create(title, node_uuid, username, password, url):
print(f"creating {title}")
return json.loads(run_op([
"item", "create",
"--category", "LOGIN",
"--title", title,
"--tags", BW_TAG,
"--url", url,
"--format", "json",
f"username={username}",
f"password={password}",
f"{BUNDLEWRAP_FIELD_LABEL}[text]={node_uuid}",
]).stdout)
def op_item_edit(item_id, title, username, password, url):
print(f"updating {title}")
return json.loads(run_op([
"item", "edit",
item_id,
"--title", title,
"--url", url,
"--format", "json",
f"username={username}",
f"password={password}",
]).stdout)
def find_node_item_id(node_uuid):
for summary in op_item_list_bw():
item_id = summary.get("id")
if not item_id:
continue
item = op_item_get(item_id)
for field in item.get("fields") or []:
label = field.get("label")
value = field.get("value")
if label == BUNDLEWRAP_FIELD_LABEL and value == node_uuid:
return item_id
return None
def upsert_node_item(node_name, node_uuid, username, password, url):
if item_id := find_node_item_id(node_uuid):
return op_item_edit(
item_id=item_id,
title=node_name,
username=username,
password=password,
url=url,
)
else:
return op_item_create(
title=node_name,
node_uuid=node_uuid,
username=username,
password=password,
url=url,
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python3
# purpose: add missing EXIF/QuickTime timestamps to photos in a directory using mdls + exiftool — usage: timestamp_icloud_photos_for_nextcloud -d <dir>.
from subprocess import check_output, CalledProcessError
from datetime import datetime, timedelta
from pathlib import Path
import json
from argparse import ArgumentParser
from concurrent.futures import ThreadPoolExecutor, as_completed
from os import cpu_count
from time import sleep
EXT_GROUPS = {
"quicktime": {".mp4", ".mov", ".heic", ".cr3"},
"exif": {".jpg", ".jpeg", ".cr2"},
}
DATETIME_KEYS = [
("Composite", "SubSecDateTimeOriginal"),
("Composite", "SubSecCreateDate"),
('ExifIFD', 'DateTimeOriginal'),
('ExifIFD', 'CreateDate'),
('XMP-xmp', 'CreateDate'),
('Keys', 'CreationDate'),
('QuickTime', 'CreateDate'),
('XMP-photoshop', 'DateCreated'),
]
def run(command):
return check_output(command, text=True).strip()
def mdls_timestamp(file):
for i in range(5): # retry a few times in case of transient mdls failures
try:
output = run(('mdls', '-raw', '-name', 'kMDItemContentCreationDate', file))
except CalledProcessError as e:
print(f"{file}: Error running mdls (attempt {i+1}/5): {e}")
continue
try:
return datetime.strptime(output, "%Y-%m-%d %H:%M:%S %z")
except ValueError as e:
print(f"{file}: Error parsing mdls output (attempt {i+1}/5): {e}")
continue
sleep(1)
raise RuntimeError(f"Failed to get mdls timestamp for {file} after 5 attempts")
def exiftool_data(file):
try:
output = run((
'exiftool',
'-j', # json
'-a', # unknown tags
'-u', # unknown values
'-g1', # group by category
'-time:all', # all time tags
'-api', 'QuickTimeUTC=1', # use UTC for QuickTime timestamps
'-d', '%Y-%m-%dT%H:%M:%S%z',
file,
))
except CalledProcessError as e:
print(f"Error running exiftool: {e}")
return None
else:
return json.loads(output)[0]
def exiftool_timestamp(file):
data = exiftool_data(file)
for category, key in DATETIME_KEYS:
try:
value = data[category][key]
return category, key, datetime.strptime(value, '%Y-%m-%dT%H:%M:%S%z')
except (TypeError, KeyError, ValueError) as e:
continue
print(f"⚠️ {file}: No timestamp found in exiftool: " + json.dumps(data, indent=2))
return None, None, None
def photo_has_embedded_timestamp(file):
mdls_ts = mdls_timestamp(file)
category, key, exiftool_ts = exiftool_timestamp(file)
if not exiftool_ts:
print(f"⚠️ {file}: No timestamp found in exiftool")
return False
# normalize timezone for comparison
exiftool_ts = exiftool_ts.astimezone(mdls_ts.tzinfo)
delta = abs(mdls_ts - exiftool_ts)
if delta < timedelta(hours=1): # allow for small differences
print(f"✅ {file}: {mdls_ts.isoformat()} (#{category}:{key})")
return True
else:
print(f"⚠️ {file}: {mdls_ts.isoformat()} != {exiftool_ts} (Δ={delta})")
return False
def photos_without_embedded_timestamps(directory):
executor = ThreadPoolExecutor(max_workers=cpu_count()//2)
try:
futures = {
executor.submit(photo_has_embedded_timestamp, file): file
for file in directory.iterdir()
if file.is_file()
if file.suffix.lower() not in {".aae"}
if not file.name.startswith('.')
}
for future in as_completed(futures):
file = futures[future]
has_ts = future.result() # raises immediately on first failed future
if has_ts:
file.rename(file.parent / 'ok' / file.name)
else:
yield file
except Exception:
executor.shutdown(wait=False, cancel_futures=True)
raise
else:
executor.shutdown(wait=True)
def exiftool_write(file, assignments):
print(f"🔵 {file}: Writing -- {assignments}")
return run((
"exiftool", "-overwrite_original",
"-api", "QuickTimeUTC=1",
*[
f"-{group}:{tag}={value}"
for group, tag, value in assignments
],
str(file),
))
def add_missing_timestamp(file):
data = exiftool_data(file)
mdls_ts = mdls_timestamp(file)
offset = mdls_ts.strftime("%z")
offset = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
exif_ts = mdls_ts.strftime("%Y:%m:%d %H:%M:%S")
qt_ts = mdls_ts.strftime("%Y:%m:%d %H:%M:%S")
qt_ts_tz = f"{qt_ts}{offset}"
ext = file.suffix.lower()
try:
if ext in {".heic"}:
exiftool_write(file, [
("ExifIFD", "DateTimeOriginal", qt_ts),
("ExifIFD", "CreateDate", qt_ts),
("ExifIFD", "OffsetTime", offset),
("ExifIFD", "OffsetTimeOriginal", offset),
("ExifIFD", "OffsetTimeDigitized", offset),
("QuickTime", "CreateDate", qt_ts_tz),
("Keys", "CreationDate", qt_ts_tz),
("XMP-xmp", "CreateDate", qt_ts_tz),
])
elif "QuickTime" in data or ext in {".mp4", ".mov", ".heic", ".cr3"}:
exiftool_write(file, [
("QuickTime", "CreateDate", qt_ts_tz),
("Keys", "CreationDate", qt_ts_tz),
])
elif "ExifIFD" in data or ext in {".jpg", ".jpeg", ".cr2", ".webp"}:
exiftool_write(file, [
("ExifIFD", "DateTimeOriginal", exif_ts),
("ExifIFD", "CreateDate", exif_ts),
("IFD0", "ModifyDate", exif_ts),
("ExifIFD", "OffsetTime", offset),
("ExifIFD", "OffsetTimeOriginal", offset),
("ExifIFD", "OffsetTimeDigitized", offset),
])
elif ext in {".png", ".gif", ".avif"}:
exiftool_write(file, [
("XMP-xmp", "CreateDate", qt_ts_tz),
("XMP-photoshop", "DateCreated", exif_ts),
])
else:
print(f"❌ {file}: unsupported type, skipped")
return
if photo_has_embedded_timestamp(file):
print(f"✅ {file}: Timestamp successfully added: {mdls_ts.isoformat()}")
file.rename(file.parent / 'processed' / file.name)
return
else:
category, key, exiftool_ts = exiftool_timestamp(file)
print(f"❌ {file}: Timestamp still wrong/missing after write '{category}:{key}:{exiftool_ts}': #{json.dumps(data, indent=4)}")
return
except CalledProcessError as e:
print(f"❌ {file}: Failed to write timestamp: {e}")
return
if __name__ == "__main__":
parser = ArgumentParser(description="Print timestamps of photos in the current directory.")
parser.add_argument("-d", "--directory", help="Directory to scan for photos")
args = parser.parse_args()
directory = Path(args.directory)
(directory/'ok').mkdir(exist_ok=True)
(directory/'processed').mkdir(exist_ok=True)
_photos_without_embedded_timestamps = list(photos_without_embedded_timestamps(directory))
print(f"{len(_photos_without_embedded_timestamps)} photos without embedded timestamps found.")
print("Press Enter to add missing timestamps...")
input()
for file in _photos_without_embedded_timestamps:
add_missing_timestamp(file)

View file

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: apt-update and full-upgrade every non-dummy debian node, then reboot in WireGuard-aware order.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname
@ -23,7 +24,7 @@ for node in nodes:
print(node.run('DEBIAN_FRONTEND=noninteractive apt update').stdout.decode()) print(node.run('DEBIAN_FRONTEND=noninteractive apt update').stdout.decode())
print(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable').stdout.decode()) print(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable').stdout.decode())
if int(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable 2> /dev/null | grep upgradable | wc -l').stdout.decode()): if int(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable 2> /dev/null | grep upgradable | wc -l').stdout.decode()):
print(node.run('DEBIAN_FRONTEND=noninteractive apt -y dist-upgrade').stdout.decode()) print(node.run('DEBIAN_FRONTEND=noninteractive apt -qy full-upgrade').stdout.decode())
# REBOOT IN ORDER # REBOOT IN ORDER

View file

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: wake one node via WoL by name — usage: wake <node>.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname

View file

@ -1,23 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# purpose: print or QR-render a WireGuard client config from htz.mails metadata — usage: wireguard-client-config <client>.
from bundlewrap.repo import Repository from bundlewrap.repo import Repository
from os.path import realpath, dirname from os.path import realpath, dirname
from sys import argv from sys import argv
from ipaddress import ip_network, ip_interface from ipaddress import ip_network, ip_interface
import argparse
if len(argv) != 3:
print(f'usage: {argv[0]} <node> <client>')
exit(1)
# get info from repo
repo = Repository(dirname(dirname(realpath(__file__)))) repo = Repository(dirname(dirname(realpath(__file__))))
server_node = repo.get_node(argv[1]) server_node = repo.get_node('htz.mails')
available_clients = server_node.metadata.get('wireguard/clients').keys()
if argv[2] not in server_node.metadata.get('wireguard/clients'): # parse args
print(f'client {argv[2]} not found in: {server_node.metadata.get("wireguard/clients").keys()}') parser = argparse.ArgumentParser(description='Generate WireGuard client configuration.')
exit(1) parser.add_argument('client', choices=available_clients, help='The client name to generate the configuration for.')
args = parser.parse_args()
data = server_node.metadata.get(f'wireguard/clients/{argv[2]}')
# get cert
data = server_node.metadata.get(f'wireguard/clients/{args.client}')
vpn_network = ip_interface(server_node.metadata.get('wireguard/my_ip')).network vpn_network = ip_interface(server_node.metadata.get('wireguard/my_ip')).network
allowed_ips = [ allowed_ips = [
vpn_network, vpn_network,
@ -43,10 +45,15 @@ Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:
PersistentKeepalive = 10 PersistentKeepalive = 10
''' '''
print('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>') answer = input("print config or qrcode? [Cq]: ").strip().upper()
print(conf) match answer:
print('<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<') case '' | 'C':
print('>>>>>>>>>>>>>>>')
if input("print qrcode? [Yn]: ").upper() in ['', 'Y']: print(conf)
import pyqrcode print('<<<<<<<<<<<<<<<')
print(pyqrcode.create(conf).terminal(quiet_zone=1)) case 'Q':
import pyqrcode
print(pyqrcode.create(conf).terminal(quiet_zone=1))
case _:
print(f'Invalid option "{answer}".')
exit(1)

204
bundles/AGENTS.md Normal file
View file

@ -0,0 +1,204 @@
# bundles/
## Before you start
Read [`docs/agents/conventions.md`](../docs/agents/conventions.md) first
— it covers vault calls, demagify, the `repo.libs.<x>` helpers, and the
files agents must not modify. Skipping it leads to subtly broken bundles
(vault calls in the wrong place, dict-in-set `TypeError` because of
unhashable nesting, etc.).
For bundlewrap-language reference (item types, dep keywords,
`metadata_reactor`, `defaults`, item-file template syntax) see the fork's
[`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
and its [`docs/content/guide/item_file_templates.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/docs/content/guide/item_file_templates.md).
## What's here
103 bundles. Each is a directory `bundles/<name>/` containing some of:
```
bundles/<name>/
├── items.py # the items this bundle creates (files, services, packages, …)
├── metadata.py # `defaults` + `@metadata_reactor` functions
├── files/ # static or templated file payloads referenced from items.py
└── README.md # one doc per bundle, for humans and agents (see "Per-bundle README" below)
```
## Conventions
- **Bundle names** are lowercase, hyphen-separated: `backup-server`,
`bind-acme`, `dm-crypt`. No underscores in new bundle names — see
[`conventions.md#naming-conventions`](../docs/agents/conventions.md#naming-conventions).
- **`items.py`** is plain Python; it produces `files = {...}`,
`pkg_apt = {...}`, `svc_systemd = {...}`, etc. dicts at module scope.
Cross-item dependencies use `needs` / `triggers` / `triggered_by`
see the fork's `AGENTS.md` for the full keyword cheat sheet.
- **`metadata.py`** uses `defaults = {...}` for static seed values and
`@metadata_reactor.provides(...)` for derived values. Reactors are
pure functions of `(metadata,)` — no side effects, no I/O.
- **Helpers go in [`libs/`](../libs/AGENTS.md)** when they're useful to
more than one bundle. Don't duplicate logic across bundles.
- **Custom item types** (e.g. `download:`) live in
[`items/`](../items/AGENTS.md), not per-bundle.
- **Bundles own application-wide knowledge; nodes carry only the few
per-host knobs the bundle actually needs.** When designing a bundle,
identify the per-node knobs (e.g. domain, uplink interface, a
vault-id suffix) and put everything else in `defaults`, or in a
reactor that derives from those knobs. Per-node random secrets
belong in `defaults` via `repo.vault.random_bytes_as_base64_for(...)`
keyed on the node — not in the node file. See
`bundles/left4me/metadata.py:10` (`secret_key` derived in defaults)
and `bundles/postgresql/metadata.py:4` (vault-derived `password_for`
at module scope).
## How to add a new bundle
1. `mkdir bundles/<name>/` (lowercase, hyphenated).
2. Write `items.py` and (if anything is configurable) `metadata.py`.
Use `repo.libs.hashable.hashable(...)` when you need to nest a dict
or set inside a metadata set; raw dicts/sets aren't hashable.
3. Drop static payloads into `bundles/<name>/files/`. For Mako-templated
files, declare `'content_type': 'mako'` on the `file:` item — see
the fork's
[item-file-templates guide](https://github.com/CroneKorkN/bundlewrap/blob/main/docs/content/guide/item_file_templates.md).
4. **Wire to nodes.** Either add an entry to the relevant
[`groups/<axis>/<x>.py`](../groups/AGENTS.md) (preferred for shared
bundles) or to the node's `bundles` list directly
([`nodes/AGENTS.md`](../nodes/AGENTS.md)).
5. **Verify, in this order:**
- `bw test` — repo-wide parse + cross-cutting hooks. Loads every
bundle, but reactors don't fire for nodes that haven't opted into
the bundle yet — bugs in new reactors stay hidden here.
- **Attach the bundle to a node** (via the node's `bundles` list, or
a group it belongs to). Until you do, the next steps don't actually
exercise the bundle.
- `bw test <node>` — exercises every reactor and item-graph edge for
that node. This is where most new-bundle bugs surface.
- `bw items <node> --blame` — confirm items materialise with the
right paths, authored by the expected bundle.
- `bw metadata <node> -k <a/b>` — spot-check derived metadata.
- `bw hash <node>` — preview vs current host state.
See [`docs/agents/commands.md#bundle-validation-workflow`](../docs/agents/commands.md#bundle-validation-workflow)
for the rationale.
6. Add a `bundles/<name>/README.md`. See "Per-bundle README" below
for what to cover.
## How to remove a bundle
1. `git grep '<name>'` in `nodes/`, `groups/`, and other `bundles/` to
find references.
2. Remove those references.
3. `rm -rf bundles/<name>/`.
4. `bw test` and `bw nodes` to confirm clean.
## Pitfalls
- **`metadata.py` is evaluated at load time** for *every* node, every
invocation of `bw`. Heavy work or I/O slows the whole repo. Keep
reactors pure and fast; pre-compute in `libs/` if you must.
- **Static files vs templates.** `bundles/<x>/files/<f>` is static
unless the matching `file:` item declares `content_type='mako'`
(or a templating extension triggers it). To check, read the matching
`file:` entry in `items.py`.
- **`file:` `source` defaults to the destination basename.** For a
destination of `/etc/foo/bar.conf` with no `source` key, bw looks
for `bundles/<bundle>/files/bar.conf`. Only declare `source`
explicitly when the basename you want differs (e.g. shipping a Mako
template named `bar.conf.mako` to a destination of
`/etc/foo/bar.conf`).
- **Reactors writing across namespaces.** Some bundles' reactors write
into other bundles' metadata namespaces (e.g. `nextcloud` writes
into `apt.packages`, `archive.paths`). When you change such a bundle,
every consumer's metadata changes too. The bundle's `README.md`
often calls these out — but the authoritative source is `metadata.py`
itself; grep `'<other-bundle>':` in the reactors when in doubt.
- **`bw hash` doesn't accept selectors.** Use `bw hash <node>` per
literal name; see the fork's runbook.
- **Reactors must read metadata.** If a reactor body returns a static
dict without calling `metadata.get(...)`, bw raises
`ValueError: <reactor> on <node> did not request any metadata, you
might want to use defaults instead` once a node consumes the bundle.
Fix: fold the contribution into `defaults`. The rule applies even
when the reactor writes into another bundle's namespace — a static
contribution to e.g. `nftables/output` belongs in `defaults`, where
bw merges it with other bundles' contributions.
- **`triggers``triggered: True` invariant.** Any item listed in
another's `triggers` list must declare `triggered: True`. bw
enforces this at `bw test` time: *"…triggered by …, but missing
'triggered' attribute"*. Corollary: an action can't be both in an
upstream `triggers` list AND self-healing every apply — pick one.
- **Triggered actions don't recover from partial failure.** When an
upstream item's apply succeeds but its triggered downstream action
fails, subsequent applies can't recover via the trigger chain —
upstream is "already in desired state" and never re-triggers. For
actions that must self-heal (pip installs, chowns, migrations),
drop `triggered: True` and gate the command with `unless: <fast-check>`.
`unless` is a shell command on the target host whose exit status
decides whether the main command runs (exit 0 = skip); it's checked
at fire time, after `triggered:` filtering.
## Per-bundle README
Each bundle has (or should have) a `README.md`. One doc per bundle,
written for humans and agents both. There's no fixed structure —
match the bundle's actual surface, write what helps a future reader
(or future you) avoid trial-and-error.
The existing READMEs vary in quality and shape. For orientation,
look at the bigger ones, not the two-line ones:
- [`bundles/flask/README.md`](flask/README.md) — title + one-sentence
purpose, a metadata example as a Python dict, then the contract
the consuming git repo has to satisfy + a logging pitfall. The
closest thing to a "balanced doc" in tree.
- [`bundles/dm-crypt/README.md`](dm-crypt/README.md) — same shape,
shorter: purpose + metadata example + one sentence on effect.
- [`bundles/apt/README.md`](apt/README.md) — relevant upstream URLs
at the top, then a Python metadata example with rich inline
comments (type / optionality / where keys come from).
- [`bundles/nextcloud/README.md`](nextcloud/README.md) — operational
scratchpad: iPhone-import recipe, preview-generator commands,
reset queries. Captures muscle-memory the maintainer would
otherwise re-learn each time.
Useful things to include, when relevant:
- A sentence or two on what the bundle does and when you'd attach it.
- A metadata example as a Python dict literal, with `#` comments
on each key (type, required vs default, units, where it comes
from). This is the cleanest way to communicate the schema and
matches how `metadata.py` actually looks.
- Anything non-obvious about wiring it up — required keys without
defaults, group-membership expectations, manual one-time steps.
- Cross-namespace metadata writes, when this bundle's reactors
populate another bundle's namespace. Easy to miss, cheap to flag.
- Gotchas, debug recipes, failure modes you've actually hit.
What to skip:
- An exhaustive item list — `items.py` is shorter and more accurate.
- Anything that would just rot — version numbers, "TODO" lists,
change notes. Use git history.
If a single paragraph is enough to say what's worth saying, write a
single paragraph. Verbosity isn't a goal.
Convention going forward is leave-as-you-go: any time you materially
edit a bundle, top up its README (or write one if it's missing).
Don't burn a session bulk-reformatting the existing ones — uneven
quality is part of what we accept in exchange for not blocking other
work.
## See also
- [`docs/agents/conventions.md`](../docs/agents/conventions.md) — repo
idioms (vault, demagify, naming, do-not-touch list).
- [`docs/agents/commands.md`](../docs/agents/commands.md) — repo-specific
command deltas.
- [`items/AGENTS.md`](../items/AGENTS.md) — custom item types
(`download:`); when to write a new one vs use `file:`.
- [`libs/AGENTS.md`](../libs/AGENTS.md) — shared helpers.
- Fork's [`AGENTS.md`](https://github.com/CroneKorkN/bundlewrap/blob/main/AGENTS.md)
— bundlewrap-language reference + safety envelope.

View file

@ -13,16 +13,14 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': { 'inputs': {
'inputs': { 'exec': {
'exec': { 'apcupsd': {
repo.libs.hashable.hashable({ 'commands': ["sudo /usr/local/share/telegraf/apcupsd"],
'commands': ["sudo /usr/local/share/telegraf/apcupsd"], 'name_override': "apcupsd",
'name_override': "apcupsd", 'data_format': "influx",
'data_format': "influx", 'interval': '30s',
'interval': '30s', 'flush_interval': '30s',
'flush_interval': '30s',
}),
}, },
}, },
}, },

View file

@ -13,6 +13,9 @@
'deb', 'deb',
'deb-src', 'deb-src',
}, },
'options': { # optional
'aarch': 'amd64',
},
'urls': { 'urls': {
'https://deb.debian.org/debian', 'https://deb.debian.org/debian',
}, },

View file

@ -62,6 +62,7 @@ files = {
'/usr/lib/nagios/plugins/check_apt_upgradable': { '/usr/lib/nagios/plugins/check_apt_upgradable': {
'mode': '0755', 'mode': '0755',
}, },
# /etc/kernel/postinst.d/apt-auto-removal
} }
actions = { actions = {

View file

@ -4,6 +4,8 @@ defaults = {
'apt-listchanges': { 'apt-listchanges': {
'installed': False, 'installed': False,
}, },
'ca-certificates': {},
'unattended-upgrades': {},
}, },
'config': { 'config': {
'DPkg': { 'DPkg': {
@ -21,6 +23,10 @@ defaults = {
}, },
}, },
'APT': { 'APT': {
'Periodic': {
'Update-Package-Lists': '1',
'Unattended-Upgrade': '1',
},
'NeverAutoRemove': { 'NeverAutoRemove': {
'^firmware-linux.*', '^firmware-linux.*',
'^linux-firmware$', '^linux-firmware$',
@ -48,6 +54,11 @@ defaults = {
'Error-Mode': 'any', 'Error-Mode': 'any',
}, },
}, },
'Unattended-Upgrade': {
'Origins-Pattern': {
"origin=*",
},
},
}, },
'sources': {}, 'sources': {},
}, },
@ -106,33 +117,6 @@ def signed_by(metadata):
} }
@metadata_reactor.provides(
'apt/config',
'apt/packages',
)
def unattended_upgrades(metadata):
return {
'apt': {
'config': {
'APT': {
'Periodic': {
'Update-Package-Lists': '1',
'Unattended-Upgrade': '1',
},
},
'Unattended-Upgrade': {
'Origins-Pattern': {
"origin=*",
},
},
},
'packages': {
'unattended-upgrades': {},
},
},
}
# @metadata_reactor.provides( # @metadata_reactor.provides(
# 'apt/config', # 'apt/config',
# 'apt/list_changes', # 'apt/list_changes',

View file

@ -1,13 +1,31 @@
#!/bin/bash #!/bin/bash
set -exu set -u
# FIXME: inelegant # FIXME: inelegant
% if wol_command: % if wol_command:
${wol_command} ${wol_command}
% endif % endif
exit=0
failed_paths=""
for path in $(jq -r '.paths | .[]' < /etc/backup/config.json) for path in $(jq -r '.paths | .[]' < /etc/backup/config.json)
do do
echo backing up $path
/opt/backup/backup_path "$path" /opt/backup/backup_path "$path"
# set exit to 1 if any backup fails
if [ $? -ne 0 ]
then
echo ERROR: backing up $path failed >&2
exit=5
failed_paths="$failed_paths $path"
fi
done done
if [ $exit -ne 0 ]
then
echo "ERROR: failed to backup paths: $failed_paths" >&2
fi
exit $exit

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
set -exu set -eu
path=$1 path=$1
uuid=$(jq -r .client_uuid < /etc/backup/config.json) uuid=$(jq -r .client_uuid < /etc/backup/config.json)

View file

@ -33,6 +33,7 @@ def acme_zone(metadata):
str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip) str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip)
for other_node in repo.nodes for other_node in repo.nodes
if other_node.metadata.get('letsencrypt/domains', {}) if other_node.metadata.get('letsencrypt/domains', {})
and other_node.metadata.get('network/internal/ipv4', None)
}, },
*{ *{
str(ip_interface(other_node.metadata.get('wireguard/my_ip')).ip) str(ip_interface(other_node.metadata.get('wireguard/my_ip')).ip)

30
bundles/bind/README.md Normal file
View file

@ -0,0 +1,30 @@
# bind
Authoritative DNS — primary plus optional `bind/master_node` slaves.
## Applying changes needs both nodes
The slave's bw-managed zone files are rendered from the master's
metadata at slave-apply time (see `bundles/bind/items.py:100`). When
you change a record on the master (adding a `letsencrypt/domains`
entry, a new vhost, etc.), the change is only published once you
apply BOTH:
```sh
bw apply htz.mails # primary (where the source records live)
bw apply ovh.secondary # secondary (renders its own zone files)
```
Until both have been applied, `bw verify ovh.secondary` will show
stale zones and consumers that hit the secondary (Let's Encrypt's
secondary-region validators in particular) will see NXDOMAIN. Even
though the slave's named.conf.local declares `type slave;`, don't
rely on bind's own AXFR catching up — the bw-rendered file on disk
is what `bw verify` measures.
## See also
- `bundles/bind-acme/` — the in-house ACME-update receiver.
- `bundles/letsencrypt/README.md` — DNS-01 prerequisites and the
negative-cache penalty (the most common operational consequence
of forgetting to apply the secondary).

View file

@ -0,0 +1,8 @@
$TTL 86400
@ IN SOA localhost. root.localhost. (
1 ; Serial
604800 ; Refresh
86400 ; Retry
2419200 ; Expire
86400 ) ; Negative Cache TTL
IN NS localhost.

View file

@ -29,6 +29,7 @@ view "${view_name}" {
% if view_conf['is_internal']: % if view_conf['is_internal']:
recursion yes; recursion yes;
include "/etc/bind/zones.rfc1918";
% else: % else:
recursion no; recursion no;
rate-limit { rate-limit {
@ -62,9 +63,6 @@ view "${view_name}" {
file "/var/lib/bind/${view_name}/${zone_name}"; file "/var/lib/bind/${view_name}/${zone_name}";
}; };
% endfor % endfor
include "/etc/bind/named.conf.default-zones";
include "/etc/bind/zones.rfc1918";
}; };
% endfor % endfor

View file

@ -10,7 +10,7 @@ options {
% if type == 'master': % if type == 'master':
notify yes; notify yes;
also-notify { ${' '.join([f'{ip};' for ip in slave_ips])} }; also-notify { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} };
allow-transfer { ${' '.join([f'{ip};' for ip in slave_ips])} }; allow-transfer { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} };
% endif % endif
}; };

View file

@ -0,0 +1,19 @@
zone "10.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "16.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "17.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "18.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "19.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "20.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "21.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "22.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "23.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "24.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "25.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "26.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "27.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "28.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "29.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "30.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "31.172.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "168.192.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };
zone "254.169.in-addr.arpa" { type master; file "/etc/bind/db.empty"; };

View file

@ -142,3 +142,21 @@ actions['named-checkconf'] = {
'svc_systemd:bind9:reload', 'svc_systemd:bind9:reload',
] ]
} }
# beantwortet Anfragen nach privaten IP-Adressen mit NXDOMAIN, statt sie ins Internet weiterzuleiten
files['/etc/bind/zones.rfc1918'] = {
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
files['/etc/bind/db.empty'] = {
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}

View file

@ -3,6 +3,7 @@ from json import dumps
h = repo.libs.hashable.hashable h = repo.libs.hashable.hashable
repo.libs.bind.repo = repo repo.libs.bind.repo = repo
defaults = { defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
@ -48,13 +49,13 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': { 'inputs': {
'inputs': { 'bind': {
'bind': [{ 'default': {
'urls': ['http://localhost:8053/xml/v3'], 'urls': ['http://localhost:8053/xml/v3'],
'gather_memory_contexts': False, 'gather_memory_contexts': False,
'gather_views': True, 'gather_views': True,
}], },
}, },
}, },
}, },
@ -211,7 +212,7 @@ def generate_keys(metadata):
'token':repo.libs.hmac.hmac_sha512( 'token':repo.libs.hmac.hmac_sha512(
key, key,
str(repo.vault.random_bytes_as_base64_for( str(repo.vault.random_bytes_as_base64_for(
f"{metadata.get('id')} bind key {key}", f"{metadata.get('id')} bind key {key} 20250713",
length=32, length=32,
)), )),
) )

165
bundles/bootshorn/files/process Executable file
View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
import os
import datetime
import numpy as np
import matplotlib.pyplot as plt
import soundfile as sf
from scipy.fft import rfft, rfftfreq
import shutil
import traceback
RECORDINGS_DIR = "recordings"
PROCESSED_RECORDINGS_DIR = "recordings/processed"
DETECTIONS_DIR = "events"
DETECT_FREQUENCY = 211 # Hz
DETECT_FREQUENCY_TOLERANCE = 2 # Hz
ADJACENCY_FACTOR = 2 # area to look for the frequency (e.g. 2 means 100Hz to 400Hz for 200Hz detection)
BLOCK_SECONDS = 3 # seconds (longer means more frequency resolution, but less time resolution)
DETECTION_DISTANCE_SECONDS = 30 # seconds (minimum time between detections)
BLOCK_OVERLAP_FACTOR = 0.9 # overlap between blocks (0.2 means 20% overlap)
MIN_SIGNAL_QUALITY = 1000.0 # maximum noise level (relative DB) to consider a detection valid
PLOT_PADDING_START_SECONDS = 2 # seconds (padding before and after the event in the plot)
PLOT_PADDING_END_SECONDS = 3 # seconds (padding before and after the event in the plot)
DETECTION_DISTANCE_BLOCKS = DETECTION_DISTANCE_SECONDS // BLOCK_SECONDS # number of blocks to skip after a detection
DETECT_FREQUENCY_FROM = DETECT_FREQUENCY - DETECT_FREQUENCY_TOLERANCE # Hz
DETECT_FREQUENCY_TO = DETECT_FREQUENCY + DETECT_FREQUENCY_TOLERANCE # Hz
def process_recording(filename):
print('processing', filename)
# get ISO 8601 nanosecond recording date from filename
date_string_from_filename = os.path.splitext(filename)[0]
recording_date = datetime.datetime.strptime(date_string_from_filename, "%Y-%m-%d_%H-%M-%S.%f%z")
# get data and metadata from recording
path = os.path.join(RECORDINGS_DIR, filename)
soundfile = sf.SoundFile(path)
samplerate = soundfile.samplerate
samples_per_block = int(BLOCK_SECONDS * samplerate)
overlapping_samples = int(samples_per_block * BLOCK_OVERLAP_FACTOR)
sample_num = 0
current_event = None
while sample_num < len(soundfile):
soundfile.seek(sample_num)
block = soundfile.read(frames=samples_per_block, dtype='float32', always_2d=False)
if len(block) == 0:
break
# calculate FFT
labels = rfftfreq(len(block), d=1/samplerate)
complex_amplitudes = rfft(block)
amplitudes = np.abs(complex_amplitudes)
# get the frequency with the highest amplitude within the search range
search_amplitudes = amplitudes[(labels >= DETECT_FREQUENCY_FROM/ADJACENCY_FACTOR) & (labels <= DETECT_FREQUENCY_TO*ADJACENCY_FACTOR)]
search_labels = labels[(labels >= DETECT_FREQUENCY_FROM/ADJACENCY_FACTOR) & (labels <= DETECT_FREQUENCY_TO*ADJACENCY_FACTOR)]
max_amplitude = max(search_amplitudes)
max_amplitude_index = np.argmax(search_amplitudes)
max_freq = search_labels[max_amplitude_index]
max_freq_detected = DETECT_FREQUENCY_FROM <= max_freq <= DETECT_FREQUENCY_TO
# calculate signal quality
adjacent_amplitudes = amplitudes[(labels < DETECT_FREQUENCY_FROM) | (labels > DETECT_FREQUENCY_TO)]
signal_quality = max_amplitude/np.mean(adjacent_amplitudes)
good_signal_quality = signal_quality > MIN_SIGNAL_QUALITY
# conclude detection
if (
max_freq_detected and
good_signal_quality
):
block_date = recording_date + datetime.timedelta(seconds=sample_num / samplerate)
# detecting an event
if not current_event:
current_event = {
'start_at': block_date,
'end_at': block_date,
'start_sample': sample_num,
'end_sample': sample_num + samples_per_block,
'start_freq': max_freq,
'end_freq': max_freq,
'max_amplitude': max_amplitude,
}
else:
current_event.update({
'end_at': block_date,
'end_freq': max_freq,
'end_sample': sample_num + samples_per_block,
'max_amplitude': max(max_amplitude, current_event['max_amplitude']),
})
print(f'- {block_date.strftime('%Y-%m-%d %H:%M:%S')}: {max_amplitude:.1f}rDB @ {max_freq:.1f}Hz (signal {signal_quality:.3f}x)')
else:
# not detecting an event
if current_event:
duration = (current_event['end_at'] - current_event['start_at']).total_seconds()
current_event['duration'] = duration
print(f'🔊 {current_event['start_at'].strftime('%Y-%m-%d %H:%M:%S')} ({duration:.1f}s): {current_event['start_freq']:.1f}Hz->{current_event['end_freq']:.1f}Hz @{current_event['max_amplitude']:.0f}rDB')
# read full audio clip again for writing
write_event(current_event=current_event, soundfile=soundfile, samplerate=samplerate)
current_event = None
sample_num += DETECTION_DISTANCE_BLOCKS * samples_per_block
sample_num += samples_per_block - overlapping_samples
# move to PROCESSED_RECORDINGS_DIR
os.makedirs(PROCESSED_RECORDINGS_DIR, exist_ok=True)
shutil.move(os.path.join(RECORDINGS_DIR, filename), os.path.join(PROCESSED_RECORDINGS_DIR, filename))
# write a spectrogram using the sound from start to end of the event
def write_event(current_event, soundfile, samplerate):
# date and filename
event_date = current_event['start_at'] - datetime.timedelta(seconds=PLOT_PADDING_START_SECONDS)
filename_prefix = event_date.strftime('%Y-%m-%d_%H-%M-%S.%f%z')
# event clip
event_start_sample = current_event['start_sample'] - samplerate * PLOT_PADDING_START_SECONDS
event_end_sample = current_event['end_sample'] + samplerate * PLOT_PADDING_END_SECONDS
total_samples = event_end_sample - event_start_sample
soundfile.seek(event_start_sample)
event_clip = soundfile.read(frames=total_samples, dtype='float32', always_2d=False)
# write flac
flac_path = os.path.join(DETECTIONS_DIR, f"{filename_prefix}.flac")
sf.write(flac_path, event_clip, samplerate, format='FLAC')
# write spectrogram
plt.figure(figsize=(8, 6))
plt.specgram(event_clip, Fs=samplerate, NFFT=samplerate, noverlap=samplerate//2, cmap='inferno', vmin=-100, vmax=-10)
plt.title(f"Bootshorn @{event_date.strftime('%Y-%m-%d %H:%M:%S%z')}")
plt.xlabel(f"Time {current_event['duration']:.1f}s")
plt.ylabel(f"Frequency {current_event['start_freq']:.1f}Hz -> {current_event['end_freq']:.1f}Hz")
plt.colorbar(label="Intensity (rDB)")
plt.ylim(50, 1000)
plt.savefig(os.path.join(DETECTIONS_DIR, f"{filename_prefix}.png"))
plt.close()
def main():
os.makedirs(RECORDINGS_DIR, exist_ok=True)
os.makedirs(PROCESSED_RECORDINGS_DIR, exist_ok=True)
for filename in sorted(os.listdir(RECORDINGS_DIR)):
if filename.endswith(".flac"):
try:
process_recording(filename)
except Exception as e:
print(f"Error processing {filename}: {e}")
# print stacktrace
traceback.print_exc()
if __name__ == "__main__":
main()

25
bundles/bootshorn/files/record Executable file
View file

@ -0,0 +1,25 @@
#!/bin/sh
mkdir -p recordings
while true
do
# get date in ISO 8601 format with nanoseconds
PROGRAMM=$(test $(uname) = "Darwin" && echo "gdate" || echo "date")
DATE=$($PROGRAMM "+%Y-%m-%d_%H-%M-%S.%6N%z")
# record audio using ffmpeg
ffmpeg \
-y \
-f pulse \
-i "alsa_input.usb-HANMUS_USB_AUDIO_24BIT_2I2O_1612310-00.analog-stereo" \
-ac 1 \
-ar 96000 \
-sample_fmt s32 \
-t "3600" \
-c:a flac \
-compression_level 12 \
"recordings/current/$DATE.flac"
mv "recordings/current/$DATE.flac" "recordings/$DATE.flac"
done

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
import requests
import urllib3
import datetime
import csv
urllib3.disable_warnings()
import os
HUE_IP = "${hue_ip}" # replace with your bridge IP
HUE_APP_KEY = "${hue_app_key}" # local only
HUE_DEVICE_ID = "31f58786-3242-4e88-b9ce-23f44ba27bbe"
TEMPERATURE_LOG_DIR = "/opt/bootshorn/temperatures"
response = requests.get(
f"https://{HUE_IP}/clip/v2/resource/temperature",
headers={"hue-application-key": HUE_APP_KEY},
verify=False,
)
response.raise_for_status()
data = response.json()
for item in data["data"]:
if item["id"] == HUE_DEVICE_ID:
temperature = item["temperature"]["temperature"]
temperature_date_string = item["temperature"]["temperature_report"]["changed"]
temperature_date = datetime.datetime.fromisoformat(temperature_date_string).astimezone(datetime.timezone.utc)
break
print(f"@{temperature_date}: {temperature}°C")
filename = temperature_date.strftime("%Y-%m-%d_00-00-00.000000%z") + ".log"
logpath = os.path.join(TEMPERATURE_LOG_DIR, filename)
now_utc = datetime.datetime.now(datetime.timezone.utc)
with open(logpath, "a+", newline="") as logfile:
writer = csv.writer(logfile)
writer.writerow([
now_utc.strftime('%Y-%m-%d_%H-%M-%S.%f%z'), # current UTC time
temperature_date.strftime('%Y-%m-%d_%H-%M-%S.%f%z'), # date of temperature reading
temperature,
])

View file

@ -0,0 +1,61 @@
# nano /etc/selinux/config
# SELINUX=disabled
# reboot
directories = {
'/opt/bootshorn': {
'owner': 'ckn',
'group': 'ckn',
},
'/opt/bootshorn/temperatures': {
'owner': 'ckn',
'group': 'ckn',
},
'/opt/bootshorn/recordings': {
'owner': 'ckn',
'group': 'ckn',
},
'/opt/bootshorn/recordings/current': {
'owner': 'ckn',
'group': 'ckn',
},
'/opt/bootshorn/recordings/processed': {
'owner': 'ckn',
'group': 'ckn',
},
'/opt/bootshorn/events': {
'owner': 'ckn',
'group': 'ckn',
},
}
files = {
'/opt/bootshorn/record': {
'owner': 'ckn',
'group': 'ckn',
'mode': '755',
},
'/opt/bootshorn/temperature': {
'content_type': 'mako',
'context': {
'hue_ip': repo.get_node('home.hue').hostname,
'hue_app_key': repo.vault.decrypt('encrypt$gAAAAABoc2WxZCLbxl-Z4IrSC97CdOeFgBplr9Fp5ujpd0WCCCPNBUY_WquHN86z8hKLq5Y04dwq8TdJW0PMSOSgTFbGgdp_P1q0jOBLEKaW9IIT1YM88h-JYwLf9QGDV_5oEfvnBCtO'),
},
'owner': 'ckn',
'group': 'ckn',
'mode': '755',
},
'/opt/bootshorn/process': {
'owner': 'ckn',
'group': 'ckn',
'mode': '755',
},
}
svc_systemd = {
'bootshorn-record.service': {
'needs': {
'file:/opt/bootshorn/record',
},
},
}

View file

@ -0,0 +1,44 @@
defaults = {
'systemd': {
'units': {
'bootshorn-record.service': {
'Unit': {
'Description': 'Bootshorn Recorder',
'After': 'network.target',
},
'Service': {
'User': 'ckn',
'Group': 'ckn',
'Type': 'simple',
'WorkingDirectory': '/opt/bootshorn',
'ExecStart': '/opt/bootshorn/record',
'Restart': 'always',
'RestartSec': 5,
'Environment': {
"XDG_RUNTIME_DIR": "/run/user/1000",
"PULSE_SERVER": "unix:/run/user/1000/pulse/native",
},
},
},
},
},
'systemd-timers': {
'bootshorn-temperature': {
'command': '/opt/bootshorn/temperature',
'when': '*:0/10',
'working_dir': '/opt/bootshorn',
'user': 'ckn',
'group': 'ckn',
},
# 'bootshorn-process': {
# 'command': '/opt/bootshorn/process',
# 'when': 'hourly',
# 'working_dir': '/opt/bootshorn',
# 'user': 'ckn',
# 'group': 'ckn',
# 'after': {
# 'bootshorn-process.service',
# },
# },
},
}

View file

@ -27,7 +27,7 @@ def ssh_keys(metadata):
'users': { 'users': {
'build-agent': { 'build-agent': {
'authorized_users': { 'authorized_users': {
f'build-server@{other_node.name}' f'build-server@{other_node.name}': {}
for other_node in repo.nodes for other_node in repo.nodes
if other_node.has_bundle('build-server') if other_node.has_bundle('build-server')
for architecture in other_node.metadata.get('build-server/architectures').values() for architecture in other_node.metadata.get('build-server/architectures').values()

View file

@ -14,7 +14,7 @@ def ssh_keys(metadata):
'users': { 'users': {
'build-ci': { 'build-ci': {
'authorized_users': { 'authorized_users': {
f'build-server@{other_node.name}' f'build-server@{other_node.name}': {}
for other_node in repo.nodes for other_node in repo.nodes
if other_node.has_bundle('build-server') if other_node.has_bundle('build-server')
}, },

View file

@ -8,6 +8,7 @@ defaults = {
'sources': { 'sources': {
'crystal': { 'crystal': {
# https://software.opensuse.org/download.html?project=devel%3Alanguages%3Acrystal&package=crystal # https://software.opensuse.org/download.html?project=devel%3Alanguages%3Acrystal&package=crystal
# curl -fsSL https://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/Release.key
'urls': { 'urls': {
'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/', 'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/',
}, },

View file

@ -1,17 +0,0 @@
connect = host=${host} dbname=${name} user=${user} password=${password}
driver = pgsql
default_pass_scheme = ARGON2ID
user_query = SELECT '/var/vmail/%u' AS home, 'vmail' AS uid, 'vmail' AS gid
iterate_query = SELECT CONCAT(users.name, '@', domains.name) AS user \
FROM users \
LEFT JOIN domains ON users.domain_id = domains.id \
WHERE redirect IS NULL
password_query = SELECT CONCAT(users.name, '@', domains.name) AS user, password \
FROM users \
LEFT JOIN domains ON users.domain_id = domains.id \
WHERE redirect IS NULL \
AND users.name = SPLIT_PART('%u', '@', 1) \
AND domains.name = SPLIT_PART('%u', '@', 2)

View file

@ -1,13 +1,17 @@
dovecot_config_version = ${config_version}
dovecot_storage_version = ${storage_version}
protocols = imap lmtp sieve protocols = imap lmtp sieve
auth_mechanisms = plain login auth_mechanisms = plain login
mail_privileged_group = mail
ssl = required ssl = required
ssl_cert = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/fullchain.pem ssl_server_cert_file = /var/lib/dehydrated/certs/${hostname}/fullchain.pem
ssl_key = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/privkey.pem ssl_server_key_file = /var/lib/dehydrated/certs/${hostname}/privkey.pem
ssl_dh = </etc/dovecot/dhparam.pem ssl_server_dh_file = /etc/dovecot/dhparam.pem
ssl_client_ca_dir = /etc/ssl/certs ssl_client_ca_dir = /etc/ssl/certs
mail_location = maildir:${node.metadata.get('mailserver/maildir')}/%u:INDEX=${node.metadata.get('mailserver/maildir')}/index/%u mail_driver = maildir
mail_plugins = fts fts_xapian mail_path = ${maildir}/%{user}
mail_index_path = ${maildir}/index/%{user}
mail_plugins = fts fts_flatcurve
namespace inbox { namespace inbox {
inbox = yes inbox = yes
@ -30,14 +34,46 @@ namespace inbox {
} }
} }
passdb { # postgres passdb userdb
driver = sql
args = /etc/dovecot/dovecot-sql.conf sql_driver = pgsql
pgsql main {
parameters {
host = ${db_host}
dbname = ${db_name}
user = ${db_user}
password = ${db_password}
}
} }
# use sql for userdb too, to enable iterate_query
userdb { passdb sql {
driver = sql passdb_default_password_scheme = ARGON2ID
args = /etc/dovecot/dovecot-sql.conf
query = SELECT \
CONCAT(users.name, '@', domains.name) AS "user", \
password \
FROM users \
LEFT JOIN domains ON users.domain_id = domains.id \
WHERE redirect IS NULL \
AND users.name = SPLIT_PART('%{user}', '@', 1) \
AND domains.name = SPLIT_PART('%{user}', '@', 2)
}
mail_uid = vmail
mail_gid = vmail
userdb sql {
query = SELECT \
'/var/vmail/%{user}' AS home, \
'vmail' AS uid, \
'vmail' AS gid
iterate_query = SELECT \
CONCAT(users.name, '@', domains.name) AS username \
FROM users \
LEFT JOIN domains ON users.domain_id = domains.id \
WHERE redirect IS NULL
} }
service auth { service auth {
@ -67,10 +103,9 @@ service stats {
} }
} }
service managesieve-login { service managesieve-login {
inet_listener sieve { #inet_listener sieve {}
} process_min_avail = 1
process_min_avail = 0 process_limit = 1
service_count = 1
vsz_limit = 64 M vsz_limit = 64 M
} }
service managesieve { service managesieve {
@ -78,31 +113,53 @@ service managesieve {
} }
protocol imap { protocol imap {
mail_plugins = $mail_plugins imap_sieve mail_plugins = fts fts_flatcurve imap_sieve
mail_max_userip_connections = 50 mail_max_userip_connections = 50
imap_idle_notify_interval = 29 mins imap_idle_notify_interval = 29 mins
} }
protocol lmtp { protocol lmtp {
mail_plugins = $mail_plugins sieve mail_plugins = fts fts_flatcurve sieve
} }
protocol sieve {
plugin { # Persönliches Skript (deine alte Datei /var/vmail/sieve/%u.sieve)
sieve = /var/vmail/sieve/%u.sieve sieve_script personal {
sieve_storage = /var/vmail/sieve/%u/ driver = file
} # Verzeichnis mit (evtl. mehreren) Sieve-Skripten des Users
path = /var/vmail/sieve/%{user}/
# Aktives Skript (entspricht früher "sieve = /var/vmail/sieve/%u.sieve")
active_path = /var/vmail/sieve/%{user}.sieve
}
# Globales After-Skript (dein früheres "sieve_after = …")
sieve_script after {
type = after
driver = file
path = /var/vmail/sieve/global/spam-to-folder.sieve
} }
# fulltext search # fulltext search
plugin { language en {
fts = xapian
fts_xapian = partial=3 full=20 verbose=0
fts_autoindex = yes
fts_enforced = yes
# Index attachements
fts_decoder = decode2text
} }
language de {
default = yes
}
language_tokenizers = generic email-address
fts flatcurve {
substring_search = yes
# rotate_count = 5000 # DB-Rotation nach X Mails
# rotate_time = 5s # oder zeitbasiert rotieren
# optimize_limit = 10
# min_term_size = 3
}
fts_autoindex = yes
fts_decoder_driver = script
fts_decoder_script_socket_path = decode2text
service indexer-worker { service indexer-worker {
vsz_limit = ${indexer_ram} process_limit = ${indexer_cores}
vsz_limit = ${indexer_ram}M
} }
service decode2text { service decode2text {
executable = script /usr/local/libexec/dovecot/decode2text.sh executable = script /usr/local/libexec/dovecot/decode2text.sh
@ -112,24 +169,39 @@ service decode2text {
} }
} }
# spam filter mailbox Junk {
plugin { sieve_script learn_spam {
sieve_plugins = sieve_imapsieve sieve_extprograms driver = file
sieve_dir = /var/vmail/sieve/%u/ type = before
sieve = /var/vmail/sieve/%u.sieve cause = copy
sieve_pipe_bin_dir = /var/vmail/sieve/bin path = /var/vmail/sieve/global/learn-spam.sieve
sieve_extensions = +vnd.dovecot.pipe }
sieve_after = /var/vmail/sieve/global/spam-to-folder.sieve
# From elsewhere to Spam folder
imapsieve_mailbox1_name = Junk
imapsieve_mailbox1_causes = COPY
imapsieve_mailbox1_before = file:/var/vmail/sieve/global/learn-spam.sieve
# From Spam folder to elsewhere
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = Junk
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:/var/vmail/sieve/global/learn-ham.sieve
} }
imapsieve_from Junk {
sieve_script learn_ham {
driver = file
type = before
cause = copy
path = /var/vmail/sieve/global/learn-ham.sieve
}
}
# Extprograms-Plugin einschalten
sieve_plugins {
sieve_extprograms = yes
}
# Welche Sieve-Erweiterungen dürfen genutzt werden?
# Empfehlung: nur global erlauben (nicht in User-Skripten):
sieve_global_extensions {
vnd.dovecot.pipe = yes
# vnd.dovecot.filter = yes # nur falls gebraucht
# vnd.dovecot.execute = yes # nur falls gebraucht
}
# Verzeichnis mit deinen Skripten/Binaries für :pipe
sieve_pipe_bin_dir = /var/vmail/sieve/bin
# (optional, analog für :filter / :execute)
# sieve_filter_bin_dir = /var/vmail/sieve/filter
# sieve_execute_bin_dir = /var/vmail/sieve/execute

View file

@ -44,6 +44,16 @@ files = {
'context': { 'context': {
'admin_email': node.metadata.get('mailserver/admin_email'), 'admin_email': node.metadata.get('mailserver/admin_email'),
'indexer_ram': node.metadata.get('dovecot/indexer_ram'), 'indexer_ram': node.metadata.get('dovecot/indexer_ram'),
'config_version': node.metadata.get('dovecot/config_version'),
'storage_version': node.metadata.get('dovecot/storage_version'),
'maildir': node.metadata.get('mailserver/maildir'),
'hostname': node.metadata.get('mailserver/hostname'),
'db_host': node.metadata.get('mailserver/database/host'),
'db_name': node.metadata.get('mailserver/database/name'),
'db_user': node.metadata.get('mailserver/database/user'),
'db_password': node.metadata.get('mailserver/database/password'),
'indexer_cores': node.metadata.get('vm/cores'),
'indexer_ram': node.metadata.get('vm/ram')//2,
}, },
'needs': { 'needs': {
'pkg_apt:' 'pkg_apt:'
@ -52,29 +62,9 @@ files = {
'svc_systemd:dovecot:restart', 'svc_systemd:dovecot:restart',
}, },
}, },
'/etc/dovecot/dovecot-sql.conf': {
'content_type': 'mako',
'context': node.metadata.get('mailserver/database'),
'needs': {
'pkg_apt:'
},
'triggers': {
'svc_systemd:dovecot:restart',
},
},
'/etc/dovecot/dhparam.pem': { '/etc/dovecot/dhparam.pem': {
'content_type': 'any', 'content_type': 'any',
}, },
'/etc/dovecot/dovecot-sql.conf': {
'content_type': 'mako',
'context': node.metadata.get('mailserver/database'),
'needs': {
'pkg_apt:'
},
'triggers': {
'svc_systemd:dovecot:restart',
},
},
'/var/vmail/sieve/global/spam-to-folder.sieve': { '/var/vmail/sieve/global/spam-to-folder.sieve': {
'owner': 'vmail', 'owner': 'vmail',
'group': 'vmail', 'group': 'vmail',
@ -131,7 +121,6 @@ svc_systemd = {
'action:letsencrypt_update_certificates', 'action:letsencrypt_update_certificates',
'action:dovecot_generate_dhparam', 'action:dovecot_generate_dhparam',
'file:/etc/dovecot/dovecot.conf', 'file:/etc/dovecot/dovecot.conf',
'file:/etc/dovecot/dovecot-sql.conf',
}, },
}, },
} }

View file

@ -8,7 +8,7 @@ defaults = {
'dovecot-sieve': {}, 'dovecot-sieve': {},
'dovecot-managesieved': {}, 'dovecot-managesieved': {},
# fulltext search # fulltext search
'dovecot-fts-xapian': {}, # buster-backports 'dovecot-flatcurve': {}, # buster-backports
'poppler-utils': {}, # pdftotext 'poppler-utils': {}, # pdftotext
'catdoc': {}, # catdoc, catppt, xls2csv 'catdoc': {}, # catdoc, catppt, xls2csv
}, },

View file

@ -5,6 +5,11 @@ defaults = {
'needs': { 'needs': {
'zfs_dataset:tank/downloads' 'zfs_dataset:tank/downloads'
}, },
'authorized_users': {
f'build-server@{other_node.name}': {}
for other_node in repo.nodes
if other_node.has_bundle('build-server')
},
}, },
}, },
'zfs': { 'zfs': {
@ -14,21 +19,13 @@ defaults = {
}, },
}, },
}, },
} 'systemd-mount': {
'/var/lib/downloads_nginx': {
'source': '/var/lib/downloads',
@metadata_reactor.provides( 'user': 'www-data',
'systemd-mount'
)
def mount_certs(metadata):
return {
'systemd-mount': {
'/var/lib/downloads_nginx': {
'source': '/var/lib/downloads',
'user': 'www-data',
},
}, },
} },
}
@metadata_reactor.provides( @metadata_reactor.provides(
@ -47,20 +44,3 @@ def nginx(metadata):
}, },
}, },
} }
@metadata_reactor.provides(
'users/downloads/authorized_users',
)
def ssh_keys(metadata):
return {
'users': {
'downloads': {
'authorized_users': {
f'build-server@{other_node.name}'
for other_node in repo.nodes
if other_node.has_bundle('build-server')
},
},
},
}

View file

@ -43,11 +43,11 @@ def units(metadata):
'Service': { 'Service': {
'Environment': { 'Environment': {
f'{k}={v}' f'{k}={v}'
for k, v in conf.get('env', {}).items() for k, v in metadata.get(f'flask/{name}/env', {}).items()
}, },
'User': conf['user'], 'User': metadata.get(f'flask/{name}/user'),
'Group': conf['group'], 'Group': metadata.get(f'flask/{name}/group'),
'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {conf['workers']} -b 127.0.0.1:{conf['port']} --timeout {conf['timeout']} {conf['app_module']}:app" 'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {metadata.get(f'flask/{name}/workers')} -b 127.0.0.1:{metadata.get(f'flask/{name}/port')} --timeout {metadata.get(f'flask/{name}/timeout')} {metadata.get(f'flask/{name}/app_module')}:app"
}, },
'Install': { 'Install': {
'WantedBy': { 'WantedBy': {
@ -55,7 +55,7 @@ def units(metadata):
} }
}, },
} }
for name, conf in metadata.get('flask').items() for name in metadata.get('flask')
} }
} }
} }

View file

@ -11,3 +11,13 @@ Enter it again:
freescout=# freescout=#
\q \q
``` ```
# problems
# check if /opt/freescout/.env is resettet
# ckeck `psql -h localhost -d freescout -U freescout -W`with pw from .env
# chown -R www-data:www-data /opt/freescout
# sudo su - www-data -c 'php /opt/freescout/artisan freescout:clear-cache' -s /bin/bash
# javascript funny? `sudo su - www-data -c 'php /opt/freescout/artisan storage:link' -s /bin/bash`
# benutzer bilder weg? aus dem backup holen: `/opt/freescout/.zfs/snapshot/zfs-auto-snap_hourly-2024-11-22-1700/storage/app/public/users` `./customers`

View file

@ -12,33 +12,33 @@ directories = {
} }
actions = { actions = {
'clone_freescout': { # 'clone_freescout': {
'command': run_as('www-data', 'git clone https://github.com/freescout-helpdesk/freescout.git /opt/freescout'), # 'command': run_as('www-data', 'git clone https://github.com/freescout-helpdesk/freescout.git /opt/freescout'),
'unless': 'test -e /opt/freescout/.git', # 'unless': 'test -e /opt/freescout/.git',
'needs': [ # 'needs': [
'pkg_apt:git', # 'pkg_apt:git',
'directory:/opt/freescout', # 'directory:/opt/freescout',
], # ],
}, # },
'pull_freescout': { # 'pull_freescout': {
'command': run_as('www-data', 'git -C /opt/freescout fetch origin dist && git -C /opt/freescout reset --hard origin/dist && git -C /opt/freescout clean -f'), # 'command': run_as('www-data', 'git -C /opt/freescout fetch origin dist && git -C /opt/freescout reset --hard origin/dist && git -C /opt/freescout clean -f'),
'unless': run_as('www-data', 'git -C /opt/freescout fetch origin && git -C /opt/freescout status -uno | grep -q "Your branch is up to date"'), # 'unless': run_as('www-data', 'git -C /opt/freescout fetch origin && git -C /opt/freescout status -uno | grep -q "Your branch is up to date"'),
'needs': [ # 'needs': [
'action:clone_freescout', # 'action:clone_freescout',
], # ],
'triggers': [ # 'triggers': [
'action:freescout_artisan_update', # 'action:freescout_artisan_update',
f'svc_systemd:php{php_version}-fpm.service:restart', # f'svc_systemd:php{php_version}-fpm.service:restart',
], # ],
}, # },
'freescout_artisan_update': { # 'freescout_artisan_update': {
'command': run_as('www-data', 'php /opt/freescout/artisan freescout:after-app-update'), # 'command': run_as('www-data', 'php /opt/freescout/artisan freescout:after-app-update'),
'triggered': True, # 'triggered': True,
'needs': [ # 'needs': [
f'svc_systemd:php{php_version}-fpm.service:restart', # f'svc_systemd:php{php_version}-fpm.service:restart',
'action:pull_freescout', # 'action:pull_freescout',
], # ],
}, # },
} }
# svc_systemd = { # svc_systemd = {

View file

@ -40,7 +40,7 @@ ENABLE_OPENID_SIGNUP = false
[service] [service]
REGISTER_EMAIL_CONFIRM = true REGISTER_EMAIL_CONFIRM = true
ENABLE_NOTIFY_MAIL = true ENABLE_NOTIFY_MAIL = true
DISABLE_REGISTRATION = false DISABLE_REGISTRATION = true
ALLOW_ONLY_EXTERNAL_REGISTRATION = false ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false ENABLE_CAPTCHA = false
REQUIRE_SIGNIN_VIEW = false REQUIRE_SIGNIN_VIEW = false

View file

@ -49,7 +49,7 @@ files['/etc/gitea/app.ini'] = {
), ),
'owner': 'git', 'owner': 'git',
'mode': '0600', 'mode': '0600',
'context': node.metadata['gitea'], 'context': node.metadata.get('gitea'),
'triggers': { 'triggers': {
'svc_systemd:gitea:restart', 'svc_systemd:gitea:restart',
}, },

View file

@ -127,7 +127,7 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
panel['gridPos']['y'] = (row_id - 1) * panel['gridPos']['h'] panel['gridPos']['y'] = (row_id - 1) * panel['gridPos']['h']
if 'display_name' in panel_config: if 'display_name' in panel_config:
panel['fieldConfig']['defaults']['displayName'] = '${'+panel_config['display_name']+'}' panel['fieldConfig']['defaults']['displayName'] = panel_config['display_name']
if panel_config.get('stacked'): if panel_config.get('stacked'):
panel['fieldConfig']['defaults']['custom']['stacking']['mode'] = 'normal' panel['fieldConfig']['defaults']['custom']['stacking']['mode'] = 'normal'
@ -158,13 +158,14 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
host=monitored_node.name, host=monitored_node.name,
negative=query_config.get('negative', False), negative=query_config.get('negative', False),
boolean_to_int=query_config.get('boolean_to_int', False), boolean_to_int=query_config.get('boolean_to_int', False),
minimum=query_config.get('minimum', None), over=query_config.get('over', None),
filters={ filters={
'host': monitored_node.name, 'host': monitored_node.name,
**query_config['filters'], **query_config['filters'],
}, },
exists=query_config.get('exists', []), exists=query_config.get('exists', []),
function=query_config.get('function', None), function=query_config.get('function', None),
multiply=query_config.get('multiply', None),
).strip() ).strip()
}) })
@ -178,4 +179,3 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
] ]
} }

View file

@ -26,9 +26,15 @@ defaults = {
'config': { 'config': {
'server': { 'server': {
'http_port': 8300, 'http_port': 8300,
'http_addr': '127.0.0.1',
'enable_gzip': True,
}, },
'database': { 'database': {
'url': f'postgres://grafana:{postgres_password}@localhost:5432/grafana', 'type': 'postgres',
'host': '127.0.0.1:5432',
'name': 'grafana',
'user': 'grafana',
'password': postgres_password,
}, },
'remote_cache': { 'remote_cache': {
'type': 'redis', 'type': 'redis',
@ -133,11 +139,13 @@ def dns(metadata):
@metadata_reactor.provides( @metadata_reactor.provides(
'nginx/has_websockets',
'nginx/vhosts', 'nginx/vhosts',
) )
def nginx(metadata): def nginx(metadata):
return { return {
'nginx': { 'nginx': {
'has_websockets': True,
'vhosts': { 'vhosts': {
metadata.get('grafana/hostname'): { metadata.get('grafana/hostname'): {
'content': 'grafana/vhost.conf', 'content': 'grafana/vhost.conf',

View file

@ -2,7 +2,7 @@ files = {
'/usr/local/share/telegraf/cpu_frequency': { '/usr/local/share/telegraf/cpu_frequency': {
'mode': '0755', 'mode': '0755',
'triggers': { 'triggers': {
'svc_systemd:telegraf:restart', 'svc_systemd:telegraf.service:restart',
}, },
}, },
} }

View file

@ -14,25 +14,25 @@ defaults = {
}, },
}, },
'telegraf': { 'telegraf': {
'config': { 'inputs': {
'inputs': { 'sensors': {
'sensors': {repo.libs.hashable.hashable({ 'default': {
'timeout': '2s', 'timeout': '2s',
})},
'exec': {
repo.libs.hashable.hashable({
'commands': ["sudo /usr/local/share/telegraf/cpu_frequency"],
'name_override': "cpu_frequency",
'data_format': "influx",
}),
# repo.libs.hashable.hashable({
# 'commands': ["/bin/bash -c 'expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000'"],
# 'name_override': "cpu_temperature",
# 'data_format': "value",
# 'data_type': "integer",
# }),
}, },
}, },
'exec': {
'cpu_frequency': {
'commands': ["sudo /usr/local/share/telegraf/cpu_frequency"],
'name_override': "cpu_frequency",
'data_format': "influx",
},
# repo.libs.hashable.hashable({
# 'commands': ["/bin/bash -c 'expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000'"],
# 'name_override': "cpu_temperature",
# 'data_format': "value",
# 'data_type': "integer",
# }),
},
}, },
}, },
} }

View file

@ -1,23 +0,0 @@
https://github.com/home-assistant/supervised-installer?tab=readme-ov-file
https://github.com/home-assistant/os-agent/tree/main?tab=readme-ov-file#using-home-assistant-supervised-on-debian
https://docs.docker.com/engine/install/debian/
https://www.home-assistant.io/installation/linux#install-home-assistant-supervised
https://github.com/home-assistant/supervised-installer
https://github.com/home-assistant/architecture/blob/master/adr/0014-home-assistant-supervised.md
DATA_SHARE=/usr/share/hassio dpkg --force-confdef --force-confold -i homeassistant-supervised.deb
neu debian
ha installieren
gucken ob geht
dann bw drüberbügeln
https://www.home-assistant.io/integrations/http/#ssl_certificate
`wget "$(curl -L https://api.github.com/repos/home-assistant/supervised-installer/releases/latest | jq -r '.assets[0].browser_download_url')" -O homeassistant-supervised.deb && dpkg -i homeassistant-supervised.deb`

View file

@ -1,30 +0,0 @@
from shlex import quote
version = node.metadata.get('homeassistant/os_agent_version')
directories = {
'/usr/share/hassio': {},
}
actions = {
'install_os_agent': {
'command': ' && '.join([
f'wget -O /tmp/os-agent.deb https://github.com/home-assistant/os-agent/releases/download/{quote(version)}/os-agent_{quote(version)}_linux_aarch64.deb',
'DEBIAN_FRONTEND=noninteractive dpkg -i /tmp/os-agent.deb',
]),
'unless': f'test "$(apt -qq list os-agent | cut -d" " -f2)" = "{quote(version)}"',
'needs': {
'pkg_apt:',
'zfs_dataset:tank/homeassistant',
},
},
'install_homeassistant_supervised': {
'command': 'wget -O /tmp/homeassistant-supervised.deb https://github.com/home-assistant/supervised-installer/releases/latest/download/homeassistant-supervised.deb && apt install /tmp/homeassistant-supervised.deb',
'unless': 'apt -qq list homeassistant-supervised | grep -q "installed"',
'needs': {
'action:install_os_agent',
},
},
}

View file

@ -1,65 +0,0 @@
defaults = {
'apt': {
'packages': {
# homeassistant-supervised
'apparmor': {},
'bluez': {},
'cifs-utils': {},
'curl': {},
'dbus': {},
'jq': {},
'libglib2.0-bin': {},
'lsb-release': {},
'network-manager': {},
'nfs-common': {},
'systemd-journal-remote': {},
'systemd-resolved': {},
'udisks2': {},
'wget': {},
# docker
'docker-ce': {},
'docker-ce-cli': {},
'containerd.io': {},
'docker-buildx-plugin': {},
'docker-compose-plugin': {},
},
'sources': {
# docker: https://docs.docker.com/engine/install/debian/#install-using-the-repository
'docker': {
'urls': {
'https://download.docker.com/linux/debian',
},
'suites': {
'{codename}',
},
'components': {
'stable',
},
},
},
},
'zfs': {
'datasets': {
'tank/homeassistant': {
'mountpoint': '/usr/share/hassio',
'needed_by': {
'directory:/usr/share/hassio',
},
},
},
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx(metadata):
return {
'nginx': {
'vhosts': {
metadata.get('homeassistant/domain'): {
'content': 'homeassistant/vhost.conf',
},
},
},
}

View file

@ -179,6 +179,7 @@ def nginx(metadata):
'context': { 'context': {
'php_version': metadata.get('php/version'), 'php_version': metadata.get('php/version'),
}, },
'check_path': '/icingaweb2/index.php',
}, },
}, },
}, },

View file

@ -0,0 +1,3 @@
# svc_systemd = {
# 'ifupdown.service': {},
# }

View file

@ -39,6 +39,17 @@ defaults = {
}, },
} }
if node.has_bundle('zfs'):
defaults['zfs'] = {
'datasets': {
'tank/influxdb': {
'mountpoint': '/var/lib/influxdb',
'recordsize': '8192',
'atime': 'off',
},
},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'influxdb/password', 'influxdb/password',
'influxdb/admin_token', 'influxdb/admin_token',
@ -52,26 +63,6 @@ def admin_password(metadata):
} }
@metadata_reactor.provides(
'zfs/datasets',
)
def zfs(metadata):
if not node.has_bundle('zfs'):
return {}
return {
'zfs': {
'datasets': {
'tank/influxdb': {
'mountpoint': '/var/lib/influxdb',
'recordsize': '8192',
'atime': 'off',
},
},
},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'dns', 'dns',
) )

View file

@ -15,6 +15,7 @@ svc_systemd = {
'needs': [ 'needs': [
'pkg_apt:kea-dhcp4-server', 'pkg_apt:kea-dhcp4-server',
'file:/etc/kea/kea-dhcp4.conf', 'file:/etc/kea/kea-dhcp4.conf',
'svc_systemd:systemd-networkd.service:restart',
], ],
}, },
} }

View file

@ -52,13 +52,14 @@ def subnets(metadata):
if 'mac' in network_conf if 'mac' in network_conf
) )
for network_name, network_conf in metadata.get('network').items(): for id, (network_name, network_conf) in enumerate(sorted(metadata.get('network').items())):
dhcp_server_config = network_conf.get('dhcp_server_config', None) dhcp_server_config = network_conf.get('dhcp_server_config', None)
if dhcp_server_config: if dhcp_server_config:
_network = ip_network(dhcp_server_config['subnet']) _network = ip_network(dhcp_server_config['subnet'])
subnet4.add(hashable({ subnet4.add(hashable({
'id': id + 1,
'subnet': dhcp_server_config['subnet'], 'subnet': dhcp_server_config['subnet'],
'pools': [ 'pools': [
{ {
@ -72,7 +73,7 @@ def subnets(metadata):
}, },
{ {
'name': 'domain-name-servers', 'name': 'domain-name-servers',
'data': '10.0.10.2', 'data': '10.0.0.1',
}, },
], ],
'reservations': set( 'reservations': set(

View file

@ -1,58 +1,22 @@
https://developer.valvesoftware.com/wiki/List_of_L4D2_Cvars https://github.com/SirPlease/L4D2-Competitive-Rework/blob/master/Dedicated%20Server%20Install%20Guide/README.md
Dead Center c1m1_hotel ```python
Dead Center c1m2_streets 'tick60_maps': {
Dead Center c1m3_mall 'port': 27030,
Dead Center c1m4_atrium # add command line arguments
Dark Carnival c2m1_highway 'arguments': ['-tickrate 60'],
Dark Carnival c2m2_fairgrounds # stack overlays, first is uppermost
Dark Carnival c2m3_coaster 'overlays': ['tickrate', 'standard'],
Dark Carnival c2m4_barns # server.cfg contents
Dark Carnival c2m5_concert 'config': [
Swamp Fever c3m1_plankcountry # configs from overlays are accessible via server_${overlay}.cfg
Swamp Fever c3m2_swamp 'exec server_tickrate.cfg',
Swamp Fever c3m3_shantytown # add more options
Swamp Fever c3m4_plantation 'sv_minupdaterate 101',
Hard Rain c4m1_milltown_a 'sv_maxupdaterate 101',
Hard Rain c4m2_sugarmill_a 'sv_mincmdrate 101',
Hard Rain c4m3_sugarmill_b 'sv_maxcmdrate 101',
Hard Rain c4m4_milltown_b 'sv_consistency 0',
Hard Rain c4m5_milltown_escape ],
The Parish c5m1_waterfront_sndscape },
The Parish c5m1_waterfront ```
The Parish c5m2_park
The Parish c5m3_cemetery
The Parish c5m4_quarter
The Parish c5m5_bridge
The Passing c6m1_riverbank
The Passing c6m2_bedlam
The Passing c6m3_port
The Sacrifice c7m1_docks
The Sacrifice c7m2_barge
The Sacrifice c7m3_port
No Mercy c8m1_apartment
No Mercy c8m2_subway
No Mercy c8m3_sewers
No Mercy c8m4_interior
No Mercy c8m5_rooftop
Crash Course c9m1_alleys
Crash Course c9m2_lots
Death Toll c10m1_caves
Death Toll c10m2_drainage
Death Toll c10m3_ranchhouse
Death Toll c10m4_mainstreet
Death Toll c10m5_houseboat
Dead Air c11m1_greenhouse
Dead Air c11m2_offices
Dead Air c11m3_garage
Dead Air c11m4_terminal
Dead Air c11m5_runway
Blood Harvest c12m1_hilltop
Blood Harvest c12m2_traintunnel
Blood Harvest c12m3_bridge
Blood Harvest c12m4_barn
Blood Harvest c12m5_cornfield
Cold Stream c13m1_alpinecreek
Cold Stream c13m2_southpinestream
Cold Stream c13m3_memorialbridge
Cold Stream c13m4_cutthroatcreek

View file

@ -0,0 +1,13 @@
#!/bin/bash
set -xeuo pipefail
function steam() {
# for systemd, so it can terminate the process (for other things sudo would have been enough)
setpriv --reuid=steam --regid=steam --init-groups "$@" <&0
export HOME=/opt/l4d2/steam
}
function workshop() {
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/addons"
steam /opt/l4d2/scripts/steam-workshop-download --out "/opt/l4d2/overlays/${overlay}/left4dead2/addons" "$@"
}

View file

@ -0,0 +1,10 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# https://github.com/SirPlease/L4D2-Competitive-Rework
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2
test -d /opt/l4d2/overlays/$overlay/left4dead2/cfg/cfgogl || \
curl -L https://github.com/SirPlease/L4D2-Competitive-Rework/archive/refs/heads/master.tar.gz | steam tar -xz --strip-components=1 -C /opt/l4d2/overlays/$overlay/left4dead2

View file

@ -0,0 +1,128 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2/addons
cd /opt/l4d2/overlays/$overlay/left4dead2/addons
# https://l4d2center.com/maps/servers/l4d2center_maps_sync.sh.txt ->
# Exit immediately if a command exits with a non-zero status.
set -e
# Function to print error messages
error_exit() {
echo "Error: $1" >&2
exit 1
}
# Check if the current directory ends with /left4dead2/addons
current_dir=$(pwd)
expected_dir="/left4dead2/addons"
if [[ ! "$current_dir" == *"$expected_dir" ]]; then
error_exit "Script must be run from your L4D2 \"addons\" folder. Current directory: $current_dir"
fi
# Check for required commands
for cmd in curl md5sum 7z; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error_exit "Required command '$cmd' is not installed. Please install it and retry."
fi
done
# URL of the CSV file
CSV_URL="https://l4d2center.com/maps/servers/index.csv"
# Temporary file to store CSV
TEMP_CSV=$(mktemp)
# Ensure temporary file is removed on exit
trap 'rm -f "$TEMP_CSV"' EXIT
echo "Downloading CSV from $CSV_URL..."
curl -sSL -o "$TEMP_CSV" "$CSV_URL" || error_exit "Failed to download CSV."
declare -A map_md5
declare -A map_links
# Read CSV and populate associative arrays
{
# Skip the first line (header)
IFS= read -r header
while IFS=';' read -r Name Size MD5 DownloadLink || [[ $Name ]]; do
# Trim whitespace
Name=$(echo "$Name" | xargs)
MD5=$(echo "$MD5" | xargs)
DownloadLink=$(echo "$DownloadLink" | xargs)
# Populate associative arrays
map_md5["$Name"]="$MD5"
map_links["$Name"]="$DownloadLink"
done
} < "$TEMP_CSV"
# Get list of expected VPK files
expected_vpk=("${!map_md5[@]}")
# Remove VPK files not in expected list or with mismatched MD5
echo "Cleaning up existing VPK files..."
for file in *.vpk; do
# Check if it's a regular file
if [[ -f "$file" ]]; then
if [[ -z "${map_md5["$file"]}" ]]; then
echo "Removing unexpected file: $file"
rm -f "$file"
else
# Calculate MD5
echo "Calculating MD5 for existing file: $file..."
current_md5=$(md5sum "$file" | awk '{print $1}')
expected_md5="${map_md5["$file"]}"
if [[ "$current_md5" != "$expected_md5" ]]; then
echo "MD5 mismatch for $file. Removing."
rm -f "$file"
fi
fi
fi
done
# Download and extract missing or updated VPK files
echo "Processing required VPK files..."
for vpk in "${expected_vpk[@]}"; do
if [[ ! -f "$vpk" ]]; then
echo "Downloading and extracting $vpk..."
download_url="${map_links["$vpk"]}"
if [[ -z "$download_url" ]]; then
echo "No download link found for $vpk. Skipping."
continue
fi
encoded_url=$(echo "$download_url" | sed 's/ /%20/g')
# Download the .7z file to a temporary location
TEMP_7Z=$(mktemp --suffix=.7z)
curl -# -L -o "$TEMP_7Z" "$encoded_url"
# Check if the download was successful
if [[ $? -ne 0 ]]; then
echo "Failed to download $download_url. Skipping."
rm -f "$TEMP_7Z"
continue
fi
# Extract the .7z file
7z x -y "$TEMP_7Z" || { echo "Failed to extract $TEMP_7Z. Skipping."; rm -f "$TEMP_7Z"; continue; }
# Remove the temporary .7z file
rm -f "$TEMP_7Z"
else
echo "$vpk is already up to date."
fi
done
echo "Synchronization complete."

View file

@ -0,0 +1,12 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# Ions Vocalizer
workshop -i 698857882
# admin system
workshop --item 2524204971
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/ems/admin system"
steam echo "STEAM_1:0:12376499" > "/opt/l4d2/overlays/${overlay}/left4dead2/ems/admin system/admins.txt"

View file

@ -0,0 +1,25 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# server config
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
# https://www.programmersought.com/article/513810199514/
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2/cfg
steam cat <<'EOF' > /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
sv_minrate 100000
sv_maxrate 100000
nb_update_frequency 0.014
net_splitpacket_maxrate 50000
net_maxcleartime 0.0001
fps_max 0
EOF
# install tickrate enabler
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/addons"
for file in tickrate_enabler.dll tickrate_enabler.so tickrate_enabler.vdf
do
curl -L "https://github.com/SirPlease/L4D2-Competitive-Rework/raw/refs/heads/master/addons/${file}" -o "/opt/l4d2/overlays/${overlay}/left4dead2/addons/${file}"
done

View file

@ -0,0 +1,13 @@
#!/bin/bash
set -xeuo pipefail
source /opt/l4d2/scripts/helpers
overlay=$(basename "$0")
# workshop --collection 121115793 # Back To School
# workshop --item 2957035482 # hehe30-part1
# workshop --item 2973628334 # hehe30-part2
# workshop --item 3013844371 # hehe30-part3
# workshop --item 3478461158 # 虚伪黎明(Dawn's Deception)
# workshop --item 3478934394 # 虚伪黎明(Dawn's Deception)PART2

View file

@ -1,40 +1,13 @@
hostname "CroneKorkN : ${name}" // defaults
sv_contact "admin@sublimity.de" hostname ${server_name}
sv_steamgroup "${','.join(steamgroups)}"
rcon_password "${rcon_password}"
motd_enabled 0 motd_enabled 0
rcon_password ${rcon_password}
sv_steamgroup "38347879"
mp_autoteambalance 0
sv_forcepreload 1
sv_cheats 1 // server specific
% for line in config:
${line}
sv_consistency 0 % endfor
sv_lan 0
sv_allow_lobby_connect_only 0
sv_gametypes "coop,realism,survival,versus,teamversus,scavenge,teamscavenge"
sv_minrate 30000
sv_maxrate 60000
sv_mincmdrate 66
sv_maxcmdrate 101
sv_logsdir "logs-${name}" //Folder in the game directory where server logs will be stored.
log on //Creates a logfile (on | off)
sv_logecho 0 //default 0; Echo log information to the console.
sv_logfile 1 //default 1; Log server information in the log file.
sv_log_onefile 0 //default 0; Log server information to only one file.
sv_logbans 1 //default 0;Log server bans in the server logs.
sv_logflush 0 //default 0; Flush the log files to disk on each write (slow).

View file

@ -0,0 +1,72 @@
#!/bin/bash
set -xeuo pipefail
# -- DEFINE FUNCTIONS AND VARIABLES -- #
function steam() {
# for systemd, so it can terminate the process (for other things sudo would have been enough)
setpriv --reuid=steam --regid=steam --init-groups "$@" <&0
export HOME=/opt/l4d2/steam
}
# -- PREPARE SYSTEM -- #
getent passwd steam >/dev/null || useradd -M -d /opt/l4d2 -s /bin/bash steam
mkdir -p /opt/l4d2 /tmp/dumps
chown steam:steam /opt/l4d2 /tmp/dumps
dpkg --add-architecture i386
apt update
DEBIAN_FRONTEND=noninteractive apt install -y libc6:i386 lib32z1
# workshop downloader
test -f /opt/l4d2/scripts/steam-workshop-download || \
steam wget -4 https://git.sublimity.de/cronekorkn/steam-workshop-downloader/raw/branch/master/steam-workshop-download -P /opt/l4d2/scripts
steam chmod +x /opt/l4d2/scripts/steam-workshop-download
# -- STEAM -- #
steam mkdir -p /opt/l4d2/steam
test -f /opt/l4d2/steam/steamcmd_linux.tar.gz || \
steam wget http://media.steampowered.com/installer/steamcmd_linux.tar.gz -P /opt/l4d2/steam
test -f /opt/l4d2/steam/steamcmd.sh || \
steam tar -xvzf /opt/l4d2/steam/steamcmd_linux.tar.gz -C /opt/l4d2/steam
# fix for: /opt/l4d2/.steam/sdk32/steamclient.so: cannot open shared object file: No such file or directory
steam mkdir -p /opt/l4d2/steam/.steam # needs to be in steam users home dir
readlink /opt/l4d2/steam/.steam/sdk32 | grep -q ^/opt/l4d2/steam/linux32$ || \
steam ln -sf /opt/l4d2/steam/linux32 /opt/l4d2/steam/.steam/sdk32
readlink /opt/l4d2/steam/.steam/sdk64 | grep -q ^/opt/l4d2/steam/linux64$ || \
steam ln -sf /opt/l4d2/steam/linux64 /opt/l4d2/steam/.steam/sdk64
# -- INSTALL -- #
# erst die windows deps zu installieren scheint ein workaround für x64 zu sein?
steam mkdir -p /opt/l4d2/installation
steam /opt/l4d2/steam/steamcmd.sh \
+force_install_dir /opt/l4d2/installation \
+login anonymous \
+@sSteamCmdForcePlatformType windows \
+app_update 222860 validate \
+quit
steam /opt/l4d2/steam/steamcmd.sh \
+force_install_dir /opt/l4d2/installation \
+login anonymous \
+@sSteamCmdForcePlatformType linux \
+app_update 222860 validate \
+quit
# -- OVERLAYS -- #
for overlay_path in /opt/l4d2/scripts/overlays/*; do
overlay=$(basename "$overlay_path")
steam mkdir -p /opt/l4d2/overlays/$overlay
bash -xeuo pipefail "$overlay_path"
test -f /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg && \
steam cp /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg /opt/l4d2/overlays/$overlay/left4dead2/cfg/server_$overlay.cfg
done
# -- SERVERS -- #
#steam rm -rf /opt/l4d2/servers
steam mkdir -p /opt/l4d2/servers

View file

@ -0,0 +1,75 @@
#!/bin/bash
set -xeuo pipefail
name=""
port=""
configfile=""
overlays=""
arguments=""
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--name)
name="$2"; shift 2
;;
-p|--port)
port="$2"; shift 2
;;
-c|--config)
configfile="$2"; shift 2
;;
-o|--overlay)
overlays="/opt/l4d2/overlays/$2:$overlays"; shift 2
;;
--)
shift
arguments+="$@"
break
;;
*)
echo "ERROR: unknown argument $1"; exit 1
;;
esac
done
[[ -n "${name}" ]] || { echo "ERROR: -n/--name missing"; exit 1; }
[[ -n "${port}" ]] || { echo "ERROR: -p/--port missing"; exit 1; }
# -- HELPER FUNCTIONS -- #
function steam() {
# für systemd, damit es den prozess beenden kann
setpriv --reuid=steam --regid=steam --init-groups "$@"
export HOME=/opt/l4d2/steam
}
# -- TIDY UP -- #
mountpoint -q "/opt/l4d2/servers/$name/merged" && umount "/opt/l4d2/servers/$name/merged"
steam rm -rf "/opt/l4d2/servers/$name"
# -- CREATE DIRECTORIES -- #
steam mkdir -p \
"/opt/l4d2/servers/$name" \
"/opt/l4d2/servers/$name/work" \
"/opt/l4d2/servers/$name/upper" \
"/opt/l4d2/servers/$name/merged"
# -- MOUNT OVERLAYFS -- #
mount -t overlay overlay \
-o "lowerdir=$overlays/opt/l4d2/installation,upperdir=/opt/l4d2/servers/$name/upper,workdir=/opt/l4d2/servers/$name/work" \
"/opt/l4d2/servers/$name/merged"
# -- REPLACE SERVER.CFG -- #
if [[ -n "$configfile" ]]; then
cp "$configfile" "/opt/l4d2/servers/$name/merged/left4dead2/cfg/server.cfg"
chown steam:steam "/opt/l4d2/servers/$name/merged/left4dead2/cfg/server.cfg"
fi
# -- RUN L4D2 -- #
steam "/opt/l4d2/servers/$name/merged/srcds_run" -norestart -pidfile "/opt/l4d2/servers/$name/pid" -game left4dead2 -ip 0.0.0.0 -port "$port" +hostname "Crone_$name" +map c1m1_hotel $arguments

View file

@ -0,0 +1,19 @@
#!/bin/bash
set -xeuo pipefail
name=""
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--name)
name="$2"; shift 2
;;
*)
echo "ERROR: unknown argument $1"; exit 1
;;
esac
done
mountpoint -q "/opt/l4d2/servers/$name/merged" && umount "/opt/l4d2/servers/$name/merged"
steam rm -rf "/opt/l4d2/servers/$name"

View file

@ -1,122 +1,105 @@
assert node.has_bundle('steam') and node.has_bundle('steam-workshop-download') users = {
'steam': {
'home': '/opt/l4d2/steam',
'shell': '/bin/bash',
},
}
directories = { directories = {
'/opt/steam/left4dead2-servers': { '/opt/l4d2': {
'owner': 'steam', 'owner': 'steam', 'group': 'steam',
'group': 'steam', },
'mode': '0755', '/opt/l4d2/steam': {
'owner': 'steam', 'group': 'steam',
},
'/opt/l4d2/configs': {
'owner': 'steam', 'group': 'steam',
'purge': True, 'purge': True,
}, },
# Current zfs doesnt support zfs upperdir. The support was added in October 2022. Move upperdir - unused anyway - '/opt/l4d2/scripts': {
# to another dir. Also move workdir alongside it, as it has to be on same fs. 'owner': 'steam', 'group': 'steam',
'/opt/steam-zfs-overlay-workarounds': { },
'owner': 'steam', '/opt/l4d2/scripts/overlays': {
'group': 'steam', 'owner': 'steam', 'group': 'steam',
'mode': '0755',
'purge': True, 'purge': True,
}, },
} }
# /opt/steam/steam/.steam/sdk32/steamclient.so: cannot open shared object file: No such file or directory files = {
symlinks = { '/opt/l4d2/setup': {
'/opt/steam/steam/.steam/sdk32': { 'mode': '755',
'target': '/opt/steam/steam/linux32', 'triggers': {
'owner': 'steam', 'svc_systemd:left4dead2-initialize.service:restart',
'group': 'steam', },
} },
'/opt/l4d2/start': {
'mode': '755',
'triggers': {
f'svc_systemd:left4dead2-{server_name}.service:restart'
for server_name in node.metadata.get('left4dead2/servers').keys()
},
},
'/opt/l4d2/stop': {
'mode': '755',
'triggers': {
f'svc_systemd:left4dead2-{server_name}.service:restart'
for server_name in node.metadata.get('left4dead2/servers').keys()
},
},
'/opt/l4d2/scripts/helpers': {
'source': 'scripts/helpers',
'mode': '755',
'triggers': {
'svc_systemd:left4dead2-initialize.service:restart',
},
},
} }
# for overlay in node.metadata.get('left4dead2/overlays'):
# SERVERS files[f'/opt/l4d2/scripts/overlays/{overlay}'] = {
# 'source': f'scripts/overlays/{overlay}',
'mode': '755',
for name, config in node.metadata.get('left4dead2/servers').items(): 'triggers': {
'svc_systemd:left4dead2-initialize.service:restart',
#overlay },
directories[f'/opt/steam/left4dead2-servers/{name}'] = {
'owner': 'steam',
'group': 'steam',
}
directories[f'/opt/steam-zfs-overlay-workarounds/{name}/upper'] = {
'owner': 'steam',
'group': 'steam',
}
directories[f'/opt/steam-zfs-overlay-workarounds/{name}/workdir'] = {
'owner': 'steam',
'group': 'steam',
} }
# conf svc_systemd = {
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/cfg/server.cfg'] = { 'left4dead2-initialize.service': {
'content_type': 'mako', 'enabled': True,
'running': None,
'needs': {
'tag:left4dead2-packages',
'file:/opt/l4d2/setup',
'file:/usr/local/lib/systemd/system/left4dead2-initialize.service',
},
},
}
for server_name, config in node.metadata.get('left4dead2/servers').items():
files[f'/opt/l4d2/configs/{server_name}.cfg'] = {
'source': 'server.cfg', 'source': 'server.cfg',
'content_type': 'mako',
'context': { 'context': {
'name': name, 'server_name': server_name,
'steamgroups': node.metadata.get('left4dead2/steamgroups'), 'rcon_password': repo.vault.decrypt('encrypt$gAAAAABpAdZhxwJ47I1AXotuZmBvyZP1ecVTt9IXFkLI28JiVS74LKs9QdgIBz-FC-iXtIHHh_GVGxxKQZprn4UrXZcvZ57kCKxfHBs3cE2JiGnbWE8_mfs=').value,
'rcon_password': config['rcon_password'], 'config': config.get('config', []),
}, },
'owner': 'steam', 'owner': 'steam',
'group': 'steam', 'mode': '644',
'triggers': [ 'triggers': {
f'svc_systemd:left4dead2-{name}.service:restart', f'svc_systemd:left4dead2-{server_name}.service:restart',
], },
} }
# service svc_systemd[f'left4dead2-{server_name}.service'] = {
svc_systemd[f'left4dead2-{name}.service'] = { 'enabled': True,
'needs': [ 'running': True,
f'file:/opt/steam/left4dead2-servers/{name}/left4dead2/cfg/server.cfg', 'tags': {
f'file:/usr/local/lib/systemd/system/left4dead2-{name}.service', 'left4dead2-servers',
], },
} 'needs': {
'svc_systemd:left4dead2-initialize.service',
# f'file:/usr/local/lib/systemd/system/left4dead2-{server_name}.service',
# ADDONS },
#
# base
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons/readme.txt'] = {
'content_type': 'any',
'owner': 'steam',
'group': 'steam',
}
directories[f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons'] = {
'owner': 'steam',
'group': 'steam',
'purge': True,
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
}
for id in [
*config.get('workshop', []),
*node.metadata.get('left4dead2/workshop'),
]:
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons/{id}.vpk'] = {
'content_type': 'any',
'owner': 'steam',
'group': 'steam',
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
}
# admin system
directories[f'/opt/steam/left4dead2-servers/{name}/left4dead2/ems/admin system'] = {
'owner': 'steam',
'group': 'steam',
'mode': '0755',
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
}
files[f'/opt/steam/left4dead2-servers/{name}/left4dead2/ems/admin system/admins.txt'] = {
'owner': 'steam',
'group': 'steam',
'mode': '0755',
'content': '\n'.join(sorted(node.metadata.get('left4dead2/admins'))),
'triggers': [
f'svc_systemd:left4dead2-{name}.service:restart',
],
} }

View file

@ -1,110 +1,112 @@
assert node.has_bundle('steam') from re import match
from os import path, listdir
from shlex import quote
defaults = { defaults = {
'steam': { 'apt': {
'games': { 'packages': {
'left4dead2': 222860, 'libc6_i386': { # installs libc6:i386
'tags': {'left4dead2-packages'},
},
'lib32z1': {
'tags': {'left4dead2-packages'},
},
'unzip': {
'tags': {'left4dead2-packages'},
},
'p7zip-full': { # l4d2center_maps_sync.sh
'tags': {'left4dead2-packages'},
},
}, },
}, },
'left4dead2': { 'left4dead2': {
'servers': {}, 'overlays': set(listdir(path.join(repo.path, 'bundles/left4dead2/files/scripts/overlays'))),
'admins': set(), 'servers': {
'workshop': set(), # 'port': 27017,
# 'overlays': ['competitive_rework'],
# 'arguments': ['-tickrate 60'],
# 'config': [
# 'exec server_original.cfg',
# 'sm_forcematch zonemod',
# ],
},
},
'nftables': {
'input': {
'udp dport { 27005, 27020 } accept',
},
},
'systemd': {
'units': {
'left4dead2-initialize.service': {
'Unit': {
'Description': 'initialize left4dead2',
'After': 'network-online.target',
},
'Service': {
'Type': 'oneshot',
'RemainAfterExit': 'yes',
'ExecStart': '/opt/l4d2/setup',
'StandardOutput': 'journal',
'StandardError': 'journal',
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
},
}, },
} }
@metadata_reactor.provides( @metadata_reactor.provides(
'left4dead2/servers',
)
def rconn_password(metadata):
# only works from localhost!
return {
'left4dead2': {
'servers': {
server: {
'rcon_password': repo.vault.password_for(f'{node.name} left4dead2 {server} rcon', length=24),
}
for server in metadata.get('left4dead2/servers')
},
},
}
@metadata_reactor.provides(
'steam-workshop-download',
'systemd/units', 'systemd/units',
) )
def server_units(metadata): def server_units(metadata):
units = {} units = {}
workshop = {}
for name, config in metadata.get('left4dead2/servers').items(): for name, config in metadata.get('left4dead2/servers').items():
# mount overlay assert match(r'^[A-z0-9-_-]+$', name)
mountpoint = f'/opt/steam/left4dead2-servers/{name}' assert 27000 <= config["port"] <= 27100
mount_unit_name = mountpoint[1:].replace('-', '\\x2d').replace('/', '-') + '.mount' for overlay in config.get('overlays', []):
units[mount_unit_name] = { assert overlay in metadata.get('left4dead2/overlays'), f"unknown overlay {overlay}, known: {metadata.get('left4dead2/overlays')}"
'Unit': {
'Description': f"Mount left4dead2 server {name} overlay",
'Conflicts': {'umount.target'},
'Before': {'umount.target'},
},
'Mount': {
'What': 'overlay',
'Where': mountpoint,
'Type': 'overlay',
'Options': ','.join([
'auto',
'lowerdir=/opt/steam/left4dead2',
f'upperdir=/opt/steam-zfs-overlay-workarounds/{name}/upper',
f'workdir=/opt/steam-zfs-overlay-workarounds/{name}/workdir',
]),
},
'Install': {
'RequiredBy': {
f'left4dead2-{name}.service',
},
},
}
# individual workshop cmd = f'/opt/l4d2/start -n {name} -p {config["port"]}'
workshop_ids = config.get('workshop', set()) | metadata.get('left4dead2/workshop', set())
if workshop_ids: if 'config' in config:
workshop[f'left4dead2-{name}'] = { cmd += f' -c /opt/l4d2/configs/{name}.cfg'
'ids': workshop_ids,
'path': f'/opt/steam/left4dead2-servers/{name}/left4dead2/addons', for overlay in config.get('overlays', []):
'user': 'steam', cmd += f' -o {overlay}'
'requires': {
mount_unit_name, if 'arguments' in config:
}, cmd += ' -- ' + ' '.join(config['arguments'])
'required_by': {
f'left4dead2-{name}.service',
},
}
# left4dead2 server unit
units[f'left4dead2-{name}.service'] = { units[f'left4dead2-{name}.service'] = {
'Unit': { 'Unit': {
'Description': f'left4dead2 server {name}', 'Description': f'left4dead2 server {name}',
'After': {'steam-update.service'}, 'After': {'left4dead2-initialize.service'},
'Requires': {'steam-update.service'}, 'Requires': {'left4dead2-initialize.service'},
}, },
'Service': { 'Service': {
'User': 'steam', 'Type': 'simple',
'Group': 'steam', 'ExecStart': cmd,
'WorkingDirectory': f'/opt/steam/left4dead2-servers/{name}', 'ExecStopPost': f'/opt/l4d2/stop -n {name}',
'ExecStart': f'/opt/steam/left4dead2-servers/{name}/srcds_run -port {config["port"]} +exec server.cfg',
'Restart': 'on-failure', 'Restart': 'on-failure',
'Nice': -10,
'CPUWeight': 200,
'IOSchedulingClass': 'best-effort',
'IOSchedulingPriority': 0,
}, },
'Install': { 'Install': {
'WantedBy': {'multi-user.target'}, 'WantedBy': {'multi-user.target'},
}, },
'triggers': {
f'svc_systemd:left4dead2-{name}.service:restart',
},
} }
return { return {
'steam-workshop-download': workshop,
'systemd': { 'systemd': {
'units': units, 'units': units,
}, },
@ -114,14 +116,13 @@ def server_units(metadata):
@metadata_reactor.provides( @metadata_reactor.provides(
'nftables/input', 'nftables/input',
) )
def firewall(metadata): def nftables(metadata):
ports = set(str(server['port']) for server in metadata.get('left4dead2/servers').values()) ports = sorted(str(config["port"]) for config in metadata.get('left4dead2/servers').values())
return { return {
'nftables': { 'nftables': {
'input': { 'input': {
f"tcp dport {{ {', '.join(sorted(ports))} }} accept", f'ip protocol {{ tcp, udp }} th dport {{ {", ".join(ports)} }} accept'
f"udp dport {{ {', '.join(sorted(ports))} }} accept",
}, },
}, },
} }

167
bundles/left4me/README.md Normal file
View file

@ -0,0 +1,167 @@
# left4me
L4D2 game-server management platform: a Flask web UI on gunicorn that
provisions per-instance srcds servers via templated systemd units, with
kernel-overlayfs layering for shared installations + per-overlay maps,
and uid-based DSCP/priority marking on the egress path so CAKE on the
external interface prioritizes srcds UDP over bulk traffic.
## Metadata
```python
'metadata': {
'left4me': {
'domain': 'whatever.tld', # required — the only per-node knob
# Everything below is optional and has a sensible default in the
# bundle. Override per-node only if the default is wrong:
# 'git_url': 'git@git.sublimity.de:cronekorkn/left4me',
# 'git_branch': 'master',
# 'gunicorn_workers': 1,
# 'gunicorn_threads': 32,
# 'job_worker_threads': 4,
# 'port_range_start': 27015,
# 'port_range_end': 27115,
# secret_key is auto-derived per node
# (repo.vault.random_bytes_as_base64_for f'{node.name} left4me secret_key').
},
},
```
The bundle's `derived_from_domain` reactor reads `left4me/domain` and
emits the corresponding `nginx/vhosts`, `letsencrypt/domains`,
`monitoring/services/left4me-web` (HTTPS health check), and the game-
port `nftables/input` accept rules. Backup paths
(`/var/lib/left4me`, `/etc/left4me`) are set-merged into `backup/paths`
from defaults. None of these need to be declared per-node.
## What this bundle does
The bundle delivers to `ovh.left4me` a mix of:
### Target-side symlinks into the left4me checkout
After `git_deploy:/opt/left4me/src` (root-owned — left4me cannot rewrite
its own deployment artifacts at runtime), ckn-bw creates symlinks from
canonical on-host paths into the checkout:
| On-host path | Source in checkout |
|---|---|
| `/etc/sudoers.d/left4me` | `deploy/files/etc/sudoers.d/left4me` |
| `/etc/sysctl.d/99-left4me.conf` | `deploy/files/etc/sysctl.d/99-left4me.conf` |
| `/etc/systemd/system/left4me-web.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf` |
| `/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` |
| `/usr/local/libexec/left4me/{left4me-overlay,left4me-systemctl,left4me-journalctl,left4me-script-sandbox}` | `deploy/scripts/libexec/*` |
| `/usr/local/sbin/left4me` | `deploy/scripts/sbin/left4me` |
The hardening drop-ins and sudoers are the application's own security
knowledge — they live in the left4me repo and are version-controlled there.
The privileged helpers are also application code. The symlink pattern
lets bw manage placement without duplicating content.
Design rationale:
`left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`.
### Reactor-emitted units (per-host shape)
Via `systemd/units` metadata in `metadata.py` (consumed by `bundles/systemd/`):
- `left4me-web.service` — gunicorn on `127.0.0.1:8000`; worker/thread
counts from `web.env.mako`. TLS terminates upstream.
- `left4me-server@.service` — per-instance srcds template; `SocketBindAllow=`
ranges from metadata.
- `l4d2-game.slice` / `l4d2-build.slice` — cgroup slices with per-host
`AllowedCPUs=` from `left4me/system_cpus`.
- `system.slice.d/99-left4me-cpuset.conf` + `user.slice.d/99-left4me-cpuset.conf`
— host CPU-set drop-ins, same source.
### bw `files{}` — templated env files
- `host.env.mako``/etc/left4me/host.env`
- `web.env.mako``/etc/left4me/web.env`
- `sandbox-resolv.conf``/etc/left4me/sandbox-resolv.conf`
### Action chains — deploy lifecycle
- `git_deploy``uv_sync` (`uv sync --frozen` against the workspace's
committed `uv.lock`; hatchling PEP 660 editable, doesn't touch source)
`alembic_upgrade``seed_overlays` + web restart.
- One-shot bootstrap: `install_uv` downloads a pinned `uv` binary
(SHA256-verified) into `/usr/local/bin` because `uv` isn't in Trixie's
apt archive. `unless`-gated, so it's a no-op once the version pin is
installed; re-runs only when the constant is bumped.
- Idempotent gates: `chmod-sudoers` (0440 root:root), `chmod-scripts` (0755 root:root).
- Post-git-deploy reloads: `systemctl daemon-reload`, `sysctl --system`.
- Post-apply self-test: `verify-hardening-dropins` (asserts the drop-ins are
loaded by the live units before declaring apply done).
### System user
`left4me` (uid/gid 980, home `/var/lib/left4me`, mode 0755) — the same uid
hosts the web app, gameservers, and the script-overlay sandbox unit (which
drops privileges via systemd-run with a fully hardened transient service).
Runtime mutable state lives under `/var/lib/left4me/`; `/opt/left4me/`
stays as a root-owned deploy-artifact root.
### nftables / nginx / monitoring
- Contributes uid-based DSCP/priority marks for srcds UDP egress to
`nftables/output` (via `defaults`).
- `derived_from_domain` reactor emits the corresponding `nginx/vhosts`,
`letsencrypt/domains`, and `monitoring/services/left4me-web` (HTTPS
health check).
## Gotchas
- **Requires `bundles/nftables` and `bundles/systemd` on the node.** The
bundle asserts membership at `bw test` time. On Debian-13 these ride
in via the `debian-13` group, so attaching the bundle to a Debian-13
node is enough.
- **`left4me-web.service` does not have `NoNewPrivileges=true`.** This is
intentional — workers `sudo` the privileged helpers; `NoNewPrivileges`
would block setuid escalation. Per-instance `server@.service` units
*do* have it.
- **CAKE shaping is configured separately**, via
`network/<iface>/cake` on the node (consumed by `bundles/network/`),
not by this bundle.
- **First-run admin user is manual.** After `bw apply`, ssh to the host and
bootstrap the admin via the `left4me` wrapper (it sources the env files,
drops to the `left4me` user, and runs the flask CLI):
`sudo left4me create-user <username> --admin` (prompts for password via
the flask CLI, or set `LEFT4ME_ADMIN_PASSWORD` first). The bundle
deliberately doesn't seed an admin to keep credentials out of the
metadata pipeline. The same `left4me` wrapper accepts any other flask
subcommand: `sudo left4me seed-script-overlays <dir>`,
`sudo left4me routes`, `sudo left4me shell`, etc.
- **CPU isolation is managed by this bundle**, driven by one required
per-node knob: `left4me/system_cpus` — a set of int CPU ids that
pins `system.slice` / `user.slice` / `l4d2-build.slice`. The
complement (`set(range(vm/threads)) - system_cpus`) pins
`l4d2-game.slice`. On HT hosts, list both SMT siblings of every
physical core you want to reserve for system, otherwise games end
up sharing L1/L2 with system. Find pairings via
`/sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list`. On
the prod node (`ovh.left4me`, 4 physical / 8 threads, pairings
(0,4) (1,5) (2,6) (3,7)) the node sets `'system_cpus': {0, 4}` to
reserve physical core 0 entirely. `l4d2-game.slice` and
`l4d2-build.slice` carry `AllowedCPUs=` inline on their unit
definitions; `system.slice` and `user.slice` get drop-ins registered
under `systemd/units` with the `'<parent>.d/<basename>.conf'` key
convention (same shape nginx and autologin use), landing at
`/usr/local/lib/systemd/system/<slice>.d/99-left4me-cpuset.conf`.
The reactor raises if `system_cpus` includes CPUs outside
`[0, vm/threads)` or leaves no cores for games.
- **Kernel feature requirement:** kernel-overlayfs (`CONFIG_OVERLAY_FS`).
Standard on debian-13.
- **Game ports** open by the web app on demand in the range 27015-27115
(UDP+TCP). Add corresponding accept rules to `nftables/input` per
node if the host's policy is default-drop on input.
- **Pinned UIDs/GIDs (980/981).** Chosen for deterministic ownership
across rebuilds and backup restores. If you add another bundle that
pins UIDs in this repo, make sure it doesn't collide.
## Slice support requires `bundles/systemd` ≥ commit cc1c6a5
This bundle's `l4d2-game.slice` and `l4d2-build.slice` units rely on
`bundles/systemd/items.py` accepting the `.slice` extension. Older
revisions raised `Exception(f'unknown type slice')` at apply time.
The repo-wide `bw test` will catch this if it regresses.

View file

@ -0,0 +1,6 @@
# Managed by ckn-bw bundles/left4me. Local edits will be reverted.
# Deployment units use fixed /var/lib/left4me paths; regenerate units if this changes.
LEFT4ME_ROOT=/var/lib/left4me
# l4d2host invokes steamcmd by absolute path — bypasses PATH lookup so the
# script's `cd "$(dirname "$0")"` resolves next to the real install dir.
LEFT4ME_STEAMCMD=/var/lib/left4me/steam/steamcmd.sh

View file

@ -0,0 +1,6 @@
# Sandbox-only resolver config — bind-mounted into script-overlay sandboxes
# at /etc/resolv.conf. The host's resolver (often a private/LAN DNS server)
# is unreachable from inside the sandbox because IPAddressDeny= blocks
# egress to RFC1918 / loopback. Public resolvers keep DNS working.
nameserver 1.1.1.1
nameserver 8.8.8.8

View file

@ -0,0 +1,14 @@
# Managed by ckn-bw bundles/left4me. Local edits will be reverted.
DATABASE_URL=sqlite:////var/lib/left4me/left4me.db
SECRET_KEY=${node.metadata.get('left4me/secret_key')}
JOB_WORKER_THREADS=${node.metadata.get('left4me/job_worker_threads')}
SESSION_COOKIE_SECURE=true
LEFT4ME_PORT_RANGE_START=${node.metadata.get('left4me/port_range_start')}
LEFT4ME_PORT_RANGE_END=${node.metadata.get('left4me/port_range_end')}
STEAM_WEB_API_KEY=${node.metadata.get('left4me/steam_web_api_key')}
# Log listener destination — MUST be non-loopback because Source silently
# drops logaddress destinations in 127.0.0.0/8. Derived from the node's
# external IPv4; kernel routes same-host traffic via lo internally but the
# destination IP in the packet header must not literally be 127.x.
LOG_LISTENER_ADDR=${node.metadata.get('network/external/ipv4').split('/')[0]}:28000
LOG_LISTENER_BIND=0.0.0.0:28000

439
bundles/left4me/items.py Normal file
View file

@ -0,0 +1,439 @@
# Items for the left4me bundle.
# Systemd units come from metadata via bundles/systemd/ — there are no
# .service or .slice files in this bundle's files/ tree. Cpuset drop-ins
# for system.slice / user.slice are likewise emitted via systemd/units
# in metadata.py (key: '<parent>.d/<basename>.conf').
directories = {
'/opt/left4me': {
# Deploy-artifact root. Only /opt/left4me/src lives here; runtime
# state (.venv, steamcmd) lives under /var/lib/left4me/. Root-owned
# so left4me cannot drop new files alongside src/ (e.g. an attacker
# with web-compromise can't plant a 'scripts.d/' loaded by future
# deploy logic).
'owner': 'root',
'group': 'root',
'mode': '0755',
},
'/opt/left4me/src': {
# Source checkout. Root-owned because the production install model
# is non-editable: pip_install copies the source to a left4me-owned
# tempdir before building, so the source tree on disk is never
# mutated at runtime and left4me only needs read access (which
# world-readable bits provide). Keeps left4me from being able to
# rewrite its own future hardening drop-ins / unit files under
# /opt/left4me/src/deploy/ (target-side symlink model in the
# deployment-responsibility reshape).
'owner': 'root',
'group': 'root',
},
'/etc/left4me': {
'owner': 'root',
'group': 'root',
'mode': '0755',
},
'/var/lib/left4me': {
# left4me's home dir — useradd creates with 0700; loosen to 0755 so
# the systemd-imposed FS view for transient script-sandbox units
# (running as left4me with TemporaryFileSystem=/var/lib + selective
# binds) can traverse on its way to the overlay bind targets.
'owner': 'left4me',
'group': 'left4me',
'mode': '0755',
},
'/var/lib/left4me/installation': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/overlays': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/instances': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/runtime': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/workshop_cache': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/tmp': {'owner': 'left4me', 'group': 'left4me'},
'/var/lib/left4me/steam': {'owner': 'left4me', 'group': 'left4me'},
# Note: the venv (/var/lib/left4me/.venv) is created by the
# left4me_create_venv action; declaring it here too would race with
# `python -m venv` which expects to create the directory itself.
'/usr/local/libexec/left4me': {
'owner': 'root',
'group': 'root',
'mode': '0755',
},
'/etc/systemd/system/left4me-web.service.d': {
'owner': 'root', 'group': 'root', 'mode': '0755',
},
'/etc/systemd/system/left4me-server@.service.d': {
'owner': 'root', 'group': 'root', 'mode': '0755',
},
}
groups = {
'left4me': {'gid': 980},
}
users = {
'left4me': {
'uid': 980,
'gid': 980,
'home': '/var/lib/left4me',
'shell': '/usr/sbin/nologin',
},
}
# UID/GID pinned in the system-package range (100-999, per Debian
# policy) so file ownership is deterministic across rebuilds and
# backup restores. 980 is unused elsewhere in this repo.
# (981 — formerly l4d2-sandbox — was collapsed into 980 on 2026-05-15;
# see left4me/docs/superpowers/plans/2026-05-15-uid-collapse.md.)
# Privileged helpers are delivered via target-side symlinks (see the
# `symlinks` dict below) pointing into the left4me checkout at
# `/opt/left4me/src/deploy/scripts/{libexec,sbin}/`. No verbatim copy
# in this bundle's files/ tree. Sudoers (further below) lists the
# specific paths that left4me may invoke as root NOPASSWD.
files = {
'/etc/left4me/sandbox-resolv.conf': {
'source': 'etc/left4me/sandbox-resolv.conf',
'mode': '0644',
'owner': 'root',
'group': 'root',
},
'/etc/left4me/host.env': {
'source': 'etc/left4me/host.env.mako',
'content_type': 'mako',
'mode': '0640',
'owner': 'root',
# group=left4me so the alembic + seed-overlays actions (which run as
# `sudo -u left4me sh -c '. /etc/left4me/host.env'`) can source it.
# Same pattern as web.env below.
'group': 'left4me',
'needs': [
'group:left4me',
],
},
'/etc/left4me/web.env': {
'source': 'etc/left4me/web.env.mako',
'content_type': 'mako',
'mode': '0640',
'owner': 'root',
'group': 'left4me',
'needs': [
'group:left4me',
],
},
}
symlinks = {
'/etc/sysctl.d/99-left4me.conf': {
'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
'owner': 'root',
'group': 'root',
'needs': [
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_sysctl_reload',
],
},
'/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/etc/systemd/system/left4me-web.service.d',
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_daemon_reload',
],
},
'/etc/systemd/system/left4me-server@.service.d/10-hardening.conf': {
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/etc/systemd/system/left4me-server@.service.d',
'git_deploy:/opt/left4me/src',
],
'triggers': [
'action:left4me_daemon_reload',
],
},
'/etc/sudoers.d/left4me': {
'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
'owner': 'root', 'group': 'root',
'needs': [
'action:left4me_chmod_sudoers',
'git_deploy:/opt/left4me/src',
],
# sudo follows symlinks; with the target file at root:root 0440
# in a root-owned source tree, sudo accepts it. No daemon-reload
# equivalent — sudo re-reads /etc/sudoers.d/ on each invocation.
},
}
# Helper script source paths (in left4me's checkout) → deployed-form paths.
# Each gets a symlink item merged into the symlinks dict above.
_LEFT4ME_LIBEXEC_SCRIPTS = (
'left4me-overlay',
'left4me-systemctl',
'left4me-journalctl',
'left4me-script-sandbox',
)
_LEFT4ME_SBIN_SCRIPTS = (
'left4me',
)
for _script in _LEFT4ME_LIBEXEC_SCRIPTS:
symlinks[f'/usr/local/libexec/left4me/{_script}'] = {
'target': f'/opt/left4me/src/deploy/scripts/libexec/{_script}',
'owner': 'root', 'group': 'root',
'needs': [
'directory:/usr/local/libexec/left4me',
'action:left4me_chmod_scripts',
'git_deploy:/opt/left4me/src',
],
}
for _script in _LEFT4ME_SBIN_SCRIPTS:
symlinks[f'/usr/local/sbin/{_script}'] = {
'target': f'/opt/left4me/src/deploy/scripts/sbin/{_script}',
'owner': 'root', 'group': 'root',
'needs': [
'action:left4me_chmod_scripts',
'git_deploy:/opt/left4me/src',
],
}
actions = {
'left4me_sysctl_reload': {
'command': 'sysctl --system >/dev/null',
'triggered': True,
},
'left4me_daemon_reload': {
'command': 'systemctl daemon-reload',
'triggered': True,
'cascade_skip': False,
},
'left4me_verify_hardening_dropins_loaded': {
# Post-apply self-test: confirm systemd actually picked up the
# hardening drop-ins we shipped via symlink. Catches the failure
# mode where the symlink lands but daemon-reload didn't take or
# someone manually unlinked the drop-in. For the gameserver template
# we query an imaginary instance — systemd resolves drop-in paths
# for `name@instance.service` against the template (`name@.service.d/`),
# so the instance need not exist or ever have run.
'command': (
'systemctl show left4me-server@verify.service -p DropInPaths --value '
'| tr " " "\\n" '
'| grep -qx /etc/systemd/system/left4me-server@.service.d/10-hardening.conf '
'&& '
'systemctl show left4me-web.service -p DropInPaths --value '
'| tr " " "\\n" '
'| grep -qx /etc/systemd/system/left4me-web.service.d/10-hardening.conf'
),
'cascade_skip': False,
'needs': [
'action:left4me_daemon_reload',
'symlink:/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
'symlink:/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
],
},
'left4me_chmod_sudoers': {
# sudo refuses sudoers.d entries that aren't 0440 (or 0400) root:root.
# git_deploy extracts as root with the in-repo file mode; this action
# is belt-and-braces in case the repo mode drifts. Idempotent via
# the `unless` gate.
'command': 'chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
'unless': 'test "$(stat -c %a /opt/left4me/src/deploy/files/etc/sudoers.d/left4me)" = "440"',
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
],
},
'left4me_dpkg_add_i386_arch': {
# steamcmd is 32-bit and pulls libc6:i386 + lib32z1 from the i386 arch.
# apt-get update is part of this action because newly-added foreign
# archs need a fresh package list before any :i386 package resolves.
'command': 'dpkg --add-architecture i386 && apt-get update',
'unless': 'dpkg --print-foreign-architectures | grep -qx i386',
'cascade_skip': False,
},
'left4me_install_steamcmd': {
# Steam's tarball is rolling with no published checksum, so we can't
# use download: (which requires a hash). Guard with a presence check
# on steamcmd.sh — steamcmd self-updates at runtime, so chasing the
# tarball version from bw isn't useful.
'command': (
'sudo -u left4me sh -c "'
'cd /var/lib/left4me/steam && '
'curl -fsSL https://media.steampowered.com/installer/steamcmd_linux.tar.gz | '
'tar -xz'
'"'
),
'unless': 'test -x /var/lib/left4me/steam/steamcmd.sh',
'cascade_skip': False,
'needs': [
'directory:/var/lib/left4me/steam',
'pkg_apt:curl',
'pkg_apt:libc6_i386', # bw pkg_apt convention: _ → :
'pkg_apt:lib32z1',
'user:left4me',
],
},
}
# steamcmd is invoked by absolute path (LEFT4ME_STEAMCMD in host.env),
# not via PATH lookup — see l4d2host/cli.py:install. We don't need to put
# anything in /usr/local/bin for it.
git_deploy = {
'/opt/left4me/src': {
'repo': node.metadata.get('left4me/git_url'),
'rev': node.metadata.get('left4me/git_branch'),
'triggers': [
# Re-sync the workspace whenever the checkout changes. uv reads
# the committed uv.lock at /opt/left4me/src and installs both
# workspace members (l4d2host, l4d2web) editable into
# /var/lib/left4me/.venv. Hatchling's PEP 660 editable install
# doesn't write to the source tree, so /opt/left4me/src stays
# root-owned and untouched. uv_sync cascades into
# alembic_upgrade → seed_overlays → web restart.
'action:left4me_uv_sync',
# alembic upgrade head is idempotent — keeping it as a direct
# trigger off git_deploy is belt-and-braces in case the
# uv_sync cascade is ever short-circuited.
'action:left4me_alembic_upgrade',
# Reload systemd unit definitions whenever the checkout changes;
# handles updates to hardening drop-in content without requiring
# a symlink change.
'action:left4me_daemon_reload',
],
},
}
actions['left4me_chmod_scripts'] = {
# sudo invokes the helpers by absolute path under /usr/local/...;
# those resolve to the checkout via the symlinks above. The target
# files must be executable (mode 0755). git_deploy extracts with
# the in-repo file modes; this action is belt-and-braces in case
# any helper's repo mode regresses to 0644.
'command': (
'chmod 0755 '
'/opt/left4me/src/deploy/scripts/libexec/* '
'/opt/left4me/src/deploy/scripts/sbin/*'
),
'unless': (
'! find /opt/left4me/src/deploy/scripts -type f \\! -perm 755 -print -quit 2>/dev/null | grep -q .'
),
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
],
}
actions['left4me_install_uv'] = {
# uv is not in Debian Trixie's apt archive (only experimental/sid).
# Pin to a specific release; download the tarball + its SHA256
# sibling from astral-sh/uv releases, verify, install to
# /usr/local/bin. Idempotent via `unless` — only re-runs when the
# pinned version changes (bump the constant in two places below).
# Pattern matches left4me_install_steamcmd (curl+tar) elsewhere in
# this bundle. Bump cadence: as needed; both dev (brew uv) and
# prod should track the same minor.
'command': """set -e
tmpdir=$(mktemp -d); trap "rm -rf $tmpdir" EXIT
base=https://github.com/astral-sh/uv/releases/download/0.11.8
tar=uv-x86_64-unknown-linux-gnu.tar.gz
curl -fsSL -o $tmpdir/$tar $base/$tar
curl -fsSL -o $tmpdir/$tar.sha256 $base/$tar.sha256
(cd $tmpdir && sha256sum -c $tar.sha256)
tar -xzf $tmpdir/$tar -C $tmpdir --strip-components=1
install -m 0755 $tmpdir/uv /usr/local/bin/uv
install -m 0755 $tmpdir/uvx /usr/local/bin/uvx
""",
'unless': '/usr/local/bin/uv --version 2>/dev/null | grep -qx "uv 0.11.8"',
'cascade_skip': False,
'needs': [
'pkg_apt:curl',
],
# No triggers — install_uv is a one-shot bootstrap. uv_sync needs
# it (via `needs`), so the dependency runs install_uv first on a
# clean host. After that, this action is a no-op on every apply
# unless the version pin changes.
}
actions['left4me_uv_sync'] = {
# The whole "install/refresh the workspace" deploy step, in one
# action. uv reads /opt/left4me/src/uv.lock + the workspace's
# pyproject.toml and installs both members (l4d2host, l4d2web)
# editable into /var/lib/left4me/.venv. Hatchling's PEP 660
# editable install drops a .pth pointing at the source tree — no
# writes to source, so the root-owned /opt/left4me/src stays clean.
#
# UV_PROJECT_ENVIRONMENT redirects uv's default venv path
# (<project>/.venv) to our writable runtime location. HOME is set
# explicitly so uv's cache lands in /var/lib/left4me/.cache/uv
# instead of the inherited sudo HOME (which can be unwritable for
# the left4me user). cd /var/lib/left4me ensures uv's project-config
# walk-up doesn't trip over an unreadable parent (e.g., /root or
# /home/ckn). --frozen requires uv.lock to be present and
# consistent with pyproject.toml — refuses to silently update the
# lockfile during deploy.
'command': (
'sudo -u left4me sh -c "'
'cd /var/lib/left4me && '
'env HOME=/var/lib/left4me '
'UV_PROJECT_ENVIRONMENT=/var/lib/left4me/.venv '
'/usr/local/bin/uv sync --frozen --project /opt/left4me/src'
'"'
),
'triggered': True,
'cascade_skip': False,
'needs': [
'git_deploy:/opt/left4me/src',
'action:left4me_install_uv',
'directory:/var/lib/left4me',
'user:left4me',
],
'triggers': [
'action:left4me_alembic_upgrade',
],
}
actions['left4me_alembic_upgrade'] = {
# Mirrors deploy-test-server.sh:239-242. Runs as left4me with both env
# files sourced; JOB_WORKER_ENABLED=false so a stray worker doesn't race
# with the migration.
'command': (
'sudo -u left4me sh -c "'
'cd /opt/left4me/src/l4d2web && '
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
'env JOB_WORKER_ENABLED=false '
'/var/lib/left4me/.venv/bin/alembic -c /opt/left4me/src/l4d2web/alembic.ini upgrade head'
'"'
),
'triggered': True,
'cascade_skip': False,
'needs': [
'action:left4me_uv_sync',
'file:/etc/left4me/host.env',
'file:/etc/left4me/web.env',
],
'triggers': [
'action:left4me_seed_overlays',
'svc_systemd:left4me-web.service:restart',
],
}
actions['left4me_seed_overlays'] = {
# Idempotent: refreshes script bodies in place; existing overlay rows keep their ids.
'command': (
'sudo -u left4me sh -c "'
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
'env JOB_WORKER_ENABLED=false '
'/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app '
'seed-script-overlays /opt/left4me/src/examples/script-overlays'
'"'
),
'triggered': True,
'cascade_skip': False,
'needs': [
'action:left4me_alembic_upgrade',
],
}

305
bundles/left4me/metadata.py Normal file
View file

@ -0,0 +1,305 @@
assert node.has_bundle('nftables')
assert node.has_bundle('systemd')
assert node.has_bundle('systemd-timers')
defaults = {
'left4me': {
# Application-wide defaults; node only overrides if it really needs to.
'git_url': 'https://git.sublimity.de/cronekorkn/left4me.git',
'git_branch': 'master',
'secret_key': repo.vault.random_bytes_as_base64_for(f'{node.name} left4me secret_key', length=32).value,
'gunicorn_workers': 1,
'gunicorn_threads': 32,
'job_worker_threads': 4,
# Steam Web API key for the live-state panel's GetPlayerSummaries
# lookups (persona names + avatars). Empty default — nodes override
# in their own metadata with the actual key. If left empty in prod,
# the live-state panel still works but falls back to RCON in-game
# names and placeholder avatars.
'steam_web_api_key': '',
# Whole 27000-block: covers Steam's defaults (27015 game, 27005
# client/RCON) plus headroom for ad-hoc ports without further
# nftables changes. Mirrored into LEFT4ME_PORT_RANGE_{START,END}
# by web.env.mako and into the nftables input rule by the
# nftables_input reactor below.
'port_range_start': 27000,
'port_range_end': 27999,
},
'apt': {
'packages': {
'p7zip-full': {},
'nftables': {},
'iproute2': {},
'curl': {},
'ca-certificates': {},
'python3': {},
'python3-dev': {},
# steamcmd is a 32-bit ELF; needs i386 multiarch + these libs.
# `_` → `:` is bundlewrap's pkg_apt convention for multiarch
# names (see pkg_apt.py:48).
'libc6_i386': { # installs libc6:i386
'needs': ['action:left4me_dpkg_add_i386_arch'],
},
'lib32z1': {
'needs': ['action:left4me_dpkg_add_i386_arch'],
},
},
},
'nftables': {
# Match deploy/files/usr/local/lib/left4me/nft/left4me-mark.nft.
# Mark srcds UDP egress (uid left4me) with DSCP EF + skb priority 6
# so CAKE classifies it into the priority tin.
'output': {
'meta skuid "left4me" meta l4proto udp ip dscp set ef meta priority set 0006:0000',
'meta skuid "left4me" meta l4proto udp ip6 dscp set ef meta priority set 0006:0000',
},
},
'systemd': {
'services': {
'left4me-web.service': {
'enabled': True,
'running': True,
'needs': [
'action:left4me_alembic_upgrade',
'file:/etc/left4me/host.env',
'file:/etc/left4me/web.env',
],
},
# Note: left4me-server@.service is a TEMPLATE — instances are
# started on-demand by the web app via the left4me-systemctl
# helper. Don't enable/start it from here.
# The slices are installed (file present) but don't need
# enable/start — they're activated implicitly when a unit
# uses Slice=.
},
},
'backup': {
# Application-owned paths. Set-merged with backup group / node-level paths.
'paths': {
'/var/lib/left4me',
'/etc/left4me',
},
},
'systemd-timers': {
# Daily re-fetch of Steam Workshop metadata + .vpk downloads for any
# item whose author published an update. The CLI just inserts a
# `refresh_workshop_items` job; the web worker picks it up next.
# Idempotent — a re-fire while a refresh is already queued/running
# is a no-op (see l4d2web/cli.py:workshop_refresh).
'left4me-workshop-refresh': {
'command': '/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app workshop-refresh',
'when': '*-*-* 04:00:00',
'persistent': True,
'user': 'left4me',
'working_dir': '/opt/left4me/src',
'environment_files': (
'/etc/left4me/host.env',
'/etc/left4me/web.env',
),
'after': {
'network-online.target',
'left4me-web.service',
},
},
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx_vhosts(metadata):
# letsencrypt/domains and monitoring/services for the vhost are auto-
# populated by bundles/nginx/metadata.py. We just declare check_path:
# '/health' so the auto-check hits the Flask health endpoint, not '/'.
domain = metadata.get('left4me/domain')
return {
'nginx': {
'vhosts': {
domain: {
'content': 'nginx/proxy_pass.conf',
'context': {
'target': 'http://127.0.0.1:8000',
},
'check_path': '/health',
},
},
},
}
@metadata_reactor.provides(
'nftables/input',
)
def nftables_input(metadata):
port_start = metadata.get('left4me/port_range_start')
port_end = metadata.get('left4me/port_range_end')
return {
'nftables': {
'input': {
# Players connect via UDP. TCP on the same port range is RCON
# — only the local web app should reach it. Loopback bypasses
# the input chain (iifname lo accept in bundles/nftables/...),
# so 127.0.0.1 RCON works without an explicit TCP accept here.
# External TCP on these ports stays blocked by the default
# input-chain drop.
f'udp dport {port_start}-{port_end} accept',
},
},
}
@metadata_reactor.provides(
'systemd/units',
)
def systemd_units(metadata):
workers = metadata.get('left4me/gunicorn_workers')
threads = metadata.get('left4me/gunicorn_threads')
# cgroup-v2 cpuset. `system_cpus` (set of int CPU ids, declared per
# node) pins system/user/build; the complement pins l4d2-game. On HT
# hosts, list both siblings of a physical core so games don't share
# L1/L2 with system work — pairings via
# /sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list.
vm_threads = metadata.get('vm/threads', metadata.get('vm/cores'))
all_cpus = set(range(vm_threads))
system_cpus = metadata.get('left4me/system_cpus')
if not system_cpus <= all_cpus:
raise Exception(
f'left4me/system_cpus={sorted(system_cpus)} on {vm_threads}-thread host '
f'includes CPUs outside [0, {vm_threads})'
)
game_cpus = all_cpus - system_cpus
if not game_cpus:
raise Exception(
f'left4me/system_cpus={sorted(system_cpus)} on {vm_threads}-thread host '
f'leaves no cores for games'
)
system_cpus_string = ','.join(str(t) for t in sorted(system_cpus))
game_cpus_string = ','.join(str(t) for t in sorted(game_cpus))
# Drop-in for upstream system.slice / user.slice (units we don't own).
# Same '<parent>.d/<basename>.conf' convention as nginx and autologin.
cpuset_dropin = {'Slice': {'AllowedCPUs': system_cpus_string}}
return {
'systemd': {
'units': {
'left4me-web.service': {
'Unit': {
'Description': 'left4me web application',
'After': 'network-online.target',
'Wants': 'network-online.target',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'WorkingDirectory': '/opt/left4me/src',
'Environment': {
'HOME=/var/lib/left4me',
'PATH=/var/lib/left4me/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
},
'EnvironmentFile': (
'/etc/left4me/host.env',
'/etc/left4me/web.env',
),
'ExecStart': (
'/var/lib/left4me/.venv/bin/gunicorn '
f'--workers {workers} --threads {threads} '
"--bind 127.0.0.1:8000 'l4d2web.app:create_app()'"
),
'Restart': 'on-failure',
'RestartSec': '3',
# Web app writes broadly under /var/lib/left4me. Kept inline
# because it's web-specific (server@ uses BindPaths to bind
# only its instance dir).
'ReadWritePaths': '/var/lib/left4me',
# Hardening profile delivered via
# /etc/systemd/system/left4me-web.service.d/10-hardening.conf
# (target-side symlink into left4me/deploy/files/, owned by left4me).
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
'left4me-server@.service': {
'Unit': {
'Description': 'left4me server instance %i',
'After': 'network-online.target',
'Wants': 'network-online.target',
'StartLimitBurst': '5',
'StartLimitIntervalSec': '60s',
},
'Service': {
'Type': 'simple',
'User': 'left4me',
'Group': 'left4me',
'EnvironmentFile': (
'/etc/left4me/host.env',
'/var/lib/left4me/instances/%i/instance.env',
),
'WorkingDirectory': '-/var/lib/left4me/runtime/%i/merged/left4dead2',
'ExecStartPre': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i',
# +ip 0.0.0.0 binds RCON (TCP) to all interfaces incl. loopback;
# without this, Source auto-selects the primary IP and the web
# app's 127.0.0.1 RCON connect gets ECONNREFUSED. External TCP
# on the game port range is firewall-blocked in nftables_input.
'ExecStart': '/var/lib/left4me/runtime/%i/merged/srcds_run -game left4dead2 +ip 0.0.0.0 +hostport ${L4D2_PORT} $L4D2_ARGS',
'ExecStopPost': '+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay umount %i',
'Restart': 'on-failure',
'RestartSec': '5',
# Resource control (baseline from prior performance work).
'Slice': 'l4d2-game.slice',
'Nice': '-5',
'IOSchedulingClass': 'best-effort',
'IOSchedulingPriority': '4',
'OOMScoreAdjust': '-200',
'MemoryHigh': '1.5G',
'MemoryMax': '2G',
'TasksMax': '256',
'LimitNOFILE': '65536',
'KillSignal': 'SIGINT',
'TimeoutStopSec': '15s',
'LogRateLimitIntervalSec': '0',
# Hardening profile delivered via
# /etc/systemd/system/left4me-server@.service.d/10-hardening.conf
# (target-side symlink into left4me/deploy/files/, owned by left4me).
},
'Install': {
'WantedBy': {'multi-user.target'},
},
},
'l4d2-game.slice': {
'Unit': {
'Description': 'left4me game-server slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '1000',
'IOWeight': '1000',
'AllowedCPUs': game_cpus_string,
},
},
'l4d2-build.slice': {
'Unit': {
'Description': 'left4me script-sandbox build slice',
'Before': 'slices.target',
},
'Slice': {
'CPUWeight': '10',
'IOWeight': '10',
'AllowedCPUs': system_cpus_string,
},
},
'system.slice.d/99-left4me-cpuset.conf': cpuset_dropin,
'user.slice.d/99-left4me-cpuset.conf': cpuset_dropin,
},
},
}

View file

@ -1,9 +1,60 @@
https://github.com/dehydrated-io/dehydrated/wiki/example-dns-01-nsupdate-script # letsencrypt
Issues and renews Let's Encrypt certs via [dehydrated][upstream] with
DNS-01 against the in-house bind-acme server.
[upstream]: https://github.com/dehydrated-io/dehydrated/wiki/example-dns-01-nsupdate-script
## First-apply behaviour
Immediately after `bw apply <node>`, nginx serves a **self-signed
cert** for each declared domain — generated by
`/etc/dehydrated/letsencrypt-ensure-some-certificate` so nginx has
something to start with. The real Let's Encrypt cert arrives at most
24h later when the systemd timer fires
(`/usr/bin/dehydrated --cron --accept-terms --challenge dns-01`). To
shortcut the wait:
```sh
ssh <node> 'sudo /usr/bin/dehydrated --cron --accept-terms --challenge dns-01'
ssh <node> 'sudo systemctl reload nginx'
```
## DNS-01 prerequisites
`hook.sh` does `nsupdate` against the bind-acme server (referenced
by `letsencrypt/acme_node`). For the challenge to succeed:
1. The acme node must be in the same metadata graph (so
`bw metadata <node> -k letsencrypt/acme_node` resolves).
2. **All NS servers** for the validated domain must serve the
`_acme-challenge.<domain>` CNAME — Let's Encrypt validates from
primary AND secondary geographic regions; both authoritative
servers must agree. If a secondary NS is also a bw-managed node,
`bw apply` it after adding the domain (see e.g. `ovh.secondary`).
3. The bind-acme node's TSIG key must be reachable. `hook.sh` is
rendered with the bind-acme server's `network/internal/ipv4`
for clients outside that LAN, the route must exist (typically via
wireguard `s2s` peer membership).
## Negative-cache penalty
If the first DNS-01 attempt fails (e.g. zone not yet applied to the
secondary NS), Let's Encrypt's resolvers cache NXDOMAIN for the SOA's
negative TTL (often 900s = 15 min). Subsequent attempts during that
window also fail and refresh the cache. Combined with LE's rate limit
of **5 failed authorisations per domain per hour**, recovery requires
you to **stop retrying** for ~15 minutes after fixing the DNS, then
make at most one attempt.
## nsupdate sample
For interactive testing of the bind-acme TSIG path:
```sh ```sh
printf "server 127.0.0.1 printf "server 127.0.0.1
zone acme.resolver.name. zone acme.resolver.name.
update add _acme-challenge.ckn.li.acme.resolver.name. 600 IN TXT "hello" update add _acme-challenge.ckn.li.acme.resolver.name. 600 IN TXT \"hello\"
send send
" | nsupdate -y hmac-sha512:acme:Y9BHl85l352BGZDXa/vg90hh2+5PYe4oJxpkq/oQvIODDkW8bAyQSFr0gKQQxjyIOyYlTjf0MGcdWFv46G/3Rg== " | nsupdate -y hmac-sha512:acme:XXXXXX
``` ```

View file

@ -31,6 +31,12 @@ deploy_cert() {
% for domain, conf in sorted(domains.items()): % for domain, conf in sorted(domains.items()):
<% if not conf: continue %>\ <% if not conf: continue %>\
${domain}) ${domain})
% if conf.get('scp', None):
scp "$KEYFILE" "${conf['scp']}/${conf.get('privkey_name', 'privkey.pem')}"
scp "$CERTFILE" "${conf['scp']}/${conf.get('cert_name', 'cert.pem')}"
scp "$FULLCHAINFILE" "${conf['scp']}/${conf.get('fullchain_name', 'fullchain.pem')}"
scp "$CHAINFILE" "${conf['scp']}/${conf.get('chain_name', 'chain.pem')}"
% endif
% if conf.get('location', None): % if conf.get('location', None):
cat "$KEYFILE" > "${conf['location']}/${conf.get('privkey_name', 'privkey.pem')}" cat "$KEYFILE" > "${conf['location']}/${conf.get('privkey_name', 'privkey.pem')}"
cat "$CERTFILE" > "${conf['location']}/${conf.get('cert_name', 'cert.pem')}" cat "$CERTFILE" > "${conf['location']}/${conf.get('cert_name', 'cert.pem')}"

View file

@ -42,7 +42,7 @@ files = {
} }
actions['letsencrypt_update_certificates'] = { actions['letsencrypt_update_certificates'] = {
'command': 'dehydrated --cron --accept-terms --challenge dns-01', 'command': 'systemctl start letsencrypt.service',
'triggered': True, 'triggered': True,
'skip': delegated, 'skip': delegated,
'needs': { 'needs': {

View file

@ -2,7 +2,7 @@ defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'dehydrated': {}, 'dehydrated': {},
'dnsutils': {}, 'bind9-dnsutils': {},
}, },
}, },
'letsencrypt': { 'letsencrypt': {

View file

@ -12,9 +12,8 @@ def generate_sysctl_key_value_pairs_from_json(json_data, parents=[]):
key_value_pairs = generate_sysctl_key_value_pairs_from_json(node.metadata.get('sysctl')) key_value_pairs = generate_sysctl_key_value_pairs_from_json(node.metadata.get('sysctl'))
files= { files= {
'/etc/sysctl.conf': { '/etc/sysctl.d/managed.conf': {
'content': '\n'.join( 'content': '\n'.join(
sorted( sorted(
f"{'.'.join(path)}={value}" f"{'.'.join(path)}={value}"
@ -25,6 +24,9 @@ files= {
'svc_systemd:systemd-sysctl.service:restart', 'svc_systemd:systemd-sysctl.service:restart',
], ],
}, },
'/etc/modules-load.d/managed.conf': {
'content': '\n'.join(sorted(node.metadata.get('modules-load'))),
}
} }
svc_systemd = { svc_systemd = {

View file

@ -1,3 +1,6 @@
defaults = { defaults = {
'sysctl': {}, 'sysctl': {
'net.ipv4.icmp_ratelimit': '100',
},
'modules-load': set(),
} }

View file

@ -7,12 +7,7 @@ defaults = {
'locale': { 'locale': {
'default': ('en_US.UTF-8', 'UTF-8'), 'default': ('en_US.UTF-8', 'UTF-8'),
'installed': { 'installed': {
('de_AT.UTF-8', 'UTF-8'),
('de_CH.UTF-8', 'UTF-8'),
('de_DE.UTF-8', 'UTF-8'), ('de_DE.UTF-8', 'UTF-8'),
('de_LU.UTF-8', 'UTF-8'),
('en_CA.UTF-8', 'UTF-8'),
('en_GB.UTF-8', 'UTF-8'),
('en_US.UTF-8', 'UTF-8'), ('en_US.UTF-8', 'UTF-8'),
}, },
}, },

View file

@ -2,5 +2,5 @@
cd "$OLDPWD" cd "$OLDPWD"
export BW_ITEM_WORKERS=$(expr "$(nproc)" '*' 12 '/' 10) export BW_ITEM_WORKERS=$(expr "$(sysctl -n hw.logicalcpu)" '*' 12 '/' 10)
export BW_NODE_WORKERS=$(expr 320 '/' "$BW_ITEM_WORKERS") export BW_NODE_WORKERS=$(expr 320 '/' "$BW_ITEM_WORKERS")

View file

@ -2,7 +2,5 @@
cd "$OLDPWD" cd "$OLDPWD"
GNU_PATH="$HOME/.local/gnu_bin" PATH_add "/opt/homebrew/opt/gnu-sed/libexec/gnubin"
mkdir -p "$GNU_PATH" PATH_add "/opt/homebrew/opt/grep/libexec/gnubin"
test -f "$GNU_PATH/sed" || ln -s "$(which gsed)" "$GNU_PATH/sed"
PATH_add "$GNU_PATH"

View file

@ -2,6 +2,8 @@
cd "$OLDPWD" cd "$OLDPWD"
pyenv install --skip-existing
if test -f .venv/bin/python && test "$(realpath .venv/bin/python)" != "$(realpath "$(pyenv which python)")" if test -f .venv/bin/python && test "$(realpath .venv/bin/python)" != "$(realpath "$(pyenv which python)")"
then then
echo "rebuilding venv für new python version" echo "rebuilding venv für new python version"

26
bundles/mailman/README.md Normal file
View file

@ -0,0 +1,26 @@
# Mailman
- django admin udner /admin
## Testmail
`echo export REST_API_PASS=$(bw metadata mseibert.mailman -k mailman/api_password | jq -r .mailman.api_password)`
```sh
curl -s -o /dev/null \
-w "Status: %{http_code}\nTime: %{time_total}s\n" \
-u restadmin:$REST_API_PASS \
-H "Content-Type: application/json" \
-X POST http://localhost:8001/3.1/queues/in \
-d "{
\"list_id\": \"testlist-2.mailman.ckn.li\",
\"text\": \"From: i@ckn.li\nTo: testlist-2@mailman.ckn.li\nSubject: Curl Test $(date '+%Y-%m-%d %H:%M:%S')\n\nThis message was sent at $(date '+%Y-%m-%d %H:%M:%S').\"
}"
```
## Log locations
`tail -f /var/log/mailman3/*.log`
`journalctl -f | grep postfix/`
`mailq | head -20`

View file

@ -0,0 +1,22 @@
# This is the mailman extension configuration file to enable HyperKitty as an
# archiver. Remember to add the following lines in the mailman.cfg file:
#
# [archiver.hyperkitty]
# class: mailman_hyperkitty.Archiver
# enable: yes
# configuration: /etc/mailman3/mailman-hyperkitty.cfg
#
[general]
# This is your HyperKitty installation, preferably on the localhost. This
# address will be used by Mailman to forward incoming emails to HyperKitty
# for archiving. It does not need to be publicly available, in fact it's
# better if it is not.
# However, if your Mailman installation is accessed via HTTPS, the URL needs
# to match your SSL certificate (e.g. https://lists.example.com/hyperkitty).
base_url: http://${hostname}/mailman3/hyperkitty/
# The shared api_key, must be identical except for quoting to the value of
# MAILMAN_ARCHIVER_KEY in HyperKitty's settings.
api_key: ${archiver_key}

View file

@ -0,0 +1,190 @@
ACCOUNT_EMAIL_VERIFICATION='none'
# This file is imported by the Mailman Suite. It is used to override
# the default settings from /usr/share/mailman3-web/settings.py.
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '${secret_key}'
ADMINS = (
('Mailman Suite Admin', 'root@localhost'),
)
# Hosts/domain names that are valid for this site; required if DEBUG is False
# See https://docs.djangoproject.com/en/1.8/ref/settings/#allowed-hosts
# Set to '*' per default in the Deian package to allow all hostnames. Mailman3
# is meant to run behind a webserver reverse proxy anyway.
ALLOWED_HOSTS = [
'${hostname}',
]
# Mailman API credentials
MAILMAN_REST_API_URL = 'http://localhost:8001'
MAILMAN_REST_API_USER = 'restadmin'
MAILMAN_REST_API_PASS = '${api_password}'
MAILMAN_ARCHIVER_KEY = '${archiver_key}'
MAILMAN_ARCHIVER_FROM = ('127.0.0.1', '::1')
# Application definition
INSTALLED_APPS = (
'hyperkitty',
'postorius',
'django_mailman3',
# Uncomment the next line to enable the admin:
'django.contrib.admin',
# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'django_gravatar',
'compressor',
'haystack',
'django_extensions',
'django_q',
'allauth',
'allauth.account',
'allauth.socialaccount',
'django_mailman3.lib.auth.fedora',
#'allauth.socialaccount.providers.openid',
#'allauth.socialaccount.providers.github',
#'allauth.socialaccount.providers.gitlab',
#'allauth.socialaccount.providers.google',
#'allauth.socialaccount.providers.facebook',
#'allauth.socialaccount.providers.twitter',
#'allauth.socialaccount.providers.stackexchange',
)
# Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
DATABASES = {
'default': {
# Use 'sqlite3', 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
#'ENGINE': 'django.db.backends.sqlite3',
'ENGINE': 'django.db.backends.postgresql_psycopg2',
#'ENGINE': 'django.db.backends.mysql',
# DB name or path to database file if using sqlite3.
#'NAME': '/var/lib/mailman3/web/mailman3web.db',
'NAME': 'mailman',
# The following settings are not used with sqlite3:
'USER': 'mailman',
'PASSWORD': '${db_password}',
# HOST: empty for localhost through domain sockets or '127.0.0.1' for
# localhost through TCP.
'HOST': '127.0.0.1',
# PORT: set to empty string for default.
'PORT': '5432',
# OPTIONS: Extra parameters to use when connecting to the database.
'OPTIONS': {
# Set sql_mode to 'STRICT_TRANS_TABLES' for MySQL. See
# https://docs.djangoproject.com/en/1.11/ref/
# databases/#setting-sql-mode
#'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
# If you're behind a proxy, use the X-Forwarded-Host header
# See https://docs.djangoproject.com/en/1.8/ref/settings/#use-x-forwarded-host
USE_X_FORWARDED_HOST = True
# And if your proxy does your SSL encoding for you, set SECURE_PROXY_SSL_HEADER
# https://docs.djangoproject.com/en/1.8/ref/settings/#secure-proxy-ssl-header
# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_SCHEME', 'https')
# Other security settings
# SECURE_SSL_REDIRECT = True
# If you set SECURE_SSL_REDIRECT to True, make sure the SECURE_REDIRECT_EXEMPT
# contains at least this line:
# SECURE_REDIRECT_EXEMPT = [
# "archives/api/mailman/.*", # Request from Mailman.
# ]
# SESSION_COOKIE_SECURE = True
# SECURE_CONTENT_TYPE_NOSNIFF = True
# SECURE_BROWSER_XSS_FILTER = True
# CSRF_COOKIE_SECURE = True
# CSRF_COOKIE_HTTPONLY = True
# X_FRAME_OPTIONS = 'DENY'
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Set default domain for email addresses.
EMAILNAME = 'localhost.local'
# If you enable internal authentication, this is the address that the emails
# will appear to be coming from. Make sure you set a valid domain name,
# otherwise the emails may get rejected.
# https://docs.djangoproject.com/en/1.8/ref/settings/#default-from-email
# DEFAULT_FROM_EMAIL = "mailing-lists@you-domain.org"
DEFAULT_FROM_EMAIL = 'postorius@{}'.format(EMAILNAME)
# If you enable email reporting for error messages, this is where those emails
# will appear to be coming from. Make sure you set a valid domain name,
# otherwise the emails may get rejected.
# https://docs.djangoproject.com/en/1.8/ref/settings/#std:setting-SERVER_EMAIL
# SERVER_EMAIL = 'root@your-domain.org'
SERVER_EMAIL = 'root@{}'.format(EMAILNAME)
# Django Allauth
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
#
# Social auth
#
SOCIALACCOUNT_PROVIDERS = {
#'openid': {
# 'SERVERS': [
# dict(id='yahoo',
# name='Yahoo',
# openid_url='http://me.yahoo.com'),
# ],
#},
#'google': {
# 'SCOPE': ['profile', 'email'],
# 'AUTH_PARAMS': {'access_type': 'online'},
#},
#'facebook': {
# 'METHOD': 'oauth2',
# 'SCOPE': ['email'],
# 'FIELDS': [
# 'email',
# 'name',
# 'first_name',
# 'last_name',
# 'locale',
# 'timezone',
# ],
# 'VERSION': 'v2.4',
#},
}
# On a production setup, setting COMPRESS_OFFLINE to True will bring a
# significant performance improvement, as CSS files will not need to be
# recompiled on each requests. It means running an additional "compress"
# management command after each code upgrade.
# http://django-compressor.readthedocs.io/en/latest/usage/#offline-compression
COMPRESS_OFFLINE = True
POSTORIUS_TEMPLATE_BASE_URL = 'http://${hostname}/mailman3/'

View file

@ -0,0 +1,271 @@
# Copyright (C) 2008-2017 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
# This file contains the Debian configuration for mailman. It uses ini-style
# formats under the lazr.config regime to define all system configuration
# options. See <https://launchpad.net/lazr.config> for details.
[mailman]
# This address is the "site owner" address. Certain messages which must be
# delivered to a human, but which can't be delivered to a list owner (e.g. a
# bounce from a list owner), will be sent to this address. It should point to
# a human.
site_owner: ${site_owner_email}
# This is the local-part of an email address used in the From field whenever a
# message comes from some entity to which there is no natural reply recipient.
# Mailman will append '@' and the host name of the list involved. This
# address must not bounce and it must not point to a Mailman process.
noreply_address: noreply
# The default language for this server.
default_language: de
# Membership tests for posting purposes are usually performed by looking at a
# set of headers, passing the test if any of their values match a member of
# the list. Headers are checked in the order given in this variable. The
# value From_ means to use the envelope sender. Field names are case
# insensitive. This is a space separate list of headers.
sender_headers: from from_ reply-to sender
# Mail command processor will ignore mail command lines after designated max.
email_commands_max_lines: 10
# Default length of time a pending request is live before it is evicted from
# the pending database.
pending_request_life: 3d
# How long should files be saved before they are evicted from the cache?
cache_life: 7d
# A callable to run with no arguments early in the initialization process.
# This runs before database initialization.
pre_hook:
# A callable to run with no arguments late in the initialization process.
# This runs after adapters are initialized.
post_hook:
# Which paths.* file system layout to use.
# You should not change this variable.
layout: debian
# Can MIME filtered messages be preserved by list owners?
filtered_messages_are_preservable: no
# How should text/html parts be converted to text/plain when the mailing list
# is set to convert HTML to plaintext? This names a command to be called,
# where the substitution variable $filename is filled in by Mailman, and
# contains the path to the temporary file that the command should read from.
# The command should print the converted text to stdout.
html_to_plain_text_command: /usr/bin/lynx -dump $filename
# Specify what characters are allowed in list names. Characters outside of
# the class [-_.+=!$*{}~0-9a-z] matched case insensitively are never allowed,
# but this specifies a subset as the only allowable characters. This must be
# a valid character class regexp or the effect on list creation is
# unpredictable.
listname_chars: [-_.0-9a-z]
[shell]
# `mailman shell` (also `withlist`) gives you an interactive prompt that you
# can use to interact with an initialized and configured Mailman system. Use
# --help for more information. This section allows you to configure certain
# aspects of this interactive shell.
# Customize the interpreter prompt.
prompt: >>>
# Banner to show on startup.
banner: Welcome to the GNU Mailman shell
# Use IPython as the shell, which must be found on the system. Valid values
# are `no`, `yes`, and `debug` where the latter is equivalent to `yes` except
# that any import errors will be displayed to stderr.
use_ipython: no
# Set this to allow for command line history if readline is available. This
# can be as simple as $var_dir/history.py to put the file in the var directory.
history_file:
[paths.debian]
# Important directories for Mailman operation. These are defined here so that
# different layouts can be supported. For example, a developer layout would
# be different from a FHS layout. Most paths are based off the var_dir, and
# often just setting that will do the right thing for all the other paths.
# You might also have to set spool_dir though.
#
# Substitutions are allowed, but must be of the form $var where 'var' names a
# configuration variable in the paths.* section. Substitutions are expanded
# recursively until no more $-variables are present. Beware of infinite
# expansion loops!
#
# This is the root of the directory structure that Mailman will use to store
# its run-time data.
var_dir: /var/lib/mailman3
# This is where the Mailman queue files directories will be created.
queue_dir: $var_dir/queue
# This is the directory containing the Mailman 'runner' and 'master' commands
# if set to the string '$argv', it will be taken as the directory containing
# the 'mailman' command.
bin_dir: /usr/lib/mailman3/bin
# All list-specific data.
list_data_dir: $var_dir/lists
# Directory where log files go.
log_dir: /var/log/mailman3
# Directory for system-wide locks.
lock_dir: $var_dir/locks
# Directory for system-wide data.
data_dir: $var_dir/data
# Cache files.
cache_dir: $var_dir/cache
# Directory for configuration files and such.
etc_dir: /etc/mailman3
# Directory containing Mailman plugins.
ext_dir: $var_dir/ext
# Directory where the default IMessageStore puts its messages.
messages_dir: $var_dir/messages
# Directory for archive backends to store their messages in. Archivers should
# create a subdirectory in here to store their files.
archive_dir: $var_dir/archives
# Root directory for site-specific template override files.
template_dir: $var_dir/templates
# There are also a number of paths to specific file locations that can be
# defined. For these, the directory containing the file must already exist,
# or be one of the directories created by Mailman as per above.
#
# This is where PID file for the master runner is stored.
pid_file: /run/mailman3/master.pid
# Lock file.
lock_file: $lock_dir/master.lck
[database]
# The class implementing the IDatabase.
class: mailman.database.sqlite.SQLiteDatabase
#class: mailman.database.mysql.MySQLDatabase
#class: mailman.database.postgresql.PostgreSQLDatabase
# Use this to set the Storm database engine URL. You generally have one
# primary database connection for all of Mailman. List data and most rosters
# will store their data in this database, although external rosters may access
# other databases in their own way. This string supports standard
# 'configuration' substitutions.
url: sqlite:///$DATA_DIR/mailman.db
#url: mysql+pymysql://mailman3:mmpass@localhost/mailman3?charset=utf8&use_unicode=1
#url: postgresql://mailman3:mmpass@localhost/mailman3
debug: no
[logging.debian]
# This defines various log settings. The options available are:
#
# - level -- Overrides the default level; this may be any of the
# standard Python logging levels, case insensitive.
# - format -- Overrides the default format string
# - datefmt -- Overrides the default date format string
# - path -- Overrides the default logger path. This may be a relative
# path name, in which case it is relative to Mailman's LOG_DIR,
# or it may be an absolute path name. You cannot change the
# handler class that will be used.
# - propagate -- Boolean specifying whether to propagate log message from this
# logger to the root "mailman" logger. You cannot override
# settings for the root logger.
#
# In this section, you can define defaults for all loggers, which will be
# prefixed by 'mailman.'. Use subsections to override settings for specific
# loggers. The names of the available loggers are:
#
# - archiver -- All archiver output
# - bounce -- All bounce processing logs go here
# - config -- Configuration issues
# - database -- Database logging (SQLAlchemy and Alembic)
# - debug -- Only used for development
# - error -- All exceptions go to this log
# - fromusenet -- Information related to the Usenet to Mailman gateway
# - http -- Internal wsgi-based web interface
# - locks -- Lock state changes
# - mischief -- Various types of hostile activity
# - runner -- Runner process start/stops
# - smtp -- Successful SMTP activity
# - smtp-failure -- Unsuccessful SMTP activity
# - subscribe -- Information about leaves/joins
# - vette -- Message vetting information
format: %(asctime)s (%(process)d) %(message)s
datefmt: %b %d %H:%M:%S %Y
propagate: no
level: info
path: mailman.log
[webservice]
# The hostname at which admin web service resources are exposed.
hostname: localhost
# The port at which the admin web service resources are exposed.
port: 8001
# Whether or not requests to the web service are secured through SSL.
use_https: no
# Whether or not to show tracebacks in an HTTP response for a request that
# raised an exception.
show_tracebacks: yes
# The API version number for the current (highest) API.
api_version: 3.1
# The administrative username.
admin_user: restadmin
# The administrative password.
admin_pass: ${api_password}
[mta]
# The class defining the interface to the incoming mail transport agent.
#incoming: mailman.mta.exim4.LMTP
incoming: mailman.mta.postfix.LMTP
# The callable implementing delivery to the outgoing mail transport agent.
# This must accept three arguments, the mailing list, the message, and the
# message metadata dictionary.
outgoing: mailman.mta.deliver.deliver
# How to connect to the outgoing MTA. If smtp_user and smtp_pass is given,
# then Mailman will attempt to log into the MTA when making a new connection.
smtp_host: 127.0.0.1
smtp_port: 25
smtp_user:
smtp_pass:
# Where the LMTP server listens for connections. Use 127.0.0.1 instead of
# localhost for Postfix integration, because Postfix only consults DNS
# (e.g. not /etc/hosts).
lmtp_host: 127.0.0.1
lmtp_port: 8024
# Where can we find the mail server specific configuration file? The path can
# be either a file system path or a Python import path. If the value starts
# with python: then it is a Python import path, otherwise it is a file system
# path. File system paths must be absolute since no guarantees are made about
# the current working directory. Python paths should not include the trailing
# .cfg, which the file must end with.
#configuration: python:mailman.config.exim4
configuration: python:mailman.config.postfix

View file

@ -0,0 +1,53 @@
# See /usr/share/postfix/main.cf.dist for a commented, more complete version
# Debian specific: Specifying a file name will cause the first
# line of that file to be used as the name. The Debian default
# is /etc/mailname.
#myorigin = /etc/mailname
smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU)
biff = no
# appending .domain is the MUA's job.
append_dot_mydomain = no
# Uncomment the next line to generate "delayed mail" warnings
#delay_warning_time = 4h
readme_directory = no
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 3.6 on
# fresh installs.
compatibility_level = 3.6
# TLS parameters
smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=may
smtp_tls_session_cache_database = <%text>btree:${data_directory}/smtp_scache</%text>
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = ${hostname}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
mydestination = $myhostname, localhost, localhost.localdomain, ${hostname}
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
#inet_protocols = all
inet_protocols = ipv4
unknown_local_recipient_reject_code = 550
owner_request_special = no
transport_maps =
hash:/var/lib/mailman3/data/postfix_lmtp
local_recipient_maps =
hash:/var/lib/mailman3/data/postfix_lmtp
relay_domains =
hash:/var/lib/mailman3/data/postfix_domains

View file

@ -0,0 +1,50 @@
[uwsgi]
# Port on which uwsgi will be listening.
uwsgi-socket = /run/mailman3-web/uwsgi.sock
#Enable threading for python
enable-threads = true
# Move to the directory wher the django files are.
chdir = /usr/share/mailman3-web
# Use the wsgi file provided with the django project.
wsgi-file = wsgi.py
# Setup default number of processes and threads per process.
master = true
process = 2
threads = 2
# Drop privielges and don't run as root.
uid = www-data
gid = www-data
plugins = python3
# Setup the django_q related worker processes.
attach-daemon = python3 manage.py qcluster
# Setup hyperkitty's cron jobs.
#unique-cron = -1 -1 -1 -1 -1 ./manage.py runjobs minutely
#unique-cron = -15 -1 -1 -1 -1 ./manage.py runjobs quarter_hourly
#unique-cron = 0 -1 -1 -1 -1 ./manage.py runjobs hourly
#unique-cron = 0 0 -1 -1 -1 ./manage.py runjobs daily
#unique-cron = 0 0 1 -1 -1 ./manage.py runjobs monthly
#unique-cron = 0 0 -1 -1 0 ./manage.py runjobs weekly
#unique-cron = 0 0 1 1 -1 ./manage.py runjobs yearly
# Setup the request log.
#req-logger = file:/var/log/mailman3/web/mailman-web.log
# Log cron seperately.
#logger = cron file:/var/log/mailman3/web/mailman-web-cron.log
#log-route = cron uwsgi-cron
# Log qcluster commands seperately.
#logger = qcluster file:/var/log/mailman3/web/mailman-web-qcluster.log
#log-route = qcluster uwsgi-daemons
# Last log and it logs the rest of the stuff.
#logger = file:/var/log/mailman3/web/mailman-web-error.log
logto = /var/log/mailman3/web/mailman-web.log

104
bundles/mailman/items.py Normal file
View file

@ -0,0 +1,104 @@
directories = {
'/var/lib/mailman3': {
'owner': 'list',
'group': 'list',
'needs': {
'zfs_dataset:tank/mailman',
'pkg_apt:mailman3-full',
},
'needed_by': {
'svc_systemd:mailman3.service',
'svc_systemd:mailman3-web.service',
},
},
}
files = {
'/etc/postfix/main.cf': {
'source': 'postfix.cf',
'content_type': 'mako',
'mode': '0644',
'context': {
'hostname': node.metadata.get('mailman/hostname'),
},
'needs': {
'pkg_apt:postfix',
},
'triggers': {
'svc_systemd:postfix.service:restart',
},
},
'/etc/mailman3/mailman.cfg': {
'content_type': 'mako',
'owner': 'root',
'group': 'list',
'mode': '0640',
'context': node.metadata.get('mailman'),
'needs': {
'pkg_apt:mailman3-full',
},
'triggers': {
'svc_systemd:mailman3.service:restart',
'svc_systemd:mailman3-web.service:restart',
},
},
'/etc/mailman3/mailman-web.py': {
'content_type': 'mako',
'owner': 'root',
'group': 'www-data',
'mode': '0640',
'context': node.metadata.get('mailman'),
'needs': {
'pkg_apt:mailman3-full',
},
'triggers': {
'svc_systemd:mailman3.service:restart',
'svc_systemd:mailman3-web.service:restart',
},
},
'/etc/mailman3/mailman-hyperkitty.cfg': {
'content_type': 'mako',
'owner': 'root',
'group': 'list',
'mode': '0640',
'context': node.metadata.get('mailman'),
'needs': {
'pkg_apt:mailman3-full',
},
'triggers': {
'svc_systemd:mailman3.service:restart',
'svc_systemd:mailman3-web.service:restart',
},
},
'/etc/mailman3/uwsgi.ini': {
'content_type': 'text',
'owner': 'root',
'group': 'root',
'mode': '0644',
'needs': {
'pkg_apt:mailman3-full',
},
'triggers': {
'svc_systemd:mailman3.service:restart',
'svc_systemd:mailman3-web.service:restart',
},
},
}
svc_systemd = {
'postfix.service': {
'needs': {
'pkg_apt:postfix',
},
},
'mailman3.service': {
'needs': {
'pkg_apt:mailman3-full',
},
},
'mailman3-web.service': {
'needs': {
'pkg_apt:mailman3-full',
},
},
}

149
bundles/mailman/metadata.py Normal file
View file

@ -0,0 +1,149 @@
import base64
def derive_mailadmin_secret(metadata, salt):
node_id = metadata.get('id')
raw = base64.b64decode(
repo.vault.random_bytes_as_base64_for(f'{node_id}_{salt}', length=32).value
)
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
defaults = {
'apt': {
'packages': {
'mailman3-full': {
'needs': {
'postgres_db:mailman',
'postgres_role:mailman',
'zfs_dataset:tank/mailman',
}
},
'postfix': {},
'python3-psycopg2': {
'needed_by': {
'pkg_apt:mailman3-full',
},
},
'apache2': {
'installed': False,
'needs': {
'pkg_apt:mailman3-full',
},
},
},
},
'zfs': {
'datasets': {
'tank/mailman': {
'mountpoint': '/var/lib/mailman3',
},
},
},
}
@metadata_reactor.provides(
'postgresql',
'mailman',
)
def postgresql(metadata):
node_id = metadata.get('id')
db_password = repo.vault.password_for(f'{node_id} database mailman')
return {
'postgresql': {
'databases': {
'mailman': {
'owner': 'mailman',
},
},
'roles': {
'mailman': {
'password': db_password,
},
},
},
'mailman': {
'db_password': db_password,
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx(metadata):
return {
'nginx': {
'vhosts': {
metadata.get('mailman/hostname'): {
'content': 'mailman/vhost.conf',
},
},
},
}
@metadata_reactor.provides(
'mailman/secret_key',
)
def secret_key(metadata):
import base64
node_id = metadata.get('id')
raw = base64.b64decode(
repo.vault.random_bytes_as_base64_for(f'{node_id}_mailman_secret_key', length=32).value
)
secret_key = base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
return {
'mailman': {
'secret_key': secret_key,
},
}
@metadata_reactor.provides(
'mailman',
)
def secrets(metadata):
return {
'mailman': {
'web_secret': derive_mailadmin_secret(metadata, 'secret_key'),
'api_password': derive_mailadmin_secret(metadata, 'api_password'),
'archiver_key': derive_mailadmin_secret(metadata, 'archiver_key'),
},
}
@metadata_reactor.provides(
'dns',
)
def dns(metadata):
report_email = metadata.get('mailman/dmarc_report_email')
return {
'dns': {
metadata.get('mailman/hostname'): {
'MX': [f"5 {metadata.get('mailman/hostname')}."],
'TXT': [
'v=spf1 a mx -all',
'; '.join(f'{k}={v}' for k, v in {
# dmarc version
'v': 'DMARC1',
# reject on failure
'p': 'reject',
# standard reports
'rua': f'mailto:{report_email}',
# forensic reports
'fo': 1,
'ruf': f'mailto:{report_email}',
# require alignment between the DKIM domain and the parent Header From domain
'adkim': 's',
# require alignment between the SPF domain (the sender) and the Header From domain
'aspf': 's',
}.items())
],
},
},
}

View file

@ -32,10 +32,14 @@ defaults = {
'tank/vmail': { 'tank/vmail': {
'mountpoint': '/var/vmail', 'mountpoint': '/var/vmail',
'compression': 'on', 'compression': 'on',
'atime': 'off',
'recordsize': '16384',
}, },
'tank/vmail/index': { 'tank/vmail/index': {
'mountpoint': '/var/vmail/index', 'mountpoint': '/var/vmail/index',
'compression': 'on', 'compression': 'on',
'atime': 'off',
'recordsize': '4096',
'com.sun:auto-snapshot': 'false', 'com.sun:auto-snapshot': 'false',
'backup': False, 'backup': False,
}, },
@ -78,6 +82,7 @@ def dns(metadata):
'dns': dns, 'dns': dns,
} }
@metadata_reactor.provides( @metadata_reactor.provides(
'letsencrypt/domains', 'letsencrypt/domains',
) )

View file

@ -1,11 +0,0 @@
% for section, options in sorted(conf.items()):
[${section}]
% for key, value in sorted(options.items()):
% if value is None:
${key}
% else:
${key} = ${value}
% endif
% endfor
% endfor

View file

@ -10,8 +10,6 @@ directories = {
'group': 'mysql', 'group': 'mysql',
'needs': [ 'needs': [
'zfs_dataset:tank/mariadb', 'zfs_dataset:tank/mariadb',
],
'needed_by': [
'pkg_apt:mariadb-server', 'pkg_apt:mariadb-server',
'pkg_apt:mariadb-client', 'pkg_apt:mariadb-client',
], ],
@ -20,10 +18,8 @@ directories = {
files = { files = {
'/etc/mysql/conf.d/override.conf': { '/etc/mysql/conf.d/override.conf': {
'context': { 'content': repo.libs.ini.dumps(node.metadata.get('mariadb/conf')),
'conf': node.metadata.get('mariadb/conf'), 'content_type': 'text',
},
'content_type': 'mako',
}, },
} }

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