Compare commits

..

No commits in common. "master" and "temp130752653" have entirely different histories.

411 changed files with 2192 additions and 19863 deletions

14
.envrc
View file

@ -1,7 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
python3 -m venv .venv
source ./.venv/bin/activate
PATH_add .venv/bin
PATH_add bin PATH_add bin
python3 -m pip install --upgrade pip
layout uv rm -rf .cache/bw/git_deploy
export BW_GIT_DEPLOY_CACHE=.cache/bw/git_deploy
source_env ~/.local/share/direnv/bundlewrap mkdir -p "$BW_GIT_DEPLOY_CACHE"
export EXPERIMENTAL_UPLOAD_VIA_CAT=1
export BW_ITEM_WORKERS=16
export BW_NODE_WORKERS=32
unset PS1

5
.gitignore vendored
View file

@ -2,8 +2,3 @@
.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
View file

@ -1,108 +0,0 @@
# 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.

View file

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

View file

@ -13,6 +13,10 @@ 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
@ -33,12 +37,3 @@ 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"

View file

@ -1,62 +0,0 @@
# 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.

View file

@ -1,149 +0,0 @@
#!/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)

View file

@ -1,23 +0,0 @@
#!/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,33 +0,0 @@
#!/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 os.path import realpath, dirname
from shlex import quote
from bundlewrap.repo import Repository
repo = Repository(dirname(dirname(realpath(__file__))))
if len(argv) == 1:
for node in repo.nodes:
for name in node.metadata.get('left4dead2/servers', {}):
print(name)
exit(0)
server = argv[1]
command = argv[2]
remote_code = """
from rcon.source import Client
with Client('127.0.0.1', {port}, passwd='''{password}''') as client:
response = client.run('''{command}''')
print(response)
"""
for node in repo.nodes:
for name, conf in node.metadata.get('left4dead2/servers', {}).items():
if name == server:
response = node.run('python3 -c ' + quote(remote_code.format(port=conf['port'], password=conf['rcon_password'], command=command)))
print(response.stdout.decode())

View file

@ -1,7 +1,6 @@
#!/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
bw = Repository(dirname(dirname(realpath(__file__)))) repo = Repository(dirname(dirname(realpath(__file__))))

View file

@ -1,133 +0,0 @@
#!/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

