Compare commits
No commits in common. "master" and "temp130752653" have entirely different histories.
master
...
temp130752
411 changed files with 2192 additions and 19863 deletions
14
.envrc
14
.envrc
|
|
@ -1,7 +1,15 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
source ./.venv/bin/activate
|
||||||
|
PATH_add .venv/bin
|
||||||
PATH_add bin
|
PATH_add bin
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
|
||||||
layout uv
|
rm -rf .cache/bw/git_deploy
|
||||||
|
export BW_GIT_DEPLOY_CACHE=.cache/bw/git_deploy
|
||||||
source_env ~/.local/share/direnv/bundlewrap
|
mkdir -p "$BW_GIT_DEPLOY_CACHE"
|
||||||
|
export EXPERIMENTAL_UPLOAD_VIA_CAT=1
|
||||||
|
export BW_ITEM_WORKERS=16
|
||||||
|
export BW_NODE_WORKERS=32
|
||||||
|
unset PS1
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,8 +2,3 @@
|
||||||
.venv
|
.venv
|
||||||
.cache
|
.cache
|
||||||
*.pyc
|
*.pyc
|
||||||
.bw_debug_history
|
|
||||||
# CocoIndex Code (ccc)
|
|
||||||
/.cocoindex_code/
|
|
||||||
# bundlewrap git_deploy local-mirror map (operator-specific paths)
|
|
||||||
git_deploy_repos
|
|
||||||
|
|
|
||||||
108
AGENTS.md
108
AGENTS.md
|
|
@ -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.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
AGENTS.md
|
|
||||||
13
README.md
13
README.md
|
|
@ -13,6 +13,10 @@ Raspberry pi as soundcard
|
||||||
- OTG g_audio
|
- OTG g_audio
|
||||||
- https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824
|
- https://audiosciencereview.com/forum/index.php?threads/raspberry-pi-as-usb-to-i2s-adapter.8567/post-215824
|
||||||
|
|
||||||
|
# install bw fork
|
||||||
|
|
||||||
|
pip3 install --editable git+file:///Users/mwiegand/Projekte/bundlewrap-fork@main#egg=bundlewrap
|
||||||
|
|
||||||
# monitor timers
|
# monitor timers
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -33,12 +37,3 @@ fi
|
||||||
telegraf: execd for daemons
|
telegraf: execd for daemons
|
||||||
|
|
||||||
TEST
|
TEST
|
||||||
|
|
||||||
# git signing
|
|
||||||
|
|
||||||
git config --global gpg.format ssh
|
|
||||||
git config --global commit.gpgsign true
|
|
||||||
|
|
||||||
git config user.name CroneKorkN
|
|
||||||
git config user.email i@ckn.li
|
|
||||||
git config user.signingkey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILMVroYmswD4tLk6iH+2tvQiyaMe42yfONDsPDIdFv6I"
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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}")
|
|
||||||
33
bin/rcon
33
bin/rcon
|
|
@ -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())
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# purpose: starter template for new operator scripts under bin/.
|
|
||||||
|
|
||||||
from bundlewrap.repo import Repository
|
from bundlewrap.repo import Repository
|
||||||
from os.path import realpath, dirname
|
from os.path import realpath, dirname
|
||||||
|
|
||||||
bw = Repository(dirname(dirname(realpath(__file__))))
|
repo = Repository(dirname(dirname(realpath(__file__))))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# purpose: apt-update and full-upgrade every non-dummy debian node, then reboot in WireGuard-aware order.
|
|
||||||
|
|
||||||
from bundlewrap.repo import Repository
|
from bundlewrap.repo import Repository
|
||||||
from os.path import realpath, dirname
|
from os.path import realpath, dirname
|
||||||
|
|
@ -11,6 +10,7 @@ nodes = [
|
||||||
for node in sorted(repo.nodes_in_group('debian'))
|
for node in sorted(repo.nodes_in_group('debian'))
|
||||||
if not node.dummy
|
if not node.dummy
|
||||||
]
|
]
|
||||||
|
reboot_nodes = []
|
||||||
|
|
||||||
print('updating nodes:', sorted(node.name for node in nodes))
|
print('updating nodes:', sorted(node.name for node in nodes))
|
||||||
|
|
||||||
|
|
@ -24,13 +24,14 @@ for node in nodes:
|
||||||
print(node.run('DEBIAN_FRONTEND=noninteractive apt update').stdout.decode())
|
print(node.run('DEBIAN_FRONTEND=noninteractive apt update').stdout.decode())
|
||||||
print(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable').stdout.decode())
|
print(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable').stdout.decode())
|
||||||
if int(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable 2> /dev/null | grep upgradable | wc -l').stdout.decode()):
|
if int(node.run('DEBIAN_FRONTEND=noninteractive apt list --upgradable 2> /dev/null | grep upgradable | wc -l').stdout.decode()):
|
||||||
print(node.run('DEBIAN_FRONTEND=noninteractive apt -qy full-upgrade').stdout.decode())
|
print(node.run('DEBIAN_FRONTEND=noninteractive apt -y dist-upgrade').stdout.decode())
|
||||||
|
reboot_nodes.append(node)
|
||||||
|
|
||||||
# REBOOT IN ORDER
|
# REBOOT IN ORDER
|
||||||
|
|
||||||
wireguard_servers = [
|
wireguard_servers = [
|
||||||
node
|
node
|
||||||
for node in nodes
|
for node in reboot_nodes
|
||||||
if node.has_bundle('wireguard')
|
if node.has_bundle('wireguard')
|
||||||
and (
|
and (
|
||||||
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen <
|
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen <
|
||||||
|
|
@ -40,7 +41,7 @@ wireguard_servers = [
|
||||||
|
|
||||||
wireguard_s2s = [
|
wireguard_s2s = [
|
||||||
node
|
node
|
||||||
for node in nodes
|
for node in reboot_nodes
|
||||||
if node.has_bundle('wireguard')
|
if node.has_bundle('wireguard')
|
||||||
and (
|
and (
|
||||||
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen ==
|
ip_interface(node.metadata.get('wireguard/my_ip')).network.prefixlen ==
|
||||||
|
|
@ -50,7 +51,7 @@ wireguard_s2s = [
|
||||||
|
|
||||||
everything_else = [
|
everything_else = [
|
||||||
node
|
node
|
||||||
for node in nodes
|
for node in reboot_nodes
|
||||||
if not node.has_bundle('wireguard')
|
if not node.has_bundle('wireguard')
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -61,11 +62,8 @@ for node in [
|
||||||
*wireguard_s2s,
|
*wireguard_s2s,
|
||||||
*wireguard_servers,
|
*wireguard_servers,
|
||||||
]:
|
]:
|
||||||
|
print('rebooting', node.name)
|
||||||
try:
|
try:
|
||||||
if node.run('test -e /var/run/reboot-required', may_fail=True).return_code == 0:
|
print(node.run('systemctl reboot').stdout.decode())
|
||||||
print('rebooting', node.name)
|
|
||||||
print(node.run('systemctl reboot').stdout.decode())
|
|
||||||
else:
|
|
||||||
print('not rebooting', node.name)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
|
||||||
1
bin/wake
1
bin/wake
|
|
@ -1,5 +1,4 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# purpose: wake one node via WoL by name — usage: wake <node>.
|
|
||||||
|
|
||||||
from bundlewrap.repo import Repository
|
from bundlewrap.repo import Repository
|
||||||
from os.path import realpath, dirname
|
from os.path import realpath, dirname
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,15 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# purpose: print or QR-render a WireGuard client config from htz.mails metadata — usage: wireguard-client-config <client>.
|
|
||||||
|
|
||||||
from bundlewrap.repo import Repository
|
from bundlewrap.repo import Repository
|
||||||
from os.path import realpath, dirname
|
from os.path import realpath, dirname
|
||||||
from sys import argv
|
from sys import argv
|
||||||
from ipaddress import ip_network, ip_interface
|
from ipaddress import ip_network, ip_interface
|
||||||
import argparse
|
|
||||||
|
|
||||||
|
|
||||||
# get info from repo
|
|
||||||
repo = Repository(dirname(dirname(realpath(__file__))))
|
repo = Repository(dirname(dirname(realpath(__file__))))
|
||||||
server_node = repo.get_node('htz.mails')
|
|
||||||
available_clients = server_node.metadata.get('wireguard/clients').keys()
|
|
||||||
|
|
||||||
# parse args
|
server_node = repo.get_node(argv[1])
|
||||||
parser = argparse.ArgumentParser(description='Generate WireGuard client configuration.')
|
data = server_node.metadata.get(f'wireguard/clients/{argv[2]}')
|
||||||
parser.add_argument('client', choices=available_clients, help='The client name to generate the configuration for.')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# get cert
|
|
||||||
data = server_node.metadata.get(f'wireguard/clients/{args.client}')
|
|
||||||
vpn_network = ip_interface(server_node.metadata.get('wireguard/my_ip')).network
|
vpn_network = ip_interface(server_node.metadata.get('wireguard/my_ip')).network
|
||||||
allowed_ips = [
|
allowed_ips = [
|
||||||
vpn_network,
|
vpn_network,
|
||||||
|
|
@ -30,7 +20,9 @@ for peer in server_node.metadata.get('wireguard/s2s').values():
|
||||||
if not ip_network(network).subnet_of(vpn_network):
|
if not ip_network(network).subnet_of(vpn_network):
|
||||||
allowed_ips.append(ip_network(network))
|
allowed_ips.append(ip_network(network))
|
||||||
|
|
||||||
conf = f'''
|
conf = \
|
||||||
|
f'''>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
|
||||||
|
|
||||||
[Interface]
|
[Interface]
|
||||||
PrivateKey = {repo.libs.wireguard.privkey(data['peer_id'])}
|
PrivateKey = {repo.libs.wireguard.privkey(data['peer_id'])}
|
||||||
ListenPort = 51820
|
ListenPort = 51820
|
||||||
|
|
@ -43,17 +35,11 @@ PresharedKey = {repo.libs.wireguard.psk(data['peer_id'], server_node.metadata.ge
|
||||||
AllowedIPs = {', '.join(str(client_route) for client_route in sorted(allowed_ips))}
|
AllowedIPs = {', '.join(str(client_route) for client_route in sorted(allowed_ips))}
|
||||||
Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:51820
|
Endpoint = {ip_interface(server_node.metadata.get('network/external/ipv4')).ip}:51820
|
||||||
PersistentKeepalive = 10
|
PersistentKeepalive = 10
|
||||||
'''
|
|
||||||
|
|
||||||
answer = input("print config or qrcode? [Cq]: ").strip().upper()
|
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<'''
|
||||||
match answer:
|
|
||||||
case '' | 'C':
|
print(conf)
|
||||||
print('>>>>>>>>>>>>>>>')
|
|
||||||
print(conf)
|
if input("print qrcode? [yN]: ").upper() == 'Y':
|
||||||
print('<<<<<<<<<<<<<<<')
|
import pyqrcode
|
||||||
case 'Q':
|
print(pyqrcode.create(conf).terminal(quiet_zone=1))
|
||||||
import pyqrcode
|
|
||||||
print(pyqrcode.create(conf).terminal(quiet_zone=1))
|
|
||||||
case _:
|
|
||||||
print(f'Invalid option "{answer}".')
|
|
||||||
exit(1)
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -13,14 +13,16 @@ defaults = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'telegraf': {
|
'telegraf': {
|
||||||
'inputs': {
|
'config': {
|
||||||
'exec': {
|
'inputs': {
|
||||||
'apcupsd': {
|
'exec': {
|
||||||
'commands': ["sudo /usr/local/share/telegraf/apcupsd"],
|
repo.libs.hashable.hashable({
|
||||||
'name_override': "apcupsd",
|
'commands': ["sudo /usr/local/share/telegraf/apcupsd"],
|
||||||
'data_format': "influx",
|
'name_override': "apcupsd",
|
||||||
'interval': '30s',
|
'data_format': "influx",
|
||||||
'flush_interval': '30s',
|
'interval': '30s',
|
||||||
|
'flush_interval': '30s',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
# https://manpages.debian.org/latest/apt/sources.list.5.de.html
|
|
||||||
# https://repolib.readthedocs.io/en/latest/deb822-format.html
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
'apt': {
|
'apt': {
|
||||||
|
|
@ -8,32 +5,8 @@
|
||||||
'apt-transport-https': {},
|
'apt-transport-https': {},
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'debian': {
|
# place key under data/apt/keys/packages.cloud.google.com.{asc|gpg}
|
||||||
'types': { # optional, defaults to `{'deb'}``
|
'deb https://packages.cloud.google.com/apt cloud-sdk main',
|
||||||
'deb',
|
|
||||||
'deb-src',
|
|
||||||
},
|
|
||||||
'options': { # optional
|
|
||||||
'aarch': 'amd64',
|
|
||||||
},
|
|
||||||
'urls': {
|
|
||||||
'https://deb.debian.org/debian',
|
|
||||||
},
|
|
||||||
'suites': { # at least one
|
|
||||||
'{codename}',
|
|
||||||
'{codename}-updates',
|
|
||||||
'{codename}-backports',
|
|
||||||
},
|
|
||||||
'components': { # optional
|
|
||||||
'main',
|
|
||||||
'contrib',
|
|
||||||
'non-frese',
|
|
||||||
},
|
|
||||||
# key:
|
|
||||||
# - optional, defaults to source name (`debian` in this example)
|
|
||||||
# - place key under data/apt/keys/debian-12.{asc|gpg}
|
|
||||||
'key': 'debian-{version}',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
bundles/apt/files/20auto-upgrades
Normal file
2
bundles/apt/files/20auto-upgrades
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
3
bundles/apt/files/50unattended-upgrades
Normal file
3
bundles/apt/files/50unattended-upgrades
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Unattended-Upgrade::Origins-Pattern {
|
||||||
|
"origin=*";
|
||||||
|
};
|
||||||
|
|
@ -1,68 +1,36 @@
|
||||||
# TODO pin repo: https://superuser.com/a/1595920
|
from os.path import join
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from glob import glob
|
||||||
from os.path import join, basename
|
from os.path import join, basename
|
||||||
|
|
||||||
directories = {
|
directories = {
|
||||||
'/etc/apt': {
|
|
||||||
'purge': True,
|
|
||||||
'triggers': {
|
|
||||||
'action:apt_update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/etc/apt/apt.conf.d': {
|
|
||||||
# existance is expected
|
|
||||||
'purge': True,
|
|
||||||
'triggers': {
|
|
||||||
'action:apt_update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/etc/apt/keyrings': {
|
|
||||||
# https://askubuntu.com/a/1307181
|
|
||||||
'purge': True,
|
|
||||||
'triggers': {
|
|
||||||
'action:apt_update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# '/etc/apt/listchanges.conf.d': {
|
|
||||||
# 'purge': True,
|
|
||||||
# 'triggers': {
|
|
||||||
# 'action:apt_update',
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
'/etc/apt/preferences.d': {
|
|
||||||
'purge': True,
|
|
||||||
'triggers': {
|
|
||||||
'action:apt_update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/etc/apt/sources.list.d': {
|
'/etc/apt/sources.list.d': {
|
||||||
'purge': True,
|
'purge': True,
|
||||||
'triggers': {
|
'triggers': {
|
||||||
'action:apt_update',
|
'action:apt_update',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/etc/apt/trusted.gpg.d': {
|
||||||
|
'purge': True,
|
||||||
|
'triggers': {
|
||||||
|
'action:apt_update',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/etc/apt/preferences.d': {
|
||||||
|
'purge': True,
|
||||||
|
'triggers': {
|
||||||
|
'action:apt_update',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
files = {
|
files = {
|
||||||
'/etc/apt/apt.conf': {
|
|
||||||
'content': repo.libs.apt.render_apt_conf(node.metadata.get('apt/config')),
|
|
||||||
'triggers': {
|
|
||||||
'action:apt_update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/etc/apt/sources.list': {
|
'/etc/apt/sources.list': {
|
||||||
'content': '# managed by bundlewrap\n',
|
'content': '# managed'
|
||||||
'triggers': {
|
|
||||||
'action:apt_update',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
# '/etc/apt/listchanges.conf': {
|
|
||||||
# 'content': repo.libs.ini.dumps(node.metadata.get('apt/list_changes')),
|
|
||||||
# },
|
|
||||||
'/usr/lib/nagios/plugins/check_apt_upgradable': {
|
'/usr/lib/nagios/plugins/check_apt_upgradable': {
|
||||||
'mode': '0755',
|
'mode': '0755',
|
||||||
},
|
},
|
||||||
# /etc/kernel/postinst.d/apt-auto-removal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actions = {
|
actions = {
|
||||||
|
|
@ -76,22 +44,41 @@ actions = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# create sources.lists and respective keyfiles
|
# group sources by apt server hostname
|
||||||
|
|
||||||
for name, config in node.metadata.get('apt/sources').items():
|
hosts = {}
|
||||||
# place keyfile
|
|
||||||
keyfile_destination_path = repo.libs.apt.format_variables(node, config['options']['Signed-By'])
|
for source_string in node.metadata.get('apt/sources'):
|
||||||
files[keyfile_destination_path] = {
|
source = repo.libs.apt.AptSource(source_string)
|
||||||
'source': join(repo.path, 'data', 'apt', 'keys', basename(keyfile_destination_path)),
|
hosts\
|
||||||
'content_type': 'binary',
|
.setdefault(source.url.hostname, list())\
|
||||||
|
.append(source)
|
||||||
|
|
||||||
|
# create sources lists and keyfiles
|
||||||
|
|
||||||
|
for host, sources in hosts.items():
|
||||||
|
keyfile = basename(glob(join(repo.path, 'data', 'apt', 'keys', f'{host}.*'))[0])
|
||||||
|
destination_path = f'/etc/apt/trusted.gpg.d/{keyfile}'
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
source.options['signed-by'] = [destination_path]
|
||||||
|
|
||||||
|
files[f'/etc/apt/sources.list.d/{host}.list'] = {
|
||||||
|
'content': '\n'.join(sorted(set(
|
||||||
|
str(source).format(
|
||||||
|
release=node.metadata.get('os_release'),
|
||||||
|
version=node.os_version[0], # WIP crystal
|
||||||
|
)
|
||||||
|
for source in sources
|
||||||
|
))),
|
||||||
'triggers': {
|
'triggers': {
|
||||||
'action:apt_update',
|
'action:apt_update',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# place sources.list
|
files[destination_path] = {
|
||||||
files[f'/etc/apt/sources.list.d/{name}.sources'] = {
|
'source': join(repo.path, 'data', 'apt', 'keys', keyfile),
|
||||||
'content': repo.libs.apt.render_source(node, name),
|
'content_type': 'binary',
|
||||||
'triggers': {
|
'triggers': {
|
||||||
'action:apt_update',
|
'action:apt_update',
|
||||||
},
|
},
|
||||||
|
|
@ -106,7 +93,7 @@ for package, options in node.metadata.get('apt/packages', {}).items():
|
||||||
files[f'/etc/apt/preferences.d/{package}'] = {
|
files[f'/etc/apt/preferences.d/{package}'] = {
|
||||||
'content': '\n'.join([
|
'content': '\n'.join([
|
||||||
f"Package: {package}",
|
f"Package: {package}",
|
||||||
f"Pin: release a={node.metadata.get('os_codename')}-backports",
|
f"Pin: release a={node.metadata.get('os_release')}-backports",
|
||||||
f"Pin-Priority: 900",
|
f"Pin-Priority: 900",
|
||||||
]),
|
]),
|
||||||
'needed_by': [
|
'needed_by': [
|
||||||
|
|
@ -123,6 +110,8 @@ for package, options in node.metadata.get('apt/packages', {}).items():
|
||||||
# apt-daily.timer: performs apt update
|
# apt-daily.timer: performs apt update
|
||||||
# apt-daily-upgrade.timer: performs apt upgrade
|
# apt-daily-upgrade.timer: performs apt upgrade
|
||||||
|
|
||||||
|
files['/etc/apt/apt.conf.d/20auto-upgrades'] = {}
|
||||||
|
files['/etc/apt/apt.conf.d/50unattended-upgrades'] = {}
|
||||||
svc_systemd['unattended-upgrades.service'] = {
|
svc_systemd['unattended-upgrades.service'] = {
|
||||||
'needs': [
|
'needs': [
|
||||||
'pkg_apt:unattended-upgrades',
|
'pkg_apt:unattended-upgrades',
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,9 @@
|
||||||
defaults = {
|
defaults = {
|
||||||
'apt': {
|
'apt': {
|
||||||
'packages': {
|
'packages': {
|
||||||
'apt-listchanges': {
|
|
||||||
'installed': False,
|
|
||||||
},
|
|
||||||
'ca-certificates': {},
|
|
||||||
'unattended-upgrades': {},
|
'unattended-upgrades': {},
|
||||||
},
|
},
|
||||||
'config': {
|
'sources': set(),
|
||||||
'DPkg': {
|
|
||||||
'Pre-Install-Pkgs': {
|
|
||||||
'/usr/sbin/dpkg-preconfigure --apt || true',
|
|
||||||
},
|
|
||||||
'Post-Invoke': {
|
|
||||||
# keep package cache empty
|
|
||||||
'/bin/rm -f /var/cache/apt/archives/*.deb || true',
|
|
||||||
},
|
|
||||||
'Options': {
|
|
||||||
# https://unix.stackexchange.com/a/642541/357916
|
|
||||||
'--force-confold',
|
|
||||||
'--force-confdef',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'APT': {
|
|
||||||
'Periodic': {
|
|
||||||
'Update-Package-Lists': '1',
|
|
||||||
'Unattended-Upgrade': '1',
|
|
||||||
},
|
|
||||||
'NeverAutoRemove': {
|
|
||||||
'^firmware-linux.*',
|
|
||||||
'^linux-firmware$',
|
|
||||||
'^linux-image-[a-z0-9]*$',
|
|
||||||
'^linux-image-[a-z0-9]*-[a-z0-9]*$',
|
|
||||||
},
|
|
||||||
'VersionedKernelPackages': {
|
|
||||||
# kernels
|
|
||||||
'linux-.*',
|
|
||||||
'kfreebsd-.*',
|
|
||||||
'gnumach-.*',
|
|
||||||
# (out-of-tree) modules
|
|
||||||
'.*-modules',
|
|
||||||
'.*-kernel',
|
|
||||||
},
|
|
||||||
'Never-MarkAuto-Sections': {
|
|
||||||
'metapackages',
|
|
||||||
'tasks',
|
|
||||||
},
|
|
||||||
'Move-Autobit-Sections': {
|
|
||||||
'oldlibs',
|
|
||||||
},
|
|
||||||
'Update': {
|
|
||||||
# https://unix.stackexchange.com/a/653377/357916
|
|
||||||
'Error-Mode': 'any',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'Unattended-Upgrade': {
|
|
||||||
'Origins-Pattern': {
|
|
||||||
"origin=*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'sources': {},
|
|
||||||
},
|
},
|
||||||
'monitoring': {
|
'monitoring': {
|
||||||
'services': {
|
'services': {
|
||||||
|
|
@ -80,82 +23,3 @@ defaults = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
|
||||||
'apt/sources',
|
|
||||||
)
|
|
||||||
def key(metadata):
|
|
||||||
return {
|
|
||||||
'apt': {
|
|
||||||
'sources': {
|
|
||||||
source_name: {
|
|
||||||
'key': source_name,
|
|
||||||
}
|
|
||||||
for source_name, source_config in metadata.get('apt/sources').items()
|
|
||||||
if 'key' not in source_config
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
|
||||||
'apt/sources',
|
|
||||||
)
|
|
||||||
def signed_by(metadata):
|
|
||||||
return {
|
|
||||||
'apt': {
|
|
||||||
'sources': {
|
|
||||||
source_name: {
|
|
||||||
'options': {
|
|
||||||
'Signed-By': '/etc/apt/keyrings/' + metadata.get(f'apt/sources/{source_name}/key') + '.' + repo.libs.apt.find_keyfile_extension(node, metadata.get(f'apt/sources/{source_name}/key')),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for source_name in metadata.get('apt/sources')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# @metadata_reactor.provides(
|
|
||||||
# 'apt/config',
|
|
||||||
# 'apt/list_changes',
|
|
||||||
# )
|
|
||||||
# def listchanges(metadata):
|
|
||||||
# return {
|
|
||||||
# 'apt': {
|
|
||||||
# 'config': {
|
|
||||||
# 'DPkg': {
|
|
||||||
# 'Pre-Install-Pkgs': {
|
|
||||||
# '/usr/bin/apt-listchanges --apt || test $? -lt 10',
|
|
||||||
# },
|
|
||||||
# 'Tools': {
|
|
||||||
# 'Options': {
|
|
||||||
# '/usr/bin/apt-listchanges': {
|
|
||||||
# 'Version': '2',
|
|
||||||
# 'InfoFD': '20',
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# 'Dir': {
|
|
||||||
# 'Etc': {
|
|
||||||
# 'apt-listchanges-main': 'listchanges.conf',
|
|
||||||
# 'apt-listchanges-parts': 'listchanges.conf.d',
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# 'list_changes': {
|
|
||||||
# 'apt': {
|
|
||||||
# 'frontend': 'pager',
|
|
||||||
# 'which': 'news',
|
|
||||||
# 'email_address': 'root',
|
|
||||||
# 'email_format': 'text',
|
|
||||||
# 'confirm': 'false',
|
|
||||||
# 'headers': 'false',
|
|
||||||
# 'reverse': 'false',
|
|
||||||
# 'save_seen': '/var/lib/apt/listchanges.db',
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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']
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -35,7 +35,6 @@ def zfs(metadata):
|
||||||
|
|
||||||
for other_node in repo.nodes:
|
for other_node in repo.nodes:
|
||||||
if (
|
if (
|
||||||
not other_node.dummy and
|
|
||||||
other_node.has_bundle('backup') and
|
other_node.has_bundle('backup') and
|
||||||
other_node.metadata.get('backup/server') == node.name
|
other_node.metadata.get('backup/server') == node.name
|
||||||
):
|
):
|
||||||
|
|
@ -99,7 +98,7 @@ def zfs(metadata):
|
||||||
def dns(metadata):
|
def dns(metadata):
|
||||||
return {
|
return {
|
||||||
'dns': {
|
'dns': {
|
||||||
metadata.get('backup-server/hostname'): repo.libs.ip.get_a_records(metadata),
|
metadata.get('backup-server/hostname'): repo.libs.dns.get_a_records(metadata),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,11 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
set -u
|
|
||||||
|
|
||||||
# FIXME: inelegant
|
# FIXME: inelegant
|
||||||
% if wol_command:
|
% if wol_command:
|
||||||
${wol_command}
|
${wol_command}
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
exit=0
|
|
||||||
failed_paths=""
|
|
||||||
|
|
||||||
for path in $(jq -r '.paths | .[]' < /etc/backup/config.json)
|
for path in $(jq -r '.paths | .[]' < /etc/backup/config.json)
|
||||||
do
|
do
|
||||||
echo backing up $path
|
|
||||||
/opt/backup/backup_path "$path"
|
/opt/backup/backup_path "$path"
|
||||||
# set exit to 1 if any backup fails
|
|
||||||
if [ $? -ne 0 ]
|
|
||||||
then
|
|
||||||
echo ERROR: backing up $path failed >&2
|
|
||||||
exit=5
|
|
||||||
failed_paths="$failed_paths $path"
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ $exit -ne 0 ]
|
|
||||||
then
|
|
||||||
echo "ERROR: failed to backup paths: $failed_paths" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit $exit
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
set -exu
|
|
||||||
|
|
||||||
path=$1
|
path=$1
|
||||||
|
|
||||||
if zfs list -H -o mountpoint | grep -q "^$path$"
|
if zfs list -H -o mountpoint | grep -q "$path"
|
||||||
then
|
then
|
||||||
/opt/backup/backup_path_via_zfs "$path"
|
/opt/backup/backup_path_via_zfs "$path"
|
||||||
elif test -e "$path"
|
elif test -d "$path"
|
||||||
then
|
then
|
||||||
/opt/backup/backup_path_via_rsync "$path"
|
/opt/backup/backup_path_via_rsync "$path"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,5 @@ uuid=$(jq -r .client_uuid < /etc/backup/config.json)
|
||||||
server=$(jq -r .server_hostname < /etc/backup/config.json)
|
server=$(jq -r .server_hostname < /etc/backup/config.json)
|
||||||
ssh="ssh -o ConnectTimeout=5 backup-receiver@$server"
|
ssh="ssh -o ConnectTimeout=5 backup-receiver@$server"
|
||||||
|
|
||||||
if test -d "$path"
|
rsync -av --rsync-path="sudo rsync" "$path/" "backup-receiver@$server:/mnt/backups/$uuid$path/"
|
||||||
then
|
$ssh sudo zfs snap "tank/$uuid/fs@auto-backup_$(date +"%Y-%m-%d_%H:%M:%S")"
|
||||||
postfix="/"
|
|
||||||
elif test -f "$path"
|
|
||||||
then
|
|
||||||
postfix=""
|
|
||||||
else
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
rsync -av --rsync-path="sudo rsync" "$path$postfix" "backup-receiver@$server:/mnt/backups/$uuid$path$postfix"
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
set -eu
|
set -exu
|
||||||
|
|
||||||
path=$1
|
path=$1
|
||||||
uuid=$(jq -r .client_uuid < /etc/backup/config.json)
|
uuid=$(jq -r .client_uuid < /etc/backup/config.json)
|
||||||
|
|
@ -46,13 +46,13 @@ if [[ "$?" == "0" ]]
|
||||||
then
|
then
|
||||||
|
|
||||||
# delete old local bookmarks
|
# delete old local bookmarks
|
||||||
for destroyable_bookmark in $(zfs list -t bookmark -H -o name "$source_dataset" | grep "^$source_dataset#$bookmark_prefix")
|
for destroyable_bookmark in $(zfs list -t bookmark -H -o name "$dataset" | grep "^$dataset#$bookmark_prefix")
|
||||||
do
|
do
|
||||||
zfs destroy "$destroyable_bookmark"
|
zfs destroy "$destroyable_bookmark"
|
||||||
done
|
done
|
||||||
|
|
||||||
# delete remote snapshots from bookmarks (except newest, even of not necessary; maybe for resuming tho)
|
# delete snapshots from bookmarks (except newest, even of not necessary; maybe for resuming tho)
|
||||||
for destroyable_snapshot in $($ssh sudo zfs list -t snapshot -H -o name "$target_dataset" | grep "^$target_dataset@$bookmark_prefix" | grep -v "$new_bookmark")
|
for destroyable_snapshot in $($ssh sudo zfs list -t snapshot -H -o name "$dataset" | grep "^$dataset@$bookmark_prefix" | grep -v "$new_bookmark")
|
||||||
do
|
do
|
||||||
$ssh sudo zfs destroy "$destroyable_snapshot"
|
$ssh sudo zfs destroy "$destroyable_snapshot"
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ def acme_zone(metadata):
|
||||||
str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip)
|
str(ip_interface(other_node.metadata.get('network/internal/ipv4')).ip)
|
||||||
for other_node in repo.nodes
|
for other_node in repo.nodes
|
||||||
if other_node.metadata.get('letsencrypt/domains', {})
|
if other_node.metadata.get('letsencrypt/domains', {})
|
||||||
and other_node.metadata.get('network/internal/ipv4', None)
|
|
||||||
},
|
},
|
||||||
*{
|
*{
|
||||||
str(ip_interface(other_node.metadata.get('wireguard/my_ip')).ip)
|
str(ip_interface(other_node.metadata.get('wireguard/my_ip')).ip)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -29,7 +29,6 @@ view "${view_name}" {
|
||||||
|
|
||||||
% if view_conf['is_internal']:
|
% if view_conf['is_internal']:
|
||||||
recursion yes;
|
recursion yes;
|
||||||
include "/etc/bind/zones.rfc1918";
|
|
||||||
% else:
|
% else:
|
||||||
recursion no;
|
recursion no;
|
||||||
rate-limit {
|
rate-limit {
|
||||||
|
|
@ -63,6 +62,9 @@ view "${view_name}" {
|
||||||
file "/var/lib/bind/${view_name}/${zone_name}";
|
file "/var/lib/bind/${view_name}/${zone_name}";
|
||||||
};
|
};
|
||||||
% endfor
|
% endfor
|
||||||
|
|
||||||
|
include "/etc/bind/named.conf.default-zones";
|
||||||
|
include "/etc/bind/zones.rfc1918";
|
||||||
};
|
};
|
||||||
|
|
||||||
% endfor
|
% endfor
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ options {
|
||||||
|
|
||||||
% if type == 'master':
|
% if type == 'master':
|
||||||
notify yes;
|
notify yes;
|
||||||
also-notify { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} };
|
also-notify { ${' '.join([f'{ip};' for ip in slave_ips])} };
|
||||||
allow-transfer { ${' '.join(sorted(f'{ip};' for ip in slave_ips))} };
|
allow-transfer { ${' '.join([f'{ip};' for ip in slave_ips])} };
|
||||||
% endif
|
% endif
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"; };
|
|
||||||
|
|
@ -19,7 +19,7 @@ directories[f'/var/lib/bind'] = {
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ files['/etc/default/bind9'] = {
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ files['/etc/bind/named.conf'] = {
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ files['/etc/bind/named.conf.options'] = {
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ files['/etc/bind/named.conf.local'] = {
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ for view_name, view_conf in master_node.metadata.get('bind/views').items():
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +127,7 @@ for view_name, view_conf in master_node.metadata.get('bind/views').items():
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
],
|
],
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,24 +139,6 @@ actions['named-checkconf'] = {
|
||||||
'unless': 'named-checkconf -z',
|
'unless': 'named-checkconf -z',
|
||||||
'needs': [
|
'needs': [
|
||||||
'svc_systemd:bind9',
|
'svc_systemd:bind9',
|
||||||
'svc_systemd:bind9:reload',
|
'svc_systemd:bind9:restart',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# beantwortet Anfragen nach privaten IP-Adressen mit NXDOMAIN, statt sie ins Internet weiterzuleiten
|
|
||||||
files['/etc/bind/zones.rfc1918'] = {
|
|
||||||
'needed_by': [
|
|
||||||
'svc_systemd:bind9',
|
|
||||||
],
|
|
||||||
'triggers': [
|
|
||||||
'svc_systemd:bind9:reload',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
files['/etc/bind/db.empty'] = {
|
|
||||||
'needed_by': [
|
|
||||||
'svc_systemd:bind9',
|
|
||||||
],
|
|
||||||
'triggers': [
|
|
||||||
'svc_systemd:bind9:reload',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from json import dumps
|
||||||
h = repo.libs.hashable.hashable
|
h = repo.libs.hashable.hashable
|
||||||
repo.libs.bind.repo = repo
|
repo.libs.bind.repo = repo
|
||||||
|
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'apt': {
|
'apt': {
|
||||||
'packages': {
|
'packages': {
|
||||||
|
|
@ -49,13 +48,13 @@ defaults = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'telegraf': {
|
'telegraf': {
|
||||||
'inputs': {
|
'config': {
|
||||||
'bind': {
|
'inputs': {
|
||||||
'default': {
|
'bind': [{
|
||||||
'urls': ['http://localhost:8053/xml/v3'],
|
'urls': ['http://localhost:8053/xml/v3'],
|
||||||
'gather_memory_contexts': False,
|
'gather_memory_contexts': False,
|
||||||
'gather_views': True,
|
'gather_views': True,
|
||||||
},
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -93,7 +92,7 @@ def master_slave(metadata):
|
||||||
def dns(metadata):
|
def dns(metadata):
|
||||||
return {
|
return {
|
||||||
'dns': {
|
'dns': {
|
||||||
metadata.get('bind/hostname'): repo.libs.ip.get_a_records(metadata),
|
metadata.get('bind/hostname'): repo.libs.dns.get_a_records(metadata),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +211,7 @@ def generate_keys(metadata):
|
||||||
'token':repo.libs.hmac.hmac_sha512(
|
'token':repo.libs.hmac.hmac_sha512(
|
||||||
key,
|
key,
|
||||||
str(repo.vault.random_bytes_as_base64_for(
|
str(repo.vault.random_bytes_as_base64_for(
|
||||||
f"{metadata.get('id')} bind key {key} 20250713",
|
f"{metadata.get('id')} bind key {key}",
|
||||||
length=32,
|
length=32,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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,
|
|
||||||
])
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
|
||||||
# },
|
|
||||||
# },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -27,7 +27,7 @@ def ssh_keys(metadata):
|
||||||
'users': {
|
'users': {
|
||||||
'build-agent': {
|
'build-agent': {
|
||||||
'authorized_users': {
|
'authorized_users': {
|
||||||
f'build-server@{other_node.name}': {}
|
f'build-server@{other_node.name}'
|
||||||
for other_node in repo.nodes
|
for other_node in repo.nodes
|
||||||
if other_node.has_bundle('build-server')
|
if other_node.has_bundle('build-server')
|
||||||
for architecture in other_node.metadata.get('build-server/architectures').values()
|
for architecture in other_node.metadata.get('build-server/architectures').values()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
from shlex import quote
|
from shlex import quote
|
||||||
|
|
||||||
|
|
||||||
defaults = {
|
|
||||||
'build-ci': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
'users/build-ci/authorized_users',
|
'users/build-ci/authorized_users',
|
||||||
'sudoers/build-ci',
|
'sudoers/build-ci',
|
||||||
|
|
@ -14,7 +10,7 @@ def ssh_keys(metadata):
|
||||||
'users': {
|
'users': {
|
||||||
'build-ci': {
|
'build-ci': {
|
||||||
'authorized_users': {
|
'authorized_users': {
|
||||||
f'build-server@{other_node.name}': {}
|
f'build-server@{other_node.name}'
|
||||||
for other_node in repo.nodes
|
for other_node in repo.nodes
|
||||||
if other_node.has_bundle('build-server')
|
if other_node.has_bundle('build-server')
|
||||||
},
|
},
|
||||||
|
|
@ -22,7 +18,7 @@ def ssh_keys(metadata):
|
||||||
},
|
},
|
||||||
'sudoers': {
|
'sudoers': {
|
||||||
'build-ci': {
|
'build-ci': {
|
||||||
f"/usr/bin/chown -R build-ci\\:{quote(ci['group'])} {quote(ci['path'])}"
|
f"/usr/bin/chown -R build-ci\:{quote(ci['group'])} {quote(ci['path'])}"
|
||||||
for ci in metadata.get('build-ci').values()
|
for ci in metadata.get('build-ci').values()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
debian_version = min([node.os_version, (11,)])[0] # FIXME
|
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'apt': {
|
'apt': {
|
||||||
'packages': {
|
'packages': {
|
||||||
'crystal': {},
|
'crystal': {},
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'crystal': {
|
'deb https://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_{version}/ /',
|
||||||
# https://software.opensuse.org/download.html?project=devel%3Alanguages%3Acrystal&package=crystal
|
|
||||||
# curl -fsSL https://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/Release.key
|
|
||||||
'urls': {
|
|
||||||
'http://download.opensuse.org/repositories/devel:/languages:/crystal/Debian_Testing/',
|
|
||||||
},
|
|
||||||
'suites': {
|
|
||||||
'/',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
DOVECOT
|
DOVECOT
|
||||||
=======
|
=======
|
||||||
|
|
||||||
rescan index
|
rescan index: https://doc.dovecot.org/configuration_manual/fts/#rescan
|
||||||
------------
|
|
||||||
|
|
||||||
https://doc.dovecot.org/configuration_manual/fts/#rescan
|
|
||||||
|
|
||||||
```
|
```
|
||||||
doveadm fts rescan -u 'i@ckn.li'
|
sudo -u vmail doveadm fts rescan -u 'test@mail2.sublimity.de'
|
||||||
doveadm index -u 'i@ckn.li' -q '*'
|
sudo -u vmail doveadm index -u 'test@mail2.sublimity.de' -q '*'
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ xmlunzip() {
|
||||||
trap "rm -rf $path $tempdir" 0 1 2 3 14 15
|
trap "rm -rf $path $tempdir" 0 1 2 3 14 15
|
||||||
cd $tempdir || exit 1
|
cd $tempdir || exit 1
|
||||||
unzip -q "$path" 2>/dev/null || exit 0
|
unzip -q "$path" 2>/dev/null || exit 0
|
||||||
find . -name "$name" -print0 | xargs -0 cat | /usr/lib/dovecot/xml2text
|
find . -name "$name" -print0 | xargs -0 cat |
|
||||||
|
$libexec_dir/xml2text
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_timeout() {
|
wait_timeout() {
|
||||||
|
|
|
||||||
10
bundles/dovecot/files/dovecot-sql.conf
Normal file
10
bundles/dovecot/files/dovecot-sql.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
connect = host=${host} dbname=${name} user=${user} password=${password}
|
||||||
|
driver = pgsql
|
||||||
|
default_pass_scheme = ARGON2ID
|
||||||
|
|
||||||
|
password_query = SELECT CONCAT(users.name, '@', domains.name) AS user, password\
|
||||||
|
FROM users \
|
||||||
|
LEFT JOIN domains ON users.domain_id = domains.id \
|
||||||
|
WHERE redirect IS NULL \
|
||||||
|
AND users.name = SPLIT_PART('%u', '@', 1) \
|
||||||
|
AND domains.name = SPLIT_PART('%u', '@', 2)
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
dovecot_config_version = ${config_version}
|
|
||||||
dovecot_storage_version = ${storage_version}
|
|
||||||
|
|
||||||
protocols = imap lmtp sieve
|
protocols = imap lmtp sieve
|
||||||
auth_mechanisms = plain login
|
auth_mechanisms = plain login
|
||||||
|
mail_privileged_group = mail
|
||||||
ssl = required
|
ssl = required
|
||||||
ssl_server_cert_file = /var/lib/dehydrated/certs/${hostname}/fullchain.pem
|
ssl_cert = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/fullchain.pem
|
||||||
ssl_server_key_file = /var/lib/dehydrated/certs/${hostname}/privkey.pem
|
ssl_key = </var/lib/dehydrated/certs/${node.metadata.get('mailserver/hostname')}/privkey.pem
|
||||||
ssl_server_dh_file = /etc/dovecot/dhparam.pem
|
ssl_dh = </etc/dovecot/dhparam.pem
|
||||||
ssl_client_ca_dir = /etc/ssl/certs
|
ssl_client_ca_dir = /etc/ssl/certs
|
||||||
mail_driver = maildir
|
mail_location = maildir:~
|
||||||
mail_path = ${maildir}/%{user}
|
mail_plugins = fts fts_xapian
|
||||||
mail_index_path = ${maildir}/index/%{user}
|
|
||||||
mail_plugins = fts fts_flatcurve
|
|
||||||
|
|
||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
|
|
@ -34,46 +30,13 @@ namespace inbox {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# postgres passdb userdb
|
passdb {
|
||||||
|
driver = sql
|
||||||
sql_driver = pgsql
|
args = /etc/dovecot/dovecot-sql.conf
|
||||||
|
|
||||||
pgsql main {
|
|
||||||
parameters {
|
|
||||||
host = ${db_host}
|
|
||||||
dbname = ${db_name}
|
|
||||||
user = ${db_user}
|
|
||||||
password = ${db_password}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
userdb {
|
||||||
passdb sql {
|
driver = static
|
||||||
passdb_default_password_scheme = ARGON2ID
|
args = uid=vmail gid=vmail home=/var/vmail/%u
|
||||||
|
|
||||||
query = SELECT \
|
|
||||||
CONCAT(users.name, '@', domains.name) AS "user", \
|
|
||||||
password \
|
|
||||||
FROM users \
|
|
||||||
LEFT JOIN domains ON users.domain_id = domains.id \
|
|
||||||
WHERE redirect IS NULL \
|
|
||||||
AND users.name = SPLIT_PART('%{user}', '@', 1) \
|
|
||||||
AND domains.name = SPLIT_PART('%{user}', '@', 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
mail_uid = vmail
|
|
||||||
mail_gid = vmail
|
|
||||||
|
|
||||||
userdb sql {
|
|
||||||
query = SELECT \
|
|
||||||
'/var/vmail/%{user}' AS home, \
|
|
||||||
'vmail' AS uid, \
|
|
||||||
'vmail' AS gid
|
|
||||||
|
|
||||||
iterate_query = SELECT \
|
|
||||||
CONCAT(users.name, '@', domains.name) AS username \
|
|
||||||
FROM users \
|
|
||||||
LEFT JOIN domains ON users.domain_id = domains.id \
|
|
||||||
WHERE redirect IS NULL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
service auth {
|
service auth {
|
||||||
|
|
@ -103,9 +66,10 @@ service stats {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service managesieve-login {
|
service managesieve-login {
|
||||||
#inet_listener sieve {}
|
inet_listener sieve {
|
||||||
process_min_avail = 1
|
}
|
||||||
process_limit = 1
|
process_min_avail = 0
|
||||||
|
service_count = 1
|
||||||
vsz_limit = 64 M
|
vsz_limit = 64 M
|
||||||
}
|
}
|
||||||
service managesieve {
|
service managesieve {
|
||||||
|
|
@ -113,53 +77,31 @@ service managesieve {
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol imap {
|
protocol imap {
|
||||||
mail_plugins = fts fts_flatcurve imap_sieve
|
mail_plugins = $mail_plugins imap_sieve
|
||||||
mail_max_userip_connections = 50
|
mail_max_userip_connections = 50
|
||||||
imap_idle_notify_interval = 29 mins
|
imap_idle_notify_interval = 29 mins
|
||||||
}
|
}
|
||||||
protocol lmtp {
|
protocol lmtp {
|
||||||
mail_plugins = fts fts_flatcurve sieve
|
mail_plugins = $mail_plugins sieve
|
||||||
}
|
}
|
||||||
|
protocol sieve {
|
||||||
# Persönliches Skript (deine alte Datei /var/vmail/sieve/%u.sieve)
|
plugin {
|
||||||
sieve_script personal {
|
sieve = /var/vmail/sieve/%u.sieve
|
||||||
driver = file
|
sieve_storage = /var/vmail/sieve/%u/
|
||||||
# Verzeichnis mit (evtl. mehreren) Sieve-Skripten des Users
|
}
|
||||||
path = /var/vmail/sieve/%{user}/
|
|
||||||
# Aktives Skript (entspricht früher "sieve = /var/vmail/sieve/%u.sieve")
|
|
||||||
active_path = /var/vmail/sieve/%{user}.sieve
|
|
||||||
}
|
|
||||||
|
|
||||||
# Globales After-Skript (dein früheres "sieve_after = …")
|
|
||||||
sieve_script after {
|
|
||||||
type = after
|
|
||||||
driver = file
|
|
||||||
path = /var/vmail/sieve/global/spam-to-folder.sieve
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# fulltext search
|
# fulltext search
|
||||||
language en {
|
plugin {
|
||||||
|
fts = xapian
|
||||||
|
fts_xapian = partial=3 full=20 verbose=0
|
||||||
|
fts_autoindex = yes
|
||||||
|
fts_enforced = yes
|
||||||
|
# Index attachements
|
||||||
|
fts_decoder = decode2text
|
||||||
}
|
}
|
||||||
language de {
|
|
||||||
default = yes
|
|
||||||
}
|
|
||||||
language_tokenizers = generic email-address
|
|
||||||
|
|
||||||
fts flatcurve {
|
|
||||||
substring_search = yes
|
|
||||||
# rotate_count = 5000 # DB-Rotation nach X Mails
|
|
||||||
# rotate_time = 5s # oder zeitbasiert rotieren
|
|
||||||
# optimize_limit = 10
|
|
||||||
# min_term_size = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
fts_autoindex = yes
|
|
||||||
fts_decoder_driver = script
|
|
||||||
fts_decoder_script_socket_path = decode2text
|
|
||||||
|
|
||||||
service indexer-worker {
|
service indexer-worker {
|
||||||
process_limit = ${indexer_cores}
|
vsz_limit = ${indexer_ram}
|
||||||
vsz_limit = ${indexer_ram}M
|
|
||||||
}
|
}
|
||||||
service decode2text {
|
service decode2text {
|
||||||
executable = script /usr/local/libexec/dovecot/decode2text.sh
|
executable = script /usr/local/libexec/dovecot/decode2text.sh
|
||||||
|
|
@ -169,39 +111,24 @@ service decode2text {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mailbox Junk {
|
# spam filter
|
||||||
sieve_script learn_spam {
|
plugin {
|
||||||
driver = file
|
sieve_plugins = sieve_imapsieve sieve_extprograms
|
||||||
type = before
|
sieve_dir = /var/vmail/sieve/%u/
|
||||||
cause = copy
|
sieve = /var/vmail/sieve/%u.sieve
|
||||||
path = /var/vmail/sieve/global/learn-spam.sieve
|
sieve_pipe_bin_dir = /var/vmail/sieve/bin
|
||||||
}
|
sieve_extensions = +vnd.dovecot.pipe
|
||||||
}
|
|
||||||
|
|
||||||
imapsieve_from Junk {
|
sieve_after = /var/vmail/sieve/global/spam-to-folder.sieve
|
||||||
sieve_script learn_ham {
|
|
||||||
driver = file
|
|
||||||
type = before
|
|
||||||
cause = copy
|
|
||||||
path = /var/vmail/sieve/global/learn-ham.sieve
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extprograms-Plugin einschalten
|
# From elsewhere to Spam folder
|
||||||
sieve_plugins {
|
imapsieve_mailbox1_name = Junk
|
||||||
sieve_extprograms = yes
|
imapsieve_mailbox1_causes = COPY
|
||||||
}
|
imapsieve_mailbox1_before = file:/var/vmail/sieve/global/learn-spam.sieve
|
||||||
|
|
||||||
# Welche Sieve-Erweiterungen dürfen genutzt werden?
|
# From Spam folder to elsewhere
|
||||||
# Empfehlung: nur global erlauben (nicht in User-Skripten):
|
imapsieve_mailbox2_name = *
|
||||||
sieve_global_extensions {
|
imapsieve_mailbox2_from = Junk
|
||||||
vnd.dovecot.pipe = yes
|
imapsieve_mailbox2_causes = COPY
|
||||||
# vnd.dovecot.filter = yes # nur falls gebraucht
|
imapsieve_mailbox2_before = file:/var/vmail/sieve/global/learn-ham.sieve
|
||||||
# vnd.dovecot.execute = yes # nur falls gebraucht
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verzeichnis mit deinen Skripten/Binaries für :pipe
|
|
||||||
sieve_pipe_bin_dir = /var/vmail/sieve/bin
|
|
||||||
# (optional, analog für :filter / :execute)
|
|
||||||
# sieve_filter_bin_dir = /var/vmail/sieve/filter
|
|
||||||
# sieve_execute_bin_dir = /var/vmail/sieve/execute
|
|
||||||
|
|
@ -20,10 +20,6 @@ directories = {
|
||||||
'owner': 'vmail',
|
'owner': 'vmail',
|
||||||
'group': 'vmail',
|
'group': 'vmail',
|
||||||
},
|
},
|
||||||
'/var/vmail/index': {
|
|
||||||
'owner': 'vmail',
|
|
||||||
'group': 'vmail',
|
|
||||||
},
|
|
||||||
'/var/vmail/sieve': {
|
'/var/vmail/sieve': {
|
||||||
'owner': 'vmail',
|
'owner': 'vmail',
|
||||||
'group': 'vmail',
|
'group': 'vmail',
|
||||||
|
|
@ -44,16 +40,6 @@ files = {
|
||||||
'context': {
|
'context': {
|
||||||
'admin_email': node.metadata.get('mailserver/admin_email'),
|
'admin_email': node.metadata.get('mailserver/admin_email'),
|
||||||
'indexer_ram': node.metadata.get('dovecot/indexer_ram'),
|
'indexer_ram': node.metadata.get('dovecot/indexer_ram'),
|
||||||
'config_version': node.metadata.get('dovecot/config_version'),
|
|
||||||
'storage_version': node.metadata.get('dovecot/storage_version'),
|
|
||||||
'maildir': node.metadata.get('mailserver/maildir'),
|
|
||||||
'hostname': node.metadata.get('mailserver/hostname'),
|
|
||||||
'db_host': node.metadata.get('mailserver/database/host'),
|
|
||||||
'db_name': node.metadata.get('mailserver/database/name'),
|
|
||||||
'db_user': node.metadata.get('mailserver/database/user'),
|
|
||||||
'db_password': node.metadata.get('mailserver/database/password'),
|
|
||||||
'indexer_cores': node.metadata.get('vm/cores'),
|
|
||||||
'indexer_ram': node.metadata.get('vm/ram')//2,
|
|
||||||
},
|
},
|
||||||
'needs': {
|
'needs': {
|
||||||
'pkg_apt:'
|
'pkg_apt:'
|
||||||
|
|
@ -62,9 +48,29 @@ files = {
|
||||||
'svc_systemd:dovecot:restart',
|
'svc_systemd:dovecot:restart',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/etc/dovecot/dovecot-sql.conf': {
|
||||||
|
'content_type': 'mako',
|
||||||
|
'context': node.metadata.get('mailserver/database'),
|
||||||
|
'needs': {
|
||||||
|
'pkg_apt:'
|
||||||
|
},
|
||||||
|
'triggers': {
|
||||||
|
'svc_systemd:dovecot:restart',
|
||||||
|
},
|
||||||
|
},
|
||||||
'/etc/dovecot/dhparam.pem': {
|
'/etc/dovecot/dhparam.pem': {
|
||||||
'content_type': 'any',
|
'content_type': 'any',
|
||||||
},
|
},
|
||||||
|
'/etc/dovecot/dovecot-sql.conf': {
|
||||||
|
'content_type': 'mako',
|
||||||
|
'context': node.metadata.get('mailserver/database'),
|
||||||
|
'needs': {
|
||||||
|
'pkg_apt:'
|
||||||
|
},
|
||||||
|
'triggers': {
|
||||||
|
'svc_systemd:dovecot:restart',
|
||||||
|
},
|
||||||
|
},
|
||||||
'/var/vmail/sieve/global/spam-to-folder.sieve': {
|
'/var/vmail/sieve/global/spam-to-folder.sieve': {
|
||||||
'owner': 'vmail',
|
'owner': 'vmail',
|
||||||
'group': 'vmail',
|
'group': 'vmail',
|
||||||
|
|
@ -121,6 +127,7 @@ svc_systemd = {
|
||||||
'action:letsencrypt_update_certificates',
|
'action:letsencrypt_update_certificates',
|
||||||
'action:dovecot_generate_dhparam',
|
'action:dovecot_generate_dhparam',
|
||||||
'file:/etc/dovecot/dovecot.conf',
|
'file:/etc/dovecot/dovecot.conf',
|
||||||
|
'file:/etc/dovecot/dovecot-sql.conf',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ defaults = {
|
||||||
'dovecot-sieve': {},
|
'dovecot-sieve': {},
|
||||||
'dovecot-managesieved': {},
|
'dovecot-managesieved': {},
|
||||||
# fulltext search
|
# fulltext search
|
||||||
'dovecot-flatcurve': {}, # buster-backports
|
'dovecot-fts-xapian': {}, # buster-backports
|
||||||
'poppler-utils': {}, # pdftotext
|
'poppler-utils': {}, # pdftotext
|
||||||
'catdoc': {}, # catdoc, catppt, xls2csv
|
'catdoc': {}, # catdoc, catppt, xls2csv
|
||||||
},
|
},
|
||||||
|
|
@ -29,12 +29,6 @@ defaults = {
|
||||||
'tcp dport {143, 993, 4190} accept',
|
'tcp dport {143, 993, 4190} accept',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'systemd-timers': {
|
|
||||||
'dovecot-optimize-index': {
|
|
||||||
'command': '/usr/bin/doveadm fts optimize -A',
|
|
||||||
'when': 'daily',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
|
|
|
||||||
6
bundles/download-server/items.py
Normal file
6
bundles/download-server/items.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# directories = {
|
||||||
|
# '/var/lib/downloads': {
|
||||||
|
# 'owner': 'downloads',
|
||||||
|
# 'group': 'www-data',
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
@ -5,11 +5,6 @@ defaults = {
|
||||||
'needs': {
|
'needs': {
|
||||||
'zfs_dataset:tank/downloads'
|
'zfs_dataset:tank/downloads'
|
||||||
},
|
},
|
||||||
'authorized_users': {
|
|
||||||
f'build-server@{other_node.name}': {}
|
|
||||||
for other_node in repo.nodes
|
|
||||||
if other_node.has_bundle('build-server')
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'zfs': {
|
'zfs': {
|
||||||
|
|
@ -19,15 +14,23 @@ defaults = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'systemd-mount': {
|
|
||||||
'/var/lib/downloads_nginx': {
|
|
||||||
'source': '/var/lib/downloads',
|
|
||||||
'user': 'www-data',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@metadata_reactor.provides(
|
||||||
|
'systemd-mount'
|
||||||
|
)
|
||||||
|
def mount_certs(metadata):
|
||||||
|
return {
|
||||||
|
'systemd-mount': {
|
||||||
|
'/var/lib/downloads_nginx': {
|
||||||
|
'source': '/var/lib/downloads',
|
||||||
|
'user': 'www-data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
'nginx/vhosts',
|
'nginx/vhosts',
|
||||||
)
|
)
|
||||||
|
|
@ -44,3 +47,20 @@ def nginx(metadata):
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@metadata_reactor.provides(
|
||||||
|
'users/downloads/authorized_users',
|
||||||
|
)
|
||||||
|
def ssh_keys(metadata):
|
||||||
|
return {
|
||||||
|
'users': {
|
||||||
|
'downloads': {
|
||||||
|
'authorized_users': {
|
||||||
|
f'build-server@{other_node.name}'
|
||||||
|
for other_node in repo.nodes
|
||||||
|
if other_node.has_bundle('build-server')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@ def units(metadata):
|
||||||
'Service': {
|
'Service': {
|
||||||
'Environment': {
|
'Environment': {
|
||||||
f'{k}={v}'
|
f'{k}={v}'
|
||||||
for k, v in metadata.get(f'flask/{name}/env', {}).items()
|
for k, v in conf.get('env', {}).items()
|
||||||
},
|
},
|
||||||
'User': metadata.get(f'flask/{name}/user'),
|
'User': conf['user'],
|
||||||
'Group': metadata.get(f'flask/{name}/group'),
|
'Group': conf['group'],
|
||||||
'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {metadata.get(f'flask/{name}/workers')} -b 127.0.0.1:{metadata.get(f'flask/{name}/port')} --timeout {metadata.get(f'flask/{name}/timeout')} {metadata.get(f'flask/{name}/app_module')}:app"
|
'ExecStart': f"/opt/{name}/venv/bin/gunicorn -w {conf['workers']} -b 127.0.0.1:{conf['port']} --timeout {conf['timeout']} {conf['app_module']}:app"
|
||||||
},
|
},
|
||||||
'Install': {
|
'Install': {
|
||||||
'WantedBy': {
|
'WantedBy': {
|
||||||
|
|
@ -55,7 +55,7 @@ def units(metadata):
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for name in metadata.get('flask')
|
for name, conf in metadata.get('flask').items()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -8,15 +8,7 @@ defaults = {
|
||||||
'python3-crcmod': {},
|
'python3-crcmod': {},
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'google-cloud': {
|
'deb https://packages.cloud.google.com/apt cloud-sdk main',
|
||||||
'url': 'https://packages.cloud.google.com/apt/',
|
|
||||||
'suites': {
|
|
||||||
'cloud-sdk',
|
|
||||||
},
|
|
||||||
'components': {
|
|
||||||
'main',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ ENABLE_OPENID_SIGNUP = false
|
||||||
[service]
|
[service]
|
||||||
REGISTER_EMAIL_CONFIRM = true
|
REGISTER_EMAIL_CONFIRM = true
|
||||||
ENABLE_NOTIFY_MAIL = true
|
ENABLE_NOTIFY_MAIL = true
|
||||||
DISABLE_REGISTRATION = true
|
DISABLE_REGISTRATION = false
|
||||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||||
ENABLE_CAPTCHA = false
|
ENABLE_CAPTCHA = false
|
||||||
REQUIRE_SIGNIN_VIEW = false
|
REQUIRE_SIGNIN_VIEW = false
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@ from os.path import join
|
||||||
from bundlewrap.utils.dicts import merge_dict
|
from bundlewrap.utils.dicts import merge_dict
|
||||||
|
|
||||||
|
|
||||||
version = node.metadata.get('gitea/version')
|
version = version=node.metadata.get('gitea/version')
|
||||||
assert not version.startswith('v')
|
|
||||||
arch = node.metadata.get('system/architecture')
|
|
||||||
|
|
||||||
downloads['/usr/local/bin/gitea'] = {
|
downloads['/usr/local/bin/gitea'] = {
|
||||||
# https://forgejo.org/releases/
|
'url': f'https://dl.gitea.io/gitea/{version}/gitea-{version}-linux-amd64',
|
||||||
'url': f'https://codeberg.org/forgejo/forgejo/releases/download/v{version}/forgejo-{version}-linux-{arch}',
|
'sha256': node.metadata.get('gitea/sha256'),
|
||||||
'sha256_url': '{url}.sha256',
|
|
||||||
'triggers': {
|
'triggers': {
|
||||||
'svc_systemd:gitea:restart',
|
'svc_systemd:gitea:restart',
|
||||||
},
|
},
|
||||||
|
|
@ -48,8 +45,7 @@ files['/etc/gitea/app.ini'] = {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'owner': 'git',
|
'owner': 'git',
|
||||||
'mode': '0600',
|
'context': node.metadata['gitea'],
|
||||||
'context': node.metadata.get('gitea'),
|
|
||||||
'triggers': {
|
'triggers': {
|
||||||
'svc_systemd:gitea:restart',
|
'svc_systemd:gitea:restart',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
database_password = repo.vault.password_for(f'{node.name} postgresql gitea').value
|
database_password = repo.vault.password_for(f'{node.name} postgresql gitea')
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'apt': {
|
'apt': {
|
||||||
|
|
@ -11,20 +11,7 @@ defaults = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'gitea': {
|
'gitea': {
|
||||||
'conf': {
|
'conf': {},
|
||||||
'DEFAULT': {
|
|
||||||
'WORK_PATH': '/var/lib/gitea',
|
|
||||||
},
|
|
||||||
'database': {
|
|
||||||
'DB_TYPE': 'postgres',
|
|
||||||
'HOST': 'localhost:5432',
|
|
||||||
'NAME': 'gitea',
|
|
||||||
'USER': 'gitea',
|
|
||||||
'PASSWD': database_password,
|
|
||||||
'SSL_MODE': 'disable',
|
|
||||||
'LOG_SQL': 'false',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'postgresql': {
|
'postgresql': {
|
||||||
'roles': {
|
'roles': {
|
||||||
|
|
@ -43,7 +30,8 @@ defaults = {
|
||||||
'gitea.service': {
|
'gitea.service': {
|
||||||
'Unit': {
|
'Unit': {
|
||||||
'Description': 'gitea',
|
'Description': 'gitea',
|
||||||
'After': {'syslog.target', 'network.target'},
|
'After': 'syslog.target',
|
||||||
|
'After': 'network.target',
|
||||||
'Requires': 'postgresql.service',
|
'Requires': 'postgresql.service',
|
||||||
},
|
},
|
||||||
'Service': {
|
'Service': {
|
||||||
|
|
@ -96,6 +84,15 @@ def conf(metadata):
|
||||||
'INTERNAL_TOKEN': repo.vault.password_for(f'{node.name} gitea internal_token'),
|
'INTERNAL_TOKEN': repo.vault.password_for(f'{node.name} gitea internal_token'),
|
||||||
'SECRET_KEY': repo.vault.password_for(f'{node.name} gitea security_secret_key'),
|
'SECRET_KEY': repo.vault.password_for(f'{node.name} gitea security_secret_key'),
|
||||||
},
|
},
|
||||||
|
'database': {
|
||||||
|
'DB_TYPE': 'postgres',
|
||||||
|
'HOST': 'localhost:5432',
|
||||||
|
'NAME': 'gitea',
|
||||||
|
'USER': 'gitea',
|
||||||
|
'PASSWD': database_password,
|
||||||
|
'SSL_MODE': 'disable',
|
||||||
|
'LOG_SQL': 'false',
|
||||||
|
},
|
||||||
'service': {
|
'service': {
|
||||||
'NO_REPLY_ADDRESS': f'noreply.{domain}',
|
'NO_REPLY_ADDRESS': f'noreply.{domain}',
|
||||||
},
|
},
|
||||||
|
|
@ -118,7 +115,7 @@ def nginx(metadata):
|
||||||
'content': 'nginx/proxy_pass.conf',
|
'content': 'nginx/proxy_pass.conf',
|
||||||
'context': {
|
'context': {
|
||||||
'target': 'http://127.0.0.1:3500',
|
'target': 'http://127.0.0.1:3500',
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,16 @@ admin_password = node.metadata.get('grafana/config/security/admin_password')
|
||||||
port = node.metadata.get('grafana/config/server/http_port')
|
port = node.metadata.get('grafana/config/server/http_port')
|
||||||
actions['reset_grafana_admin_password'] = {
|
actions['reset_grafana_admin_password'] = {
|
||||||
'command': f"grafana-cli admin reset-admin-password {quote(admin_password)}",
|
'command': f"grafana-cli admin reset-admin-password {quote(admin_password)}",
|
||||||
'unless': f"sleep 5 && curl http://admin:{quote(admin_password)}@localhost:{port}/api/org --fail",
|
'unless': f"curl http://admin:{quote(admin_password)}@localhost:{port}/api/org",
|
||||||
'needs': [
|
'needs': [
|
||||||
'svc_systemd:grafana-server',
|
'svc_systemd:grafana-server',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
directories = {
|
directories = {
|
||||||
'/etc/grafana': {},
|
'/etc/grafana': {
|
||||||
|
},
|
||||||
'/etc/grafana/provisioning': {
|
'/etc/grafana/provisioning': {
|
||||||
'owner': 'grafana',
|
|
||||||
'group': 'grafana',
|
|
||||||
},
|
},
|
||||||
'/etc/grafana/provisioning/datasources': {
|
'/etc/grafana/provisioning/datasources': {
|
||||||
'purge': True,
|
'purge': True,
|
||||||
|
|
@ -36,13 +35,8 @@ directories = {
|
||||||
'/etc/grafana/provisioning/dashboards': {
|
'/etc/grafana/provisioning/dashboards': {
|
||||||
'purge': True,
|
'purge': True,
|
||||||
},
|
},
|
||||||
'/var/lib/grafana': {
|
'/var/lib/grafana': {},
|
||||||
'owner': 'grafana',
|
|
||||||
'group': 'grafana',
|
|
||||||
},
|
|
||||||
'/var/lib/grafana/dashboards': {
|
'/var/lib/grafana/dashboards': {
|
||||||
'owner': 'grafana',
|
|
||||||
'group': 'grafana',
|
|
||||||
'purge': True,
|
'purge': True,
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:grafana-server:restart',
|
'svc_systemd:grafana-server:restart',
|
||||||
|
|
@ -53,8 +47,6 @@ directories = {
|
||||||
files = {
|
files = {
|
||||||
'/etc/grafana/grafana.ini': {
|
'/etc/grafana/grafana.ini': {
|
||||||
'content': repo.libs.ini.dumps(node.metadata.get('grafana/config')),
|
'content': repo.libs.ini.dumps(node.metadata.get('grafana/config')),
|
||||||
'owner': 'grafana',
|
|
||||||
'group': 'grafana',
|
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:grafana-server:restart',
|
'svc_systemd:grafana-server:restart',
|
||||||
],
|
],
|
||||||
|
|
@ -64,8 +56,6 @@ files = {
|
||||||
'apiVersion': 1,
|
'apiVersion': 1,
|
||||||
'datasources': list(node.metadata.get('grafana/datasources').values()),
|
'datasources': list(node.metadata.get('grafana/datasources').values()),
|
||||||
}),
|
}),
|
||||||
'owner': 'grafana',
|
|
||||||
'group': 'grafana',
|
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:grafana-server:restart',
|
'svc_systemd:grafana-server:restart',
|
||||||
],
|
],
|
||||||
|
|
@ -82,8 +72,6 @@ files = {
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
'owner': 'grafana',
|
|
||||||
'group': 'grafana',
|
|
||||||
'triggers': [
|
'triggers': [
|
||||||
'svc_systemd:grafana-server:restart',
|
'svc_systemd:grafana-server:restart',
|
||||||
],
|
],
|
||||||
|
|
@ -127,7 +115,7 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
|
||||||
panel['gridPos']['y'] = (row_id - 1) * panel['gridPos']['h']
|
panel['gridPos']['y'] = (row_id - 1) * panel['gridPos']['h']
|
||||||
|
|
||||||
if 'display_name' in panel_config:
|
if 'display_name' in panel_config:
|
||||||
panel['fieldConfig']['defaults']['displayName'] = panel_config['display_name']
|
panel['fieldConfig']['defaults']['displayName'] = '${'+panel_config['display_name']+'}'
|
||||||
|
|
||||||
if panel_config.get('stacked'):
|
if panel_config.get('stacked'):
|
||||||
panel['fieldConfig']['defaults']['custom']['stacking']['mode'] = 'normal'
|
panel['fieldConfig']['defaults']['custom']['stacking']['mode'] = 'normal'
|
||||||
|
|
@ -158,14 +146,13 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
|
||||||
host=monitored_node.name,
|
host=monitored_node.name,
|
||||||
negative=query_config.get('negative', False),
|
negative=query_config.get('negative', False),
|
||||||
boolean_to_int=query_config.get('boolean_to_int', False),
|
boolean_to_int=query_config.get('boolean_to_int', False),
|
||||||
over=query_config.get('over', None),
|
minimum=query_config.get('minimum', None),
|
||||||
filters={
|
filters={
|
||||||
'host': monitored_node.name,
|
'host': monitored_node.name,
|
||||||
**query_config['filters'],
|
**query_config['filters'],
|
||||||
},
|
},
|
||||||
exists=query_config.get('exists', []),
|
exists=query_config.get('exists', []),
|
||||||
function=query_config.get('function', None),
|
function=query_config.get('function', None),
|
||||||
multiply=query_config.get('multiply', None),
|
|
||||||
).strip()
|
).strip()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -179,3 +166,4 @@ for dashboard_id, monitored_node in enumerate(monitored_nodes, start=1):
|
||||||
'svc_systemd:grafana-server:restart',
|
'svc_systemd:grafana-server:restart',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,16 @@ defaults = {
|
||||||
'grafana': {},
|
'grafana': {},
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'grafana': {
|
'deb https://packages.grafana.com/oss/deb stable main',
|
||||||
'urls': {
|
|
||||||
'https://packages.grafana.com/oss/deb',
|
|
||||||
},
|
|
||||||
'suites': {
|
|
||||||
'stable',
|
|
||||||
},
|
|
||||||
'components': {
|
|
||||||
'main',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
'grafana': {
|
'grafana': {
|
||||||
'config': {
|
'config': {
|
||||||
'server': {
|
'server': {
|
||||||
'http_port': 8300,
|
'http_port': 8300,
|
||||||
'http_addr': '127.0.0.1',
|
|
||||||
'enable_gzip': True,
|
|
||||||
},
|
},
|
||||||
'database': {
|
'database': {
|
||||||
'type': 'postgres',
|
'url': f'postgres://grafana:{postgres_password}@localhost:5432/grafana',
|
||||||
'host': '127.0.0.1:5432',
|
|
||||||
'name': 'grafana',
|
|
||||||
'user': 'grafana',
|
|
||||||
'password': postgres_password,
|
|
||||||
},
|
},
|
||||||
'remote_cache': {
|
'remote_cache': {
|
||||||
'type': 'redis',
|
'type': 'redis',
|
||||||
|
|
@ -133,19 +116,17 @@ def datasource_key_to_name(metadata):
|
||||||
def dns(metadata):
|
def dns(metadata):
|
||||||
return {
|
return {
|
||||||
'dns': {
|
'dns': {
|
||||||
metadata.get('grafana/hostname'): repo.libs.ip.get_a_records(metadata),
|
metadata.get('grafana/hostname'): repo.libs.dns.get_a_records(metadata),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
'nginx/has_websockets',
|
|
||||||
'nginx/vhosts',
|
'nginx/vhosts',
|
||||||
)
|
)
|
||||||
def nginx(metadata):
|
def nginx(metadata):
|
||||||
return {
|
return {
|
||||||
'nginx': {
|
'nginx': {
|
||||||
'has_websockets': True,
|
|
||||||
'vhosts': {
|
'vhosts': {
|
||||||
metadata.get('grafana/hostname'): {
|
metadata.get('grafana/hostname'): {
|
||||||
'content': 'grafana/vhost.conf',
|
'content': 'grafana/vhost.conf',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ files = {
|
||||||
'/usr/local/share/telegraf/cpu_frequency': {
|
'/usr/local/share/telegraf/cpu_frequency': {
|
||||||
'mode': '0755',
|
'mode': '0755',
|
||||||
'triggers': {
|
'triggers': {
|
||||||
'svc_systemd:telegraf.service:restart',
|
'svc_systemd:telegraf:restart',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,25 @@ defaults = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'telegraf': {
|
'telegraf': {
|
||||||
'inputs': {
|
'config': {
|
||||||
'sensors': {
|
'inputs': {
|
||||||
'default': {
|
'sensors': {repo.libs.hashable.hashable({
|
||||||
'timeout': '2s',
|
'timeout': '2s',
|
||||||
|
})},
|
||||||
|
'exec': {
|
||||||
|
repo.libs.hashable.hashable({
|
||||||
|
'commands': ["sudo /usr/local/share/telegraf/cpu_frequency"],
|
||||||
|
'name_override': "cpu_frequency",
|
||||||
|
'data_format': "influx",
|
||||||
|
}),
|
||||||
|
# repo.libs.hashable.hashable({
|
||||||
|
# 'commands': ["/bin/bash -c 'expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000'"],
|
||||||
|
# 'name_override': "cpu_temperature",
|
||||||
|
# 'data_format': "value",
|
||||||
|
# 'data_type': "integer",
|
||||||
|
# }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'exec': {
|
|
||||||
'cpu_frequency': {
|
|
||||||
'commands': ["sudo /usr/local/share/telegraf/cpu_frequency"],
|
|
||||||
'name_override': "cpu_frequency",
|
|
||||||
'data_format': "influx",
|
|
||||||
},
|
|
||||||
# repo.libs.hashable.hashable({
|
|
||||||
# 'commands': ["/bin/bash -c 'expr $(cat /sys/class/thermal/thermal_zone0/temp) / 1000'"],
|
|
||||||
# 'name_override': "cpu_temperature",
|
|
||||||
# 'data_format': "value",
|
|
||||||
# 'data_type': "integer",
|
|
||||||
# }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
bundles/homeassistant/items.py
Normal file
20
bundles/homeassistant/items.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
users = {
|
||||||
|
'homeassistant': {
|
||||||
|
'home': '/var/lib/homeassistant',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
directories = {
|
||||||
|
'/var/lib/homeassistant': {
|
||||||
|
'owner': 'homeassistant',
|
||||||
|
},
|
||||||
|
'/var/lib/homeassistant/config': {
|
||||||
|
'owner': 'homeassistant',
|
||||||
|
},
|
||||||
|
'/var/lib/homeassistant/venv': {
|
||||||
|
'owner': 'homeassistant',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# https://wiki.instar.com/de/Software/Linux/Home_Assistant/
|
||||||
20
bundles/homeassistant/metadata.py
Normal file
20
bundles/homeassistant/metadata.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
defaults = {
|
||||||
|
'apt': {
|
||||||
|
'packages': {
|
||||||
|
'python3': {},
|
||||||
|
'python3-dev': {},
|
||||||
|
'python3-pip': {},
|
||||||
|
'python3-venv': {},
|
||||||
|
'libffi-dev': {},
|
||||||
|
'libssl-dev': {},
|
||||||
|
'libjpeg-dev': {},
|
||||||
|
'zlib1g-dev': {},
|
||||||
|
'autoconf': {},
|
||||||
|
'build-essential': {},
|
||||||
|
'libopenjp2-7': {},
|
||||||
|
'libtiff5': {},
|
||||||
|
'libturbojpeg0-dev': {},
|
||||||
|
'tzdata': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,6 @@ def hostname_file(metadata):
|
||||||
def dns(metadata):
|
def dns(metadata):
|
||||||
return {
|
return {
|
||||||
'dns': {
|
'dns': {
|
||||||
metadata.get('hostname'): repo.libs.ip.get_a_records(metadata),
|
metadata.get('hostname'): repo.libs.dns.get_a_records(metadata, external=False),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ header_margin=1
|
||||||
detailed_cpu_time=0
|
detailed_cpu_time=0
|
||||||
cpu_count_from_one=1
|
cpu_count_from_one=1
|
||||||
show_cpu_usage=0
|
show_cpu_usage=0
|
||||||
show_cpu_frequency=1
|
show_cpu_frequency=0
|
||||||
show_cpu_temperature=0
|
show_cpu_temperature=0
|
||||||
degree_fahrenheit=0
|
degree_fahrenheit=0
|
||||||
update_process_names=0
|
update_process_names=0
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ apply Notification "mail-icingaadmin" to Host {
|
||||||
user_groups = host.vars.notification.mail.groups
|
user_groups = host.vars.notification.mail.groups
|
||||||
users = host.vars.notification.mail.users
|
users = host.vars.notification.mail.users
|
||||||
|
|
||||||
|
//interval = 2h
|
||||||
|
|
||||||
|
//vars.notification_logtosyslog = true
|
||||||
|
|
||||||
|
|
||||||
assign where host.vars.notification.mail
|
assign where host.vars.notification.mail
|
||||||
}
|
}
|
||||||
|
|
@ -25,9 +25,9 @@ apply Notification "mail-icingaadmin" to Service {
|
||||||
user_groups = host.vars.notification.mail.groups
|
user_groups = host.vars.notification.mail.groups
|
||||||
users = host.vars.notification.mail.users
|
users = host.vars.notification.mail.users
|
||||||
|
|
||||||
|
//interval = 2h
|
||||||
|
|
||||||
|
//vars.notification_logtosyslog = true
|
||||||
|
|
||||||
|
|
||||||
assign where host.vars.notification.mail
|
assign where host.vars.notification.mail
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/**
|
|
||||||
* The JournaldLogger type writes log information to the systemd journal.
|
|
||||||
*/
|
|
||||||
|
|
||||||
object JournaldLogger "journald" {
|
|
||||||
severity = "warning"
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/**
|
|
||||||
* This file is requires for inital apt install.
|
|
||||||
* The JournaldLogger type writes log information to the systemd journal.
|
|
||||||
*/
|
|
||||||
3
bundles/icinga2/files/features/syslog.conf
Normal file
3
bundles/icinga2/files/features/syslog.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
object SyslogLogger "syslog" {
|
||||||
|
severity = "warning"
|
||||||
|
}
|
||||||
|
|
@ -10,24 +10,6 @@ directories = {
|
||||||
'svc_systemd:icinga2.service:restart',
|
'svc_systemd:icinga2.service:restart',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'/etc/icinga2/pki': { # required for apt install
|
|
||||||
'purge': True,
|
|
||||||
'owner': 'nagios',
|
|
||||||
'group': 'nagios',
|
|
||||||
'mode': '0750',
|
|
||||||
'triggers': [
|
|
||||||
'svc_systemd:icinga2.service:restart',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'/etc/icinga2/zones.d': { # required for apt install
|
|
||||||
'purge': True,
|
|
||||||
'owner': 'nagios',
|
|
||||||
'group': 'nagios',
|
|
||||||
'mode': '0750',
|
|
||||||
'triggers': [
|
|
||||||
'svc_systemd:icinga2.service:restart',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'/etc/icinga2/conf.d': {
|
'/etc/icinga2/conf.d': {
|
||||||
'purge': True,
|
'purge': True,
|
||||||
'owner': 'nagios',
|
'owner': 'nagios',
|
||||||
|
|
@ -213,8 +195,7 @@ files = {
|
||||||
# FEATURES
|
# FEATURES
|
||||||
|
|
||||||
for feature, context in {
|
for feature, context in {
|
||||||
'mainlog': {},
|
'syslog': {},
|
||||||
# 'journald': {}, FIXME
|
|
||||||
'notification': {},
|
'notification': {},
|
||||||
'checker': {},
|
'checker': {},
|
||||||
'api': {},
|
'api': {},
|
||||||
|
|
@ -269,7 +250,7 @@ svc_systemd = {
|
||||||
'icinga2.service': {
|
'icinga2.service': {
|
||||||
'needs': [
|
'needs': [
|
||||||
'pkg_apt:icinga2-ido-pgsql',
|
'pkg_apt:icinga2-ido-pgsql',
|
||||||
'svc_systemd:postgresql.service',
|
'svc_systemd:postgresql',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,7 @@ defaults = {
|
||||||
'monitoring-plugins': {},
|
'monitoring-plugins': {},
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'icinga': {
|
'deb https://packages.icinga.com/debian icinga-{release} main',
|
||||||
'types': {
|
|
||||||
'deb',
|
|
||||||
'deb-src',
|
|
||||||
},
|
|
||||||
'urls': {
|
|
||||||
'https://packages.icinga.com/debian',
|
|
||||||
},
|
|
||||||
'suites': {
|
|
||||||
'icinga-{codename}',
|
|
||||||
},
|
|
||||||
'components': {
|
|
||||||
'main',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'icinga2': {
|
'icinga2': {
|
||||||
|
|
@ -63,6 +49,7 @@ defaults = {
|
||||||
'mountpoint': '/var/lib/icinga2',
|
'mountpoint': '/var/lib/icinga2',
|
||||||
'needed_by': {
|
'needed_by': {
|
||||||
'pkg_apt:icinga2',
|
'pkg_apt:icinga2',
|
||||||
|
'pkg_apt:icingaweb2',
|
||||||
'pkg_apt:icinga2-ido-pgsql',
|
'pkg_apt:icinga2-ido-pgsql',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,3 @@
|
||||||
- open /icingaweb2/setup in browser
|
- open /icingaweb2/setup in browser
|
||||||
- fill in values from metadata
|
- fill in values from metadata
|
||||||
- apply
|
- apply
|
||||||
- make sure tls cert exists and is owned by nagios
|
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,18 @@ directories = {
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'mode': '2770',
|
'mode': '2770',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'/etc/icingaweb2/enabledModules': {
|
'/etc/icingaweb2/enabledModules': {
|
||||||
# 'purge': True,
|
# 'purge': True,
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'mode': '2770',
|
'mode': '2770',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'/etc/icingaweb2/modules': {
|
'/etc/icingaweb2/modules': {
|
||||||
# 'purge': True,
|
# 'purge': True,
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'mode': '2770',
|
'mode': '2770',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,9 +25,6 @@ files = {
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'mode': '0660',
|
'mode': '0660',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,9 +33,6 @@ symlinks = {
|
||||||
'target': '/usr/share/icingaweb2/modules/monitoring',
|
'target': '/usr/share/icingaweb2/modules/monitoring',
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,9 +48,6 @@ for name in [
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'mode': '0660',
|
'mode': '0660',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in [
|
for name in [
|
||||||
|
|
@ -78,7 +60,4 @@ for name in [
|
||||||
'owner': 'www-data',
|
'owner': 'www-data',
|
||||||
'group': 'icingaweb2',
|
'group': 'icingaweb2',
|
||||||
'mode': '0660',
|
'mode': '0660',
|
||||||
'needs': [
|
|
||||||
'pkg_apt:icingaweb2',
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from hashlib import sha3_256
|
||||||
defaults = {
|
defaults = {
|
||||||
'apt': {
|
'apt': {
|
||||||
'packages': {
|
'packages': {
|
||||||
|
'icingaweb2': {},
|
||||||
'php-ldap': {},
|
'php-ldap': {},
|
||||||
'php-json': {},
|
'php-json': {},
|
||||||
'php-intl': {},
|
'php-intl': {},
|
||||||
|
|
@ -10,25 +11,11 @@ defaults = {
|
||||||
'php-gd': {},
|
'php-gd': {},
|
||||||
'php-imagick': {},
|
'php-imagick': {},
|
||||||
'php-pgsql': {},
|
'php-pgsql': {},
|
||||||
'icingaweb2': {},
|
'icingaweb2-module-monitoring': {},
|
||||||
#'icingaweb2-module-monitoring': {}, # ?
|
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'icinga': {
|
'deb https://packages.icinga.com/debian icinga-{release} main',
|
||||||
'types': {
|
'deb https://packages.icinga.com/debian icinga-{release}-snapshots main',
|
||||||
'deb',
|
|
||||||
'deb-src',
|
|
||||||
},
|
|
||||||
'urls': {
|
|
||||||
'https://packages.icinga.com/debian',
|
|
||||||
},
|
|
||||||
'suites': {
|
|
||||||
'icinga-{codename}',
|
|
||||||
},
|
|
||||||
'components': {
|
|
||||||
'main',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'icingaweb2': {
|
'icingaweb2': {
|
||||||
|
|
@ -177,9 +164,7 @@ def nginx(metadata):
|
||||||
metadata.get('icingaweb2/hostname'): {
|
metadata.get('icingaweb2/hostname'): {
|
||||||
'content': 'icingaweb2/vhost.conf',
|
'content': 'icingaweb2/vhost.conf',
|
||||||
'context': {
|
'context': {
|
||||||
'php_version': metadata.get('php/version'),
|
|
||||||
},
|
},
|
||||||
'check_path': '/icingaweb2/index.php',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# svc_systemd = {
|
|
||||||
# 'ifupdown.service': {},
|
|
||||||
# }
|
|
||||||
|
|
@ -4,7 +4,6 @@ from shlex import quote
|
||||||
directories['/var/lib/influxdb'] = {
|
directories['/var/lib/influxdb'] = {
|
||||||
'owner': 'influxdb',
|
'owner': 'influxdb',
|
||||||
'group': 'influxdb',
|
'group': 'influxdb',
|
||||||
'mode': '0750',
|
|
||||||
'needs': [
|
'needs': [
|
||||||
'zfs_dataset:tank/influxdb',
|
'zfs_dataset:tank/influxdb',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,7 @@ defaults = {
|
||||||
'influxdb2-cli': {},
|
'influxdb2-cli': {},
|
||||||
},
|
},
|
||||||
'sources': {
|
'sources': {
|
||||||
'influxdata': {
|
'deb https://repos.influxdata.com/debian {release} stable',
|
||||||
'urls': {
|
|
||||||
'https://repos.influxdata.com/debian',
|
|
||||||
},
|
|
||||||
'suites': {
|
|
||||||
'stable',
|
|
||||||
},
|
|
||||||
'components': {
|
|
||||||
'main',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'nftables': {
|
'nftables': {
|
||||||
|
|
@ -39,17 +29,6 @@ defaults = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if node.has_bundle('zfs'):
|
|
||||||
defaults['zfs'] = {
|
|
||||||
'datasets': {
|
|
||||||
'tank/influxdb': {
|
|
||||||
'mountpoint': '/var/lib/influxdb',
|
|
||||||
'recordsize': '8192',
|
|
||||||
'atime': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
'influxdb/password',
|
'influxdb/password',
|
||||||
'influxdb/admin_token',
|
'influxdb/admin_token',
|
||||||
|
|
@ -63,13 +42,33 @@ def admin_password(metadata):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@metadata_reactor.provides(
|
||||||
|
'zfs/datasets',
|
||||||
|
)
|
||||||
|
def zfs(metadata):
|
||||||
|
if not node.has_bundle('zfs'):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'zfs': {
|
||||||
|
'datasets': {
|
||||||
|
'tank/influxdb': {
|
||||||
|
'mountpoint': '/var/lib/influxdb',
|
||||||
|
'recordsize': '8192',
|
||||||
|
'atime': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
'dns',
|
'dns',
|
||||||
)
|
)
|
||||||
def dns(metadata):
|
def dns(metadata):
|
||||||
return {
|
return {
|
||||||
'dns': {
|
'dns': {
|
||||||
metadata.get('influxdb/hostname'): repo.libs.ip.get_a_records(metadata),
|
metadata.get('influxdb/hostname'): repo.libs.dns.get_a_records(metadata),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ def apt(metadata):
|
||||||
return {
|
return {
|
||||||
'apt': {
|
'apt': {
|
||||||
'packages': {
|
'packages': {
|
||||||
f'openjdk-{metadata.get("java/version")}-jre-headless': {},
|
f'openjdk-{metadata.get("java/version")}-jre': {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
from json import dumps
|
|
||||||
from bundlewrap.metadata import MetadataJSONEncoder
|
|
||||||
|
|
||||||
files = {
|
|
||||||
'/etc/kea/kea-dhcp4.conf': {
|
|
||||||
'content': dumps(node.metadata.get('kea'), indent=4, sort_keys=True, cls=MetadataJSONEncoder),
|
|
||||||
'triggers': [
|
|
||||||
'svc_systemd:kea-dhcp4-server:restart',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc_systemd = {
|
|
||||||
'kea-dhcp4-server': {
|
|
||||||
'needs': [
|
|
||||||
'pkg_apt:kea-dhcp4-server',
|
|
||||||
'file:/etc/kea/kea-dhcp4.conf',
|
|
||||||
'svc_systemd:systemd-networkd.service:restart',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
from ipaddress import ip_interface, ip_network
|
|
||||||
|
|
||||||
hashable = repo.libs.hashable.hashable
|
|
||||||
|
|
||||||
|
|
||||||
defaults = {
|
|
||||||
'apt': {
|
|
||||||
'packages': {
|
|
||||||
'kea-dhcp4-server': {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'kea': {
|
|
||||||
'Dhcp4': {
|
|
||||||
'interfaces-config': {
|
|
||||||
'interfaces': set(),
|
|
||||||
},
|
|
||||||
'lease-database': {
|
|
||||||
'type': 'memfile',
|
|
||||||
'lfc-interval': 3600
|
|
||||||
},
|
|
||||||
'subnet4': set(),
|
|
||||||
'loggers': set([
|
|
||||||
hashable({
|
|
||||||
'name': 'kea-dhcp4',
|
|
||||||
'output_options': [
|
|
||||||
{
|
|
||||||
'output': 'syslog',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'severity': 'INFO',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
|
||||||
'kea/Dhcp4/interfaces-config/interfaces',
|
|
||||||
'kea/Dhcp4/subnet4',
|
|
||||||
)
|
|
||||||
def subnets(metadata):
|
|
||||||
subnet4 = set()
|
|
||||||
interfaces = set()
|
|
||||||
reservations = set(
|
|
||||||
hashable({
|
|
||||||
'hw-address': network_conf['mac'],
|
|
||||||
'ip-address': str(ip_interface(network_conf['ipv4']).ip),
|
|
||||||
})
|
|
||||||
for other_node in repo.nodes
|
|
||||||
for network_conf in other_node.metadata.get('network', {}).values()
|
|
||||||
if 'mac' in network_conf
|
|
||||||
)
|
|
||||||
|
|
||||||
for id, (network_name, network_conf) in enumerate(sorted(metadata.get('network').items())):
|
|
||||||
dhcp_server_config = network_conf.get('dhcp_server_config', None)
|
|
||||||
|
|
||||||
if dhcp_server_config:
|
|
||||||
_network = ip_network(dhcp_server_config['subnet'])
|
|
||||||
|
|
||||||
subnet4.add(hashable({
|
|
||||||
'id': id + 1,
|
|
||||||
'subnet': dhcp_server_config['subnet'],
|
|
||||||
'pools': [
|
|
||||||
{
|
|
||||||
'pool': f'{dhcp_server_config['pool_from']} - {dhcp_server_config['pool_to']}',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'option-data': [
|
|
||||||
{
|
|
||||||
'name': 'routers',
|
|
||||||
'data': dhcp_server_config['router'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'domain-name-servers',
|
|
||||||
'data': '10.0.0.1',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'reservations': set(
|
|
||||||
reservation
|
|
||||||
for reservation in reservations
|
|
||||||
if ip_interface(reservation['ip-address']).ip in _network
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
interfaces.add(network_conf.get('interface', network_name))
|
|
||||||
|
|
||||||
return {
|
|
||||||
'kea': {
|
|
||||||
'Dhcp4': {
|
|
||||||
'interfaces-config': {
|
|
||||||
'interfaces': interfaces,
|
|
||||||
},
|
|
||||||
'subnet4': subnet4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,58 @@
|
||||||
https://github.com/SirPlease/L4D2-Competitive-Rework/blob/master/Dedicated%20Server%20Install%20Guide/README.md
|
https://developer.valvesoftware.com/wiki/List_of_L4D2_Cvars
|
||||||
|
|
||||||
```python
|
Dead Center c1m1_hotel
|
||||||
'tick60_maps': {
|
Dead Center c1m2_streets
|
||||||
'port': 27030,
|
Dead Center c1m3_mall
|
||||||
# add command line arguments
|
Dead Center c1m4_atrium
|
||||||
'arguments': ['-tickrate 60'],
|
Dark Carnival c2m1_highway
|
||||||
# stack overlays, first is uppermost
|
Dark Carnival c2m2_fairgrounds
|
||||||
'overlays': ['tickrate', 'standard'],
|
Dark Carnival c2m3_coaster
|
||||||
# server.cfg contents
|
Dark Carnival c2m4_barns
|
||||||
'config': [
|
Dark Carnival c2m5_concert
|
||||||
# configs from overlays are accessible via server_${overlay}.cfg
|
Swamp Fever c3m1_plankcountry
|
||||||
'exec server_tickrate.cfg',
|
Swamp Fever c3m2_swamp
|
||||||
# add more options
|
Swamp Fever c3m3_shantytown
|
||||||
'sv_minupdaterate 101',
|
Swamp Fever c3m4_plantation
|
||||||
'sv_maxupdaterate 101',
|
Hard Rain c4m1_milltown_a
|
||||||
'sv_mincmdrate 101',
|
Hard Rain c4m2_sugarmill_a
|
||||||
'sv_maxcmdrate 101',
|
Hard Rain c4m3_sugarmill_b
|
||||||
'sv_consistency 0',
|
Hard Rain c4m4_milltown_b
|
||||||
],
|
Hard Rain c4m5_milltown_escape
|
||||||
},
|
The Parish c5m1_waterfront_sndscape
|
||||||
```
|
The Parish c5m1_waterfront
|
||||||
|
The Parish c5m2_park
|
||||||
|
The Parish c5m3_cemetery
|
||||||
|
The Parish c5m4_quarter
|
||||||
|
The Parish c5m5_bridge
|
||||||
|
The Passing c6m1_riverbank
|
||||||
|
The Passing c6m2_bedlam
|
||||||
|
The Passing c6m3_port
|
||||||
|
The Sacrifice c7m1_docks
|
||||||
|
The Sacrifice c7m2_barge
|
||||||
|
The Sacrifice c7m3_port
|
||||||
|
No Mercy c8m1_apartment
|
||||||
|
No Mercy c8m2_subway
|
||||||
|
No Mercy c8m3_sewers
|
||||||
|
No Mercy c8m4_interior
|
||||||
|
No Mercy c8m5_rooftop
|
||||||
|
Crash Course c9m1_alleys
|
||||||
|
Crash Course c9m2_lots
|
||||||
|
Death Toll c10m1_caves
|
||||||
|
Death Toll c10m2_drainage
|
||||||
|
Death Toll c10m3_ranchhouse
|
||||||
|
Death Toll c10m4_mainstreet
|
||||||
|
Death Toll c10m5_houseboat
|
||||||
|
Dead Air c11m1_greenhouse
|
||||||
|
Dead Air c11m2_offices
|
||||||
|
Dead Air c11m3_garage
|
||||||
|
Dead Air c11m4_terminal
|
||||||
|
Dead Air c11m5_runway
|
||||||
|
Blood Harvest c12m1_hilltop
|
||||||
|
Blood Harvest c12m2_traintunnel
|
||||||
|
Blood Harvest c12m3_bridge
|
||||||
|
Blood Harvest c12m4_barn
|
||||||
|
Blood Harvest c12m5_cornfield
|
||||||
|
Cold Stream c13m1_alpinecreek
|
||||||
|
Cold Stream c13m2_southpinestream
|
||||||
|
Cold Stream c13m3_memorialbridge
|
||||||
|
Cold Stream c13m4_cutthroatcreek
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -xeuo pipefail
|
|
||||||
|
|
||||||
function steam() {
|
|
||||||
# for systemd, so it can terminate the process (for other things sudo would have been enough)
|
|
||||||
setpriv --reuid=steam --regid=steam --init-groups "$@" <&0
|
|
||||||
export HOME=/opt/l4d2/steam
|
|
||||||
}
|
|
||||||
|
|
||||||
function workshop() {
|
|
||||||
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/addons"
|
|
||||||
steam /opt/l4d2/scripts/steam-workshop-download --out "/opt/l4d2/overlays/${overlay}/left4dead2/addons" "$@"
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -xeuo pipefail
|
|
||||||
source /opt/l4d2/scripts/helpers
|
|
||||||
overlay=$(basename "$0")
|
|
||||||
|
|
||||||
# https://github.com/SirPlease/L4D2-Competitive-Rework
|
|
||||||
|
|
||||||
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2
|
|
||||||
test -d /opt/l4d2/overlays/$overlay/left4dead2/cfg/cfgogl || \
|
|
||||||
curl -L https://github.com/SirPlease/L4D2-Competitive-Rework/archive/refs/heads/master.tar.gz | steam tar -xz --strip-components=1 -C /opt/l4d2/overlays/$overlay/left4dead2
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -xeuo pipefail
|
|
||||||
source /opt/l4d2/scripts/helpers
|
|
||||||
overlay=$(basename "$0")
|
|
||||||
|
|
||||||
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2/addons
|
|
||||||
cd /opt/l4d2/overlays/$overlay/left4dead2/addons
|
|
||||||
|
|
||||||
# https://l4d2center.com/maps/servers/l4d2center_maps_sync.sh.txt ->
|
|
||||||
|
|
||||||
# Exit immediately if a command exits with a non-zero status.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Function to print error messages
|
|
||||||
error_exit() {
|
|
||||||
echo "Error: $1" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if the current directory ends with /left4dead2/addons
|
|
||||||
current_dir=$(pwd)
|
|
||||||
expected_dir="/left4dead2/addons"
|
|
||||||
|
|
||||||
if [[ ! "$current_dir" == *"$expected_dir" ]]; then
|
|
||||||
error_exit "Script must be run from your L4D2 \"addons\" folder. Current directory: $current_dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for required commands
|
|
||||||
for cmd in curl md5sum 7z; do
|
|
||||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
||||||
error_exit "Required command '$cmd' is not installed. Please install it and retry."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# URL of the CSV file
|
|
||||||
CSV_URL="https://l4d2center.com/maps/servers/index.csv"
|
|
||||||
|
|
||||||
# Temporary file to store CSV
|
|
||||||
TEMP_CSV=$(mktemp)
|
|
||||||
|
|
||||||
# Ensure temporary file is removed on exit
|
|
||||||
trap 'rm -f "$TEMP_CSV"' EXIT
|
|
||||||
|
|
||||||
echo "Downloading CSV from $CSV_URL..."
|
|
||||||
curl -sSL -o "$TEMP_CSV" "$CSV_URL" || error_exit "Failed to download CSV."
|
|
||||||
|
|
||||||
declare -A map_md5
|
|
||||||
declare -A map_links
|
|
||||||
|
|
||||||
# Read CSV and populate associative arrays
|
|
||||||
{
|
|
||||||
# Skip the first line (header)
|
|
||||||
IFS= read -r header
|
|
||||||
|
|
||||||
while IFS=';' read -r Name Size MD5 DownloadLink || [[ $Name ]]; do
|
|
||||||
# Trim whitespace
|
|
||||||
Name=$(echo "$Name" | xargs)
|
|
||||||
MD5=$(echo "$MD5" | xargs)
|
|
||||||
DownloadLink=$(echo "$DownloadLink" | xargs)
|
|
||||||
|
|
||||||
# Populate associative arrays
|
|
||||||
map_md5["$Name"]="$MD5"
|
|
||||||
map_links["$Name"]="$DownloadLink"
|
|
||||||
done
|
|
||||||
} < "$TEMP_CSV"
|
|
||||||
|
|
||||||
# Get list of expected VPK files
|
|
||||||
expected_vpk=("${!map_md5[@]}")
|
|
||||||
|
|
||||||
# Remove VPK files not in expected list or with mismatched MD5
|
|
||||||
echo "Cleaning up existing VPK files..."
|
|
||||||
for file in *.vpk; do
|
|
||||||
# Check if it's a regular file
|
|
||||||
if [[ -f "$file" ]]; then
|
|
||||||
if [[ -z "${map_md5["$file"]}" ]]; then
|
|
||||||
echo "Removing unexpected file: $file"
|
|
||||||
rm -f "$file"
|
|
||||||
else
|
|
||||||
# Calculate MD5
|
|
||||||
echo "Calculating MD5 for existing file: $file..."
|
|
||||||
current_md5=$(md5sum "$file" | awk '{print $1}')
|
|
||||||
expected_md5="${map_md5["$file"]}"
|
|
||||||
|
|
||||||
if [[ "$current_md5" != "$expected_md5" ]]; then
|
|
||||||
echo "MD5 mismatch for $file. Removing."
|
|
||||||
rm -f "$file"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Download and extract missing or updated VPK files
|
|
||||||
echo "Processing required VPK files..."
|
|
||||||
for vpk in "${expected_vpk[@]}"; do
|
|
||||||
if [[ ! -f "$vpk" ]]; then
|
|
||||||
echo "Downloading and extracting $vpk..."
|
|
||||||
download_url="${map_links["$vpk"]}"
|
|
||||||
|
|
||||||
if [[ -z "$download_url" ]]; then
|
|
||||||
echo "No download link found for $vpk. Skipping."
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
encoded_url=$(echo "$download_url" | sed 's/ /%20/g')
|
|
||||||
|
|
||||||
# Download the .7z file to a temporary location
|
|
||||||
TEMP_7Z=$(mktemp --suffix=.7z)
|
|
||||||
curl -# -L -o "$TEMP_7Z" "$encoded_url"
|
|
||||||
|
|
||||||
# Check if the download was successful
|
|
||||||
if [[ $? -ne 0 ]]; then
|
|
||||||
echo "Failed to download $download_url. Skipping."
|
|
||||||
rm -f "$TEMP_7Z"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract the .7z file
|
|
||||||
7z x -y "$TEMP_7Z" || { echo "Failed to extract $TEMP_7Z. Skipping."; rm -f "$TEMP_7Z"; continue; }
|
|
||||||
|
|
||||||
# Remove the temporary .7z file
|
|
||||||
rm -f "$TEMP_7Z"
|
|
||||||
|
|
||||||
else
|
|
||||||
echo "$vpk is already up to date."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Synchronization complete."
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -xeuo pipefail
|
|
||||||
source /opt/l4d2/scripts/helpers
|
|
||||||
overlay=$(basename "$0")
|
|
||||||
|
|
||||||
# Ions Vocalizer
|
|
||||||
workshop -i 698857882
|
|
||||||
|
|
||||||
# admin system
|
|
||||||
workshop --item 2524204971
|
|
||||||
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/ems/admin system"
|
|
||||||
steam echo "STEAM_1:0:12376499" > "/opt/l4d2/overlays/${overlay}/left4dead2/ems/admin system/admins.txt"
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -xeuo pipefail
|
|
||||||
source /opt/l4d2/scripts/helpers
|
|
||||||
overlay=$(basename "$0")
|
|
||||||
|
|
||||||
# server config
|
|
||||||
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
|
|
||||||
# https://www.programmersought.com/article/513810199514/
|
|
||||||
steam mkdir -p /opt/l4d2/overlays/$overlay/left4dead2/cfg
|
|
||||||
steam cat <<'EOF' > /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg
|
|
||||||
# https://github.com/SirPlease/L4D2-Competitive-Rework/blob/7ecc3a32a5e2180d6607a40119ff2f3c072502a9/cfg/server.cfg#L58-L69
|
|
||||||
sv_minrate 100000
|
|
||||||
sv_maxrate 100000
|
|
||||||
nb_update_frequency 0.014
|
|
||||||
net_splitpacket_maxrate 50000
|
|
||||||
net_maxcleartime 0.0001
|
|
||||||
fps_max 0
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# install tickrate enabler
|
|
||||||
steam mkdir -p "/opt/l4d2/overlays/${overlay}/left4dead2/addons"
|
|
||||||
for file in tickrate_enabler.dll tickrate_enabler.so tickrate_enabler.vdf
|
|
||||||
do
|
|
||||||
curl -L "https://github.com/SirPlease/L4D2-Competitive-Rework/raw/refs/heads/master/addons/${file}" -o "/opt/l4d2/overlays/${overlay}/left4dead2/addons/${file}"
|
|
||||||
done
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -xeuo pipefail
|
|
||||||
source /opt/l4d2/scripts/helpers
|
|
||||||
overlay=$(basename "$0")
|
|
||||||
|
|
||||||
# workshop --collection 121115793 # Back To School
|
|
||||||
|
|
||||||
# workshop --item 2957035482 # hehe30-part1
|
|
||||||
# workshop --item 2973628334 # hehe30-part2
|
|
||||||
# workshop --item 3013844371 # hehe30-part3
|
|
||||||
|
|
||||||
# workshop --item 3478461158 # 虚伪黎明(Dawn's Deception)
|
|
||||||
# workshop --item 3478934394 # 虚伪黎明(Dawn's Deception)PART2
|
|
||||||
|
|
@ -1,13 +1,38 @@
|
||||||
// defaults
|
hostname "CroneKorkN : ${name}"
|
||||||
hostname ${server_name}
|
sv_contact "admin@sublimity.de"
|
||||||
|
|
||||||
|
// assign serevr to steam group
|
||||||
|
sv_steamgroup "${','.join(steamgroups)}"
|
||||||
|
|
||||||
|
// no annoying message of the day
|
||||||
motd_enabled 0
|
motd_enabled 0
|
||||||
rcon_password ${rcon_password}
|
|
||||||
sv_steamgroup "38347879"
|
|
||||||
|
|
||||||
mp_autoteambalance 0
|
// enable cheats
|
||||||
sv_forcepreload 1
|
sv_cheats 1
|
||||||
|
|
||||||
// server specific
|
// allow inconsistent files on clients (weapon mods for example)
|
||||||
% for line in config:
|
sv_consistency 0
|
||||||
${line}
|
|
||||||
% endfor
|
// connect from internet
|
||||||
|
sv_lan 0
|
||||||
|
|
||||||
|
// join game at any point
|
||||||
|
sv_allow_lobby_connect_only 0
|
||||||
|
|
||||||
|
// allowed modes
|
||||||
|
sv_gametypes "coop,realism,survival,versus,teamversus,scavenge,teamscavenge"
|
||||||
|
|
||||||
|
// network
|
||||||
|
sv_minrate 30000
|
||||||
|
sv_maxrate 60000
|
||||||
|
sv_mincmdrate 66
|
||||||
|
sv_maxcmdrate 101
|
||||||
|
|
||||||
|
// logging
|
||||||
|
sv_logsdir "logs-${name}" //Folder in the game directory where server logs will be stored.
|
||||||
|
log on //Creates a logfile (on | off)
|
||||||
|
sv_logecho 0 //default 0; Echo log information to the console.
|
||||||
|
sv_logfile 1 //default 1; Log server information in the log file.
|
||||||
|
sv_log_onefile 0 //default 0; Log server information to only one file.
|
||||||
|
sv_logbans 1 //default 0;Log server bans in the server logs.
|
||||||
|
sv_logflush 0 //default 0; Flush the log files to disk on each write (slow).
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -xeuo pipefail
|
|
||||||
|
|
||||||
# -- DEFINE FUNCTIONS AND VARIABLES -- #
|
|
||||||
|
|
||||||
function steam() {
|
|
||||||
# for systemd, so it can terminate the process (for other things sudo would have been enough)
|
|
||||||
setpriv --reuid=steam --regid=steam --init-groups "$@" <&0
|
|
||||||
export HOME=/opt/l4d2/steam
|
|
||||||
}
|
|
||||||
|
|
||||||
# -- PREPARE SYSTEM -- #
|
|
||||||
|
|
||||||
getent passwd steam >/dev/null || useradd -M -d /opt/l4d2 -s /bin/bash steam
|
|
||||||
mkdir -p /opt/l4d2 /tmp/dumps
|
|
||||||
chown steam:steam /opt/l4d2 /tmp/dumps
|
|
||||||
dpkg --add-architecture i386
|
|
||||||
apt update
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt install -y libc6:i386 lib32z1
|
|
||||||
|
|
||||||
# workshop downloader
|
|
||||||
test -f /opt/l4d2/scripts/steam-workshop-download || \
|
|
||||||
steam wget -4 https://git.sublimity.de/cronekorkn/steam-workshop-downloader/raw/branch/master/steam-workshop-download -P /opt/l4d2/scripts
|
|
||||||
steam chmod +x /opt/l4d2/scripts/steam-workshop-download
|
|
||||||
|
|
||||||
# -- STEAM -- #
|
|
||||||
|
|
||||||
steam mkdir -p /opt/l4d2/steam
|
|
||||||
test -f /opt/l4d2/steam/steamcmd_linux.tar.gz || \
|
|
||||||
steam wget http://media.steampowered.com/installer/steamcmd_linux.tar.gz -P /opt/l4d2/steam
|
|
||||||
test -f /opt/l4d2/steam/steamcmd.sh || \
|
|
||||||
steam tar -xvzf /opt/l4d2/steam/steamcmd_linux.tar.gz -C /opt/l4d2/steam
|
|
||||||
|
|
||||||
# fix for: /opt/l4d2/.steam/sdk32/steamclient.so: cannot open shared object file: No such file or directory
|
|
||||||
steam mkdir -p /opt/l4d2/steam/.steam # needs to be in steam users home dir
|
|
||||||
readlink /opt/l4d2/steam/.steam/sdk32 | grep -q ^/opt/l4d2/steam/linux32$ || \
|
|
||||||
steam ln -sf /opt/l4d2/steam/linux32 /opt/l4d2/steam/.steam/sdk32
|
|
||||||
readlink /opt/l4d2/steam/.steam/sdk64 | grep -q ^/opt/l4d2/steam/linux64$ || \
|
|
||||||
steam ln -sf /opt/l4d2/steam/linux64 /opt/l4d2/steam/.steam/sdk64
|
|
||||||
|
|
||||||
# -- INSTALL -- #
|
|
||||||
|
|
||||||
# erst die windows deps zu installieren scheint ein workaround für x64 zu sein?
|
|
||||||
steam mkdir -p /opt/l4d2/installation
|
|
||||||
steam /opt/l4d2/steam/steamcmd.sh \
|
|
||||||
+force_install_dir /opt/l4d2/installation \
|
|
||||||
+login anonymous \
|
|
||||||
+@sSteamCmdForcePlatformType windows \
|
|
||||||
+app_update 222860 validate \
|
|
||||||
+quit
|
|
||||||
steam /opt/l4d2/steam/steamcmd.sh \
|
|
||||||
+force_install_dir /opt/l4d2/installation \
|
|
||||||
+login anonymous \
|
|
||||||
+@sSteamCmdForcePlatformType linux \
|
|
||||||
+app_update 222860 validate \
|
|
||||||
+quit
|
|
||||||
|
|
||||||
# -- OVERLAYS -- #
|
|
||||||
|
|
||||||
for overlay_path in /opt/l4d2/scripts/overlays/*; do
|
|
||||||
overlay=$(basename "$overlay_path")
|
|
||||||
steam mkdir -p /opt/l4d2/overlays/$overlay
|
|
||||||
bash -xeuo pipefail "$overlay_path"
|
|
||||||
test -f /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg && \
|
|
||||||
steam cp /opt/l4d2/overlays/$overlay/left4dead2/cfg/server.cfg /opt/l4d2/overlays/$overlay/left4dead2/cfg/server_$overlay.cfg
|
|
||||||
done
|
|
||||||
|
|
||||||
# -- SERVERS -- #
|
|
||||||
|
|
||||||
#steam rm -rf /opt/l4d2/servers
|
|
||||||
steam mkdir -p /opt/l4d2/servers
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -xeuo pipefail
|
|
||||||
|
|
||||||
name=""
|
|
||||||
port=""
|
|
||||||
configfile=""
|
|
||||||
overlays=""
|
|
||||||
arguments=""
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-n|--name)
|
|
||||||
name="$2"; shift 2
|
|
||||||
;;
|
|
||||||
-p|--port)
|
|
||||||
port="$2"; shift 2
|
|
||||||
;;
|
|
||||||
-c|--config)
|
|
||||||
configfile="$2"; shift 2
|
|
||||||
;;
|
|
||||||
-o|--overlay)
|
|
||||||
overlays="/opt/l4d2/overlays/$2:$overlays"; shift 2
|
|
||||||
;;
|
|
||||||
--)
|
|
||||||
shift
|
|
||||||
arguments+="$@"
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: unknown argument $1"; exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -n "${name}" ]] || { echo "ERROR: -n/--name missing"; exit 1; }
|
|
||||||
[[ -n "${port}" ]] || { echo "ERROR: -p/--port missing"; exit 1; }
|
|
||||||
|
|
||||||
# -- HELPER FUNCTIONS -- #
|
|
||||||
|
|
||||||
function steam() {
|
|
||||||
# für systemd, damit es den prozess beenden kann
|
|
||||||
setpriv --reuid=steam --regid=steam --init-groups "$@"
|
|
||||||
export HOME=/opt/l4d2/steam
|
|
||||||
}
|
|
||||||
|
|
||||||
# -- TIDY UP -- #
|
|
||||||
|
|
||||||
mountpoint -q "/opt/l4d2/servers/$name/merged" && umount "/opt/l4d2/servers/$name/merged"
|
|
||||||
steam rm -rf "/opt/l4d2/servers/$name"
|
|
||||||
|
|
||||||
# -- CREATE DIRECTORIES -- #
|
|
||||||
|
|
||||||
steam mkdir -p \
|
|
||||||
"/opt/l4d2/servers/$name" \
|
|
||||||
"/opt/l4d2/servers/$name/work" \
|
|
||||||
"/opt/l4d2/servers/$name/upper" \
|
|
||||||
"/opt/l4d2/servers/$name/merged"
|
|
||||||
|
|
||||||
# -- MOUNT OVERLAYFS -- #
|
|
||||||
|
|
||||||
mount -t overlay overlay \
|
|
||||||
-o "lowerdir=$overlays/opt/l4d2/installation,upperdir=/opt/l4d2/servers/$name/upper,workdir=/opt/l4d2/servers/$name/work" \
|
|
||||||
"/opt/l4d2/servers/$name/merged"
|
|
||||||
|
|
||||||
# -- REPLACE SERVER.CFG -- #
|
|
||||||
|
|
||||||
if [[ -n "$configfile" ]]; then
|
|
||||||
cp "$configfile" "/opt/l4d2/servers/$name/merged/left4dead2/cfg/server.cfg"
|
|
||||||
chown steam:steam "/opt/l4d2/servers/$name/merged/left4dead2/cfg/server.cfg"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -- RUN L4D2 -- #
|
|
||||||
|
|
||||||
steam "/opt/l4d2/servers/$name/merged/srcds_run" -norestart -pidfile "/opt/l4d2/servers/$name/pid" -game left4dead2 -ip 0.0.0.0 -port "$port" +hostname "Crone_$name" +map c1m1_hotel $arguments
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -xeuo pipefail
|
|
||||||
|
|
||||||
name=""
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-n|--name)
|
|
||||||
name="$2"; shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: unknown argument $1"; exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
mountpoint -q "/opt/l4d2/servers/$name/merged" && umount "/opt/l4d2/servers/$name/merged"
|
|
||||||
steam rm -rf "/opt/l4d2/servers/$name"
|
|
||||||
|
|
@ -1,105 +1,106 @@
|
||||||
users = {
|
|
||||||
'steam': {
|
|
||||||
'home': '/opt/l4d2/steam',
|
|
||||||
'shell': '/bin/bash',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
directories = {
|
directories = {
|
||||||
'/opt/l4d2': {
|
'/opt/left4dead2': {
|
||||||
'owner': 'steam', 'group': 'steam',
|
'owner': 'steam',
|
||||||
},
|
},
|
||||||
'/opt/l4d2/steam': {
|
'/opt/left4dead2/ems/admin system': {
|
||||||
'owner': 'steam', 'group': 'steam',
|
'owner': 'steam',
|
||||||
},
|
},
|
||||||
'/opt/l4d2/configs': {
|
'/opt/left4dead2/left4dead2/cfg': {
|
||||||
'owner': 'steam', 'group': 'steam',
|
'owner': 'steam',
|
||||||
'purge': True,
|
|
||||||
},
|
},
|
||||||
'/opt/l4d2/scripts': {
|
'/opt/left4dead2/left4dead2/addons': {
|
||||||
'owner': 'steam', 'group': 'steam',
|
'owner': 'steam',
|
||||||
},
|
|
||||||
'/opt/l4d2/scripts/overlays': {
|
|
||||||
'owner': 'steam', 'group': 'steam',
|
|
||||||
'purge': True,
|
'purge': True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
files = {
|
files = {
|
||||||
'/opt/l4d2/setup': {
|
'/opt/left4dead2/ems/admin system/admins.txt': {
|
||||||
'mode': '755',
|
'owner': 'steam',
|
||||||
'triggers': {
|
'content': '\n'.join(node.metadata.get('left4dead2/admins')),
|
||||||
'svc_systemd:left4dead2-initialize.service:restart',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/opt/l4d2/start': {
|
|
||||||
'mode': '755',
|
|
||||||
'triggers': {
|
|
||||||
f'svc_systemd:left4dead2-{server_name}.service:restart'
|
|
||||||
for server_name in node.metadata.get('left4dead2/servers').keys()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/opt/l4d2/stop': {
|
|
||||||
'mode': '755',
|
|
||||||
'triggers': {
|
|
||||||
f'svc_systemd:left4dead2-{server_name}.service:restart'
|
|
||||||
for server_name in node.metadata.get('left4dead2/servers').keys()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/opt/l4d2/scripts/helpers': {
|
|
||||||
'source': 'scripts/helpers',
|
|
||||||
'mode': '755',
|
|
||||||
'triggers': {
|
|
||||||
'svc_systemd:left4dead2-initialize.service:restart',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for overlay in node.metadata.get('left4dead2/overlays'):
|
|
||||||
files[f'/opt/l4d2/scripts/overlays/{overlay}'] = {
|
|
||||||
'source': f'scripts/overlays/{overlay}',
|
|
||||||
'mode': '755',
|
|
||||||
'triggers': {
|
|
||||||
'svc_systemd:left4dead2-initialize.service:restart',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
svc_systemd = {
|
svc_systemd = {
|
||||||
'left4dead2-initialize.service': {
|
'left4dead2-workshop': {
|
||||||
'enabled': True,
|
'running': False,
|
||||||
'running': None,
|
'needs': [
|
||||||
'needs': {
|
'svc_systemd:steam-update',
|
||||||
'tag:left4dead2-packages',
|
],
|
||||||
'file:/opt/l4d2/setup',
|
|
||||||
'file:/usr/local/lib/systemd/system/left4dead2-initialize.service',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for server_name, config in node.metadata.get('left4dead2/servers').items():
|
for id in node.metadata.get('left4dead2/workshop'):
|
||||||
files[f'/opt/l4d2/configs/{server_name}.cfg'] = {
|
directories[f'/opt/left4dead2/left4dead2/addons/{id}'] = {
|
||||||
'source': 'server.cfg',
|
|
||||||
'content_type': 'mako',
|
|
||||||
'context': {
|
|
||||||
'server_name': server_name,
|
|
||||||
'rcon_password': repo.vault.decrypt('encrypt$gAAAAABpAdZhxwJ47I1AXotuZmBvyZP1ecVTt9IXFkLI28JiVS74LKs9QdgIBz-FC-iXtIHHh_GVGxxKQZprn4UrXZcvZ57kCKxfHBs3cE2JiGnbWE8_mfs=').value,
|
|
||||||
'config': config.get('config', []),
|
|
||||||
},
|
|
||||||
'owner': 'steam',
|
'owner': 'steam',
|
||||||
'mode': '644',
|
'triggers': [
|
||||||
'triggers': {
|
'svc_systemd:left4dead2-workshop:restart',
|
||||||
f'svc_systemd:left4dead2-{server_name}.service:restart',
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
svc_systemd[f'left4dead2-{server_name}.service'] = {
|
server_units = set()
|
||||||
'enabled': True,
|
for name, config in node.metadata.get('left4dead2/servers').items():
|
||||||
'running': True,
|
config.pop('port')
|
||||||
'tags': {
|
config = {
|
||||||
'left4dead2-servers',
|
'hostname': name,
|
||||||
},
|
'sv_steamgroup': ','.join(
|
||||||
'needs': {
|
str(gid) for gid in node.metadata.get('left4dead2/steamgroups')
|
||||||
'svc_systemd:left4dead2-initialize.service',
|
),
|
||||||
f'file:/usr/local/lib/systemd/system/left4dead2-{server_name}.service',
|
'z_difficulty': 'Impossible',
|
||||||
},
|
'sv_gametypes': 'realism',
|
||||||
|
'sv_region': 3, # europe
|
||||||
|
'log': 'on',
|
||||||
|
'sv_logecho': 1,
|
||||||
|
'sv_logfile': 1,
|
||||||
|
'sv_log_onefile': 0,
|
||||||
|
'sv_logbans': 1,
|
||||||
|
'sv_logflush': 0,
|
||||||
|
'sv_logsdir': 'logs', # /opt/left4dead2/left4dead2/logs
|
||||||
|
**config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
files[f'/opt/left4dead2/left4dead2/cfg/server-{name}.cfg'] = {
|
||||||
|
'content': '\n'.join(
|
||||||
|
f'{key} "{value}"' for key, value in sorted(config.items())
|
||||||
|
) + '\n',
|
||||||
|
'owner': 'steam',
|
||||||
|
'triggers': [
|
||||||
|
f'svc_systemd:left4dead2-server-{name}:restart',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
svc_systemd[f'left4dead2-server-{name}'] = {
|
||||||
|
'needs': [
|
||||||
|
f'file:/usr/local/lib/systemd/system/left4dead2-server-{name}.service',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
server_units.add(f'left4dead2-server-{name}')
|
||||||
|
|
||||||
|
|
||||||
|
for id in node.metadata.get('left4dead2/workshop'):
|
||||||
|
directories[f'/opt/left4dead2/addons/{id}'] = {
|
||||||
|
'owner': 'steam',
|
||||||
|
'triggers': [
|
||||||
|
'svc_systemd:left4dead2-workshop:restart',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# TIDYUP
|
||||||
|
|
||||||
|
find_obsolete_units = (
|
||||||
|
'find /usr/local/lib/systemd/system -type f -name "left4dead2-server-*.service" ' +
|
||||||
|
' '.join(f"! -name '{name}.service'" for name in server_units)
|
||||||
|
)
|
||||||
|
actions['remove_obsolete_left4dead2_units'] = {
|
||||||
|
'command': (
|
||||||
|
f'for unitfile in $({find_obsolete_units}); '
|
||||||
|
f'do '
|
||||||
|
f'systemctl stop $(basename "$unitfile"); '
|
||||||
|
f'systemctl disable $(basename "$unitfile"); '
|
||||||
|
f'rm "$unitfile"; '
|
||||||
|
f'systemctl daemon-reload; '
|
||||||
|
f'done'
|
||||||
|
),
|
||||||
|
'unless': (
|
||||||
|
find_obsolete_units + " | wc -l | grep -q '^0$'"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,62 @@
|
||||||
from re import match
|
assert node.has_bundle('steam')
|
||||||
from os import path, listdir
|
|
||||||
|
|
||||||
|
from shlex import quote
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'apt': {
|
'steam': {
|
||||||
'packages': {
|
'games': {
|
||||||
'libc6_i386': { # installs libc6:i386
|
'left4dead2': '222860',
|
||||||
'tags': {'left4dead2-packages'},
|
|
||||||
},
|
|
||||||
'lib32z1': {
|
|
||||||
'tags': {'left4dead2-packages'},
|
|
||||||
},
|
|
||||||
'unzip': {
|
|
||||||
'tags': {'left4dead2-packages'},
|
|
||||||
},
|
|
||||||
'p7zip-full': { # l4d2center_maps_sync.sh
|
|
||||||
'tags': {'left4dead2-packages'},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'left4dead2': {
|
'left4dead2': {
|
||||||
'overlays': set(listdir(path.join(repo.path, 'bundles/left4dead2/files/scripts/overlays'))),
|
'servers': {},
|
||||||
'servers': {
|
'admins': set(),
|
||||||
# 'port': 27017,
|
'workshop': set(),
|
||||||
# 'overlays': ['competitive_rework'],
|
|
||||||
# 'arguments': ['-tickrate 60'],
|
|
||||||
# 'config': [
|
|
||||||
# 'exec server_original.cfg',
|
|
||||||
# 'sm_forcematch zonemod',
|
|
||||||
# ],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'nftables': {
|
|
||||||
'input': {
|
|
||||||
'udp dport { 27005, 27020 } accept',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'systemd': {
|
|
||||||
'units': {
|
|
||||||
'left4dead2-initialize.service': {
|
|
||||||
'Unit': {
|
|
||||||
'Description': 'initialize left4dead2',
|
|
||||||
'After': 'network-online.target',
|
|
||||||
},
|
|
||||||
'Service': {
|
|
||||||
'Type': 'oneshot',
|
|
||||||
'RemainAfterExit': 'yes',
|
|
||||||
'ExecStart': '/opt/l4d2/setup',
|
|
||||||
'StandardOutput': 'journal',
|
|
||||||
'StandardError': 'journal',
|
|
||||||
},
|
|
||||||
'Install': {
|
|
||||||
'WantedBy': {'multi-user.target'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@metadata_reactor.provides(
|
||||||
|
'systemd/units',
|
||||||
|
)
|
||||||
|
def workshop(metadata):
|
||||||
|
command = (
|
||||||
|
'set -x; '
|
||||||
|
'for ID in ' + ' '.join(metadata.get('left4dead2/workshop')) + '; '
|
||||||
|
'do '
|
||||||
|
'if ! ls /opt/left4dead2/left4dead2/addons/$ID/*.vpk; '
|
||||||
|
'then '
|
||||||
|
'cd /opt/left4dead2/left4dead2/addons/$ID; '
|
||||||
|
'/opt/steam-workshop-downloader https://steamcommunity.com/sharedfiles/filedetails\?id\=$ID; '
|
||||||
|
'unzip $ID.zip; '
|
||||||
|
'fi; '
|
||||||
|
'done'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'systemd': {
|
||||||
|
'units': {
|
||||||
|
'left4dead2-workshop.service': {
|
||||||
|
'Unit': {
|
||||||
|
'Description': 'install workshop items',
|
||||||
|
'After': 'network.target',
|
||||||
|
'Requires': 'steam-update.service',
|
||||||
|
'PartOf': 'steam-update.service'
|
||||||
|
},
|
||||||
|
'Service': {
|
||||||
|
'Type': 'oneshot',
|
||||||
|
'User': 'steam',
|
||||||
|
'ExecStart': f'/bin/bash -c {quote(command)}',
|
||||||
|
},
|
||||||
|
'Install': {
|
||||||
|
'WantedBy': {'multi-user.target'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
@metadata_reactor.provides(
|
||||||
'systemd/units',
|
'systemd/units',
|
||||||
)
|
)
|
||||||
|
|
@ -66,44 +64,22 @@ def server_units(metadata):
|
||||||
units = {}
|
units = {}
|
||||||
|
|
||||||
for name, config in metadata.get('left4dead2/servers').items():
|
for name, config in metadata.get('left4dead2/servers').items():
|
||||||
assert match(r'^[A-z0-9-_-]+$', name)
|
units[f'left4dead2-server-{name}.service'] = {
|
||||||
assert 27000 <= config["port"] <= 27100
|
|
||||||
for overlay in config.get('overlays', []):
|
|
||||||
assert overlay in metadata.get('left4dead2/overlays'), f"unknown overlay {overlay}, known: {metadata.get('left4dead2/overlays')}"
|
|
||||||
|
|
||||||
cmd = f'/opt/l4d2/start -n {name} -p {config["port"]}'
|
|
||||||
|
|
||||||
if 'config' in config:
|
|
||||||
cmd += f' -c /opt/l4d2/configs/{name}.cfg'
|
|
||||||
|
|
||||||
for overlay in config.get('overlays', []):
|
|
||||||
cmd += f' -o {overlay}'
|
|
||||||
|
|
||||||
if 'arguments' in config:
|
|
||||||
cmd += ' -- ' + ' '.join(config['arguments'])
|
|
||||||
|
|
||||||
units[f'left4dead2-{name}.service'] = {
|
|
||||||
'Unit': {
|
'Unit': {
|
||||||
'Description': f'left4dead2 server {name}',
|
'Description': f'left4dead2 server {name}',
|
||||||
'After': {'left4dead2-initialize.service'},
|
'After': 'network.target',
|
||||||
'Requires': {'left4dead2-initialize.service'},
|
'Requires': 'steam-update.service',
|
||||||
},
|
},
|
||||||
'Service': {
|
'Service': {
|
||||||
'Type': 'simple',
|
'User': 'steam',
|
||||||
'ExecStart': cmd,
|
'Group': 'steam',
|
||||||
'ExecStopPost': f'/opt/l4d2/stop -n {name}',
|
'WorkingDirectory': '/opt/left4dead2',
|
||||||
|
'ExecStart': f'/opt/left4dead2/srcds_run -port {config["port"]} -insecure +map {config["map"]} +exec server-{name}.cfg',
|
||||||
'Restart': 'on-failure',
|
'Restart': 'on-failure',
|
||||||
'Nice': -10,
|
|
||||||
'CPUWeight': 200,
|
|
||||||
'IOSchedulingClass': 'best-effort',
|
|
||||||
'IOSchedulingPriority': 0,
|
|
||||||
},
|
},
|
||||||
'Install': {
|
'Install': {
|
||||||
'WantedBy': {'multi-user.target'},
|
'WantedBy': {'multi-user.target'},
|
||||||
},
|
},
|
||||||
'triggers': {
|
|
||||||
f'svc_systemd:left4dead2-{name}.service:restart',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -111,18 +87,3 @@ def server_units(metadata):
|
||||||
'units': units,
|
'units': units,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@metadata_reactor.provides(
|
|
||||||
'nftables/input',
|
|
||||||
)
|
|
||||||
def nftables(metadata):
|
|
||||||
ports = sorted(str(config["port"]) for config in metadata.get('left4dead2/servers').values())
|
|
||||||
|
|
||||||
return {
|
|
||||||
'nftables': {
|
|
||||||
'input': {
|
|
||||||
f'ip protocol {{ tcp, udp }} th dport {{ {", ".join(ports)} }} accept'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
# left4me
|
|
||||||
|
|
||||||
L4D2 game-server management platform: a Flask web UI on gunicorn that
|
|
||||||
provisions per-instance srcds servers via templated systemd units, with
|
|
||||||
kernel-overlayfs layering for shared installations + per-overlay maps,
|
|
||||||
and uid-based DSCP/priority marking on the egress path so CAKE on the
|
|
||||||
external interface prioritizes srcds UDP over bulk traffic.
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
```python
|
|
||||||
'metadata': {
|
|
||||||
'left4me': {
|
|
||||||
'domain': 'whatever.tld', # required — the only per-node knob
|
|
||||||
# Everything below is optional and has a sensible default in the
|
|
||||||
# bundle. Override per-node only if the default is wrong:
|
|
||||||
# 'git_url': 'git@git.sublimity.de:cronekorkn/left4me',
|
|
||||||
# 'git_branch': 'master',
|
|
||||||
# 'gunicorn_workers': 1,
|
|
||||||
# 'gunicorn_threads': 32,
|
|
||||||
# 'job_worker_threads': 4,
|
|
||||||
# 'port_range_start': 27015,
|
|
||||||
# 'port_range_end': 27115,
|
|
||||||
# secret_key is auto-derived per node
|
|
||||||
# (repo.vault.random_bytes_as_base64_for f'{node.name} left4me secret_key').
|
|
||||||
},
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
The bundle's `derived_from_domain` reactor reads `left4me/domain` and
|
|
||||||
emits the corresponding `nginx/vhosts`, `letsencrypt/domains`,
|
|
||||||
`monitoring/services/left4me-web` (HTTPS health check), and the game-
|
|
||||||
port `nftables/input` accept rules. Backup paths
|
|
||||||
(`/var/lib/left4me`, `/etc/left4me`) are set-merged into `backup/paths`
|
|
||||||
from defaults. None of these need to be declared per-node.
|
|
||||||
|
|
||||||
## What this bundle does
|
|
||||||
|
|
||||||
The bundle delivers to `ovh.left4me` a mix of:
|
|
||||||
|
|
||||||
### Target-side symlinks into the left4me checkout
|
|
||||||
|
|
||||||
After `git_deploy:/opt/left4me/src` (root-owned — left4me cannot rewrite
|
|
||||||
its own deployment artifacts at runtime), ckn-bw creates symlinks from
|
|
||||||
canonical on-host paths into the checkout:
|
|
||||||
|
|
||||||
| On-host path | Source in checkout |
|
|
||||||
|---|---|
|
|
||||||
| `/etc/sudoers.d/left4me` | `deploy/files/etc/sudoers.d/left4me` |
|
|
||||||
| `/etc/sysctl.d/99-left4me.conf` | `deploy/files/etc/sysctl.d/99-left4me.conf` |
|
|
||||||
| `/etc/systemd/system/left4me-web.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf` |
|
|
||||||
| `/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` | `deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf` |
|
|
||||||
| `/usr/local/libexec/left4me/{left4me-overlay,left4me-systemctl,left4me-journalctl,left4me-script-sandbox}` | `deploy/scripts/libexec/*` |
|
|
||||||
| `/usr/local/sbin/left4me` | `deploy/scripts/sbin/left4me` |
|
|
||||||
|
|
||||||
The hardening drop-ins and sudoers are the application's own security
|
|
||||||
knowledge — they live in the left4me repo and are version-controlled there.
|
|
||||||
The privileged helpers are also application code. The symlink pattern
|
|
||||||
lets bw manage placement without duplicating content.
|
|
||||||
|
|
||||||
Design rationale:
|
|
||||||
`left4me/docs/superpowers/specs/2026-05-15-deployment-responsibility-design.md`.
|
|
||||||
|
|
||||||
### Reactor-emitted units (per-host shape)
|
|
||||||
|
|
||||||
Via `systemd/units` metadata in `metadata.py` (consumed by `bundles/systemd/`):
|
|
||||||
|
|
||||||
- `left4me-web.service` — gunicorn on `127.0.0.1:8000`; worker/thread
|
|
||||||
counts from `web.env.mako`. TLS terminates upstream.
|
|
||||||
- `left4me-server@.service` — per-instance srcds template; `SocketBindAllow=`
|
|
||||||
ranges from metadata.
|
|
||||||
- `l4d2-game.slice` / `l4d2-build.slice` — cgroup slices with per-host
|
|
||||||
`AllowedCPUs=` from `left4me/system_cpus`.
|
|
||||||
- `system.slice.d/99-left4me-cpuset.conf` + `user.slice.d/99-left4me-cpuset.conf`
|
|
||||||
— host CPU-set drop-ins, same source.
|
|
||||||
|
|
||||||
### bw `files{}` — templated env files
|
|
||||||
|
|
||||||
- `host.env.mako` → `/etc/left4me/host.env`
|
|
||||||
- `web.env.mako` → `/etc/left4me/web.env`
|
|
||||||
- `sandbox-resolv.conf` → `/etc/left4me/sandbox-resolv.conf`
|
|
||||||
|
|
||||||
### Action chains — deploy lifecycle
|
|
||||||
|
|
||||||
- `git_deploy` → `uv_sync` (`uv sync --frozen` against the workspace's
|
|
||||||
committed `uv.lock`; hatchling PEP 660 editable, doesn't touch source)
|
|
||||||
→ `alembic_upgrade` → `seed_overlays` + web restart.
|
|
||||||
- One-shot bootstrap: `install_uv` downloads a pinned `uv` binary
|
|
||||||
(SHA256-verified) into `/usr/local/bin` because `uv` isn't in Trixie's
|
|
||||||
apt archive. `unless`-gated, so it's a no-op once the version pin is
|
|
||||||
installed; re-runs only when the constant is bumped.
|
|
||||||
- Idempotent gates: `chmod-sudoers` (0440 root:root), `chmod-scripts` (0755 root:root).
|
|
||||||
- Post-git-deploy reloads: `systemctl daemon-reload`, `sysctl --system`.
|
|
||||||
- Post-apply self-test: `verify-hardening-dropins` (asserts the drop-ins are
|
|
||||||
loaded by the live units before declaring apply done).
|
|
||||||
|
|
||||||
### System user
|
|
||||||
|
|
||||||
`left4me` (uid/gid 980, home `/var/lib/left4me`, mode 0755) — the same uid
|
|
||||||
hosts the web app, gameservers, and the script-overlay sandbox unit (which
|
|
||||||
drops privileges via systemd-run with a fully hardened transient service).
|
|
||||||
Runtime mutable state lives under `/var/lib/left4me/`; `/opt/left4me/`
|
|
||||||
stays as a root-owned deploy-artifact root.
|
|
||||||
|
|
||||||
### nftables / nginx / monitoring
|
|
||||||
|
|
||||||
- Contributes uid-based DSCP/priority marks for srcds UDP egress to
|
|
||||||
`nftables/output` (via `defaults`).
|
|
||||||
- `derived_from_domain` reactor emits the corresponding `nginx/vhosts`,
|
|
||||||
`letsencrypt/domains`, and `monitoring/services/left4me-web` (HTTPS
|
|
||||||
health check).
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Requires `bundles/nftables` and `bundles/systemd` on the node.** The
|
|
||||||
bundle asserts membership at `bw test` time. On Debian-13 these ride
|
|
||||||
in via the `debian-13` group, so attaching the bundle to a Debian-13
|
|
||||||
node is enough.
|
|
||||||
- **`left4me-web.service` does not have `NoNewPrivileges=true`.** This is
|
|
||||||
intentional — workers `sudo` the privileged helpers; `NoNewPrivileges`
|
|
||||||
would block setuid escalation. Per-instance `server@.service` units
|
|
||||||
*do* have it.
|
|
||||||
- **CAKE shaping is configured separately**, via
|
|
||||||
`network/<iface>/cake` on the node (consumed by `bundles/network/`),
|
|
||||||
not by this bundle.
|
|
||||||
- **First-run admin user is manual.** After `bw apply`, ssh to the host and
|
|
||||||
bootstrap the admin via the `left4me` wrapper (it sources the env files,
|
|
||||||
drops to the `left4me` user, and runs the flask CLI):
|
|
||||||
`sudo left4me create-user <username> --admin` (prompts for password via
|
|
||||||
the flask CLI, or set `LEFT4ME_ADMIN_PASSWORD` first). The bundle
|
|
||||||
deliberately doesn't seed an admin to keep credentials out of the
|
|
||||||
metadata pipeline. The same `left4me` wrapper accepts any other flask
|
|
||||||
subcommand: `sudo left4me seed-script-overlays <dir>`,
|
|
||||||
`sudo left4me routes`, `sudo left4me shell`, etc.
|
|
||||||
- **CPU isolation is managed by this bundle**, driven by one required
|
|
||||||
per-node knob: `left4me/system_cpus` — a set of int CPU ids that
|
|
||||||
pins `system.slice` / `user.slice` / `l4d2-build.slice`. The
|
|
||||||
complement (`set(range(vm/threads)) - system_cpus`) pins
|
|
||||||
`l4d2-game.slice`. On HT hosts, list both SMT siblings of every
|
|
||||||
physical core you want to reserve for system, otherwise games end
|
|
||||||
up sharing L1/L2 with system. Find pairings via
|
|
||||||
`/sys/devices/system/cpu/cpu<n>/topology/thread_siblings_list`. On
|
|
||||||
the prod node (`ovh.left4me`, 4 physical / 8 threads, pairings
|
|
||||||
(0,4) (1,5) (2,6) (3,7)) the node sets `'system_cpus': {0, 4}` to
|
|
||||||
reserve physical core 0 entirely. `l4d2-game.slice` and
|
|
||||||
`l4d2-build.slice` carry `AllowedCPUs=` inline on their unit
|
|
||||||
definitions; `system.slice` and `user.slice` get drop-ins registered
|
|
||||||
under `systemd/units` with the `'<parent>.d/<basename>.conf'` key
|
|
||||||
convention (same shape nginx and autologin use), landing at
|
|
||||||
`/usr/local/lib/systemd/system/<slice>.d/99-left4me-cpuset.conf`.
|
|
||||||
The reactor raises if `system_cpus` includes CPUs outside
|
|
||||||
`[0, vm/threads)` or leaves no cores for games.
|
|
||||||
- **Kernel feature requirement:** kernel-overlayfs (`CONFIG_OVERLAY_FS`).
|
|
||||||
Standard on debian-13.
|
|
||||||
- **Game ports** open by the web app on demand in the range 27015-27115
|
|
||||||
(UDP+TCP). Add corresponding accept rules to `nftables/input` per
|
|
||||||
node if the host's policy is default-drop on input.
|
|
||||||
- **Pinned UIDs/GIDs (980/981).** Chosen for deterministic ownership
|
|
||||||
across rebuilds and backup restores. If you add another bundle that
|
|
||||||
pins UIDs in this repo, make sure it doesn't collide.
|
|
||||||
|
|
||||||
## Slice support requires `bundles/systemd` ≥ commit cc1c6a5
|
|
||||||
|
|
||||||
This bundle's `l4d2-game.slice` and `l4d2-build.slice` units rely on
|
|
||||||
`bundles/systemd/items.py` accepting the `.slice` extension. Older
|
|
||||||
revisions raised `Exception(f'unknown type slice')` at apply time.
|
|
||||||
The repo-wide `bw test` will catch this if it regresses.
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# Managed by ckn-bw bundles/left4me. Local edits will be reverted.
|
|
||||||
# Deployment units use fixed /var/lib/left4me paths; regenerate units if this changes.
|
|
||||||
LEFT4ME_ROOT=/var/lib/left4me
|
|
||||||
# l4d2host invokes steamcmd by absolute path — bypasses PATH lookup so the
|
|
||||||
# script's `cd "$(dirname "$0")"` resolves next to the real install dir.
|
|
||||||
LEFT4ME_STEAMCMD=/var/lib/left4me/steam/steamcmd.sh
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue