Compare commits

..

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

569 changed files with 1215 additions and 32438 deletions

View file

@ -1,22 +0,0 @@
root = true
[*]
end_of_line = lf
[*.py]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
[*.toml]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
[*.yaml]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

7
.envrc
View file

@ -1,7 +0,0 @@
#!/usr/bin/env bash
PATH_add bin
layout uv
source_env ~/.local/share/direnv/bundlewrap

8
.gitignore vendored
View file

@ -1,9 +1 @@
.secrets.cfg* .secrets.cfg*
.venv
.cache
*.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

@ -1,44 +0,0 @@
# TODO
- dont spamfilter forwarded mails
- gollum wiki
- blog?
- fix dkim not working sometimes
- LDAP
- oauth2/OpenID
- icinga
Raspberry pi as soundcard
- gadget mode
- OTG g_audio
- https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824
# monitor timers
```sh
Timer=backup
Triggers=$(systemctl show ${Timer}.timer --property=Triggers --value)
echo $Triggers
if systemctl is-failed "$Triggers"
then
InvocationID=$(systemctl show "$Triggers" --property=InvocationID --value)
echo $InvocationID
ExitCode=$(systemctl show "$Triggers" -p ExecStartEx --value | sed 's/^{//' | sed 's/}$//' | tr ';' '\n' | xargs -n 1 | grep '^status=' | cut -d '=' -f 2)
echo $ExitCode
journalctl INVOCATION_ID="$InvocationID" --output cat
fi
```
telegraf: execd for daemons
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 +0,0 @@
#!/usr/bin/env python3
# purpose: starter template for new operator scripts under bin/.
from bundlewrap.repo import Repository
from os.path import realpath, dirname
bw = 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,71 +0,0 @@
#!/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 os.path import realpath, dirname
from ipaddress import ip_interface
repo = Repository(dirname(dirname(realpath(__file__))))
nodes = [
node
for node in sorted(repo.nodes_in_group('debian'))
if not node.dummy
]
print('updating nodes:', sorted(node.name for node in nodes))
# UPDATE
for node in nodes:
print('--------------------------------------')
print('updating', node.name)
print('--------------------------------------')
repo.libs.wol.wake(node)
print(node.run('DEBIAN_FRONTEND=noninteractive apt update').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()):
print(node.run('DEBIAN_FRONTEND=noninteractive apt -qy full-upgrade').stdout.decode())
# REBOOT IN ORDER
wireguard_servers = [
node
for node in nodes
if node.has_bundle('wireguard')
and (
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen <
ip_interface(node.metadata.get('wireguard/my_ip')).network.max_prefixlen
)
]
wireguard_s2s = [
node
for node in nodes
if node.has_bundle('wireguard')
and (
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen ==
ip_interface(node.metadata.get('wireguard/my_ip')).network.max_prefixlen
)
]
everything_else = [
node
for node in nodes
if not node.has_bundle('wireguard')
]
print('======================================')
for node in [
*everything_else,
*wireguard_s2s,
*wireguard_servers,
]:
try:
if node.run('test -e /var/run/reboot-required', may_fail=True).return_code == 0:
print('rebooting', node.name)
print(node.run('systemctl reboot').stdout.decode())
else:
print('not rebooting', node.name)
except Exception as e:
print(e)

View file

@ -1,10 +0,0 @@
#!/usr/bin/env python3
# purpose: wake one node via WoL by name — usage: wake <node>.
from bundlewrap.repo import Repository
from os.path import realpath, dirname
from sys import argv
repo = Repository(dirname(dirname(realpath(__file__))))
repo.libs.wol.wake(repo.get_node(argv[1]))

View file

@ -1,59 +0,0 @@
#!/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 os.path import realpath, dirname
from sys import argv
from ipaddress import ip_network, ip_interface
import argparse
# get info from repo
repo = Repository(dirname(dirname(realpath(__file__))))
server_node = repo.get_node('htz.mails')
available_clients = server_node.metadata.get('wireguard/clients').keys()
# parse args
parser = argparse.ArgumentParser(description='Generate WireGuard client configuration.')
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
allowed_ips = [
vpn_network,
ip_interface(server_node.metadata.get('network/internal/ipv4')).network,
]
for peer in server_node.metadata.get('wireguard/s2s').values():
for network in peer['allowed_ips']:
if not ip_network(network).subnet_of(vpn_network):
allowed_ips.append(ip_network(network))
conf = f'''
[Interface]
PrivateKey = {repo.libs.wireguard.privkey(data['peer_id'])}
ListenPort = 51820
Address = {data['peer_ip']}
DNS = 172.30.0.1
[Peer]
PublicKey = {repo.libs.wireguard.pubkey(server_node.metadata.get('id'))}
PresharedKey = {repo.libs.wireguard.psk(data['peer_id'], server_node.metadata.get('id'))}
AllowedIPs = {', '.join(str(client_route) for client_route in sorted(allowed_ips))}
Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:51820
PersistentKeepalive = 10
'''
answer = input("print config or qrcode? [Cq]: ").strip().upper()
match answer:
case '' | 'C':
print('>>>>>>>>>>>>>>>')
print(conf)
print('<<<<<<<<<<<<<<<')
case 'Q':
import pyqrcode
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

@ -1,10 +0,0 @@
http://www.apcupsd.org/manual/manual.html#power-down-during-shutdown
- onbattery: power lost
- battery drains
- when BATTERYLEVEL or MINUTES threshold is reached, server is shut down and
the ups is issued to cut the power
- when the mains power returns, the ups will reinstate power to the server
- the server will reboot
NOT IMPLEMENTED

View file

@ -1,343 +0,0 @@
## apcupsd.conf v1.1 ##
#
# "apcupsd" POSIX config file
#
# Note that the apcupsd daemon must be restarted in order for changes to
# this configuration file to become active.
#
#
# ========= General configuration parameters ============
#
# UPSNAME xxx
# Use this to give your UPS a name in log files and such. This
# is particulary useful if you have multiple UPSes. This does not
# set the EEPROM. It should be 8 characters or less.
#UPSNAME
# UPSCABLE <cable>
# Defines the type of cable connecting the UPS to your computer.
#
# Possible generic choices for <cable> are:
# simple, smart, ether, usb
#
# Or a specific cable model number may be used:
# 940-0119A, 940-0127A, 940-0128A, 940-0020B,
# 940-0020C, 940-0023A, 940-0024B, 940-0024C,
# 940-1524C, 940-0024G, 940-0095A, 940-0095B,
# 940-0095C, 940-0625A, M-04-02-2000
#
UPSCABLE usb
# To get apcupsd to work, in addition to defining the cable
# above, you must also define a UPSTYPE, which corresponds to
# the type of UPS you have (see the Description for more details).
# You must also specify a DEVICE, sometimes referred to as a port.
# For USB UPSes, please leave the DEVICE directive blank. For
# other UPS types, you must specify an appropriate port or address.
#
# UPSTYPE DEVICE Description
# apcsmart /dev/tty** Newer serial character device, appropriate for
# SmartUPS models using a serial cable (not USB).
#
# usb <BLANK> Most new UPSes are USB. A blank DEVICE
# setting enables autodetection, which is
# the best choice for most installations.
#
# net hostname:port Network link to a master apcupsd through apcupsd's
# Network Information Server. This is used if the
# UPS powering your computer is connected to a
# different computer for monitoring.
#
# snmp hostname:port:vendor:community
# SNMP network link to an SNMP-enabled UPS device.
# Hostname is the ip address or hostname of the UPS
# on the network. Vendor can be can be "APC" or
# "APC_NOTRAP". "APC_NOTRAP" will disable SNMP trap
# catching; you usually want "APC". Port is usually
# 161. Community is usually "private".
#
# netsnmp hostname:port:vendor:community
# OBSOLETE
# Same as SNMP above but requires use of the
# net-snmp library. Unless you have a specific need
# for this old driver, you should use 'snmp' instead.
#
# dumb /dev/tty** Old serial character device for use with
# simple-signaling UPSes.
#
# pcnet ipaddr:username:passphrase:port
# PowerChute Network Shutdown protocol which can be
# used as an alternative to SNMP with the AP9617
# family of smart slot cards. ipaddr is the IP
# address of the UPS management card. username and
# passphrase are the credentials for which the card
# has been configured. port is the port number on
# which to listen for messages from the UPS, normally
# 3052. If this parameter is empty or missing, the
# default of 3052 will be used.
#
# modbus /dev/tty** Serial device for use with newest SmartUPS models
# supporting the MODBUS protocol.
# modbus <BLANK> Leave the DEVICE setting blank for MODBUS over USB
# or set to the serial number of the UPS to ensure
# that apcupsd binds to that particular unit
# (helpful if you have more than one USB UPS).
#
UPSTYPE usb
#DEVICE /dev/ttyS0
# POLLTIME <int>
# Interval (in seconds) at which apcupsd polls the UPS for status. This
# setting applies both to directly-attached UPSes (UPSTYPE apcsmart, usb,
# dumb) and networked UPSes (UPSTYPE net, snmp). Lowering this setting
# will improve apcupsd's responsiveness to certain events at the cost of
# higher CPU utilization. The default of 60 is appropriate for most
# situations.
#POLLTIME 60
# LOCKFILE <path to lockfile>
# Path for device lock file for UPSes connected via USB or
# serial port. This is the directory into which the lock file
# will be written. The directory must already exist; apcupsd will not create
# it. The actual name of the lock file is computed from DEVICE.
# Not used on Win32.
LOCKFILE /var/lock
# SCRIPTDIR <path to script directory>
# Directory in which apccontrol and event scripts are located.
SCRIPTDIR /etc/apcupsd
# PWRFAILDIR <path to powerfail directory>
# Directory in which to write the powerfail flag file. This file
# is created when apcupsd initiates a system shutdown and is
# checked in the OS halt scripts to determine if a killpower
# (turning off UPS output power) is required.
PWRFAILDIR /etc/apcupsd
# NOLOGINDIR <path to nologin directory>
# Directory in which to write the nologin file. The existence
# of this flag file tells the OS to disallow new logins.
NOLOGINDIR /etc
#
# ======== Configuration parameters used during power failures ==========
#
# The ONBATTERYDELAY is the time in seconds from when a power failure
# is detected until we react to it with an onbattery event.
#
# This means that, apccontrol will be called with the powerout argument
# immediately when a power failure is detected. However, the
# onbattery argument is passed to apccontrol only after the
# ONBATTERYDELAY time. If you don't want to be annoyed by short
# powerfailures, make sure that apccontrol powerout does nothing
# i.e. comment out the wall.
ONBATTERYDELAY 6
#
# Note: BATTERYLEVEL, MINUTES, and TIMEOUT work in conjunction, so
# the first that occurs will cause the initation of a shutdown.
#
# If during a power failure, the remaining battery percentage
# (as reported by the UPS) is below or equal to BATTERYLEVEL,
# apcupsd will initiate a system shutdown.
BATTERYLEVEL 10
# If during a power failure, the remaining runtime in minutes
# (as calculated internally by the UPS) is below or equal to MINUTES,
# apcupsd, will initiate a system shutdown.
MINUTES 5
# If during a power failure, the UPS has run on batteries for TIMEOUT
# many seconds or longer, apcupsd will initiate a system shutdown.
# A value of 0 disables this timer.
#
# Note, if you have a Smart UPS, you will most likely want to disable
# this timer by setting it to zero. That way, you UPS will continue
# on batteries until either the % charge remaing drops to or below BATTERYLEVEL,
# or the remaining battery runtime drops to or below MINUTES. Of course,
# if you are testing, setting this to 60 causes a quick system shutdown
# if you pull the power plug.
# If you have an older dumb UPS, you will want to set this to less than
# the time you know you can run on batteries.
TIMEOUT 0
# Time in seconds between annoying users to signoff prior to
# system shutdown. 0 disables.
ANNOY 300
# Initial delay after power failure before warning users to get
# off the system.
ANNOYDELAY 60
# The condition which determines when users are prevented from
# logging in during a power failure.
# NOLOGON <string> [ disable | timeout | percent | minutes | always ]
NOLOGON disable
# If KILLDELAY is non-zero, apcupsd will continue running after a
# shutdown has been requested, and after the specified time in
# seconds attempt to kill the power. This is for use on systems
# where apcupsd cannot regain control after a shutdown.
# KILLDELAY <seconds> 0 disables
KILLDELAY 0
#
# ==== Configuration statements for Network Information Server ====
#
# NETSERVER [ on | off ] on enables, off disables the network
# information server. If netstatus is on, a network information
# server process will be started for serving the STATUS and
# EVENT data over the network (used by CGI programs).
NETSERVER on
# NISIP <dotted notation ip address>
# IP address on which NIS server will listen for incoming connections.
# This is useful if your server is multi-homed (has more than one
# network interface and IP address). Default value is 0.0.0.0 which
# means any incoming request will be serviced. Alternatively, you can
# configure this setting to any specific IP address of your server and
# NIS will listen for connections only on that interface. Use the
# loopback address (127.0.0.1) to accept connections only from the
# local machine.
NISIP 127.0.0.1
# NISPORT <port> default is 3551 as registered with the IANA
# port to use for sending STATUS and EVENTS data over the network.
# It is not used unless NETSERVER is on. If you change this port,
# you will need to change the corresponding value in the cgi directory
# and rebuild the cgi programs.
NISPORT 3551
# If you want the last few EVENTS to be available over the network
# by the network information server, you must define an EVENTSFILE.
EVENTSFILE /var/log/apcupsd.events
# EVENTSFILEMAX <kilobytes>
# By default, the size of the EVENTSFILE will be not be allowed to exceed
# 10 kilobytes. When the file grows beyond this limit, older EVENTS will
# be removed from the beginning of the file (first in first out). The
# parameter EVENTSFILEMAX can be set to a different kilobyte value, or set
# to zero to allow the EVENTSFILE to grow without limit.
EVENTSFILEMAX 10
#
# ========== Configuration statements used if sharing =============
# a UPS with more than one machine
#
# Remaining items are for ShareUPS (APC expansion card) ONLY
#
# UPSCLASS [ standalone | shareslave | sharemaster ]
# Normally standalone unless you share an UPS using an APC ShareUPS
# card.
UPSCLASS standalone
# UPSMODE [ disable | share ]
# Normally disable unless you share an UPS using an APC ShareUPS card.
UPSMODE disable
#
# ===== Configuration statements to control apcupsd system logging ========
#
# Time interval in seconds between writing the STATUS file; 0 disables
STATTIME 0
# Location of STATUS file (written to only if STATTIME is non-zero)
STATFILE /var/log/apcupsd.status
# LOGSTATS [ on | off ] on enables, off disables
# Note! This generates a lot of output, so if
# you turn this on, be sure that the
# file defined in syslog.conf for LOG_NOTICE is a named pipe.
# You probably do not want this on.
LOGSTATS off
# Time interval in seconds between writing the DATA records to
# the log file. 0 disables.
DATATIME 0
# FACILITY defines the logging facility (class) for logging to syslog.
# If not specified, it defaults to "daemon". This is useful
# if you want to separate the data logged by apcupsd from other
# programs.
#FACILITY DAEMON
#
# ========== Configuration statements used in updating the UPS EPROM =========
#
#
# These statements are used only by apctest when choosing "Set EEPROM with conf
# file values" from the EEPROM menu. THESE STATEMENTS HAVE NO EFFECT ON APCUPSD.
#
# UPS name, max 8 characters
#UPSNAME UPS_IDEN
# Battery date - 8 characters
#BATTDATE mm/dd/yy
# Sensitivity to line voltage quality (H cause faster transfer to batteries)
# SENSITIVITY H M L (default = H)
#SENSITIVITY H
# UPS delay after power return (seconds)
# WAKEUP 000 060 180 300 (default = 0)
#WAKEUP 60
# UPS Grace period after request to power off (seconds)
# SLEEP 020 180 300 600 (default = 20)
#SLEEP 180
# Low line voltage causing transfer to batteries
# The permitted values depend on your model as defined by last letter
# of FIRMWARE or APCMODEL. Some representative values are:
# D 106 103 100 097
# M 177 172 168 182
# A 092 090 088 086
# I 208 204 200 196 (default = 0 => not valid)
#LOTRANSFER 208
# High line voltage causing transfer to batteries
# The permitted values depend on your model as defined by last letter
# of FIRMWARE or APCMODEL. Some representative values are:
# D 127 130 133 136
# M 229 234 239 224
# A 108 110 112 114
# I 253 257 261 265 (default = 0 => not valid)
#HITRANSFER 253
# Battery charge needed to restore power
# RETURNCHARGE 00 15 50 90 (default = 15)
#RETURNCHARGE 15
# Alarm delay
# 0 = zero delay after pwr fail, T = power fail + 30 sec, L = low battery, N = never
# BEEPSTATE 0 T L N (default = 0)
#BEEPSTATE T
# Low battery warning delay in minutes
# LOWBATT 02 05 07 10 (default = 02)
#LOWBATT 2
# UPS Output voltage when running on batteries
# The permitted values depend on your model as defined by last letter
# of FIRMWARE or APCMODEL. Some representative values are:
# D 115
# M 208
# A 100
# I 230 240 220 225 (default = 0 => not valid)
#OUTPUTVOLTS 230
# Self test interval in hours 336=2 weeks, 168=1 week, ON=at power on
# SELFTEST 336 168 ON OFF (default = 336)
#SELFTEST 336