@ -1,217 +0,0 @@
#!/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,5 +1,4 @@
#!/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
@ -11,6 +10,7 @@ nodes = [
for node in sorted(repo.nodes_in_group('debian')) for node in sorted(repo.nodes_in_group('debian'))
if not node.dummy if not node.dummy
] ]
reboot_nodes = []
print('updating nodes:', sorted(node.name for node in nodes)) print('updating nodes:', sorted(node.name for node in nodes))
@ -24,13 +24,14 @@ 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 -qy full-upgrade').stdout.decode()) print(node.run('DEBIAN_FRONTEND=noninteractive apt -y dist-upgrade').stdout.decode())
reboot_nodes.append(node)
# REBOOT IN ORDER # REBOOT IN ORDER
wireguard_servers = [ wireguard_servers = [
node node
for node in nodes for node in reboot_nodes
if node.has_bundle('wireguard') if node.has_bundle('wireguard')
and ( and (
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen < ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen <
@ -40,7 +41,7 @@ wireguard_servers = [
wireguard_s2s = [ wireguard_s2s = [
node node
for node in nodes for node in reboot_nodes
if node.has_bundle('wireguard') if node.has_bundle('wireguard')
and ( and (
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen == ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen ==
@ -50,7 +51,7 @@ wireguard_s2s = [
everything_else = [ everything_else = [
node node
for node in nodes for node in reboot_nodes
if not node.has_bundle('wireguard') if not node.has_bundle('wireguard')
] ]
@ -61,11 +62,8 @@ for node in [
*wireguard_s2s, *wireguard_s2s,
*wireguard_servers, *wireguard_servers,
]: ]:
try:
if node.run('test -e /var/run/reboot-required', may_fail=True).return_code == 0:
print('rebooting', node.name) print('rebooting', node.name)
try:
print(node.run('systemctl reboot').stdout.decode()) print(node.run('systemctl reboot').stdout.decode())
else:
print('not rebooting', node.name)
except Exception as e: except Exception as e:
print(e) print(e)

View file

@ -1,5 +1,4 @@
#!/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,25 +1,15 @@
#!/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
# get info from repo
repo = Repository(dirname(dirname(realpath(__file__)))) repo = Repository(dirname(dirname(realpath(__file__))))
server_node = repo.get_node('htz.mails')
available_clients = server_node.metadata.get('wireguard/clients').keys()
# parse args server_node = repo.get_node(argv[1])
parser = argparse.ArgumentParser(description='Generate WireGuard client configuration.') data = server_node.metadata.get(f'wireguard/clients/{argv[2]}')
parser.add_argument('client', choices=available_clients, help='The client name to generate the configuration for.')
args = parser.parse_args()
# 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,
@ -30,7 +20,9 @@ for peer in server_node.metadata.get('wireguard/s2s').values():
if not ip_network(network).subnet_of(vpn_network): if not ip_network(network).subnet_of(vpn_network):
allowed_ips.append(ip_network(network)) allowed_ips.append(ip_network(network))
conf = f''' conf = \
f'''>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
[Interface] [Interface]
PrivateKey = {repo.libs.wireguard.privkey(data['peer_id'])} PrivateKey = {repo.libs.wireguard.privkey(data['peer_id'])}
ListenPort = 51820 ListenPort = 51820
@ -43,17 +35,11 @@ PresharedKey = {repo.libs.wireguard.psk(data['peer_id'], server_node.metadata.ge
AllowedIPs = {', '.join(str(client_route) for client_route in sorted(allowed_ips))} AllowedIPs = {', '.join(str(client_route) for client_route in sorted(allowed_ips))}
Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:51820 Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:51820
PersistentKeepalive = 10 PersistentKeepalive = 10
'''
answer = input("print config or qrcode? [Cq]: ").strip().upper() <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<'''
match answer:
case '' | 'C': print(conf)
print('>>>>>>>>>>>>>>>')
print(conf) if input("print qrcode? [yN]: ").upper() == 'Y':
print('<<<<<<<<<<<<<<<')
case 'Q':
import pyqrcode import pyqrcode
print(pyqrcode.create(conf).terminal(quiet_zone=1)) print(pyqrcode.create(conf).terminal(quiet_zone=1))
case _:
print(f'Invalid option "{answer}".')
exit(1)

View file

@ -1,204 +0,0 @@
# 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,14 +13,16 @@ 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

@ -1,6 +1,3 @@
# https://manpages.debian.org/latest/apt/sources.list.5.de.html
# https://repolib.readthedocs.io/en/latest/deb822-format.html
```python ```python
{ {
'apt': { 'apt': {
@ -8,32 +5,8 @@
'apt-transport-https': {}, 'apt-transport-https': {},
}, },
'sources': { 'sources': {
'debian': { # place key under data/apt/keys/packages.cloud.google.com.{asc|gpg}
'types': { # optional, defaults to `{'deb'}`` 'deb https://packages.cloud.google.com/apt cloud-sdk main',
'deb',
'deb-src',
},
'options': { # optional
'aarch': 'amd64',
},
'urls': {
'https://deb.debian.org/debian',
},
'suites': { # at least one
'{codename}',
'{codename}-updates',
'{codename}-backports',
},
'components': { # optional
'main',
'contrib',
'non-frese',
},
# key:
# - optional, defaults to source name (`debian` in this example)
# - place key under data/apt/keys/debian-12.{asc|gpg}
'key': 'debian-{version}',
},
}, },
}, },
} }

View file

@ -0,0 +1,2 @@
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

View file

@ -0,0 +1,3 @@
Unattended-Upgrade::Origins-Pattern {
"origin=*";
};

View file

@ -1,68 +1,36 @@
# TODO pin repo: https://superuser.com/a/1595920 from os.path import join
from urllib.parse import urlparse
from glob import glob
from os.path import join, basename from os.path import join, basename
directories = { directories = {
'/etc/apt': {
'purge': True,
'triggers': {
'action:apt_update',
},
},
'/etc/apt/apt.conf.d': {
# existance is expected
'purge': True,
'triggers': {
'action:apt_update',
},
},
'/etc/apt/keyrings': {
# https://askubuntu.com/a/1307181
'purge': True,
'triggers': {
'action:apt_update',
},
},
# '/etc/apt/listchanges.conf.d': {
# 'purge': True,
# 'triggers': {
# 'action:apt_update',
# },
# },
'/etc/apt/preferences.d': {
'purge': True,
'triggers': {
'action:apt_update',
},
},
'/etc/apt/sources.list.d': { '/etc/apt/sources.list.d': {
'purge': True, 'purge': True,
'triggers': { 'triggers': {
'action:apt_update', 'action:apt_update',
}, },
}, },
'/etc/apt/trusted.gpg.d': {
'purge': True,
'triggers': {
'action:apt_update',
},
},
'/etc/apt/preferences.d': {
'purge': True,
'triggers': {
'action:apt_update',
},
},
} }
files = { files = {
'/etc/apt/apt.conf': {
'content': repo.libs.apt.render_apt_conf(node.metadata.get('apt/config')),
'triggers': {
'action:apt_update',
},
},
'/etc/apt/sources.list': { '/etc/apt/sources.list': {
'content': '# managed by bundlewrap\n', 'content': '# managed'
'triggers': {
'action:apt_update',
}, },
},
# '/etc/apt/listchanges.conf': {
# 'content': repo.libs.ini.dumps(node.metadata.get('apt/list_changes')),
# },
'/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 = {
@ -76,22 +44,41 @@ actions = {
}, },
} }
# create sources.lists and respective keyfiles # group sources by apt server hostname
for name, config in node.metadata.get('apt/sources').items(): hosts = {}
# place keyfile
keyfile_destination_path = repo.libs.apt.format_variables(node, config['options']['Signed-By']) for source_string in node.metadata.get('apt/sources'):
files[keyfile_destination_path] = { source = repo.libs.apt.AptSource(source_string)
'source': join(repo.path, 'data', 'apt', 'keys', basename(keyfile_destination_path)), hosts\
'content_type': 'binary', .setdefault(source.url.hostname, list())\
.append(source)
# create sources lists and keyfiles
for host, sources in hosts.items():
keyfile = basename(glob(join(repo.path, 'data', 'apt', 'keys', f'{host}.*'))[0])
destination_path = f'/etc/apt/trusted.gpg.d/{keyfile}'
for source in sources:
source.options['signed-by'] = [destination_path]
files[f'/etc/apt/sources.list.d/{host}.list'] = {
'content': '\n'.join(sorted(set(
str(source).format(
release=node.metadata.get('os_release'),
version=node.os_version[0], # WIP crystal
)
for source in sources
))),
'triggers': { 'triggers': {
'action:apt_update', 'action:apt_update',
}, },
} }
# place sources.list files[destination_path] = {
files[f'/etc/apt/sources.list.d/{name}.sources'] = { 'source': join(repo.path, 'data', 'apt', 'keys', keyfile),
'content': repo.libs.apt.render_source(node, name), 'content_type': 'binary',
'triggers': { 'triggers': {
'action:apt_update', 'action:apt_update',
}, },
@ -106,7 +93,7 @@ for package, options in node.metadata.get('apt/packages', {}).items():
files[f'/etc/apt/preferences.d/{package}'] = { files[f'/etc/apt/preferences.d/{package}'] = {
'content': '\n'.join([ 'content': '\n'.join([
f"Package: {package}", f"Package: {package}",
f"Pin: release a={node.metadata.get('os_codename')}-backports", f"Pin: release a={node.metadata.get('os_release')}-backports",
f"Pin-Priority: 900", f"Pin-Priority: 900",
]), ]),
'needed_by': [ 'needed_by': [
@ -123,6 +110,8 @@ for package, options in node.metadata.get('apt/packages', {}).items():
# apt-daily.timer: performs apt update # apt-daily.timer: performs apt update
# apt-daily-upgrade.timer: performs apt upgrade # apt-daily-upgrade.timer: performs apt upgrade
files['/etc/apt/apt.conf.d/20auto-upgrades'] = {}
files['/etc/apt/apt.conf.d/50unattended-upgrades'] = {}
svc_systemd['unattended-upgrades.service'] = { svc_systemd['unattended-upgrades.service'] = {
'needs': [ 'needs': [
'pkg_apt:unattended-upgrades', 'pkg_apt:unattended-upgrades',

View file

@ -1,66 +1,9 @@
defaults = { defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'apt-listchanges': {
'installed': False,
},
'ca-certificates': {},
'unattended-upgrades': {}, 'unattended-upgrades': {},
}, },
'config': { 'sources': set(),
'DPkg': {
'Pre-Install-Pkgs': {
'/usr/sbin/dpkg-preconfigure --apt || true',
},
'Post-Invoke': {
# keep package cache empty
'/bin/rm -f /var/cache/apt/archives/*.deb || true',
},
'Options': {
# https://unix.stackexchange.com/a/642541/357916
'--force-confold',
'--force-confdef',
},
},
'APT': {
'Periodic': {
'Update-Package-Lists': '1',
'Unattended-Upgrade': '1',
},
'NeverAutoRemove': {
'^firmware-linux.*',
'^linux-firmware$',
'^linux-image-[a-z0-9]*$',
'^linux-image-[a-z0-9]*-[a-z0-9]*$',
},
'VersionedKernelPackages': {
# kernels
'linux-.*',
'kfreebsd-.*',
'gnumach-.*',
# (out-of-tree) modules
'.*-modules',
'.*-kernel',
},
'Never-MarkAuto-Sections': {
'metapackages',
'tasks',
},
'Move-Autobit-Sections': {
'oldlibs',
},
'Update': {
# https://unix.stackexchange.com/a/653377/357916
'Error-Mode': 'any',
},
},
'Unattended-Upgrade': {
'Origins-Pattern': {
"origin=*",
},
},
},
'sources': {},
}, },
'monitoring': { 'monitoring': {
'services': { 'services': {
@ -80,82 +23,3 @@ defaults = {
}, },
}, },
} }
@metadata_reactor.provides(
'apt/sources',
)
def key(metadata):
return {
'apt': {
'sources': {
source_name: {
'key': source_name,
}
for source_name, source_config in metadata.get('apt/sources').items()
if 'key' not in source_config
},
},
}
@metadata_reactor.provides(
'apt/sources',
)
def signed_by(metadata):
return {
'apt': {
'sources': {
source_name: {
'options': {
'Signed-By': '/etc/apt/keyrings/' + metadata.get(f'apt/sources/{source_name}/key') + '.' + repo.libs.apt.find_keyfile_extension(node, metadata.get(f'apt/sources/{source_name}/key')),
},
}
for source_name in metadata.get('apt/sources')
},
},
}
# @metadata_reactor.provides(
# 'apt/config',
# 'apt/list_changes',
# )
# def listchanges(metadata):
# return {
# 'apt': {
# 'config': {
# 'DPkg': {
# 'Pre-Install-Pkgs': {
# '/usr/bin/apt-listchanges --apt || test $? -lt 10',
# },
# 'Tools': {
# 'Options': {
# '/usr/bin/apt-listchanges': {
# 'Version': '2',
# 'InfoFD': '20',
# },
# },
# },
# },
# 'Dir': {
# 'Etc': {
# 'apt-listchanges-main': 'listchanges.conf',
# 'apt-listchanges-parts': 'listchanges.conf.d',
# },
# },
# },
# 'list_changes': {
# 'apt': {
# 'frontend': 'pager',
# 'which': 'news',
# 'email_address': 'root',
# 'email_format': 'text',
# 'confirm': 'false',
# 'headers': 'false',
# 'reverse': 'false',
# 'save_seen': '/var/lib/apt/listchanges.db',
# },
# },
# },
# }

View file

@ -1,47 +0,0 @@
#!/usr/bin/env python3
import json
from subprocess import check_output
from datetime import datetime, timedelta
now = datetime.now()
two_days_ago = now - timedelta(days=2)
with open('/etc/backup-freshness-check.json', 'r') as file:
config = json.load(file)
local_datasets = check_output(['zfs', 'list', '-H', '-o', 'name']).decode().splitlines()
errors = set()
for dataset in config['datasets']:
if f'tank/{dataset}' not in local_datasets:
errors.add(f'dataset "{dataset}" not present at all')
continue
snapshots = [
snapshot
for snapshot in check_output(['zfs', 'list', '-H', '-o', 'name', '-t', 'snapshot', f'tank/{dataset}', '-s', 'creation']).decode().splitlines()
if f"@{config['prefix']}" in snapshot
]
if not snapshots:
errors.add(f'dataset "{dataset}" has no backup snapshots')
continue
newest_backup_snapshot = snapshots[-1]
snapshot_datetime = datetime.utcfromtimestamp(
int(check_output(['zfs', 'list', '-p', '-H', '-o', 'creation', '-t', 'snapshot', newest_backup_snapshot]).decode())
)
if snapshot_datetime < two_days_ago:
days_ago = (now - snapshot_datetime).days
errors.add(f'dataset "{dataset}" has not been backed up for {days_ago} days')
continue
if errors:
for error in errors:
print(error)
exit(2)
else:
print(f"all {len(config['datasets'])} datasets have fresh backups.")

View file

@ -1,15 +0,0 @@
from json import dumps
from bundlewrap.metadata import MetadataJSONEncoder
files = {
'/etc/backup-freshness-check.json': {
'content': dumps({
'prefix': node.metadata.get('backup-freshness-check/prefix'),
'datasets': node.metadata.get('backup-freshness-check/datasets'),
}, indent=4, sort_keys=True, cls=MetadataJSONEncoder),
},
'/usr/lib/nagios/plugins/check_backup_freshness': {
'mode': '0755',
},
}

View file

@ -1,37 +0,0 @@
defaults = {
'backup-freshness-check': {
'server': node.name,
'prefix': 'auto-backup_',
'datasets': {},
},
'monitoring': {
'services': {
'backup freshness': {
'vars.command': '/usr/lib/nagios/plugins/check_backup_freshness',
'check_interval': '6h',
'vars.sudo': True,
},
},
},
}
@metadata_reactor.provides(
'backup-freshness-check/datasets'
)
def backup_freshness_check(metadata):
return {
'backup-freshness-check': {
'datasets': {
f"{other_node.metadata.get('id')}/{dataset}"
for other_node in repo.nodes
if not other_node.dummy
and other_node.has_bundle('backup')
and other_node.has_bundle('zfs')
and other_node.metadata.get('backup/server') == metadata.get('backup-freshness-check/server')
for dataset, options in other_node.metadata.get('zfs/datasets').items()
if options.get('backup', True)
and not options.get('mountpoint', None) in [None, 'none']
},
},
}

View file

@ -35,7 +35,6 @@ def zfs(metadata):
for other_node in repo.nodes: for other_node in repo.nodes:
if ( if (
not other_node.dummy and
other_node.has_bundle('backup') and other_node.has_bundle('backup') and
other_node.metadata.get('backup/server') == node.name other_node.metadata.get('backup/server') == node.name
): ):
@ -99,7 +98,7 @@ def zfs(metadata):
def dns(metadata): def dns(metadata):
return { return {
'dns': { 'dns': {
metadata.get('backup-server/hostname'): repo.libs.ip.get_a_records(metadata), metadata.get('backup-server/hostname'): repo.libs.dns.get_a_records(metadata),
} }
} }

View file

@ -1,31 +1,11 @@
#!/bin/bash #!/bin/bash
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,13 +1,11 @@
#!/bin/bash #!/bin/bash
set -exu
path=$1 path=$1
if zfs list -H -o mountpoint | grep -q "^$path$" if zfs list -H -o mountpoint | grep -q "$path"
then then
/opt/backup/backup_path_via_zfs "$path" /opt/backup/backup_path_via_zfs "$path"
elif test -e "$path" elif test -d "$path"
then then
/opt/backup/backup_path_via_rsync "$path" /opt/backup/backup_path_via_rsync "$path"
else else

View file

@ -7,14 +7,5 @@ uuid=$(jq -r .client_uuid < /etc/backup/config.json)
server=$(jq -r .server_hostname < /etc/backup/config.json) server=$(jq -r .server_hostname < /etc/backup/config.json)
ssh="ssh -o ConnectTimeout=5 backup-receiver@$server" ssh="ssh -o ConnectTimeout=5 backup-receiver@$server"
if test -d "$path" rsync -av --rsync-path="sudo rsync" "$path/" "backup-receiver@$server:/mnt/backups/$uuid$path/"
then $ssh sudo zfs snap "tank/$uuid/fs@auto-backup_$(date +"%Y-%m-%d_%H:%M:%S")"
postfix="/"
elif test -f "$path"
then
postfix=""
else
exit 1
fi
rsync -av --rsync-path="sudo rsync" "$path$postfix" "backup-receiver@$server:/mnt/backups/$uuid$path$postfix"

View file

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
set -eu set -exu
path=$1 path=$1
uuid=$(jq -r .client_uuid < /etc/backup/config.json) uuid=$(jq -r .client_uuid < /etc/backup/config.json)
@ -46,13 +46,13 @@ if [[ "$?" == "0" ]]
then then
# delete old local bookmarks # delete old local bookmarks
for destroyable_bookmark in $(zfs list -t bookmark -H -o name "$source_dataset" | grep "^$source_dataset#$bookmark_prefix") for destroyable_bookmark in $(zfs list -t bookmark -H -o name "$dataset" | grep "^$dataset#$bookmark_prefix")
do do
zfs destroy "$destroyable_bookmark" zfs destroy "$destroyable_bookmark"
done done
# delete remote snapshots from bookmarks (except newest, even of not necessary; maybe for resuming tho) # delete snapshots from bookmarks (except newest, even of not necessary; maybe for resuming tho)
for destroyable_snapshot in $($ssh sudo zfs list -t snapshot -H -o name "$target_dataset" | grep "^$target_dataset@$bookmark_prefix" | grep -v "$new_bookmark") for destroyable_snapshot in $($ssh sudo zfs list -t snapshot -H -o name "$dataset" | grep "^$dataset@$bookmark_prefix" | grep -v "$new_bookmark")
do do
$ssh sudo zfs destroy "$destroyable_snapshot" $ssh sudo zfs destroy "$destroyable_snapshot"
done done

View file

@ -33,7 +33,6 @@ 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)

View file

@ -1,30 +0,0 @@
# 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

@ -1,8 +0,0 @@
$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,7 +29,6 @@ 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 {
@ -63,6 +62,9 @@ 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(sorted(f'{ip};' for ip in slave_ips))} }; also-notify { ${' '.join([f'{ip};' for ip in slave_ips])} };
allow-transfer { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} }; allow-transfer { ${' '.join([f'{ip};' for ip in slave_ips])} };
% endif % endif
}; };

View file

@ -1,19 +0,0 @@
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

@ -19,7 +19,7 @@ directories[f'/var/lib/bind'] = {
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -29,7 +29,7 @@ files['/etc/default/bind9'] = {
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -43,7 +43,7 @@ files['/etc/bind/named.conf'] = {
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -63,7 +63,7 @@ files['/etc/bind/named.conf.options'] = {
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -93,7 +93,7 @@ files['/etc/bind/named.conf.local'] = {
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -106,7 +106,7 @@ for view_name, view_conf in master_node.metadata.get('bind/views').items():
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -127,7 +127,7 @@ for view_name, view_conf in master_node.metadata.get('bind/views').items():
'svc_systemd:bind9', 'svc_systemd:bind9',
], ],
'triggers': [ 'triggers': [
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
], ],
} }
@ -139,24 +139,6 @@ actions['named-checkconf'] = {
'unless': 'named-checkconf -z', 'unless': 'named-checkconf -z',
'needs': [ 'needs': [
'svc_systemd:bind9', 'svc_systemd:bind9',
'svc_systemd:bind9:reload', 'svc_systemd:bind9:restart',
] ]
} }
# 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,7 +3,6 @@ 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': {
@ -49,13 +48,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,
}, }],
}, },
}, },
}, },
@ -93,7 +92,7 @@ def master_slave(metadata):
def dns(metadata): def dns(metadata):
return { return {
'dns': { 'dns': {
metadata.get('bind/hostname'): repo.libs.ip.get_a_records(metadata), metadata.get('bind/hostname'): repo.libs.dns.get_a_records(metadata),
} }
} }
@ -212,7 +211,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} 20250713", f"{metadata.get('id')} bind key {key}",
length=32, length=32,
)), )),
) )

View file

@ -1,165 +0,0 @@
#!/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()

View file

@ -1,25 +0,0 @@
#!/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

@ -1,43 +0,0 @@
#!/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

@ -1,61 +0,0 @@
# 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

@ -1,44 +0,0 @@
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

@ -1,10 +1,6 @@
from shlex import quote from shlex import quote
defaults = {
'build-ci': {},
}
@metadata_reactor.provides( @metadata_reactor.provides(
'users/build-ci/authorized_users', 'users/build-ci/authorized_users',
'sudoers/build-ci', 'sudoers/build-ci',
@ -14,7 +10,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')
}, },
@ -22,7 +18,7 @@ def ssh_keys(metadata):
}, },
'sudoers': { 'sudoers': {
'build-ci': { 'build-ci': {
f"/usr/bin/chown -R build-ci\\:{quote(ci['group'])} {quote(ci['path'])}" f"/usr/bin/chown -R build-ci\:{quote(ci['group'])} {quote(ci['path'])}"
for ci in metadata.get('build-ci').values() for ci in metadata.get('build-ci').values()
} }
}, },

View file

@ -1,21 +1,10 @@
debian_version = min([node.os_version, (11,)])[0] # FIXME
defaults = { defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'crystal': {}, 'crystal': {},
}, },
'sources': { 'sources': {
'crystal': { 'deb https://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_{version}/ /',
# 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': {
'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/',
},
'suites': {
'/',
},
},
}, },
}, },
} }

View file

@ -1,12 +1,9 @@
DOVECOT DOVECOT
======= =======
rescan index rescan index: https://doc.dovecot.org/configuration_manual/fts/#rescan
------------
https://doc.dovecot.org/configuration_manual/fts/#rescan
``` ```
doveadm fts rescan -u 'i@ckn.li' sudo -u vmail doveadm fts rescan -u 'test@mail2.sublimity.de'
doveadm index -u 'i@ckn.li' -q '*' sudo -u vmail doveadm index -u 'test@mail2.sublimity.de' -q '*'
``` ```

View file