View file

@ -1,10 +0,0 @@
#!/bin/bash
date=$(date --utc +%s%N)
METRICS=$(apcaccess)
for METRIC in TIMELEFT LOADPCT BCHARGE
do
echo "apcupsd $METRIC=$(grep $METRIC <<< $METRICS | cut -d ':' -f 2 | xargs | cut -d ' ' -f 1 ) $date"
done

View file

@ -1,20 +0,0 @@
files = {
'/etc/apcupsd/apcupsd.conf': {
'needs': [
'pkg_apt:apcupsd',
],
},
'/usr/local/share/telegraf/apcupsd': {
'source': 'telegraf_plugin',
'mode': '755',
},
}
svc_systemd = {
'apcupsd': {
'needs': [
'pkg_apt:apcupsd',
'file:/etc/apcupsd/apcupsd.conf',
],
}
}

View file

@ -1,28 +0,0 @@
defaults = {
'apt': {
'packages': {
'apcupsd': {},
},
},
'grafana_rows': {
'ups',
},
'sudoers': {
'telegraf': {
'/usr/local/share/telegraf/apcupsd',
},
},
'telegraf': {
'inputs': {
'exec': {
'apcupsd': {
'commands': ["sudo /usr/local/share/telegraf/apcupsd"],
'name_override': "apcupsd",
'data_format': "influx",
'interval': '30s',
'flush_interval': '30s',
},
},
},
},
}

View file

@ -1,40 +0,0 @@
# https://manpages.debian.org/latest/apt/sources.list.5.de.html
# https://repolib.readthedocs.io/en/latest/deb822-format.html
```python
{
'apt': {
'packages': {
'apt-transport-https': {},
},
'sources': {
'debian': {
'types': { # optional, defaults to `{'deb'}``
'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

@ -1,15 +0,0 @@
#!/bin/bash
apt update -qq --silent 2> /dev/null
UPGRADABLE=$(apt list --upgradable -qq 2> /dev/null | cut -d '/' -f 1)
if test "$UPGRADABLE" != ""
then
echo "$(wc -l <<< $UPGRADABLE) package(s) upgradable:"
echo
echo "$UPGRADABLE"
exit 1
else
exit 0
fi

View file