@ -66,7 +66,8 @@ xmlunzip() {
trap "rm -rf $path $tempdir" 0 1 2 3 14 15 trap "rm -rf $path $tempdir" 0 1 2 3 14 15
cd $tempdir || exit 1 cd $tempdir || exit 1
unzip -q "$path" 2>/dev/null || exit 0 unzip -q "$path" 2>/dev/null || exit 0
find . -name "$name" -print0 | xargs -0 cat | /usr/lib/dovecot/xml2text find . -name "$name" -print0 | xargs -0 cat |
$libexec_dir/xml2text
} }
wait_timeout() { wait_timeout() {

View file

@ -0,0 +1,10 @@
connect = host=${host} dbname=${name} user=${user} password=${password}
driver = pgsql
default_pass_scheme = ARGON2ID
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,17 +1,13 @@
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_server_cert_file = /var/lib/dehydrated/certs/${hostname}/fullchain.pem ssl_cert = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/fullchain.pem
ssl_server_key_file = /var/lib/dehydrated/certs/${hostname}/privkey.pem ssl_key = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/privkey.pem
ssl_server_dh_file = /etc/dovecot/dhparam.pem ssl_dh = </etc/dovecot/dhparam.pem
ssl_client_ca_dir = /etc/ssl/certs ssl_client_ca_dir = /etc/ssl/certs
mail_driver = maildir mail_location = maildir:~
mail_path = ${maildir}/%{user} mail_plugins = fts fts_xapian
mail_index_path = ${maildir}/index/%{user}
mail_plugins = fts fts_flatcurve
namespace inbox { namespace inbox {
inbox = yes inbox = yes
@ -34,46 +30,13 @@ namespace inbox {
} }
} }
# postgres passdb userdb passdb {
driver = sql
sql_driver = pgsql args = /etc/dovecot/dovecot-sql.conf
pgsql main {
parameters {
host = ${db_host}
dbname = ${db_name}
user = ${db_user}
password = ${db_password}
}
} }
userdb {
passdb sql { driver = static
passdb_default_password_scheme = ARGON2ID args = uid=vmail gid=vmail home=/var/vmail/%u
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 {
@ -103,9 +66,10 @@ service stats {
} }
} }
service managesieve-login { service managesieve-login {
#inet_listener sieve {} inet_listener sieve {
process_min_avail = 1 }
process_limit = 1 process_min_avail = 0
service_count = 1
vsz_limit = 64 M vsz_limit = 64 M
} }
service managesieve { service managesieve {
@ -113,53 +77,31 @@ service managesieve {
} }
protocol imap { protocol imap {
mail_plugins = fts fts_flatcurve imap_sieve mail_plugins = $mail_plugins 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 = fts fts_flatcurve sieve mail_plugins = $mail_plugins sieve
} }
protocol sieve {
# Persönliches Skript (deine alte Datei /var/vmail/sieve/%u.sieve) plugin {
sieve_script personal { sieve = /var/vmail/sieve/%u.sieve
driver = file sieve_storage = /var/vmail/sieve/%u/
# 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
language en { plugin {
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 {
process_limit = ${indexer_cores} vsz_limit = ${indexer_ram}
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
@ -169,39 +111,24 @@ service decode2text {
} }
} }
mailbox Junk { # spam filter
sieve_script learn_spam { plugin {
driver = file sieve_plugins = sieve_imapsieve sieve_extprograms
type = before sieve_dir = /var/vmail/sieve/%u/
cause = copy sieve = /var/vmail/sieve/%u.sieve
path = /var/vmail/sieve/global/learn-spam.sieve sieve_pipe_bin_dir = /var/vmail/sieve/bin
} sieve_extensions = +vnd.dovecot.pipe
}
imapsieve_from Junk { sieve_after = /var/vmail/sieve/global/spam-to-folder.sieve
sieve_script learn_ham {
driver = file
type = before
cause = copy
path = /var/vmail/sieve/global/learn-ham.sieve
}
}
# Extprograms-Plugin einschalten # From elsewhere to Spam folder
sieve_plugins { imapsieve_mailbox1_name = Junk
sieve_extprograms = yes imapsieve_mailbox1_causes = COPY
} imapsieve_mailbox1_before = file:/var/vmail/sieve/global/learn-spam.sieve
# Welche Sieve-Erweiterungen dürfen genutzt werden? # From Spam folder to elsewhere
# Empfehlung: nur global erlauben (nicht in User-Skripten): imapsieve_mailbox2_name = *
sieve_global_extensions { imapsieve_mailbox2_from = Junk
vnd.dovecot.pipe = yes imapsieve_mailbox2_causes = COPY
# vnd.dovecot.filter = yes # nur falls gebraucht imapsieve_mailbox2_before = file:/var/vmail/sieve/global/learn-ham.sieve
# 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

@ -20,10 +20,6 @@ directories = {
'owner': 'vmail', 'owner': 'vmail',
'group': 'vmail', 'group': 'vmail',
}, },
'/var/vmail/index': {
'owner': 'vmail',
'group': 'vmail',
},
'/var/vmail/sieve': { '/var/vmail/sieve': {
'owner': 'vmail', 'owner': 'vmail',
'group': 'vmail', 'group': 'vmail',
@ -44,16 +40,6 @@ 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:'
@ -62,9 +48,29 @@ 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',
@ -121,6 +127,7 @@ 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-flatcurve': {}, # buster-backports 'dovecot-fts-xapian': {}, # buster-backports
'poppler-utils': {}, # pdftotext 'poppler-utils': {}, # pdftotext
'catdoc': {}, # catdoc, catppt, xls2csv 'catdoc': {}, # catdoc, catppt, xls2csv
}, },
@ -29,12 +29,6 @@ defaults = {
'tcp dport {143, 993, 4190} accept', 'tcp dport {143, 993, 4190} accept',
}, },
}, },
'systemd-timers': {
'dovecot-optimize-index': {
'command': '/usr/bin/doveadm fts optimize -A',
'when': 'daily',
},
},
} }
@metadata_reactor.provides( @metadata_reactor.provides(

View file

@ -0,0 +1,6 @@
# directories = {
# '/var/lib/downloads': {
# 'owner': 'downloads',
# 'group': 'www-data',
# }
# }

View file

@ -5,11 +5,6 @@ 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': {
@ -19,13 +14,21 @@ defaults = {
}, },
}, },
}, },
}
@metadata_reactor.provides(
'systemd-mount'
)
def mount_certs(metadata):
return {
'systemd-mount': { 'systemd-mount': {
'/var/lib/downloads_nginx': { '/var/lib/downloads_nginx': {
'source': '/var/lib/downloads', 'source': '/var/lib/downloads',
'user': 'www-data', 'user': 'www-data',
}, },
}, },
} }
@metadata_reactor.provides( @metadata_reactor.provides(
@ -44,3 +47,20 @@ 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 metadata.get(f'flask/{name}/env', {}).items() for k, v in conf.get('env', {}).items()
}, },
'User': metadata.get(f'flask/{name}/user'), 'User': conf['user'],
'Group': metadata.get(f'flask/{name}/group'), 'Group': conf['group'],
'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" 'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {conf['workers']} -b 127.0.0.1:{conf['port']} --timeout {conf['timeout']} {conf['app_module']}:app"
}, },
'Install': { 'Install': {
'WantedBy': { 'WantedBy': {
@ -55,7 +55,7 @@ def units(metadata):
} }
}, },
} }
for name in metadata.get('flask') for name, conf in metadata.get('flask').items()
} }
} }
} }

View file