@ -1,70 +1,3 @@
# TODO pin repo: https://superuser.com/a/1595920
from os.path import join, basename
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': {
'purge': True,
'triggers': {
'action:apt_update',
},
},
}
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': {
'content': '# managed by bundlewrap\n',
'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': {
'mode': '0755',
},
# /etc/kernel/postinst.d/apt-auto-removal
}
actions = { actions = {
'apt_update': { 'apt_update': {
'command': 'apt-get update', 'command': 'apt-get update',
@ -76,65 +9,5 @@ actions = {
}, },
} }
# create sources.lists and respective keyfiles
for name, config in node.metadata.get('apt/sources').items():
# place keyfile
keyfile_destination_path = repo.libs.apt.format_variables(node, config['options']['Signed-By'])
files[keyfile_destination_path] = {
'source': join(repo.path, 'data', 'apt', 'keys', basename(keyfile_destination_path)),
'content_type': 'binary',
'triggers': {
'action:apt_update',
},
}
# place sources.list
files[f'/etc/apt/sources.list.d/{name}.sources'] = {
'content': repo.libs.apt.render_source(node, name),
'triggers': {
'action:apt_update',
},
}
# create backport pinnings
for package, options in node.metadata.get('apt/packages', {}).items(): for package, options in node.metadata.get('apt/packages', {}).items():
pkg_apt[package] = options pkg_apt[package] = options
if pkg_apt[package].pop('backports', False):
files[f'/etc/apt/preferences.d/{package}'] = {
'content': '\n'.join([
f"Package: {package}",
f"Pin: release a={node.metadata.get('os_codename')}-backports",
f"Pin-Priority: 900",
]),
'needed_by': [
f'pkg_apt:{package}',
],
'triggers': {
'action:apt_update',
},
}
# unattended upgrades
#
# unattended-upgrades.service: delays shutdown if necessary
# apt-daily.timer: performs apt update
# apt-daily-upgrade.timer: performs apt upgrade
svc_systemd['unattended-upgrades.service'] = {
'needs': [
'pkg_apt:unattended-upgrades',
],
}
svc_systemd['apt-daily.timer'] = {
'needs': [
'pkg_apt:unattended-upgrades',
],
}
svc_systemd['apt-daily-upgrade.timer'] = {
'needs': [
'pkg_apt:unattended-upgrades',
],
}

View file

@ -1,161 +0,0 @@
defaults = {
'apt': {
'packages': {
'apt-listchanges': {
'installed': False,
},
'ca-certificates': {},
'unattended-upgrades': {},
},
'config': {
'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': {
'services': {
'apt upgradable': {
'vars.command': '/usr/lib/nagios/plugins/check_apt_upgradable',
'vars.sudo': True,
'check_interval': '1h',
},
'current kernel': {
'vars.command': 'ls /boot/vmlinuz-* | sort -V | tail -n 1 | xargs -n1 basename | cut -d "-" -f 2- | grep -q "^$(uname -r)$"',
'check_interval': '1h',
},
'apt reboot-required': {
'vars.command': 'ls /var/run/reboot-required 2> /dev/null && exit 1 || exit 0',
'check_interval': '1h',
},
},
},
}
@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,12 +0,0 @@
```
defaults = {
'archive': {
'/var/important': {
'exclude': [
'\.cache/',
'\.log$',
],
},
},
}
```

View file

@ -1,29 +0,0 @@
#!/bin/bash
if [[ "$1" == 'perform' ]]
then
echo 'NON-DRY RUN'
DRY=''
else
echo 'DRY RUN'
DRY='-n'
fi
% for path, options in paths.items():
# ${path}
gsutil ${'\\'}
-m ${'\\'}
-o 'GSUtil:parallel_process_count=${processes}' ${'\\'}
-o 'GSUtil:parallel_thread_count=${threads}' ${'\\'}
rsync ${'\\'}
$DRY ${'\\'}
-r ${'\\'}
-d ${'\\'}
-e ${'\\'}
% if options.get('exclude'):
-x '${'|'.join(options['exclude'])}' ${'\\'}
% endif
'${options['encrypted_path']}' ${'\\'}
'gs://${bucket}/${node_id}${path}' ${'\\'}
2>&1 | logger -st gsutil
% endfor

View file

@ -1,10 +0,0 @@
#!/bin/bash
FILENAME=$1
TMPFILE=$(mktemp /tmp/archive_file.XXXXXXXXXX)
BUCKET=$(cat /etc/gcloud/gcloud.json | jq -r .bucket)
NODE=$(cat /etc/archive/archive.json | jq -r .node_id)
MASTERKEY=$(cat /etc/gocryptfs/masterkey)
gsutil cat "gs://$BUCKET/$NODE$FILENAME" > "$TMPFILE"
/opt/gocryptfs-inspect/gocryptfs.py --aessiv --config=/etc/gocryptfs/gocryptfs.conf --masterkey="$MASTERKEY" "$TMPFILE"

View file

@ -1,15 +0,0 @@
#!/bin/bash
FILENAME=$1
ARCHIVE=$(/opt/archive/get_file "$FILENAME" | sha256sum)
ORIGINAL=$(cat "$FILENAME" | sha256sum)
if [[ "$ARCHIVE" == "$ORIGINAL" ]]
then
echo "OK"
exit 0
else
echo "ERROR"
exit 1
fi

View file

@ -1,43 +0,0 @@
assert node.has_bundle('gcloud')
assert node.has_bundle('gocryptfs')
assert node.has_bundle('gocryptfs-inspect')
assert node.has_bundle('systemd')
from json import dumps
directories['/opt/archive'] = {}
directories['/etc/archive'] = {}
files['/etc/archive/archive.json'] = {
'content': dumps(
{
'node_id': node.metadata.get('id'),
**node.metadata.get('archive'),
},
indent=4,
sort_keys=True
),
}
files['/opt/archive/archive'] = {
'content_type': 'mako',
'mode': '700',
'context': {
'node_id': node.metadata.get('id'),
'paths': node.metadata.get('archive/paths'),
'bucket': node.metadata.get('gcloud/bucket'),
'processes': 4,
'threads': 4,
},
'needs': [
'bundle:gcloud',
],
}
files['/opt/archive/get_file'] = {
'mode': '700',
}
files['/opt/archive/validate_file'] = {
'mode': '700',
}

View file

@ -1,45 +0,0 @@
defaults = {
'apt': {
'packages': {
'jq': {},
},
},
'archive': {
'paths': {},
},
}
@metadata_reactor.provides(
'archive/paths',
)
def paths(metadata):
return {
'archive': {
'paths': {
path: {
'encrypted_path': f'/mnt/archive.enc{path}',
'exclude': [
'^\..*',
'/\..*',
],
} for path in metadata.get('archive/paths')
},
}
}
@metadata_reactor.provides(
'gocryptfs/paths',
)
def gocryptfs(metadata):
return {
'gocryptfs': {
'paths': {
path: {
'mountpoint': options['encrypted_path'],
'reverse': True,
} for path, options in metadata.get('archive/paths').items()
},
}
}

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

@ -1,3 +0,0 @@
!/bin/bash
zfs send tank/nextcloud@test1 | ssh backup-receiver@10.0.0.5 sudo zfs recv tank/nextcloud

View file

@ -1,122 +0,0 @@
from ipaddress import ip_interface
defaults = {
'apt': {
'packages': {
'rsync': {},
},
},
'users': {
'backup-receiver': {
'authorized_keys': set(),
},
},
'sudoers': {
'backup-receiver': {
'/usr/bin/rsync',
'/sbin/zfs',
},
},
'zfs': {
'datasets': {
'tank': {
'recordsize': "1048576",
},
},
},
}
@metadata_reactor.provides(
'zfs/datasets'
)
def zfs(metadata):
datasets = {}
for other_node in repo.nodes:
if (
not other_node.dummy and
other_node.has_bundle('backup') and
other_node.metadata.get('backup/server') == node.name
):
id = other_node.metadata.get('id')
base_dataset = f'tank/{id}'
# container
datasets[base_dataset] = {
'mountpoint': None,
'readonly': 'on',
'compression': 'lz4',
'com.sun:auto-snapshot': 'false',
'backup': False,
}
# for rsync backups
datasets[f'{base_dataset}/fs'] = {
'mountpoint': f"/mnt/backups/{id}",
'readonly': 'off',
'compression': 'lz4',
'com.sun:auto-snapshot': 'true',
'backup': False,
}
# for zfs send/recv
if other_node.has_bundle('zfs'):
# base datasets for each tank
for pool in other_node.metadata.get('zfs/pools'):
datasets[f'{base_dataset}/{pool}'] = {
'mountpoint': None,
'readonly': 'on',
'compression': 'lz4',
'com.sun:auto-snapshot': 'false',
'backup': False,
}
# actual datasets
for path in other_node.metadata.get('backup/paths'):
for dataset, config in other_node.metadata.get('zfs/datasets').items():
if path == config.get('mountpoint'):
datasets[f'{base_dataset}/{dataset}'] = {
'mountpoint': None,
'readonly': 'on',
'compression': 'lz4',
'com.sun:auto-snapshot': 'false',
'backup': False,
}
continue
return {
'zfs': {
'datasets': datasets,
},
}
@metadata_reactor.provides(
'dns',
)
def dns(metadata):
return {
'dns': {
metadata.get('backup-server/hostname'): repo.libs.ip.get_a_records(metadata),
}
}
@metadata_reactor.provides(
'users/backup-receiver/authorized_keys'
)
def backup_authorized_keys(metadata):
return {
'users': {
'backup-receiver': {
'authorized_keys': {
other_node.metadata.get('users/root/pubkey')
for other_node in repo.nodes
if other_node.has_bundle('backup')
and other_node.metadata.get('backup/server') == node.name
},
},
},
}

View file

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

View file

@ -1,16 +0,0 @@
#!/bin/bash
set -exu
path=$1
if zfs list -H -o mountpoint | grep -q "^$path$"
then
/opt/backup/backup_path_via_zfs "$path"
elif test -e "$path"
then
/opt/backup/backup_path_via_rsync "$path"
else
echo "UNKNOWN PATH: $path"
exit 1
fi

View file

@ -1,20 +0,0 @@
#!/bin/bash
set -exu
path=$1
uuid=$(jq -r .client_uuid < /etc/backup/config.json)
server=$(jq -r .server_hostname < /etc/backup/config.json)
ssh="ssh -o ConnectTimeout=5 backup-receiver@$server"
if test -d "$path"
then
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,67 +0,0 @@
#!/bin/bash
set -eu
path=$1
uuid=$(jq -r .client_uuid < /etc/backup/config.json)
server=$(jq -r .server_hostname < /etc/backup/config.json)
ssh="ssh -o ConnectTimeout=5 backup-receiver@$server"
source_dataset=$(zfs list -H -o mountpoint,name | grep -P "^$path\t" | cut -d $'\t' -f 2)
target_dataset="tank/$uuid/$source_dataset"
target_dataset_parent=$(echo $target_dataset | rev | cut -d / -f 2- | rev)
bookmark_prefix="auto-backup_"
new_bookmark="$bookmark_prefix$(date +"%Y-%m-%d_%H:%M:%S")"
for var in path uuid server ssh source_dataset target_dataset target_dataset_parent new_bookmark
do
[[ -z "${!var}" ]] && echo "ERROR - $var is empty" && exit 96
done
$ssh true || (echo "ERROR - cant ssh connect to $server" && exit 97)
echo "BACKUP ZFS DATASET - PATH: $path, SERVER: $server, UUID: $uuid, SOURCE_DATASET: $source_dataset, TARGET_DATASET: $target_dataset"
if ! $ssh sudo zfs list -t filesystem -H -o name | grep -q "^$target_dataset_parent$"
then
echo "CREATING PARENT DATASET..."
$ssh sudo zfs create -p -o mountpoint=none "$target_dataset_parent"
fi
zfs snap "$source_dataset@$new_bookmark"
if zfs list -t bookmark -H -o name | grep "^$source_dataset#$bookmark_prefix" | wc -l | grep -q "^0$"
then
echo "INITIAL BACKUP"
# do in subshell, otherwise ctr+c will lead to 0 exitcode
$(zfs send -v "$source_dataset@$new_bookmark" | $ssh sudo zfs recv -F "$target_dataset")
else
echo "INCREMENTAL BACKUP"
last_bookmark=$(zfs list -t bookmark -H -o name | grep "^$source_dataset#$bookmark_prefix" | sort | tail -1 | cut -d '#' -f 2)
[[ -z "$last_bookmark" ]] && echo "ERROR - last_bookmark is empty" && exit 98
$(zfs send -v -L -i "#$last_bookmark" "$source_dataset@$new_bookmark" | $ssh sudo zfs recv "$target_dataset")
fi
if [[ "$?" == "0" ]]
then
# delete old local bookmarks
for destroyable_bookmark in $(zfs list -t bookmark -H -o name "$source_dataset" | grep "^$source_dataset#$bookmark_prefix")
do
zfs destroy "$destroyable_bookmark"
done
# delete remote 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")
do
$ssh sudo zfs destroy "$destroyable_snapshot"
done
zfs bookmark "$source_dataset@$new_bookmark" "$source_dataset#$new_bookmark"
zfs destroy "$source_dataset@$new_bookmark" # keep snapshots?
echo "SUCCESS"
else
zfs destroy "$source_dataset@$new_bookmark"
echo "ERROR"
exit 99
fi

View file

@ -1,37 +0,0 @@
from json import dumps
backup_node = repo.get_node(node.metadata.get('backup/server'))
directories['/opt/backup'] = {}
files['/opt/backup/backup_all'] = {
'mode': '700',
'content_type': 'mako',
'context': {
'wol_command': backup_node.metadata.get('wol-sleeper/wake_command', False),
},
}
files['/opt/backup/backup_path'] = {
'mode': '700',
}
files['/opt/backup/backup_path_via_zfs'] = {
'mode': '700',
}
files['/opt/backup/backup_path_via_rsync'] = {
'mode': '700',
}
directories['/etc/backup'] = {}
files['/etc/backup/config.json'] = {
'content': dumps(
{
'server_hostname': backup_node.metadata.get('backup-server/hostname'),
'client_uuid': node.metadata.get('id'),
'paths': sorted(set(node.metadata.get('backup/paths'))),
},
indent=4,
sort_keys=True
),
}

View file

@ -1,30 +0,0 @@
defaults = {
'apt': {
'packages': {
'jq': {
'needed_by': {
'svc_systemd:backup.timer',
},
},
'rsync': {
'needed_by': {
'svc_systemd:backup.timer',
},
},
},
},
'backup': {
'server': None,
'paths': set(),
},
'systemd-timers': {
f'backup': {
'command': '/opt/backup/backup_all',
'when': '1:00',
'persistent': True,
'after': {
'network-online.target',
},
},
},
}

View file

@ -1,70 +0,0 @@
from ipaddress import ip_interface
@metadata_reactor.provides(
'dns',
)
def acme_records(metadata):
domains = set()
for other_node in repo.nodes:
for domain, conf in other_node.metadata.get('letsencrypt/domains', {}).items():
domains.add(domain)
domains.update(conf.get('aliases', []))
return {
'dns': {
f'_acme-challenge.{domain}': {
'CNAME': {f"{domain}.{metadata.get('bind/acme_zone')}."},
}
for domain in domains
}
}
@metadata_reactor.provides(
'bind/acls/acme',
'bind/views/external/keys/acme',
'bind/views/external/zones',
)
def acme_zone(metadata):
allowed_ips = {
*{
str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip)
for other_node in repo.nodes
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)
for other_node in repo.nodes
if other_node.has_bundle('wireguard')
},
}
return {
'bind': {
'acls': {
'acme': {
'key acme',
'!{ !{' + ' '.join(f'{ip};' for ip in sorted(allowed_ips)) + '}; any;}',
},
},
'views': {
'external': {
'keys': {
'acme': {},
},
'zones': {
metadata.get('bind/acme_zone'): {
'allow_update': {
'acme',
},
},
},
},
},
},
}
#https://lists.isc.org/pipermail/bind-users/2006-January/061051.html

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,23 +0,0 @@
<%!
def column_width(column, table):
return max(map(lambda row: len(row[column]), table)) if table else 0
%>\
$TTL 600
@ IN SOA ${hostname}. admin.${hostname}. (
2021111709 ;Serial
3600 ;Refresh
200 ;Retry
1209600 ;Expire
900 ;Negative response caching TTL
)
% for record in sorted(records, key=lambda r: (tuple(reversed(r['name'].split('.'))), r['type'], r['value'])):
(${(record['name'] or '@').rjust(column_width('name', records))}) \
IN \
${record['type'].ljust(column_width('type', records))} \
% if record['type'] == 'TXT':
(${' '.join('"'+record['value'][i:i+255]+'"' for i in range(0, len(record['value']), 255))})
% else:
${record['value']}
% endif
% endfor

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

@ -1,2 +0,0 @@
RESOLVCONF=no
OPTIONS="-u bind"

View file

@ -1,6 +0,0 @@
statistics-channels {
inet 127.0.0.1 port 8053;
};
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";

View file

@ -1,68 +0,0 @@
# KEYS
% for view_name, view_conf in views.items():
% for key_name, key_conf in sorted(view_conf['keys'].items()):
key "${key_name}" {
algorithm hmac-sha512;
secret "${key_conf['token']}";
};
% endfor
% endfor
# ACLS
% for acl_name, acl_content in acls.items():
acl "${acl_name}" {
% for ac in sorted(acl_content, key=lambda e: (not e.startswith('!'), not e.startswith('key'), e)):
${ac};
% endfor
};
% endfor
# VIEWS
% for view_name, view_conf in views.items():
view "${view_name}" {
match-clients {
${view_name};
};
% if view_conf['is_internal']:
recursion yes;
include "/etc/bind/zones.rfc1918";
% else:
recursion no;
rate-limit {
responses-per-second 2;
window 25;
};
% endif
forward only;
forwarders {
1.1.1.1;
9.9.9.9;
8.8.8.8;
};
% for zone_name, zone_conf in sorted(view_conf['zones'].items()):
zone "${zone_name}" {
% if type == 'slave' and zone_conf.get('allow_update', []):
type slave;
masters { ${master_ip}; };
% else:
type master;
% if zone_conf.get('allow_update', []):
allow-update {
% for allow_update in zone_conf['allow_update']:
${allow_update};
% endfor
};
% endif
% endif
file "/var/lib/bind/${view_name}/${zone_name}";
};
% endfor
};
% endfor

View file

@ -1,16 +0,0 @@
options {
directory "/var/cache/bind";
dnssec-validation auto;
listen-on-v6 { any; };
allow-query { any; };
max-cache-size 30%;
querylog yes;
% if type == 'master':
notify yes;
also-notify { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} };
allow-transfer { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} };
% 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