@ -1,23 +0,0 @@
Pg Pass workaround: set manually:
```
root@freescout /ro psql freescout
psql (15.6 (Debian 15.6-0+deb12u1))
Type "help" for help.
freescout=# \password freescout
Enter new password for user "freescout":
Enter it again:
freescout=#
\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

@ -1,66 +0,0 @@
# https://github.com/freescout-helpdesk/freescout/wiki/Installation-Guide
run_as = repo.libs.tools.run_as
php_version = node.metadata.get('php/version')
directories = {
'/opt/freescout': {
'owner': 'www-data',
'group': 'www-data',
# chown -R www-data:www-data /opt/freescout
},
}
actions = {
# 'clone_freescout': {
# 'command': run_as('www-data', 'git clone https://github.com/freescout-helpdesk/freescout.git /opt/freescout'),
# 'unless': 'test -e /opt/freescout/.git',
# 'needs': [
# 'pkg_apt:git',
# 'directory:/opt/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'),
# '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': [
# 'action:clone_freescout',
# ],
# 'triggers': [
# 'action:freescout_artisan_update',
# f'svc_systemd:php{php_version}-fpm.service:restart',
# ],
# },
# 'freescout_artisan_update': {
# 'command': run_as('www-data', 'php /opt/freescout/artisan freescout:after-app-update'),
# 'triggered': True,
# 'needs': [
# f'svc_systemd:php{php_version}-fpm.service:restart',
# 'action:pull_freescout',
# ],
# },
}
# svc_systemd = {
# f'freescout-cron.service': {},
# }
# files = {
# '/opt/freescout/.env': {
# # https://github.com/freescout-helpdesk/freescout/blob/dist/.env.example
# # Every time you are making changes in .env file, in order changes to take an effect you need to run:
# # ´sudo su - www-data -c 'php /opt/freescout/artisan freescout:clear-cache' -s /bin/bash´
# 'owner': 'www-data',
# 'content': '\n'.join(
# f'{k}={v}' for k, v in
# sorted(node.metadata.get('freescout/env').items())
# ) + '\n',
# 'needs': [
# 'directory:/opt/freescout',
# 'action:clone_freescout',
# ],
# },
# }
#sudo su - www-data -s /bin/bash -c 'php /opt/freescout/artisan freescout:create-user --role admin --firstName M --lastName W --email freescout@freibrief.net --password gyh.jzv2bnf6hvc.HKG --no-interaction'
#sudo su - www-data -s /bin/bash -c 'php /opt/freescout/artisan freescout:create-user --role admin --firstName M --lastName W --email freescout@freibrief.net --password gyh.jzv2bnf6hvc.HKG --no-interaction'

View file

@ -1,121 +0,0 @@
from base64 import b64decode
# hash: SCRAM-SHA-256$4096:tQNfqQi7seqNDwJdHqCHbg==$r3ibECluHJaY6VRwpvPqrtCjgrEK7lAkgtUO8/tllTU=:+eeo4M0L2SowfyHFxT2FRqGzezve4ZOEocSIo11DATA=
database_password = repo.vault.password_for(f'{node.name} postgresql freescout').value
defaults = {
'apt': {
'packages': {
'git': {},
'php': {},
'php-pgsql': {},
'php-fpm': {},
'php-mbstring': {},
'php-xml': {},
'php-imap': {},
'php-zip': {},
'php-gd': {},
'php-curl': {},
'php-intl': {},
},
},
'freescout': {
'env': {
'APP_TIMEZONE': 'Europe/Berlin',
'DB_CONNECTION': 'pgsql',
'DB_HOST': '127.0.0.1',
'DB_PORT': '5432',
'DB_DATABASE': 'freescout',
'DB_USERNAME': 'freescout',
'DB_PASSWORD': database_password,
'APP_KEY': 'base64:' + repo.vault.random_bytes_as_base64_for(f'{node.name} freescout APP_KEY', length=32).value
},
},
'php': {
'php.ini': {
'cgi': {
'fix_pathinfo': '0',
},
},
},
'postgresql': {
'roles': {
'freescout': {
'password_hash': repo.libs.postgres.generate_scram_sha_256(
database_password,
b64decode(repo.vault.random_bytes_as_base64_for(f'{node.name} postgres freescout', length=16).value.encode()),
),
},
},
'databases': {
'freescout': {
'owner': 'freescout',
},
},
},
# 'systemd': {
# 'units': {
# f'freescout-cron.service': {
# 'Unit': {
# 'Description': 'Freescout Cron',
# 'After': 'network.target',
# },
# 'Service': {
# 'User': 'www-data',
# 'Nice': 10,
# 'ExecStart': f"/usr/bin/php /opt/freescout/artisan schedule:run"
# },
# 'Install': {
# 'WantedBy': {
# 'multi-user.target'
# }
# },
# }
# },
# },
'systemd-timers': {
'freescout-cron': {
'command': '/usr/bin/php /opt/freescout/artisan schedule:run',
'when': '*-*-* *:*:00',
'RuntimeMaxSec': '180',
'user': 'www-data',
},
},
'zfs': {
'datasets': {
'tank/freescout': {
'mountpoint': '/opt/freescout',
},
},
},
}
@metadata_reactor.provides(
'freescout/env/APP_URL',
)
def freescout(metadata):
return {
'freescout': {
'env': {
'APP_URL': 'https://' + metadata.get('freescout/domain') + '/',
},
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx(metadata):
return {
'nginx': {
'vhosts': {
metadata.get('freescout/domain'): {
'content': 'freescout/vhost.conf',
},
},
},
}

View file

@ -8,15 +8,7 @@ defaults = {
'python3-crcmod': {}, 'python3-crcmod': {},
}, },
'sources': { 'sources': {
'google-cloud': { 'deb https://packages.cloud.google.com/apt cloud-sdk main',
'url': 'https://packages.cloud.google.com/apt/',
'suites': {
'cloud-sdk',
},
'components': {
'main',
},
},
}, },
}, },
} }

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 = true DISABLE_REGISTRATION = false
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

@ -2,14 +2,11 @@ from os.path import join
from bundlewrap.utils.dicts import merge_dict from bundlewrap.utils.dicts import merge_dict
version = node.metadata.get('gitea/version') version = version=node.metadata.get('gitea/version')
assert not version.startswith('v')
arch = node.metadata.get('system/architecture')
downloads['/usr/local/bin/gitea'] = { downloads['/usr/local/bin/gitea'] = {
# https://forgejo.org/releases/ 'url': f'https://dl.gitea.io/gitea/{version}/gitea-{version}-linux-amd64',
'url': f'https://codeberg.org/forgejo/forgejo/releases/download/v{version}/forgejo-{version}-linux-{arch}', 'sha256': node.metadata.get('gitea/sha256'),
'sha256_url': '{url}.sha256',
'triggers': { 'triggers': {
'svc_systemd:gitea:restart', 'svc_systemd:gitea:restart',
}, },
@ -48,8 +45,7 @@ files['/etc/gitea/app.ini'] = {
), ),
), ),
'owner': 'git', 'owner': 'git',
'mode': '0600', 'context': node.metadata['gitea'],
'context': node.metadata.get('gitea'),
'triggers': { 'triggers': {
'svc_systemd:gitea:restart', 'svc_systemd:gitea:restart',
}, },

View file

@ -1,4 +1,4 @@
database_password = repo.vault.password_for(f'{node.name} postgresql gitea').value database_password = repo.vault.password_for(f'{node.name} postgresql gitea')
defaults = { defaults = {
'apt': { 'apt': {
@ -11,20 +11,7 @@ defaults = {
}, },
}, },
'gitea': { 'gitea': {
'conf': { 'conf': {},
'DEFAULT': {
'WORK_PATH': '/var/lib/gitea',
},
'database': {
'DB_TYPE': 'postgres',
'HOST': 'localhost:5432',
'NAME': 'gitea',
'USER': 'gitea',
'PASSWD': database_password,
'SSL_MODE': 'disable',
'LOG_SQL': 'false',
},
},
}, },
'postgresql': { 'postgresql': {
'roles': { 'roles': {
@ -43,7 +30,8 @@ defaults = {
'gitea.service': { 'gitea.service': {
'Unit': { 'Unit': {
'Description': 'gitea', 'Description': 'gitea',
'After': {'syslog.target', 'network.target'}, 'After': 'syslog.target',
'After': 'network.target',
'Requires': 'postgresql.service', 'Requires': 'postgresql.service',
}, },
'Service': { 'Service': {
@ -96,6 +84,15 @@ def conf(metadata):
'INTERNAL_TOKEN': repo.vault.password_for(f'{node.name} gitea internal_token'), 'INTERNAL_TOKEN': repo.vault.password_for(f'{node.name} gitea internal_token'),
'SECRET_KEY': repo.vault.password_for(f'{node.name} gitea security_secret_key'), 'SECRET_KEY': repo.vault.password_for(f'{node.name} gitea security_secret_key'),
}, },
'database': {
'DB_TYPE': 'postgres',
'HOST': 'localhost:5432',
'NAME': 'gitea',
'USER': 'gitea',
'PASSWD': database_password,
'SSL_MODE': 'disable',
'LOG_SQL': 'false',
},
'service': { 'service': {
'NO_REPLY_ADDRESS': f'noreply.{domain}', 'NO_REPLY_ADDRESS': f'noreply.{domain}',
}, },
@ -118,7 +115,7 @@ def nginx(metadata):
'content': 'nginx/proxy_pass.conf', 'content': 'nginx/proxy_pass.conf',
'context': { 'context': {
'target': 'http://127.0.0.1:3500', 'target': 'http://127.0.0.1:3500',
}, }
}, },
}, },
}, },

View file

@ -18,17 +18,16 @@ admin_password = node.metadata.get('grafana/config/security/admin_password')
port = node.metadata.get('grafana/config/server/http_port') port = node.metadata.get('grafana/config/server/http_port')
actions['reset_grafana_admin_password'] = { actions['reset_grafana_admin_password'] = {
'command': f"grafana-cli admin reset-admin-password {quote(admin_password)}", 'command': f"grafana-cli admin reset-admin-password {quote(admin_password)}",
'unless': f"sleep 5 && curl http://admin:{quote(admin_password)}@localhost:{port}/api/org --fail", 'unless': f"curl http://admin:{quote(admin_password)}@localhost:{port}/api/org",
'needs': [ 'needs': [
'svc_systemd:grafana-server', 'svc_systemd:grafana-server',
], ],
} }
directories = { directories = {
'/etc/grafana': {}, '/etc/grafana': {
},
'/etc/grafana/provisioning': { '/etc/grafana/provisioning': {
'owner': 'grafana',
'group': 'grafana',
}, },
'/etc/grafana/provisioning/datasources': { '/etc/grafana/provisioning/datasources': {
'purge': True, 'purge': True,
@ -36,13 +35,8 @@ directories = {
'/etc/grafana/provisioning/dashboards': { '/etc/grafana/provisioning/dashboards': {
'purge': True, 'purge': True,
}, },
'/var/lib/grafana': { '/var/lib/grafana': {},
'owner': 'grafana',
'group': 'grafana',
},
'/var/lib/grafana/dashboards': { '/var/lib/grafana/dashboards': {
'owner': 'grafana',
'group': 'grafana',
'purge': True, 'purge': True,
'triggers': [ 'triggers': [
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
@ -53,8 +47,6 @@ directories = {
files = { files = {
'/etc/grafana/grafana.ini': { '/etc/grafana/grafana.ini': {
'content': repo.libs.ini.dumps(node.metadata.get('grafana/config')), 'content': repo.libs.ini.dumps(node.metadata.get('grafana/config')),
'owner': 'grafana',
'group': 'grafana',
'triggers': [ 'triggers': [
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
], ],
@ -64,8 +56,6 @@ files = {
'apiVersion': 1, 'apiVersion': 1,
'datasources': list(node.metadata.get('grafana/datasources').values()), 'datasources': list(node.metadata.get('grafana/datasources').values()),
}), }),
'owner': 'grafana',
'group': 'grafana',
'triggers': [ 'triggers': [
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
], ],
@ -82,8 +72,6 @@ files = {
}, },
}], }],
}), }),
'owner': 'grafana',
'group': 'grafana',
'triggers': [ 'triggers': [
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
], ],
@ -127,7 +115,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,14 +146,13 @@ 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),
over=query_config.get('over', None), minimum=query_config.get('minimum', 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()
}) })
@ -179,3 +166,4 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
'svc_systemd:grafana-server:restart', 'svc_systemd:grafana-server:restart',
] ]
} }

View file

@ -8,33 +8,16 @@ defaults = {
'grafana': {}, 'grafana': {},
}, },
'sources': { 'sources': {
'grafana': { 'deb https://packages.grafana.com/oss/deb stable main',
'urls': {
'https://packages.grafana.com/oss/deb',
}, },
'suites': {
'stable',
},
'components': {
'main',
},
},
},
}, },
'grafana': { 'grafana': {
'config': { 'config': {
'server': { 'server': {
'http_port': 8300, 'http_port': 8300,
'http_addr': '127.0.0.1',
'enable_gzip': True,
}, },
'database': { 'database': {
'type': 'postgres', 'url': f'postgres://grafana:{postgres_password}@localhost:5432/grafana',
'host': '127.0.0.1:5432',
'name': 'grafana',
'user': 'grafana',
'password': postgres_password,
}, },
'remote_cache': { 'remote_cache': {
'type': 'redis', 'type': 'redis',
@ -133,19 +116,17 @@ def datasource_key_to_name(metadata):
def dns(metadata): def dns(metadata):
return { return {
'dns': { 'dns': {
metadata.get('grafana/hostname'): repo.libs.ip.get_a_records(metadata), metadata.get('grafana/hostname'): repo.libs.dns.get_a_records(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.service:restart', 'svc_systemd:telegraf:restart',
}, },
}, },
} }

View file

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

View file

@ -0,0 +1,20 @@
users = {
'homeassistant': {
'home': '/var/lib/homeassistant',
},
}
directories = {
'/var/lib/homeassistant': {
'owner': 'homeassistant',
},
'/var/lib/homeassistant/config': {
'owner': 'homeassistant',
},
'/var/lib/homeassistant/venv': {
'owner': 'homeassistant',
},
}
# https://wiki.instar.com/de/Software/Linux/Home_Assistant/

View file

@ -0,0 +1,20 @@
defaults = {
'apt': {
'packages': {
'python3': {},
'python3-dev': {},
'python3-pip': {},
'python3-venv': {},
'libffi-dev': {},
'libssl-dev': {},
'libjpeg-dev': {},
'zlib1g-dev': {},
'autoconf': {},
'build-essential': {},
'libopenjp2-7': {},
'libtiff5': {},
'libturbojpeg0-dev': {},
'tzdata': {},
},
},
}

View file

@ -23,6 +23,6 @@ def hostname_file(metadata):
def dns(metadata): def dns(metadata):
return { return {
'dns': { 'dns': {
metadata.get('hostname'): repo.libs.ip.get_a_records(metadata), metadata.get('hostname'): repo.libs.dns.get_a_records(metadata, external=False),
}, },
} }

View file

@ -24,7 +24,7 @@ header_margin=1
detailed_cpu_time=0 detailed_cpu_time=0
cpu_count_from_one=1 cpu_count_from_one=1
show_cpu_usage=0 show_cpu_usage=0
show_cpu_frequency=1 show_cpu_frequency=0
show_cpu_temperature=0 show_cpu_temperature=0
degree_fahrenheit=0 degree_fahrenheit=0
update_process_names=0 update_process_names=0

View file

@ -13,9 +13,9 @@ apply Notification "mail-icingaadmin" to Host {
user_groups = host.vars.notification.mail.groups user_groups = host.vars.notification.mail.groups
users = host.vars.notification.mail.users users = host.vars.notification.mail.users
//interval = 2h
//vars.notification_logtosyslog = true
assign where host.vars.notification.mail assign where host.vars.notification.mail
} }
@ -25,9 +25,9 @@ apply Notification "mail-icingaadmin" to Service {
user_groups = host.vars.notification.mail.groups user_groups = host.vars.notification.mail.groups
users = host.vars.notification.mail.users users = host.vars.notification.mail.users
//interval = 2h
//vars.notification_logtosyslog = true
assign where host.vars.notification.mail assign where host.vars.notification.mail
} }

View file

@ -1,7 +0,0 @@
/**
* The JournaldLogger type writes log information to the systemd journal.
*/
object JournaldLogger "journald" {
severity = "warning"
}

View file

@ -1,4 +0,0 @@
/**
* This file is requires for inital apt install.
* The JournaldLogger type writes log information to the systemd journal.
*/

View file

@ -0,0 +1,3 @@
object SyslogLogger "syslog" {
severity = "warning"
}

View file

@ -10,24 +10,6 @@ directories = {
'svc_systemd:icinga2.service:restart', 'svc_systemd:icinga2.service:restart',
], ],
}, },
'/etc/icinga2/pki': { # required for apt install
'purge': True,
'owner': 'nagios',
'group': 'nagios',
'mode': '0750',
'triggers': [
'svc_systemd:icinga2.service:restart',
],
},
'/etc/icinga2/zones.d': { # required for apt install
'purge': True,
'owner': 'nagios',
'group': 'nagios',
'mode': '0750',
'triggers': [
'svc_systemd:icinga2.service:restart',
],
},
'/etc/icinga2/conf.d': { '/etc/icinga2/conf.d': {
'purge': True, 'purge': True,
'owner': 'nagios', 'owner': 'nagios',
@ -213,8 +195,7 @@ files = {
# FEATURES # FEATURES
for feature, context in { for feature, context in {
'mainlog': {}, 'syslog': {},
# 'journald': {}, FIXME
'notification': {}, 'notification': {},
'checker': {}, 'checker': {},
'api': {}, 'api': {},
@ -269,7 +250,7 @@ svc_systemd = {
'icinga2.service': { 'icinga2.service': {
'needs': [ 'needs': [
'pkg_apt:icinga2-ido-pgsql', 'pkg_apt:icinga2-ido-pgsql',
'svc_systemd:postgresql.service', 'svc_systemd:postgresql',
], ],
}, },
} }

View file

@ -9,21 +9,7 @@ defaults = {
'monitoring-plugins': {}, 'monitoring-plugins': {},
}, },
'sources': { 'sources': {
'icinga': { 'deb https://packages.icinga.com/debian icinga-{release} main',
'types': {
'deb',
'deb-src',
},
'urls': {
'https://packages.icinga.com/debian',
},
'suites': {
'icinga-{codename}',
},
'components': {
'main',
},
},
}, },
}, },
'icinga2': { 'icinga2': {
@ -63,6 +49,7 @@ defaults = {
'mountpoint': '/var/lib/icinga2', 'mountpoint': '/var/lib/icinga2',
'needed_by': { 'needed_by': {
'pkg_apt:icinga2', 'pkg_apt:icinga2',
'pkg_apt:icingaweb2',
'pkg_apt:icinga2-ido-pgsql', 'pkg_apt:icinga2-ido-pgsql',
}, },
}, },

View file

@ -2,4 +2,3 @@
- open /icingaweb2/setup in browser - open /icingaweb2/setup in browser
- fill in values from metadata - fill in values from metadata
- apply - apply
- make sure tls cert exists and is owned by nagios

View file

@ -4,27 +4,18 @@ directories = {
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'mode': '2770', 'mode': '2770',
'needs': [
'pkg_apt:icingaweb2',
],
}, },
'/etc/icingaweb2/enabledModules': { '/etc/icingaweb2/enabledModules': {
# 'purge': True, # 'purge': True,
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'mode': '2770', 'mode': '2770',
'needs': [
'pkg_apt:icingaweb2',
],
}, },
'/etc/icingaweb2/modules': { '/etc/icingaweb2/modules': {
# 'purge': True, # 'purge': True,
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'mode': '2770', 'mode': '2770',
'needs': [
'pkg_apt:icingaweb2',
],
}, },
} }
@ -34,9 +25,6 @@ files = {
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'mode': '0660', 'mode': '0660',
'needs': [
'pkg_apt:icingaweb2',
],
}, },
} }
@ -45,9 +33,6 @@ symlinks = {
'target': '/usr/share/icingaweb2/modules/monitoring', 'target': '/usr/share/icingaweb2/modules/monitoring',
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'needs': [
'pkg_apt:icingaweb2',
],
}, },
} }
@ -63,9 +48,6 @@ for name in [
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'mode': '0660', 'mode': '0660',
'needs': [
'pkg_apt:icingaweb2',
],
} }
for name in [ for name in [
@ -78,7 +60,4 @@ for name in [
'owner': 'www-data', 'owner': 'www-data',
'group': 'icingaweb2', 'group': 'icingaweb2',
'mode': '0660', 'mode': '0660',
'needs': [
'pkg_apt:icingaweb2',
],
} }

View file

@ -3,6 +3,7 @@ from hashlib import sha3_256
defaults = { defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'icingaweb2': {},
'php-ldap': {}, 'php-ldap': {},
'php-json': {}, 'php-json': {},
'php-intl': {}, 'php-intl': {},
@ -10,25 +11,11 @@ defaults = {
'php-gd': {}, 'php-gd': {},
'php-imagick': {}, 'php-imagick': {},
'php-pgsql': {}, 'php-pgsql': {},
'icingaweb2': {}, 'icingaweb2-module-monitoring': {},
#'icingaweb2-module-monitoring': {}, # ?
}, },
'sources': { 'sources': {
'icinga': { 'deb https://packages.icinga.com/debian icinga-{release} main',
'types': { 'deb https://packages.icinga.com/debian icinga-{release}-snapshots main',
'deb',
'deb-src',
},
'urls': {
'https://packages.icinga.com/debian',
},
'suites': {
'icinga-{codename}',
},
'components': {
'main',
},
},
}, },
}, },
'icingaweb2': { 'icingaweb2': {
@ -177,9 +164,7 @@ def nginx(metadata):
metadata.get('icingaweb2/hostname'): { metadata.get('icingaweb2/hostname'): {
'content': 'icingaweb2/vhost.conf', 'content': 'icingaweb2/vhost.conf',
'context': { 'context': {
'php_version': metadata.get('php/version'),
}, },
'check_path': '/icingaweb2/index.php',
}, },
}, },
}, },

View file

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

View file

@ -4,7 +4,6 @@ from shlex import quote
directories['/var/lib/influxdb'] = { directories['/var/lib/influxdb'] = {
'owner': 'influxdb', 'owner': 'influxdb',
'group': 'influxdb', 'group': 'influxdb',
'mode': '0750',
'needs': [ 'needs': [
'zfs_dataset:tank/influxdb', 'zfs_dataset:tank/influxdb',
], ],

View file

@ -7,17 +7,7 @@ defaults = {
'influxdb2-cli': {}, 'influxdb2-cli': {},
}, },
'sources': { 'sources': {
'influxdata': { 'deb https://repos.influxdata.com/debian {release} stable',
'urls': {
'https://repos.influxdata.com/debian',
},
'suites': {
'stable',
},
'components': {
'main',
},
},
}, },
}, },
'nftables': { 'nftables': {
@ -39,17 +29,6 @@ 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',
@ -63,13 +42,33 @@ 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',
) )
def dns(metadata): def dns(metadata):
return { return {
'dns': { 'dns': {
metadata.get('influxdb/hostname'): repo.libs.ip.get_a_records(metadata), metadata.get('influxdb/hostname'): repo.libs.dns.get_a_records(metadata),
} }
} }

View file

@ -19,7 +19,7 @@ def apt(metadata):
return { return {
'apt': { 'apt': {
'packages': { 'packages': {
f'openjdk-{metadata.get("java/version")}-jre-headless': {}, f'openjdk-{metadata.get("java/version")}-jre': {},
} }
} }
} }

View file

@ -1,21 +0,0 @@
from json import dumps
from bundlewrap.metadata import MetadataJSONEncoder
files = {
'/etc/kea/kea-dhcp4.conf': {
'content': dumps(node.metadata.get('kea'), indent=4, sort_keys=True, cls=MetadataJSONEncoder),
'triggers': [
'svc_systemd:kea-dhcp4-server:restart',
],
},
}
svc_systemd = {
'kea-dhcp4-server': {
'needs': [
'pkg_apt:kea-dhcp4-server',
'file:/etc/kea/kea-dhcp4.conf',
'svc_systemd:systemd-networkd.service:restart',
],
},
}

View file

@ -1,97 +0,0 @@
from ipaddress import ip_interface, ip_network
hashable = repo.libs.hashable.hashable
defaults = {
'apt': {
'packages': {
'kea-dhcp4-server': {},
},
},
'kea': {
'Dhcp4': {
'interfaces-config': {
'interfaces': set(),
},
'lease-database': {
'type': 'memfile',
'lfc-interval': 3600
},
'subnet4': set(),
'loggers': set([
hashable({
'name': 'kea-dhcp4',
'output_options': [
{
'output': 'syslog',
}
],
'severity': 'INFO',
}),
]),
},
},
}
@metadata_reactor.provides(
'kea/Dhcp4/interfaces-config/interfaces',
'kea/Dhcp4/subnet4',
)
def subnets(metadata):
subnet4 = set()
interfaces = set()
reservations = set(
hashable({
'hw-address': network_conf['mac'],
'ip-address': str(ip_interface(network_conf['ipv4']).ip),
})
for other_node in repo.nodes
for network_conf in other_node.metadata.get('network', {}).values()
if 'mac' in network_conf
)
for id, (network_name, network_conf) in enumerate(sorted(metadata.get('network').items())):
dhcp_server_config = network_conf.get('dhcp_server_config', None)
if dhcp_server_config:
_network = ip_network(dhcp_server_config['subnet'])
subnet4.add(hashable({
'id': id + 1,
'subnet': dhcp_server_config['subnet'],
'pools': [
{
'pool': f'{dhcp_server_config['pool_from']} - {dhcp_server_config['pool_to']}',
},
],
'option-data': [
{
'name': 'routers',
'data': dhcp_server_config['router'],
},
{
'name': 'domain-name-servers',
'data': '10.0.0.1',
},
],
'reservations': set(
reservation
for reservation in reservations
if ip_interface(reservation['ip-address']).ip in _network
),
}))
interfaces.add(network_conf.get('interface', network_name))
return {
'kea': {
'Dhcp4': {
'interfaces-config': {
'interfaces': interfaces,
},
'subnet4': subnet4,
},
},
}

View file

@ -1,22 +1,58 @@
https://github.com/SirPlease/L4D2-Competitive-Rework/blob/master/Dedicated%20Server%20Install%20Guide/README.md https://developer.valvesoftware.com/wiki/List_of_L4D2_Cvars
```python Dead Center c1m1_hotel
'tick60_maps': { Dead Center c1m2_streets
'port': 27030, Dead Center c1m3_mall
# add command line arguments Dead Center c1m4_atrium
'arguments': ['-tickrate 60'], Dark Carnival c2m1_highway
# stack overlays, first is uppermost Dark Carnival c2m2_fairgrounds
'overlays': ['tickrate', 'standard'], Dark Carnival c2m3_coaster
# server.cfg contents Dark Carnival c2m4_barns
'config': [ Dark Carnival c2m5_concert
# configs from overlays are accessible via server_${overlay}.cfg Swamp Fever c3m1_plankcountry
'exec server_tickrate.cfg', Swamp Fever c3m2_swamp
# add more options Swamp Fever c3m3_shantytown
'sv_minupdaterate 101', Swamp Fever c3m4_plantation
'sv_maxupdaterate 101', Hard Rain c4m1_milltown_a
'sv_mincmdrate 101', Hard Rain c4m2_sugarmill_a
'sv_maxcmdrate 101', Hard Rain c4m3_sugarmill_b
'sv_consistency 0', Hard Rain c4m4_milltown_b
], 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

@ -1,13 +0,0 @@
#!/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

@ -1,10 +0,0 @@
#!/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

@ -1,128 +0,0 @@
#!/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

@ -1,12 +0,0 @@
#!/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

@ -1,25 +0,0 @@
#!/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

@ -1,13 +0,0 @@
#!/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,13 +1,38 @@
// defaults hostname "CroneKorkN : ${name}"
hostname ${server_name} sv_contact "admin@sublimity.de"
// assign serevr to steam group
sv_steamgroup "${','.join(steamgroups)}"
// no annoying message of the day
motd_enabled 0 motd_enabled 0
rcon_password ${rcon_password}
sv_steamgroup "38347879"
mp_autoteambalance 0 // enable cheats
sv_forcepreload 1 sv_cheats 1
// server specific // allow inconsistent files on clients (weapon mods for example)
% for line in config: sv_consistency 0
${line}
% endfor // connect from internet
sv_lan 0
// join game at any point
sv_allow_lobby_connect_only 0
// allowed modes
sv_gametypes "coop,realism,survival,versus,teamversus,scavenge,teamscavenge"
// network
sv_minrate 30000
sv_maxrate 60000
sv_mincmdrate 66
sv_maxcmdrate 101
// logging
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

@ -1,72 +0,0 @@
#!/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

@ -1,75 +0,0 @@
#!/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

@ -1,19 +0,0 @@
#!/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,105 +1,106 @@
users = {
'steam': {
'home': '/opt/l4d2/steam',
'shell': '/bin/bash',
},
}
directories = { directories = {
'/opt/l4d2': { '/opt/left4dead2': {
'owner': 'steam', 'group': 'steam', 'owner': 'steam',
}, },
'/opt/l4d2/steam': { '/opt/left4dead2/ems/admin system': {
'owner': 'steam', 'group': 'steam', 'owner': 'steam',
}, },
'/opt/l4d2/configs': { '/opt/left4dead2/left4dead2/cfg': {
'owner': 'steam', 'group': 'steam', 'owner': 'steam',
'purge': True,
}, },
'/opt/l4d2/scripts': { '/opt/left4dead2/left4dead2/addons': {
'owner': 'steam', 'group': 'steam', 'owner': 'steam',
},
'/opt/l4d2/scripts/overlays': {
'owner': 'steam', 'group': 'steam',
'purge': True, 'purge': True,
}, },
} }
files = { files = {
'/opt/l4d2/setup': { '/opt/left4dead2/ems/admin system/admins.txt': {
'mode': '755', 'owner': 'steam',
'triggers': { 'content': '\n'.join(node.metadata.get('left4dead2/admins')),
'svc_systemd:left4dead2-initialize.service:restart',
},
},
'/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'):
files[f'/opt/l4d2/scripts/overlays/{overlay}'] = {
'source': f'scripts/overlays/{overlay}',
'mode': '755',
'triggers': {
'svc_systemd:left4dead2-initialize.service:restart',
},
} }
}
svc_systemd = { svc_systemd = {
'left4dead2-initialize.service': { 'left4dead2-workshop': {
'enabled': True, 'running': False,
'running': None, 'needs': [
'needs': { 'svc_systemd:steam-update',
'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(): for id in node.metadata.get('left4dead2/workshop'):
files[f'/opt/l4d2/configs/{server_name}.cfg'] = { directories[f'/opt/left4dead2/left4dead2/addons/{id}'] = {
'source': 'server.cfg',
'content_type': 'mako',
'context': {
'server_name': server_name,
'rcon_password': repo.vault.decrypt('encrypt$gAAAAABpAdZhxwJ47I1AXotuZmBvyZP1ecVTt9IXFkLI28JiVS74LKs9QdgIBz-FC-iXtIHHh_GVGxxKQZprn4UrXZcvZ57kCKxfHBs3cE2JiGnbWE8_mfs=').value,
'config': config.get('config', []),
},
'owner': 'steam', 'owner': 'steam',
'mode': '644', 'triggers': [
'triggers': { 'svc_systemd:left4dead2-workshop:restart',
f'svc_systemd:left4dead2-{server_name}.service:restart', ],
},
} }
svc_systemd[f'left4dead2-{server_name}.service'] = { server_units = set()
'enabled': True, for name, config in node.metadata.get('left4dead2/servers').items():
'running': True, config.pop('port')
'tags': { config = {
'left4dead2-servers', 'hostname': name,
}, 'sv_steamgroup': ','.join(
'needs': { str(gid) for gid in node.metadata.get('left4dead2/steamgroups')
'svc_systemd:left4dead2-initialize.service', ),
f'file:/usr/local/lib/systemd/system/left4dead2-{server_name}.service', 'z_difficulty': 'Impossible',
}, 'sv_gametypes': 'realism',
'sv_region': 3, # europe
'log': 'on',
'sv_logecho': 1,
'sv_logfile': 1,
'sv_log_onefile': 0,
'sv_logbans': 1,
'sv_logflush': 0,
'sv_logsdir': 'logs', # /opt/left4dead2/left4dead2/logs
**config,
} }
files[f'/opt/left4dead2/left4dead2/cfg/server-{name}.cfg'] = {
'content': '\n'.join(
f'{key} "{value}"' for key, value in sorted(config.items())
) + '\n',
'owner': 'steam',
'triggers': [
f'svc_systemd:left4dead2-server-{name}:restart',
],
}
svc_systemd[f'left4dead2-server-{name}'] = {
'needs': [
f'file:/usr/local/lib/systemd/system/left4dead2-server-{name}.service',
],
}
server_units.add(f'left4dead2-server-{name}')
for id in node.metadata.get('left4dead2/workshop'):
directories[f'/opt/left4dead2/addons/{id}'] = {
'owner': 'steam',
'triggers': [
'svc_systemd:left4dead2-workshop:restart',
],
}
# TIDYUP
find_obsolete_units = (
'find /usr/local/lib/systemd/system -type f -name "left4dead2-server-*.service" ' +
' '.join(f"! -name '{name}.service'" for name in server_units)
)
actions['remove_obsolete_left4dead2_units'] = {
'command': (
f'for unitfile in $({find_obsolete_units}); '
f'do '
f'systemctl stop $(basename "$unitfile"); '
f'systemctl disable $(basename "$unitfile"); '
f'rm "$unitfile"; '
f'systemctl daemon-reload; '
f'done'
),
'unless': (
find_obsolete_units + " | wc -l | grep -q '^0$'"
),
}

View file

@ -1,62 +1,60 @@
from re import match assert node.has_bundle('steam')
from os import path, listdir
from shlex import quote
defaults = { defaults = {
'apt': { 'steam': {
'packages': { 'games': {
'libc6_i386': { # installs libc6:i386 'left4dead2': '222860',
'tags': {'left4dead2-packages'},
},
'lib32z1': {
'tags': {'left4dead2-packages'},
},
'unzip': {
'tags': {'left4dead2-packages'},
},
'p7zip-full': { # l4d2center_maps_sync.sh
'tags': {'left4dead2-packages'},
},
}, },
}, },
'left4dead2': { 'left4dead2': {
'overlays': set(listdir(path.join(repo.path, 'bundles/left4dead2/files/scripts/overlays'))), 'servers': {},
'servers': { 'admins': set(),
# 'port': 27017, 'workshop': set(),
# 'overlays': ['competitive_rework'],
# 'arguments': ['-tickrate 60'],
# 'config': [
# 'exec server_original.cfg',
# 'sm_forcematch zonemod',
# ],
},
},
'nftables': {
'input': {
'udp dport { 27005, 27020 } accept',
},
}, },
}
@metadata_reactor.provides(
'systemd/units',
)
def workshop(metadata):
command = (
'set -x; '
'for ID in ' + ' '.join(metadata.get('left4dead2/workshop')) + '; '
'do '
'if ! ls /opt/left4dead2/left4dead2/addons/$ID/*.vpk; '
'then '
'cd /opt/left4dead2/left4dead2/addons/$ID; '
'/opt/steam-workshop-downloader https://steamcommunity.com/sharedfiles/filedetails\?id\=$ID; '
'unzip $ID.zip; '
'fi; '
'done'
)
return {
'systemd': { 'systemd': {
'units': { 'units': {
'left4dead2-initialize.service': { 'left4dead2-workshop.service': {
'Unit': { 'Unit': {
'Description': 'initialize left4dead2', 'Description': 'install workshop items',
'After': 'network-online.target', 'After': 'network.target',
'Requires': 'steam-update.service',
'PartOf': 'steam-update.service'
}, },
'Service': { 'Service': {
'Type': 'oneshot', 'Type': 'oneshot',
'RemainAfterExit': 'yes', 'User': 'steam',
'ExecStart': '/opt/l4d2/setup', 'ExecStart': f'/bin/bash -c {quote(command)}',
'StandardOutput': 'journal',
'StandardError': 'journal',
}, },
'Install': { 'Install': {
'WantedBy': {'multi-user.target'}, 'WantedBy': {'multi-user.target'},
}, },
}, }
}, }
}, }
} }
@metadata_reactor.provides( @metadata_reactor.provides(
@ -66,44 +64,22 @@ def server_units(metadata):
units = {} units = {}
for name, config in metadata.get('left4dead2/servers').items(): for name, config in metadata.get('left4dead2/servers').items():
assert match(r'^[A-z0-9-_-]+$', name) units[f'left4dead2-server-{name}.service'] = {
assert 27000 <= config["port"] <= 27100
for overlay in config.get('overlays', []):
assert overlay in metadata.get('left4dead2/overlays'), f"unknown overlay {overlay}, known: {metadata.get('left4dead2/overlays')}"
cmd = f'/opt/l4d2/start -n {name} -p {config["port"]}'
if 'config' in config:
cmd += f' -c /opt/l4d2/configs/{name}.cfg'
for overlay in config.get('overlays', []):
cmd += f' -o {overlay}'
if 'arguments' in config:
cmd += ' -- ' + ' '.join(config['arguments'])
units[f'left4dead2-{name}.service'] = {
'Unit': { 'Unit': {
'Description': f'left4dead2 server {name}', 'Description': f'left4dead2 server {name}',
'After': {'left4dead2-initialize.service'}, 'After': 'network.target',
'Requires': {'left4dead2-initialize.service'}, 'Requires': 'steam-update.service',
}, },
'Service': { 'Service': {
'Type': 'simple', 'User': 'steam',
'ExecStart': cmd, 'Group': 'steam',
'ExecStopPost': f'/opt/l4d2/stop -n {name}', 'WorkingDirectory': '/opt/left4dead2',
'ExecStart': f'/opt/left4dead2/srcds_run -port {config["port"]} -insecure +map {config["map"]} +exec server-{name}.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 {
@ -111,18 +87,3 @@ def server_units(metadata):
'units': units, 'units': units,
}, },
} }
@metadata_reactor.provides(
'nftables/input',
)
def nftables(metadata):
ports = sorted(str(config["port"]) for config in metadata.get('left4dead2/servers').values())
return {
'nftables': {
'input': {
f'ip protocol {{ tcp, udp }} th dport {{ {", ".join(ports)} }} accept'
},
},
}

View file

@ -1,167 +0,0 @@
# 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

@ -1,6 +0,0 @@
# 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

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