@ -1,162 +0,0 @@
from ipaddress import ip_address, ip_interface
from datetime import datetime
from hashlib import sha3_512
if node.metadata.get('bind/type') == 'master':
master_node = node
else:
master_node = repo.get_node(node.metadata.get('bind/master_node'))
directories[f'/var/lib/bind'] = {
'owner': 'bind',
'group': 'bind',
'purge': True,
'needs': [
'pkg_apt:bind9',
],
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
files['/etc/default/bind9'] = {
'source': 'defaults',
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
files['/etc/bind/named.conf'] = {
'owner': 'root',
'group': 'bind',
'needs': [
'pkg_apt:bind9',
],
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
files['/etc/bind/named.conf.options'] = {
'content_type': 'mako',
'context': {
'type': node.metadata.get('bind/type'),
'slave_ips': node.metadata.get('bind/slave_ips', []),
'master_ip': node.metadata.get('bind/master_ip', None),
},
'owner': 'root',
'group': 'bind',
'needs': [
'pkg_apt:bind9',
],
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
files['/etc/bind/named.conf.local'] = {
'content_type': 'mako',
'context': {
'type': node.metadata.get('bind/type'),
'master_ip': node.metadata.get('bind/master_ip', None),
'acls': {
**master_node.metadata.get('bind/acls'),
**{
view_name: view_conf['match_clients']
for view_name, view_conf in master_node.metadata.get('bind/views').items()
},
},
'views': dict(sorted(
master_node.metadata.get('bind/views').items(),
key=lambda e: (e[1].get('default', False), e[0]),
)),
},
'owner': 'root',
'group': 'bind',
'needs': [
'pkg_apt:bind9',
],
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
for view_name, view_conf in master_node.metadata.get('bind/views').items():
directories[f"/var/lib/bind/{view_name}"] = {
'owner': 'bind',
'group': 'bind',
'purge': True,
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
for zone_name, zone_conf in view_conf['zones'].items():
files[f"/var/lib/bind/{view_name}/{zone_name}"] = {
'source': 'db',
'content_type': 'mako',
'unless': f"test -f /var/lib/bind/{view_name}/{zone_name}" if zone_conf.get('allow_update', False) else 'false',
'context': {
'serial': datetime.now().strftime('%Y%m%d%H'),
'records': zone_conf['records'],
'hostname': node.metadata.get('bind/hostname'),
'type': node.metadata.get('bind/type'),
},
'owner': 'bind',
'group': 'bind',
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
svc_systemd['bind9'] = {}
actions['named-checkconf'] = {
'command': 'named-checkconf -z',
'unless': 'named-checkconf -z',
'needs': [
'svc_systemd:bind9',
'svc_systemd:bind9:reload',
]
}
# beantwortet Anfragen nach privaten IP-Adressen mit NXDOMAIN, statt sie ins Internet weiterzuleiten
files['/etc/bind/zones.rfc1918'] = {
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}
files['/etc/bind/db.empty'] = {
'needed_by': [
'svc_systemd:bind9',
],
'triggers': [
'svc_systemd:bind9:reload',
],
}

View file

@ -1,258 +0,0 @@
from ipaddress import ip_interface
from json import dumps
h = repo.libs.hashable.hashable
repo.libs.bind.repo = repo
defaults = {
'apt': {
'packages': {
'bind9': {},
},
},
'bind': {
'slaves': {},
'acls': {
'our-nets': {
'127.0.0.1',
'10.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.168.0.0/16',
}
},
'views': {
'internal': {
'is_internal': True,
'keys': {},
'match_clients': {
'our-nets',
},
'zones': {},
},
'external': {
'default': True,
'is_internal': False,
'keys': {},
'match_clients': {
'any',
},
'zones': {},
},
},
'zones': set(),
},
'nftables': {
'input': {
'tcp dport 53 accept',
'udp dport 53 accept',
},
},
'telegraf': {
'inputs': {
'bind': {
'default': {
'urls': ['http://localhost:8053/xml/v3'],
'gather_memory_contexts': False,
'gather_views': True,
},
},
},
},
}
@metadata_reactor.provides(
'bind/type',
'bind/master_ip',
'bind/slave_ips',
)
def master_slave(metadata):
if metadata.get('bind/master_node', None):
return {
'bind': {
'type': 'slave',
'master_ip': str(ip_interface(repo.get_node(metadata.get('bind/master_node')).metadata.get('network/external/ipv4')).ip),
}
}
else:
return {
'bind': {
'type': 'master',
'slave_ips': {
str(ip_interface(repo.get_node(slave).metadata.get('network/external/ipv4')).ip)
for slave in metadata.get('bind/slaves')
}
}
}
@metadata_reactor.provides(
'dns',
)
def dns(metadata):
return {
'dns': {
metadata.get('bind/hostname'): repo.libs.ip.get_a_records(metadata),
}
}
@metadata_reactor.provides(
'bind/views',
)
def collect_records(metadata):
if metadata.get('bind/type') == 'slave':
return {}
views = {}
for view_name, view_conf in metadata.get('bind/views').items():
for other_node in repo.nodes:
for fqdn, records in other_node.metadata.get('dns', {}).items():
matching_zones = sorted(
filter(
lambda potential_zone: fqdn.endswith(potential_zone),
metadata.get('bind/zones')
),
key=len,
)
if matching_zones:
zone = matching_zones[-1]
else:
continue
name = fqdn[0:-len(zone) - 1]
for type, values in records.items():
for value in values:
if repo.libs.bind.record_matches_view(value, type, name, zone, view_name, metadata):
views\
.setdefault(view_name, {})\
.setdefault('zones', {})\
.setdefault(zone, {})\
.setdefault('records', set())\
.add(
h({'name': name, 'type': type, 'value': value})
)
return {
'bind': {
'views': views,
},
}
@metadata_reactor.provides(
'bind/views',
)
def ns_records(metadata):
if metadata.get('bind/type') == 'slave':
return {}
nameservers = [
node.metadata.get('bind/hostname'),
*[
repo.get_node(slave).metadata.get('bind/hostname')
for slave in node.metadata.get('bind/slaves')
]
]
return {
'bind': {
'views': {
view_name: {
'zones': {
zone_name: {
'records': {
# FIXME: bw currently cant handle lists of dicts :(
h({'name': '@', 'type': 'NS', 'value': f"{nameserver}."})
for nameserver in nameservers
}
}
for zone_name, zone_conf in view_conf['zones'].items()
}
}
for view_name, view_conf in metadata.get('bind/views').items()
},
},
}
@metadata_reactor.provides(
'bind/slaves',
)
def slaves(metadata):
if metadata.get('bind/type') == 'slave':
return {}
return {
'bind': {
'slaves': [
other_node.name
for other_node in repo.nodes
if other_node.has_bundle('bind') and other_node.metadata.get('bind/master_node', None) == node.name
],
},
}
@metadata_reactor.provides(
'bind/views',
)
def generate_keys(metadata):
if metadata.get('bind/type') == 'slave':
return {}
return {
'bind': {
'views': {
view_name: {
'keys': {
key: {
'token':repo.libs.hmac.hmac_sha512(
key,
str(repo.vault.random_bytes_as_base64_for(
f"{metadata.get('id')} bind key {key} 20250713",
length=32,
)),
)
}
for key in view_conf['keys']
}
}
for view_name, view_conf in metadata.get('bind/views').items()
}
}
}
@metadata_reactor.provides(
'bind/views',
)
def generate_acl_entries_for_keys(metadata):
if metadata.get('bind/type') == 'slave':
return {}
return {
'bind': {
'views': {
view_name: {
'match_clients': {
# allow keys from this view
*{
f'key {key}'
for key in view_conf['keys']
},
# reject keys from other views
*{
f'! key {key}'
for other_view_name, other_view_conf in metadata.get('bind/views').items()
if other_view_name != view_name
for key in other_view_conf.get('keys', [])
}
}
}
for view_name, view_conf in metadata.get('bind/views').items()
},
},
}

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

@ -1,38 +0,0 @@
defaults = {
'apt': {
'packages': {
'build-essential': {},
# crystal
'clang': {},
'libssl-dev': {},
'libpcre3-dev': {},
'libgc-dev': {},
'libevent-dev': {},
'zlib1g-dev': {},
},
},
'users': {
'build-agent': {
'home': '/var/lib/build-agent',
},
},
}
@metadata_reactor.provides(
'users/build-agent/authorized_users',
)
def ssh_keys(metadata):
return {
'users': {
'build-agent': {
'authorized_users': {
f'build-server@{other_node.name}': {}
for other_node in repo.nodes
if other_node.has_bundle('build-server')
for architecture in other_node.metadata.get('build-server/architectures').values()
if architecture['node'] == node.name
},
},
},
}

View file

@ -1,9 +0,0 @@
for project, options in node.metadata.get('build-ci').items():
directories[options['path']] = {
'owner': 'build-ci',
'group': options['group'],
'mode': '770',
'needs': [
'user:build-ci',
],
}

View file

@ -1,29 +0,0 @@
from shlex import quote
defaults = {
'build-ci': {},
}
@metadata_reactor.provides(
'users/build-ci/authorized_users',
'sudoers/build-ci',
)
def ssh_keys(metadata):
return {
'users': {
'build-ci': {
'authorized_users': {
f'build-server@{other_node.name}': {}
for other_node in repo.nodes
if other_node.has_bundle('build-server')
},
},
},
'sudoers': {
'build-ci': {
f"/usr/bin/chown -R build-ci\\:{quote(ci['group'])} {quote(ci['path'])}"
for ci in metadata.get('build-ci').values()
}
},
}

View file

@ -1,2 +0,0 @@
JSON=$(cat bundles/build-server/example.json)
curl -X POST 'https://build.sublimity.de/crystal?file=procio.cr' -H "Content-Type: application/json" --data-binary @- <<< $JSON

View file

@ -1,169 +0,0 @@
{
"after": "122d7843c7814079e8df4919b0208c95ec7c75e3",
"before": "7a358255247926363ef0ef34111f0bc786a8c6f4",
"commits": [
{
"added": [],
"author": {
"email": "mwiegand@seibert-media.net",
"name": "mwiegand",
"username": ""
},
"committer": {
"email": "mwiegand@seibert-media.net",
"name": "mwiegand",
"username": ""
},
"id": "122d7843c7814079e8df4919b0208c95ec7c75e3",
"message": "wip\n",
"modified": [
"README.md"
],
"removed": [],
"timestamp": "2021-11-16T22:10:05+01:00",
"url": "https://git.sublimity.de/cronekorkn/telegraf-procio/commit/122d7843c7814079e8df4919b0208c95ec7c75e3",
"verification": null
}
],
"compare_url": "https://git.sublimity.de/cronekorkn/telegraf-procio/compare/7a358255247926363ef0ef34111f0bc786a8c6f4...122d7843c7814079e8df4919b0208c95ec7c75e3",
"head_commit": {
"added": [],
"author": {
"email": "mwiegand@seibert-media.net",
"name": "mwiegand",
"username": ""
},
"committer": {
"email": "mwiegand@seibert-media.net",
"name": "mwiegand",
"username": ""
},
"id": "122d7843c7814079e8df4919b0208c95ec7c75e3",
"message": "wip\n",
"modified": [
"README.md"
],
"removed": [],
"timestamp": "2021-11-16T22:10:05+01:00",
"url": "https://git.sublimity.de/cronekorkn/telegraf-procio/commit/122d7843c7814079e8df4919b0208c95ec7c75e3",
"verification": null
},
"pusher": {
"active": false,
"avatar_url": "https://git.sublimity.de/user/avatar/cronekorkn/-1",
"created": "2021-06-13T19:19:25+02:00",
"description": "",
"email": "i@ckn.li",
"followers_count": 0,
"following_count": 0,
"full_name": "",
"id": 1,
"is_admin": false,
"language": "",
"last_login": "0001-01-01T00:00:00Z",
"location": "",
"login": "cronekorkn",
"prohibit_login": false,
"restricted": false,
"starred_repos_count": 0,
"username": "cronekorkn",
"visibility": "public",
"website": ""
},
"ref": "refs/heads/master",
"repository": {
"allow_merge_commits": true,
"allow_rebase": true,
"allow_rebase_explicit": true,
"allow_squash_merge": true,
"archived": false,
"avatar_url": "",
"clone_url": "https://git.sublimity.de/cronekorkn/telegraf-procio.git",
"created_at": "2021-11-05T18:46:04+01:00",
"default_branch": "master",
"default_merge_style": "merge",
"description": "",
"empty": false,
"fork": false,
"forks_count": 0,
"full_name": "cronekorkn/telegraf-procio",
"has_issues": true,
"has_projects": true,
"has_pull_requests": true,
"has_wiki": true,
"html_url": "https://git.sublimity.de/cronekorkn/telegraf-procio",
"id": 5,
"ignore_whitespace_conflicts": false,
"internal": false,
"internal_tracker": {
"allow_only_contributors_to_track_time": true,
"enable_issue_dependencies": true,
"enable_time_tracker": true
},
"mirror": false,
"mirror_interval": "",
"name": "telegraf-procio",
"open_issues_count": 0,
"open_pr_counter": 0,
"original_url": "",
"owner": {
"active": false,
"avatar_url": "https://git.sublimity.de/user/avatar/cronekorkn/-1",
"created": "2021-06-13T19:19:25+02:00",
"description": "",
"email": "i@ckn.li",
"followers_count": 0,
"following_count": 0,
"full_name": "",
"id": 1,
"is_admin": false,
"language": "",
"last_login": "0001-01-01T00:00:00Z",
"location": "",
"login": "cronekorkn",
"prohibit_login": false,
"restricted": false,
"starred_repos_count": 0,
"username": "cronekorkn",
"visibility": "public",
"website": ""
},
"parent": null,
"permissions": {
"admin": true,
"pull": true,
"push": true
},
"private": false,
"release_counter": 0,
"size": 28,
"ssh_url": "git@git.sublimity.de:cronekorkn/telegraf-procio.git",
"stars_count": 0,
"template": false,
"updated_at": "2021-11-16T21:41:40+01:00",
"watchers_count": 1,
"website": ""
},
"sender": {
"active": false,
"avatar_url": "https://git.sublimity.de/user/avatar/cronekorkn/-1",
"created": "2021-06-13T19:19:25+02:00",
"description": "",
"email": "i@ckn.li",
"followers_count": 0,
"following_count": 0,
"full_name": "",
"id": 1,
"is_admin": false,
"language": "",
"last_login": "0001-01-01T00:00:00Z",
"location": "",
"login": "cronekorkn",
"prohibit_login": false,
"restricted": false,
"starred_repos_count": 0,
"username": "cronekorkn",
"visibility": "public",
"website": ""
}
}

View file

@ -1,31 +0,0 @@
#!/bin/bash
set -xu
CONFIG_PATH=${config_path}
JSON="$1"
REPO_NAME=$(jq -r .repository.name <<< $JSON)
CLONE_URL=$(jq -r .repository.clone_url <<< $JSON)
REPO_BRANCH=$(jq -r .ref <<< $JSON | cut -d'/' -f3)
SSH_OPTIONS='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
for INTEGRATION in "$(cat $CONFIG_PATH | jq -r '.ci | values[]')"
do
[[ $(jq -r '.repo' <<< $INTEGRATION) = "$REPO_NAME" ]] || continue
[[ $(jq -r '.branch' <<< $INTEGRATION) = "$REPO_BRANCH" ]] || continue
HOSTNAME=$(jq -r '.hostname' <<< $INTEGRATION)
DEST_PATH=$(jq -r '.path' <<< $INTEGRATION)
DEST_GROUP=$(jq -r '.group' <<< $INTEGRATION)
[[ -z "$HOSTNAME" ]] || [[ -z "$DEST_PATH" ]] || [[ -z "$DEST_GROUP" ]] && exit 5
cd ~
rm -rf "$REPO_NAME"
git clone "$CLONE_URL" "$REPO_NAME"
ssh $SSH_OPTIONS "build-ci@$HOSTNAME" "find \"$DEST_PATH\" -mindepth 1 -delete"
scp -r $SSH_OPTIONS "$REPO_NAME"/* "build-ci@$HOSTNAME:$DEST_PATH"
ssh $SSH_OPTIONS "build-ci@$HOSTNAME" "sudo chown -R build-ci:$DEST_GROUP $(printf "%q" "$DEST_PATH")"
done

View file

@ -1,32 +0,0 @@
#!/bin/bash
set -exu
DOWNLOAD_SERVER="${download_server}"
CONFIG=$(cat ${config_path})
JSON="$1"
ARGS="$2"
REPO_NAME=$(jq -r .repository.name <<< $JSON)
CLONE_URL=$(jq -r .repository.clone_url <<< $JSON)
BUILD_FILE=$(jq -r .file <<< $ARGS)
DATE=$(date --utc +%s)
cd ~
rm -rf "$REPO_NAME"
git clone "$CLONE_URL"
cd "$REPO_NAME"
shards install
for ARCH in $(jq -r '.architectures | keys[]' <<< $CONFIG)
do
TARGET=$(jq -r .architectures.$ARCH.target <<< $CONFIG)
IP=$(jq -r .architectures.$ARCH.ip <<< $CONFIG)
BUILD_CMD=$(crystal build "$BUILD_FILE" --cross-compile --target="$TARGET" --release -o "$REPO_NAME")
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$REPO_NAME.o" "build-agent@$IP:~"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "build-agent@$IP" $BUILD_CMD
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "build-agent@$IP:~/$REPO_NAME" .
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "downloads@$DOWNLOAD_SERVER" mkdir -p "~/$REPO_NAME"
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$REPO_NAME" "downloads@$DOWNLOAD_SERVER:~/$REPO_NAME/$REPO_NAME-$ARCH-$DATE"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "downloads@$DOWNLOAD_SERVER" ln -sf "$REPO_NAME-$ARCH-$DATE" "~/$REPO_NAME/$REPO_NAME-$ARCH-latest"
done

View file

@ -1,32 +0,0 @@
import json
from bundlewrap.metadata import MetadataJSONEncoder
directories = {
'/opt/build-server/strategies': {
'owner': 'build-server',
},
}
files = {
'/etc/build-server.json': {
'owner': 'build-server',
'content': json.dumps(node.metadata.get('build-server'), indent=4, sort_keys=True, cls=MetadataJSONEncoder)
},
'/opt/build-server/strategies/crystal': {
'content_type': 'mako',
'owner': 'build-server',
'mode': '0777', # FIXME
'context': {
'config_path': '/etc/build-server.json',
'download_server': node.metadata.get('build-server/download_server_ip'),
},
},
'/opt/build-server/strategies/ci': {
'content_type': 'mako',
'owner': 'build-server',
'mode': '0777', # FIXME
'context': {
'config_path': '/etc/build-server.json',
},
},
}

View file

@ -1,78 +0,0 @@
from ipaddress import ip_interface
defaults = {
'flask': {
'build-server' : {
'git_url': "https://git.sublimity.de/cronekorkn/build-server.git",
'port': 4000,
'app_module': 'build_server',
'user': 'build-server',
'group': 'build-server',
'timeout': 900,
'env': {
'CONFIG': '/etc/build-server.json',
'STRATEGIES_DIR': '/opt/build-server/strategies',
},
},
},
'users': {
'build-server': {
'home': '/var/lib/build-server',
},
},
}
@metadata_reactor.provides(
'build-server',
)
def agent_conf(metadata):
download_server = repo.get_node(metadata.get('build-server/download_server'))
return {
'build-server': {
'architectures': {
architecture: {
'ip': str(ip_interface(repo.get_node(conf['node']).metadata.get('network/internal/ipv4')).ip),
}
for architecture, conf in metadata.get('build-server/architectures').items()
},
'download_server_ip': str(ip_interface(download_server.metadata.get('network/internal/ipv4')).ip),
},
}
@metadata_reactor.provides(
'build-server',
)
def ci(metadata):
return {
'build-server': {
'ci': {
f'{repo}@{other_node.name}': {
'hostname': other_node.metadata.get('hostname'),
'repo': repo,
**options,
}
for other_node in repo.nodes
if other_node.has_bundle('build-ci')
for repo, options in other_node.metadata.get('build-ci').items()
},
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx(metadata):
return {
'nginx': {
'vhosts': {
metadata.get('build-server/hostname'): {
'content': 'nginx/proxy_pass.conf',
'context': {
'target': 'http://127.0.0.1:4000',
},
'check_path': '/status',
},
},
},
}

View file

@ -1,21 +0,0 @@
debian_version = min([node.os_version, (11,)])[0] # FIXME
defaults = {
'apt': {
'packages': {
'crystal': {},
},
'sources': {
'crystal': {
# https://software.opensuse.org/download.html?project=devel%3Alanguages%3Acrystal&package=crystal
# curl -fsSL https://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/Release.key
'urls': {
'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/',
},
'suites': {
'/',
},
},
},
},
}

View file

@ -1,24 +0,0 @@
dm-crypt
========
Create encrypted block devices using `dm-crypt` on GNU/Linux. Unlocking
these devices will be done on runs of `bw apply`.
Metadata
--------
'dm-crypt': {
'encrypted-devices': {
'foobar': {
'device': '/dev/sdb',
# either
'salt': 'muWWU7dr+5Wtk+56OLdqUNZccnzXPUTJprMSMxkstR8=',
# or
'password': vault.decrypt('passphrase'),
},
},
},
This will encrypt `/dev/sdb` using the specified passphrase. When the
device is going to be unlocked, it will be available as
`/dev/mapper/foobar`.

View file

@ -1,46 +0,0 @@
for name, conf in node.metadata.get('dm-crypt').items():
actions[f'dm-crypt_format_{name}'] = {
'command': f"cryptsetup --batch-mode luksFormat --cipher aes-xts-plain64 --key-size 512 '{conf['device']}'",
'data_stdin': conf['password'],
'unless': f"blkid -t TYPE=crypto_LUKS '{conf['device']}'",
'comment': f"WARNING: This DESTROYS the contents of the device: '{conf['device']}'",
'needs': {
'pkg_apt:cryptsetup',
},
}
actions[f'dm-crypt_test_{name}'] = {
'command': 'false',
'unless': f"! cryptsetup --batch-mode luksOpen --test-passphrase '{conf['device']}'",
'data_stdin': conf['password'],
'needs': {
f"action:dm-crypt_format_{name}",
},
}
actions[f'dm-crypt_open_{name}'] = {
'command': f"cryptsetup --batch-mode luksOpen '{conf['device']}' '{name}'",
'data_stdin': conf['password'],
'unless': f"test -e /dev/mapper/{name}",
'comment': f"Unlocks the device '{conf['device']}' and makes it available in: '/dev/mapper/{name}'",
'needs': {
f"action:dm-crypt_test_{name}",
},
'needed_by': set(),
}
if node.has_bundle('zfs'):
for pool, pool_conf in node.metadata.get('zfs/pools').items():
if f'/dev/mapper/{name}' in pool_conf['devices']:
actions[f'dm-crypt_open_{name}']['needed_by'].add(f'zfs_pool:{pool}')
actions[f'zpool_import_{name}'] = {
'command': f"zpool import -d /dev/mapper/{name} {pool}",
'unless': f"zpool status {pool}",
'needs': {
f"action:dm-crypt_open_{name}",
},
'needed_by': {
f"zfs_pool:{pool}",
},
}

View file

@ -1,22 +0,0 @@
defaults = {
'apt': {
'packages': {
'cryptsetup': {},
},
},
'dm-crypt': {},
}
@metadata_reactor.provides(
'dm-crypt',
)
def password_from_salt(metadata):
return {
'dm-crypt': {
name: {
'password': repo.vault.password_for(f"dm-crypt/{metadata.get('id')}/{name}"),
}
for name, conf in metadata.get('dm-crypt').items()
}
}

View file

@ -1,12 +0,0 @@
DOVECOT
=======
rescan index
------------
https://doc.dovecot.org/configuration_manual/fts/#rescan
```
doveadm fts rescan -u 'i@ckn.li'
doveadm index -u 'i@ckn.li' -q '*'
```

View file

@ -1,104 +0,0 @@
#!/bin/sh
# Example attachment decoder script. The attachment comes from stdin, and
# the script is expected to output UTF-8 data to stdout. (If the output isn't
# UTF-8, everything except valid UTF-8 sequences are dropped from it.)
# The attachment decoding is enabled by setting:
#
# plugin {
# fts_decoder = decode2text
# }
# service decode2text {
# executable = script /usr/local/libexec/dovecot/decode2text.sh
# user = dovecot
# unix_listener decode2text {
# mode = 0666
# }
# }
libexec_dir=`dirname $0`
content_type=$1
# The second parameter is the format's filename extension, which is used when
# found from a filename of application/octet-stream. You can also add more
# extensions by giving more parameters.
formats='application/pdf pdf
application/x-pdf pdf
application/msword doc
application/mspowerpoint ppt
application/vnd.ms-powerpoint ppt
application/ms-excel xls
application/x-msexcel xls
application/vnd.ms-excel xls
application/vnd.openxmlformats-officedocument.wordprocessingml.document docx
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx
application/vnd.openxmlformats-officedocument.presentationml.presentation pptx
application/vnd.oasis.opendocument.text odt
application/vnd.oasis.opendocument.spreadsheet ods
application/vnd.oasis.opendocument.presentation odp
'
if [ "$content_type" = "" ]; then
echo "$formats"
exit 0
fi
fmt=`echo "$formats" | grep -w "^$content_type" | cut -d ' ' -f 2`
if [ "$fmt" = "" ]; then
echo "Content-Type: $content_type not supported" >&2
exit 1
fi
# most decoders can't handle stdin directly, so write the attachment
# to a temp file
path=`mktemp`
trap "rm -f $path" 0 1 2 3 14 15
cat > $path
xmlunzip() {
name=$1
tempdir=`mktemp -d`
if [ "$tempdir" = "" ]; then
exit 1
fi
trap "rm -rf $path $tempdir" 0 1 2 3 14 15
cd $tempdir || exit 1
unzip -q "$path" 2>/dev/null || exit 0
find . -name "$name" -print0 | xargs -0 cat | /usr/lib/dovecot/xml2text
}
wait_timeout() {
childpid=$!
trap "kill -9 $childpid; rm -f $path" 1 2 3 14 15
wait $childpid
}
LANG=en_US.UTF-8
export LANG
if [ $fmt = "pdf" ]; then
/usr/bin/pdftotext $path - 2>/dev/null&
wait_timeout 2>/dev/null
elif [ $fmt = "doc" ]; then
(/usr/bin/catdoc $path; true) 2>/dev/null&
wait_timeout 2>/dev/null
elif [ $fmt = "ppt" ]; then
(/usr/bin/catppt $path; true) 2>/dev/null&
wait_timeout 2>/dev/null
elif [ $fmt = "xls" ]; then
(/usr/bin/xls2csv $path; true) 2>/dev/null&
wait_timeout 2>/dev/null
elif [ $fmt = "odt" -o $fmt = "ods" -o $fmt = "odp" ]; then
xmlunzip "content.xml"
elif [ $fmt = "docx" ]; then
xmlunzip "document.xml"
elif [ $fmt = "xlsx" ]; then
xmlunzip "sharedStrings.xml"
elif [ $fmt = "pptx" ]; then
xmlunzip "slide*.xml"
else
echo "Buggy decoder script: $fmt not handled" >&2
exit 1
fi
exit 0

View file

@ -0,0 +1,5 @@
connect = host=${host} dbname=${name} user=${user} password=${password}
driver = pgsql
default_pass_scheme = MD5-CRYPT
password_query = SELECT username as user, password FROM mailbox WHERE username = '%u' AND active = true
user_query = SELECT '/var/mail/vmail/' || maildir as home, 65534 as uid, 65534 as gid FROM mailbox WHERE username = '%u' AND active = true

View file

@ -1,21 +1,10 @@
dovecot_config_version = ${config_version} !include conf.d/*.conf
dovecot_storage_version = ${storage_version}
protocols = imap lmtp sieve
auth_mechanisms = plain login
ssl = required
ssl_server_cert_file = /var/lib/dehydrated/certs/${hostname}/fullchain.pem
ssl_server_key_file = /var/lib/dehydrated/certs/${hostname}/privkey.pem
ssl_server_dh_file = /etc/dovecot/dhparam.pem
ssl_client_ca_dir = /etc/ssl/certs
mail_driver = maildir
mail_path = ${maildir}/%{user}
mail_index_path = ${maildir}/index/%{user}
mail_plugins = fts fts_flatcurve
namespace inbox { namespace inbox {
inbox = yes
separator = . separator = .
type = private
inbox = yes
location =
mailbox Drafts { mailbox Drafts {
auto = subscribe auto = subscribe
special_use = \Drafts special_use = \Drafts
@ -23,57 +12,64 @@ namespace inbox {
mailbox Junk { mailbox Junk {
auto = create auto = create
special_use = \Junk special_use = \Junk
} autoexpunge = 30d
mailbox Trash {
auto = subscribe
special_use = \Trash
} }
mailbox Sent { mailbox Sent {
auto = subscribe auto = subscribe
special_use = \Sent special_use = \Sent
} }
mailbox Trash {
auto = subscribe
special_use = \Trash
autoexpunge = 360d
}
prefix =
} }
# postgres passdb userdb mail_location = maildir:/var/vmail/%u
protocols = imap lmtp sieve
sql_driver = pgsql ssl = yes
ssl_cert = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/fullchain.pem
ssl_key = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/privkey.pem
ssl_dh = </etc/dovecot/ssl/dhparam.pem
ssl_min_protocol = TLSv1.2
ssl_cipher_list = EECDH+AESGCM:EDH+AESGCM
ssl_prefer_server_ciphers = yes
pgsql main { login_greeting = IMAPd ready
parameters { auth_mechanisms = plain login
host = ${db_host} first_valid_uid = 65534
dbname = ${db_name} disable_plaintext_auth = yes
user = ${db_user} mail_plugins = $mail_plugins zlib
password = ${db_password}
}
}
passdb sql { plugin {
passdb_default_password_scheme = ARGON2ID zlib_save_level = 6
zlib_save = gz
query = SELECT \ sieve_plugins = sieve_imapsieve sieve_extprograms
CONCAT(users.name, '@', domains.name) AS "user", \ sieve_dir = /var/vmail/sieve/%d/%n/
password \ sieve = /var/vmail/sieve/%d/%n.sieve
FROM users \ sieve_pipe_bin_dir = /var/vmail/sieve/bin
LEFT JOIN domains ON users.domain_id = domains.id \ sieve_extensions = +vnd.dovecot.pipe
WHERE redirect IS NULL \
AND users.name = SPLIT_PART('%{user}', '@', 1) \
AND domains.name = SPLIT_PART('%{user}', '@', 2)
}
mail_uid = vmail old_stats_refresh = 30 secs
mail_gid = vmail old_stats_track_cmds = yes
userdb sql { % if node.has_bundle('rspamd'):
query = SELECT \ sieve_before = /var/vmail/sieve/global/spam-global.sieve
'/var/vmail/%{user}' AS home, \
'vmail' AS uid, \
'vmail' AS gid
iterate_query = SELECT \ # From elsewhere to Spam folder
CONCAT(users.name, '@', domains.name) AS username \ imapsieve_mailbox1_name = Junk
FROM users \ imapsieve_mailbox1_causes = COPY
LEFT JOIN domains ON users.domain_id = domains.id \ imapsieve_mailbox1_before = file:/var/vmail/sieve/global/learn-spam.sieve
WHERE redirect IS NULL
# From Spam folder to elsewhere
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = Junk
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:/var/vmail/sieve/global/learn-ham.sieve
% endif
} }
service auth { service auth {
@ -82,126 +78,62 @@ service auth {
user = postfix user = postfix
group = postfix group = postfix
} }
unix_listener auth-userdb {
mode = 0660
user = nobody
group = nogroup
} }
}
service lmtp { service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp { unix_listener /var/spool/postfix/private/dovecot-lmtp {
group = postfix
mode = 0600 mode = 0600
user = postfix user = postfix
group = postfix
} }
} }
service stats {
unix_listener stats-reader { service imap {
user = vmail executable = imap
group = vmail
mode = 0660
} }
unix_listener stats-writer {
user = vmail service imap-login {
group = vmail service_count = 1
mode = 0660 process_min_avail = 8
}
}
service managesieve-login {
#inet_listener sieve {}
process_min_avail = 1
process_limit = 1
vsz_limit = 64M vsz_limit = 64M
} }
service managesieve {
process_limit = 100 service managesieve-login {
inet_listener sieve {
port = 4190
}
}
userdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf
}
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf
}
protocol lmtp {
mail_plugins = $mail_plugins sieve
postmaster_address = ${admin_email}
} }
protocol imap { protocol imap {
mail_plugins = fts fts_flatcurve imap_sieve mail_plugins = $mail_plugins imap_zlib imap_sieve imap_old_stats
mail_max_userip_connections = 50 mail_max_userip_connections = 50
imap_idle_notify_interval = 29 mins imap_idle_notify_interval = 29 mins
} }
protocol lmtp {
mail_plugins = fts fts_flatcurve sieve
}
# Persönliches Skript (deine alte Datei /var/vmail/sieve/%u.sieve) protocol sieve {
sieve_script personal { plugin {
driver = file sieve = /var/vmail/sieve/%d/%n.sieve
# Verzeichnis mit (evtl. mehreren) Sieve-Skripten des Users sieve_storage = /var/vmail/sieve/%d/%n/
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
language en {
}
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 {
process_limit = ${indexer_cores}
vsz_limit = ${indexer_ram}M
}
service decode2text {
executable = script /usr/local/libexec/dovecot/decode2text.sh
user = dovecot
unix_listener decode2text {
mode = 0666
} }
} }
mailbox Junk {
sieve_script learn_spam {
driver = file
type = before
cause = copy
path = /var/vmail/sieve/global/learn-spam.sieve
}
}
imapsieve_from Junk {
sieve_script learn_ham {
driver = file
type = before
cause = copy
path = /var/vmail/sieve/global/learn-ham.sieve
}
}
# Extprograms-Plugin einschalten
sieve_plugins {
sieve_extprograms = yes
}
# Welche Sieve-Erweiterungen dürfen genutzt werden?
# Empfehlung: nur global erlauben (nicht in User-Skripten):
sieve_global_extensions {
vnd.dovecot.pipe = yes
# vnd.dovecot.filter = yes # nur falls gebraucht
# vnd.dovecot.execute = yes # nur falls gebraucht
}
# Verzeichnis mit deinen Skripten/Binaries für :pipe
sieve_pipe_bin_dir = /var/vmail/sieve/bin
# (optional, analog für :filter / :execute)
# sieve_filter_bin_dir = /var/vmail/sieve/filter
# sieve_execute_bin_dir = /var/vmail/sieve/execute

View file

@ -1,2 +0,0 @@
#!/bin/sh
exec /usr/bin/rspamc learn_ham

View file

@ -1,7 +0,0 @@
require ["vnd.dovecot.pipe", "copy", "imapsieve", "variables"];
if string "${mailbox}" "Trash" {
stop;
}
pipe :copy "learn-ham.sh";

View file

@ -1,2 +0,0 @@
#!/bin/sh
exec /usr/bin/rspamc learn_spam

View file

@ -1,3 +0,0 @@
require ["vnd.dovecot.pipe", "copy", "imapsieve"];
pipe :copy "learn-spam.sh";

View file

@ -1,6 +0,0 @@
require ["fileinto", "mailbox"];
if header :contains "X-Spam" "Yes" {
fileinto :create "Junk";
stop;
}

View file

@ -1,41 +1,7 @@
assert node.has_bundle('mailserver') assert node.has_bundle('mailserver')
users['vmail'] = {
'home': '/var/vmail',
}
directories = { directories = {
'/etc/dovecot': {
'purge': True,
},
'/etc/dovecot/conf.d': {
'purge': True,
'needs': [
'pkg_apt:dovecot-sieve',
'pkg_apt:dovecot-managesieved',
]
},
'/etc/dovecot/ssl': {}, '/etc/dovecot/ssl': {},
'/var/vmail': {
'owner': 'vmail',
'group': 'vmail',
},
'/var/vmail/index': {
'owner': 'vmail',
'group': 'vmail',
},
'/var/vmail/sieve': {
'owner': 'vmail',
'group': 'vmail',
},
'/var/vmail/sieve/global': {
'owner': 'vmail',
'group': 'vmail',
},
'/var/vmail/sieve/bin': {
'owner': 'vmail',
'group': 'vmail',
},
} }
files = { files = {
@ -43,17 +9,6 @@ files = {
'content_type': 'mako', 'content_type': 'mako',
'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'),
'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,52 +17,25 @@ files = {
'svc_systemd:dovecot:restart', 'svc_systemd:dovecot:restart',
}, },
}, },
'/etc/dovecot/dhparam.pem': { '/etc/dovecot/dovecot-sql.conf': {
'content_type': 'any', 'content_type': 'mako',
'context': node.metadata.get('mailserver/database'),
'needs': {
'pkg_apt:'
}, },
'/var/vmail/sieve/global/spam-to-folder.sieve': {
'owner': 'vmail',
'group': 'vmail',
'triggers': { 'triggers': {
'svc_systemd:dovecot:restart', 'svc_systemd:dovecot:restart',
}, },
}, },
'/var/vmail/sieve/global/learn-ham.sieve': {
'owner': 'vmail',
'group': 'vmail',
'triggers': {
'svc_systemd:dovecot:restart',
},
},
'/var/vmail/sieve/bin/learn-ham.sh': {
'owner': 'vmail',
'group': 'vmail',
'mode': '550',
},
'/var/vmail/sieve/global/learn-spam.sieve': {
'owner': 'vmail',
'group': 'vmail',
'triggers': {
'svc_systemd:dovecot:restart',
},
},
# /usr/local/libexec/dovecot?
# /usr/lib/dovecot/sieve-pipe?
'/var/vmail/sieve/bin/learn-spam.sh': {
'owner': 'vmail',
'group': 'vmail',
'mode': '550',
},
} }
actions = { actions = {
'dovecot_generate_dhparam': { 'dovecot_generate_dhparam': {
'command': 'openssl dhparam -out /etc/dovecot/dhparam.pem 2048', 'command': 'openssl dhparam -out /etc/dovecot/ssl/dhparam.pem 2048',
'unless': 'test -f /etc/dovecot/dhparam.pem', 'unless': 'test -f /etc/dovecot/ssl/dhparam.pem',
'cascade_skip': False, 'cascade_skip': False,
'needs': { 'needs': {
'pkg_apt:', 'pkg_apt:'
'directory:/etc/dovecot/ssl',
}, },
'triggers': { 'triggers': {
'svc_systemd:dovecot:restart', 'svc_systemd:dovecot:restart',
@ -121,14 +49,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',
}, },
}, },
} }
# fulltext search
directories['/usr/local/libexec/dovecot'] = {}
files['/usr/local/libexec/dovecot/decode2text.sh'] = {
'owner': 'dovecot',
'mode': '500',
}

View file

@ -1,16 +1,18 @@
from bundlewrap.metadata import atomic
defaults = { defaults = {
'apt': { 'apt': {
'packages': { 'packages': {
'dovecot-imapd': {}, 'dovecot-imapd': {},
'dovecot-pgsql': {},
'dovecot-lmtpd': {}, 'dovecot-lmtpd': {},
# spam filtering
'dovecot-sieve': {},
'dovecot-managesieved': {}, 'dovecot-managesieved': {},
# fulltext search 'dovecot-pgsql': {},
'dovecot-flatcurve': {}, # buster-backports 'dovecot-sieve': {},
'poppler-utils': {}, # pdftotext },
'catdoc': {}, # catdoc, catppt, xls2csv },
'letsencrypt': {
'reload_after': {
'dovecot',
}, },
}, },
'dovecot': { 'dovecot': {
@ -19,30 +21,5 @@ defaults = {
'dbuser': 'mailserver', 'dbuser': 'mailserver',
}, },
}, },
'letsencrypt': {
'reload_after': {
'dovecot',
},
},
'nftables': {
'input': {
'tcp dport {143, 993, 4190} accept',
},
},
'systemd-timers': {
'dovecot-optimize-index': {
'command': '/usr/bin/doveadm fts optimize -A',
'when': 'daily',
},
},
}
@metadata_reactor.provides(
'dovecot/indexer_ram',
)
def indexer_ram(metadata):
return {
'dovecot': {
'indexer_ram': str(metadata.get('vm/ram')//2)+ 'M',
},
} }

View file

@ -1,46 +0,0 @@
defaults = {
'users': {
'downloads': {
'home': '/var/lib/downloads',
'needs': {
'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': {
'datasets': {
'tank/downloads': {
'mountpoint': '/var/lib/downloads',
},
},
},
'systemd-mount': {
'/var/lib/downloads_nginx': {
'source': '/var/lib/downloads',
'user': 'www-data',
},
},
}
@metadata_reactor.provides(
'nginx/vhosts',
)
def nginx(metadata):
return {
'nginx': {
'vhosts': {
metadata.get('download-server/hostname'): {
'content': 'nginx/directory_listing.conf',
'context': {
'directory': '/var/lib/downloads_nginx',
},
},
},
},
}

View file

@ -1,54 +0,0 @@
# Flask
This bundle can deploy one or more Flask applications per node.
```python
'flask': {
'myapp': {
'app_module': "myapp",
'apt_dependencies': [
"libffi-dev",
"libssl-dev",
],
'env': {
'APP_SECRETS': "/opt/client_secrets.json",
},
'json_config': {
'this json': 'is_visible',
'inside': 'your template.cfg',
},
'git_url': "ssh://git@bitbucket.apps.seibert-media.net:7999/smedia/myapp.git",
'git_branch': "master",
'deployment_triggers': ["action:do-a-thing"],
},
},
```
The git repo containing the application has to obey some conventions:
* requirements-frozen.txt (preferred) or requirements.txt
* minimal setup.py to allow for installation with pip
The `app` instance has to exists in the module defined by `app_module`.
It is also very advisable to enable logging in your app (otherwise HTTP 500s won't be logged):
```python
import logging
if not app.debug:
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
app.logger.addHandler(stream_handler)
```
If you specify `json_config`, then `/opt/${app}/config.json` will be
created. The environment variable `$APP_CONFIG` will point to the exact
name. You can use it in your app to load your config:
```python
app.config.from_json(environ['APP_CONFIG'])
```
If `json_config` is *not* specified, you *can* put a static file in
`data/flask/files/cfg/$app_name`.

View file

@ -1,10 +0,0 @@
<%
from json import dumps
from bundlewrap.metadata import MetadataJSONEncoder
%>
${dumps(
json_config,
cls=MetadataJSONEncoder,
indent=4,
sort_keys=True,
)}

View file

@ -1,14 +0,0 @@
[Unit]
Description=flask application ${name}
After=network.target
[Service]
% for key, value in env.items():
Environment=${key}=${value}
% endfor
User=${user}
Group=${group}
ExecStart=/opt/${name}/venv/bin/gunicorn -w ${workers} -b ${host}:${port} ${app_module}:app
[Install]
WantedBy=multi-user.target

View file

@ -1,119 +0,0 @@
for name, conf in node.metadata.get('flask').items():
for dep in conf.get('apt_dependencies', []):
pkg_apt[dep] = {
'needed_by': {
f'svc_systemd:{name}',
},
}
directories[f'/opt/{name}'] = {
'owner': conf['user'],
'group': conf['group'],
}
directories[f'/opt/{name}/src'] = {}
git_deploy[f'/opt/{name}/src'] = {
'repo': conf['git_url'],
'rev': conf.get('git_branch', 'master'),
'triggers': [
f'action:flask_{name}_pip_install_deps',
*conf.get('deployment_triggers', []),
],
}
# CONFIG
env = conf.get('env', {})
if conf.get('json_config', {}):
env['APP_CONFIG'] = f'/opt/{name}/config.json'
files[env['APP_CONFIG']] = {
'source': 'flask.cfg',
'context': {
'json_config': conf.get('json_config', {}),
},
}
if 'APP_CONFIG' in env:
files[env['APP_CONFIG']].update({
'content_type': 'mako',
'group': 'www-data',
'needed_by': [
f'svc_systemd:{name}',
],
'triggers': [
f'svc_systemd:{name}:restart',
],
})
# secrets
if 'secrets.json' in conf:
env['APP_SECRETS'] = f'/opt/{name}/secrets.json'
files[env['APP_SECRETS']] = {
'content': conf['secrets.json'],
'mode': '0600',
'owner': conf.get('user', 'www-data'),
'group': conf.get('group', 'www-data'),
'needed_by': [
f'svc_systemd:{name}',
],
}
# VENV
actions[f'flask_{name}_create_virtualenv'] = {
'cascade_skip': False,
'command': f'python3 -m venv /opt/{name}/venv',
'unless': f'test -d /opt/{name}/venv',
'needs': [
f'directory:/opt/{name}',
'pkg_apt:python3-venv',
],
'triggers': [
f'action:flask_{name}_pip_install_deps',
],
}
actions[f'flask_{name}_pip_install_deps'] = {
'cascade_skip': False,
'command': f'/opt/{name}/venv/bin/pip3 install -r /opt/{name}/src/requirements-frozen.txt || /opt/{name}/venv/bin/pip3 install -r /opt/{name}/src/requirements.txt',
'triggered': True, # TODO: https://stackoverflow.com/questions/16294819/check-if-my-python-has-all-required-packages
'needs': [
f'git_deploy:/opt/{name}/src',
'pkg_apt:python3-pip',
],
'triggers': [
f'action:flask_{name}_pip_install_gunicorn',
],
}
actions[f'flask_{name}_pip_install_gunicorn'] = {
'command': f'/opt/{name}/venv/bin/pip3 install -U gunicorn',
'triggered': True,
'cascade_skip': False,
'needs': [
f'action:flask_{name}_create_virtualenv',
],
'triggers': [
f'action:flask_{name}_pip_install',
],
}
actions[f'flask_{name}_pip_install'] = {
'command': f'/opt/{name}/venv/bin/pip3 install -e /opt/{name}/src',
'triggered': True,
'cascade_skip': False,
'triggers': [
f'svc_systemd:{name}:restart',
],
}
# UNIT
svc_systemd[name] = {
'needs': [
f'action:flask_{name}_pip_install',
f'file:/usr/local/lib/systemd/system/{name}.service',
],
}

View file

@ -1,61 +0,0 @@
defaults = {
'apt': {
'packages': {
'python3-pip': {},
'python3-dev': {},
'python3-venv': {},
},
},
'flask': {},
}
@metadata_reactor.provides(
'flask',
)
def app_defaults(metadata):
return {
'flask': {
name: {
'user': 'root',
'group': 'root',
'workers': 8,
'timeout': 30,
**conf,
}
for name, conf in metadata.get('flask').items()
}
}
@metadata_reactor.provides(
'systemd/units',
)
def units(metadata):
return {
'systemd': {
'units': {
f'{name}.service': {
'Unit': {
'Description': name,
'After': 'network.target',
},
'Service': {
'Environment': {
f'{k}={v}'
for k, v in metadata.get(f'flask/{name}/env', {}).items()
},
'User': metadata.get(f'flask/{name}/user'),
'Group': metadata.get(f'flask/{name}/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"
},
'Install': {
'WantedBy': {
'multi-user.target'
}
},
}
for name in metadata.get('flask')
}
}
}

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

@ -1,12 +0,0 @@
```
gcloud projects add-iam-policy-binding sublimity-182017 --member 'serviceAccount:backup@sublimity-182017.iam.gserviceaccount.com' --role 'roles/storage.objectViewer'
gcloud projects add-iam-policy-binding sublimity-182017 --member 'serviceAccount:backup@sublimity-182017.iam.gserviceaccount.com' --role 'roles/storage.objectCreator'
gcloud projects add-iam-policy-binding sublimity-182017 --member 'serviceAccount:backup@sublimity-182017.iam.gserviceaccount.com' --role 'roles/storage.objectAdmin'
gsutil -o "GSUtil:parallel_process_count=3" -o GSUtil:parallel_thread_count=4 -m rsync -r -d -e /var/vmail gs://sublimity-backup/mailserver
gsutil config
gsutil versioning set on gs://sublimity-backup
gcsfuse --key-file /root/.config/gcloud/service_account.json sublimity-backup gcsfuse
```

View file

@ -1,43 +0,0 @@
from os.path import join
from json import dumps
service_account = node.metadata.get('gcloud/service_account')
project = node.metadata.get('gcloud/project')
directories[f'/etc/gcloud'] = {
'purge': True,
}
files['/etc/gcloud/gcloud.json'] = {
'content': dumps(
node.metadata.get('gcloud'),
indent=4,
sort_keys=True
),
}
files['/etc/gcloud/service_account.json'] = {
'content': repo.vault.decrypt_file(
join(repo.path, 'data', 'gcloud', 'service_accounts', f'{service_account}@{project}.json.enc')
),
'mode': '500',
'needs': [
'pkg_apt:google-cloud-sdk',
],
}
actions['gcloud_activate_service_account'] = {
'command': 'gcloud auth activate-service-account --key-file /etc/gcloud/service_account.json',
'unless': f"gcloud auth list | grep -q '^\*[[:space:]]*{service_account}@{project}.iam.gserviceaccount.com'",
'needs': [
f'file:/etc/gcloud/service_account.json'
],
}
actions['gcloud_select_project'] = {
'command': f"gcloud config set project '{project}'",
'unless': f"gcloud config get-value project | grep -q '^{project}$'",
'needs': [
f'action:gcloud_activate_service_account'
],
}

View file

@ -1,22 +0,0 @@
defaults = {
'apt': {
'packages': {
'apt-transport-https': {},
'ca-certificates': {},
'gnupg': {},
'google-cloud-sdk': {},
'python3-crcmod': {},
},
'sources': {
'google-cloud': {
'url': 'https://packages.cloud.google.com/apt/',
'suites': {
'cloud-sdk',
},
'components': {
'main',
},
},
},
},
}

View file

@ -1,4 +1,3 @@
[DEFAULT]
APP_NAME = ckn-gitea APP_NAME = ckn-gitea
RUN_USER = git RUN_USER = git
RUN_MODE = prod RUN_MODE = prod
@ -14,24 +13,40 @@ MEMBERS_PAGING_NUM = 100
[server] [server]
PROTOCOL = http PROTOCOL = http
SSH_DOMAIN = ${domain}
DOMAIN = ${domain}
HTTP_ADDR = 0.0.0.0 HTTP_ADDR = 0.0.0.0
HTTP_PORT = 3500 HTTP_PORT = 3500
DISABLE_SSH = true ROOT_URL = https://${domain}/
DISABLE_SSH = false
SSH_PORT = 22 SSH_PORT = 22
LFS_START_SERVER = true LFS_START_SERVER = true
LFS_CONTENT_PATH = /var/lib/gitea/data/lfs LFS_CONTENT_PATH = /var/lib/gitea/data/lfs
LFS_JWT_SECRET = ${lfs_secret_key}
OFFLINE_MODE = true OFFLINE_MODE = true
START_SSH_SERVER = false START_SSH_SERVER = false
DISABLE_ROUTER_LOG = true DISABLE_ROUTER_LOG = true
LANDING_PAGE = explore LANDING_PAGE = explore
[database]
DB_TYPE = postgres
HOST = ${database.get('host', 'localhost')}:5432
NAME = ${database['database']}
USER = ${database['username']}
PASSWD = ${database['password']}
SSL_MODE = disable
LOG_SQL = false
[admin] [admin]
DEFAULT_EMAIL_NOTIFICATIONS = onmention DEFAULT_EMAIL_NOTIFICATIONS = onmention
DISABLE_REGULAR_ORG_CREATION = true DISABLE_REGULAR_ORG_CREATION = true
[security] [security]
INTERNAL_TOKEN = ${internal_token}
INSTALL_LOCK = true INSTALL_LOCK = true
SECRET_KEY = ${security_secret_key}
LOGIN_REMEMBER_DAYS = 30 LOGIN_REMEMBER_DAYS = 30
DISABLE_GIT_HOOKS = ${str(not enable_git_hooks).lower()}
[openid] [openid]
ENABLE_OPENID_SIGNIN = false ENABLE_OPENID_SIGNIN = false
@ -40,13 +55,19 @@ 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
DEFAULT_KEEP_EMAIL_PRIVATE = true DEFAULT_KEEP_EMAIL_PRIVATE = true
DEFAULT_ALLOW_CREATE_ORGANIZATION = false DEFAULT_ALLOW_CREATE_ORGANIZATION = false
DEFAULT_ENABLE_TIMETRACKING = true DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.${domain}
[mailer]
ENABLED = true
MAILER_TYPE = sendmail
FROM = "${app_name}" <noreply@${domain}>
[session] [session]
PROVIDER = file PROVIDER = file
@ -59,17 +80,9 @@ ENABLE_FEDERATED_AVATAR = false
MODE = console MODE = console
LEVEL = warn LEVEL = warn
[oauth2]
JWT_SECRET = ${oauth_secret_key}
[other] [other]
SHOW_FOOTER_BRANDING = true SHOW_FOOTER_BRANDING = true
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false SHOW_FOOTER_TEMPLATE_LOAD_TIME = false
[webhook]
ALLOWED_HOST_LIST = *
DELIVER_TIMEOUT = 600
[indexer]
REPO_INDEXER_ENABLED = true
MAX_FILE_SIZE = 10240000
[queue.issue_indexer]
LENGTH = 20

View file

@ -1,15 +1,6 @@
from os.path import join
from bundlewrap.utils.dicts import merge_dict
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': 'https://dl.gitea.io/gitea/{version}/gitea-{version}-linux-amd64'.format(version=node.metadata['gitea']['version']),
'url': f'https://codeberg.org/forgejo/forgejo/releases/download/v{version}/forgejo-{version}-linux-{arch}', 'sha256': node.metadata['gitea']['sha256'],
'sha256_url': '{url}.sha256',
'triggers': { 'triggers': {
'svc_systemd:gitea:restart', 'svc_systemd:gitea:restart',
}, },
@ -18,6 +9,8 @@ downloads['/usr/local/bin/gitea'] = {
}, },
} }
users['git'] = {}
directories['/var/lib/gitea'] = { directories['/var/lib/gitea'] = {
'owner': 'git', 'owner': 'git',
'mode': '0700', 'mode': '0700',
@ -41,24 +34,10 @@ actions = {
} }
files['/etc/gitea/app.ini'] = { files['/etc/gitea/app.ini'] = {
'content': repo.libs.ini.dumps( 'content_type': 'mako',
merge_dict(
repo.libs.ini.parse(open(join(repo.path, 'bundles', 'gitea', 'files', 'app.ini')).read()),
node.metadata.get('gitea/conf'),
),
),
'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',
}, },
} }
svc_systemd['gitea'] = {
'needs': [
'action:chmod_gitea',
'download:/usr/local/bin/gitea',
'file:/etc/gitea/app.ini',
],
}

View file

@ -1,35 +1,20 @@
database_password = repo.vault.password_for(f'{node.name} postgresql gitea').value
defaults = { defaults = {
'apt': {
'packages': {
'git': {
'needed_by': {
'svc_systemd:gitea',
}
},
},
},
'gitea': { 'gitea': {
'conf': {
'DEFAULT': {
'WORK_PATH': '/var/lib/gitea',
},
'database': { 'database': {
'DB_TYPE': 'postgres', 'username': 'gitea',
'HOST': 'localhost:5432', 'password': repo.vault.password_for('{} postgresql gitea'.format(node.name)),
'NAME': 'gitea', 'database': 'gitea',
'USER': 'gitea',
'PASSWD': database_password,
'SSL_MODE': 'disable',
'LOG_SQL': 'false',
},
}, },
'app_name': 'Gitea',
'lfs_secret_key': repo.vault.password_for('{} gitea lfs_secret_key'.format(node.name)),
'security_secret_key': repo.vault.password_for('{} gitea security_secret_key'.format(node.name)),
'oauth_secret_key': repo.vault.password_for('{} gitea oauth_secret_key'.format(node.name)),
'internal_token': repo.vault.password_for('{} gitea internal_token'.format(node.name)),
}, },
'postgresql': { 'postgresql': {
'roles': { 'roles': {
'gitea': { 'gitea': {
'password': database_password, 'password': repo.vault.password_for('{} postgresql gitea'.format(node.name)),
}, },
}, },
'databases': { 'databases': {
@ -39,11 +24,13 @@ defaults = {
}, },
}, },
'systemd': { 'systemd': {
'units': { 'services': {
'gitea.service': { 'gitea': {
'content': {
'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': {
@ -57,51 +44,16 @@ defaults = {
'Environment': 'USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea', 'Environment': 'USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea',
}, },
'Install': { 'Install': {
'WantedBy': {'multi-user.target'}, 'WantedBy': 'multi-user.target',
}, },
}, },
'needs': {
'action:chmod_gitea',
'download:/usr/local/bin/gitea',
'file:/etc/systemd/system/gitea.service',
'file:/etc/gitea/app.ini',
}, },
}, },
'users': {
'git': {
'home': '/home/git',
},
},
'zfs': {
'datasets': {
'tank/gitea': {
'mountpoint': '/var/lib/gitea',
},
},
},
}
@metadata_reactor.provides(
'gitea/conf',
)
def conf(metadata):
domain = metadata.get('gitea/domain')
return {
'gitea': {
'conf': {
'server': {
'SSH_DOMAIN': domain,
'DOMAIN': domain,
'ROOT_URL': f'https://{domain}/',
'LFS_JWT_SECRET': repo.vault.password_for(f'{node.name} gitea lfs_secret_key', length=43),
},
'security': {
'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'),
},
'service': {
'NO_REPLY_ADDRESS': f'noreply.{domain}',
},
'oauth2': {
'JWT_SECRET': repo.vault.password_for(f'{node.name} gitea oauth_secret_key', length=43),
},
}, },
}, },
} }
@ -111,15 +63,21 @@ def conf(metadata):
'nginx/vhosts', 'nginx/vhosts',
) )
def nginx(metadata): def nginx(metadata):
if not node.has_bundle('nginx'):
raise DoNotRunAgain
return { return {
'nginx': { 'nginx': {
'vhosts': { 'vhosts': {
metadata.get('gitea/domain'): { metadata.get('gitea/domain'): {
'content': 'nginx/proxy_pass.conf', 'proxy': {
'context': { '/': {
'target': 'http://127.0.0.1:3500', 'target': 'http://127.0.0.1:22000',
}, },
}, },
'website_check_path': '/user/login',
'website_check_string': 'Sign In',
},
}, },
}, },
} }

View file

@ -1,6 +0,0 @@
directories['/opt/gocryptfs-inspect'] = {}
git_deploy['/opt/gocryptfs-inspect'] = {
'repo': 'https://github.com/slackner/gocryptfs-inspect.git',
'rev': 'ecd296c8f014bf18f5889e3cb9cb64807ff6b9c4',
}

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