Author SHA1 Message Date
claude-bot dd758e6806 feat(runner): install zstd so the actions cache is not gzipped single-threaded
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
actions/cache -- and actions/setup-go, which builds on it -- packs its archive
with zstdmt when zstd is present and falls back to single-threaded gzip when
it is not. The archive name says which happened: cache.tzst against cache.tgz.

Measured in l.kirchner/patchmgr, CI run 1957. The cache is GOMODCACHE plus
GOCACHE and runs to 2-5 GB. runner-gpu has zstd and writes cache.tzst;
runner-01, -02 and -03 write cache.tgz, and go test -race (stable) spent 605
seconds packing it for a job with 41 seconds of work. Across all eight jobs of
that run, 2312 of 3146 seconds went into this step.

This does not settle whether the cache is wanted at all -- in host mode with a
persistent home both directories survive between jobs anyway, and patchmgr is
switching it off for that reason. But as long as any repository on the
instance uses it, it should not be compressed on one core.

zstd is a package for the post step, not for jobs, so it sits with the others
rather than in a workflow: this runner installs nothing at job time, by
design.
2026-09-03 01:04:02 +02:00
l.kirchner 0ec6738217 Merge pull request 'security: version the CIS Tier-A hardening pass' (#11) from security/cis-tierA-hardening into main
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (push) Successful in 2s
Reviewed-on: #11
2026-08-19 11:08:14 +02:00
claude-bot 5130b82639 security: version the CIS Tier-A hardening pass
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
The script that sets the SSH, PAM, pwquality and auditd baseline on twelve
containers existed only as a root-owned copy on the machines it hardens.
Bring it into the repo so a change reaches one place instead of twelve.

Two substantive changes over the copy that shipped on 2026-07-24:

- Deny forwarding per option instead of via DisableForwarding. Same effect,
  but DisableForwarding overrides every other forwarding option and is
  invisible in sshd -T, which makes a rejected port-forward read as a
  configuration that should work.
- Quote the command substitution in MODDIR (SC2046).

The header and README now record the rollout command, the containers left
unhardened as break-glass foundation, and why host-specific exceptions must
live in a drop-in that sorts after 99-cis-hardening.conf.
2026-08-19 11:02:15 +02:00
l.kirchner 0ab4f98f4e feat(runner): install the build toolchain compiled languages need (#10)
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (push) Successful in 2s
2026-08-18 23:02:16 +02:00
claude-bot 3c77d34b7e docs(runner): name the protoc coupling and fix a mechanism claim
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
Both from the cross-review, both fair.

"Go setzt CGO_ENABLED=0" is right about the effect and wrong about the
mechanism: Go does not set the variable, cgo simply stays off when no C
compiler is found, and go env then reports 0. Reworded.

And protoc on an instance-wide runner ties every repository to the
distribution's version -- 3.21.x on Debian 12. Unlike make and gcc that is a
code generator, so a distro upgrade changes generated code for all users at
once. The comment says so now, and says where a project that needs its own
version should pin it instead of raising it here for everybody.
2026-08-18 23:01:34 +02:00
claude-bot 8c83fb372b feat(runner): install the build toolchain compiled languages need
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
The runner is host-mode, so there is no image bringing tools along: what is
not on this LXC, no job has. Measured on l.kirchner/patchmgr, a Go project,
where all six CI jobs were assigned and every one of them died in the first
seconds:

    make all         make: command not found
    go test -race    go: -race requires cgo; enable cgo by setting CGO_ENABLED=1
    make proto       sudo: command not found

Four packages, each for a reason:

    make               the gate commands are make targets
    gcc                Go turns CGO_ENABLED off when it finds no C compiler,
                       and the race detector cannot be built without cgo
    protobuf-compiler  protoc itself
    libprotobuf-dev    the well-known .proto includes under
                       /usr/include/google/protobuf; without them protoc fails
                       even though the binary is there

sudo stays absent on purpose. A workflow must not be able to install anything
on this runner -- what is needed is declared here, in the script, and not in
somebody's pipeline. That also keeps the security note at the top of this file
honest: the LXC owns nothing, and it gains nothing at a workflow's request.

Go is not in the list. Projects fetch it through actions/setup-go, because CI
matrices run more than one version.

Applied to the running LXC (301 on pve-gamer) while the runner was idle, then
verified: make 4.3, gcc 12.2.0, libprotoc 3.21.12, 11 .proto includes present.
The service PATH already contains /usr/bin, so no restart was needed.
2026-08-18 22:54:54 +02:00
l.kirchner 6fadb27080 Merge pull request 'fix(nexus-db): UTF8-Encoding zur Installationszeit erzwingen (#8)' (#9) from fix/nexus-db-utf8-encoding into main
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (push) Successful in 1s
fix(nexus-db): UTF-8-Encoding erzwingen + Post-Install-Check (PR #9, Closes #8) — Locale aktiv zum initdb-Zeitpunkt, Re-Run-Guard, su-statt-sudo-Doku
2026-06-13 14:38:58 +02:00
claude-bot 6543fd77d8 fix(nexus-db): address codex review — early encoding guard, robust helpers
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
- assert encoding BEFORE role/password mutation on re-run, so an old
  SQL_ASCII DB aborts with no side effects (codex finding 1)
- ensure_utf8_locale_active honours its locale argument consistently in
  match, locale.gen line and export (codex finding 2)
- assert_db_encoding_utf8 uses argv-clean runuser psql with :'db' literal
  binding instead of nested su -c shell; docs keep su - postgres -c
  (codex finding 3)
2026-06-13 14:34:35 +02:00
claude-bot bd4293f53e fix(nexus-db): enforce UTF8 encoding at install time (issue #8)
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 1s
A PostgreSQL cluster/database freezes its encoding at initdb / CREATE
DATABASE time; a C (non-UTF-8) locale yields a SQL_ASCII cluster, which
makes psycopg3 return bytes and crashes SQLAlchemy. Harden the installer
and add a reusable pattern for future DB installers:

- ensure_utf8_locale_active: generate AND activate en_US.UTF-8 for the
  install process before the server package runs initdb; abort if the
  locale is not actually available
- create the database explicitly with TEMPLATE template0 ENCODING 'UTF8'
  LC_COLLATE/LC_CTYPE 'en_US.UTF-8' instead of inheriting the cluster
  default
- assert_db_encoding_utf8: post-install guard, abort with an actionable
  message if pg_encoding_to_char is not UTF8 (catches old SQL_ASCII DBs
  on re-run too)
- credentials/README docs use su - postgres -c (minimal LXCs have no sudo)
2026-06-13 14:30:40 +02:00
l.kirchner 553b923445 Merge pull request 'docs: README-Dedup — Contributing konsolidiert, Stand aktualisiert' (#7) from chore/readme-dedup into main
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (push) Successful in 2s
docs: README-Dedup nach K-114-Merge (PR #7) — ein Contributing-Abschnitt, Status/CI aktuell
2026-06-12 19:42:48 +02:00
l.kirchner 09ac4d2507 docs: consolidate duplicate contributing sections, refresh README
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 1s
- merge the two contributing sections into one (PR + cross-review rule,
  both real incidents, CI enforcement in present tense - the suite is
  live on the homelab runner since K-114/PR #5)
- script catalog: runner is in production (PR #6 merged)
- usage: document input validation behavior (re-prompt on junk bytes,
  env values abort when malformed)
- pattern: SSH root login prompt (sshd drop-in) and the locale fix in
  setup_base_apt
- repo layout: tests/ added
2026-06-12 16:46:57 +02:00
l.kirchner a397ade6e1 Merge pull request 'K-114: Input-Validierung in build.func + Mini-CI' (#5) from k114/input-validierung-mini-ci into main
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (push) Successful in 1s
K-114: Input-Validierung in build.func + Mini-CI (PR #5) — SSH-Root-Login-Prompt, locale-Fix, runs-on homelab
2026-06-12 16:40:37 +02:00
l.kirchner e5cbf3f60e Merge pull request 'feat: allgemeiner instanzweiter Actions-Runner-LXC (Label homelab, ohne Deploy-Rechte)' (#6) from feature/runner-lxc into main
feat: allgemeiner instanzweiter Actions-Runner-LXC (PR #6) — Label homelab, ohne Deploy-Rechte; Review-Findings 90/88/82/80/85 adressiert
2026-06-12 16:28:07 +02:00
l.kirchner 43c19f2cce fix(runner): Re-Review-Nits — irreführenden Validator-Kommentar korrigiert, EOF-Abbruch in Prompt-Loop 2026-06-12 15:27:45 +02:00
l.kirchner 63a735e697 fix(runner): Re-Review-Finding 85 — run_user wechselt via env -C ins RUNNER_DIR (.runner landet sonst in /) 2026-06-12 15:26:53 +02:00
l.kirchner ebdd3f5eac fix(runner): Review-Finding 80 — validierte Prompts mit Re-Prompt, env-Werte geprüft; Quoting-Falle (76) durch Charset-Validierung entschärft 2026-06-12 15:05:57 +02:00
l.kirchner f06e873118 fix(runner): Review-Findings 90/88/82/80 (+70) — Re-Run ohne deploy.env, Sicherheitsmodell dokumentiert, Requires=docker, strikte Input-Validierung, argv statt Shell-Interpolation, Unit-Härtung 2026-06-12 15:04:57 +02:00
l.kirchner 360db12cc9 docs: README neu strukturiert — vollständiger Katalog, Pattern, Security-Konventionen, PR-Pflicht 2026-06-12 14:58:49 +02:00
l.kirchner 7f64b12a77 K-114: set up locales in setup_base_apt (C.UTF-8 during install, en_US.UTF-8 default)
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 9s
LXC templates ship without a configured locale, so every apt/perl run
warned 'Setting locale failed'. setup_base_apt now exports C.UTF-8 for
the install run itself, installs the locales package, generates
en_US.UTF-8 and sets it as the system default via update-locale.
2026-06-12 14:42:03 +02:00
l.kirchner aa3ad2f716 K-114: SSH root login prompt with sshd drop-in in the install path
prompt_lxc_config asks 'SSH-Root-Login erlauben? [Y/n]' (env-presettable
via SSH_ROOT_LOGIN, validated, normalized to yes|no). The bootstrap passes
the value into the container; configure_ssh_root_login writes
/etc/ssh/sshd_config.d/zz-root-login.conf (yes -> PermitRootLogin yes,
no -> prohibit-password) and reloads sshd.
2026-06-12 14:41:31 +02:00
l.kirchner 5f532fe10a K-114: run mini CI on the instance-wide homelab runner 2026-06-12 14:40:42 +02:00
l.kirchner ad04edec5b feat(runner): Installer — act_runner instanzweit als unprivilegierter User, Docker, ohne sudoers 2026-06-12 13:25:42 +02:00
l.kirchner 2941afa53f feat(runner): allgemeiner instanzweiter Actions-Runner-LXC (Label homelab, ohne Deploy-Rechte) 2026-06-12 13:25:10 +02:00
l.kirchner 490fda2ed1 K-114: address cross-review findings
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Has been cancelled
- CTID prompt re-prompts on invalid interactive input (was: abort)
- env-provided NAMESERVER is validated when non-empty ('' stays inherit)
- prompt_validated handles EOF (no infinite loop, clean abort under -e)
- 10# base forcing in vlan/cidr/ipv4 arithmetic (leading zeros are not
  octal errors); is_clean_ascii rejects embedded newline/tab explicitly
  (command substitution strips trailing newlines); is_ipv4_list checks
  the whole string before word splitting
- nameref guard against reserved variable names in prompt_validated/
  require_valid; source-check pattern documented as the repo contract
- 8 new test cases (41 total)
2026-06-12 03:20:11 +02:00
l.kirchner 80e2ec04ff K-114: input validation in build.func + mini CI (nexus-hub card)
- validation helpers: sanitize_input trims CR/edge whitespace only;
  embedded control/non-ASCII bytes FAIL validation and re-prompt with a
  hint (2026-06-11 incident: invisible byte in a pasted VLAN tag broke
  pct create mid-run) - never silently stripped
- prompt_lxc_config: every prompt validated (uint for CTID/disk/cores/
  RAM, VLAN 1-4094, hostname/token formats, IP/CIDR/gateway, DNS list);
  env-provided values are sanitized + validated too (abort, no re-prompt
  loop in non-interactive use); helpers reusable for app prompts
- tests/test_validation.sh: 34 cases incl. the 2<0x80>0 repro, re-prompt
  simulation, BASH_REMATCH clobbering regression (is_cidr), env dry-run
  of prompt_lxc_config without PVE/TTY
- tests/check_ct_source.sh: every ct/*.sh must source build.func (bug
  shipped twice); negative proof via prepared fixture in the test suite
- .gitea/workflows/ci.yml: bash -n over all scripts, source-check,
  validation tests, shellcheck if present (documented skip otherwise)
- README: contributions via PR with cross-review (binding)
2026-06-12 03:14:32 +02:00
l.kirchner cad741a97c fix(authentik): fehlendes source von build.func ergänzt (gleicher Bug wie Codex-Finding 96 in nexus-db) 2026-06-11 17:06:41 +02:00
l.kirchner 828e3d1aad feat(authentik): Installer — Docker, offizielles Compose, headless Bootstrap-Credentials + API-Token, Blueprints-Mount 2026-06-11 17:02:47 +02:00
l.kirchner ab0354bc22 feat(authentik): LXC-Vorlage — Nesting, Agent-SSH-Key, automatisierungsfreundlich (nexus ADR-0003/K-102) 2026-06-11 17:02:10 +02:00
l.kirchner e8bace387d Merge pull request 'feat(nexus-db): PostgreSQL 16 + pgvector LXC template (nexus-hub K-102)' (#4) from k102/nexus-db into main
Reviewed-on: #4
2026-06-11 16:36:30 +02:00
l.kirchner 559ad8dc2d fix(nexus-db): cross-review findings
- source build.func (script was unrunnable without it)
- validate DB_NAME/DB_USER/DB_PORT/NEXUS_APP_IP before SQL/pg_hba use
- rotate password when role exists but credentials file is missing
2026-06-11 15:56:05 +02:00
l.kirchner 0ee7ceae55 feat(nexus-db): PostgreSQL 16 + pgvector LXC template (nexus-hub K-102)
webapp-pattern ct/install pair: PGDG repo, database nexus with
least-privilege owner role, pg_hba allowlist restricted to the nexus
app LXC (explicit reject for everything else), pgvector created by the
installer, credentials/DSN summary in /root/nexus-db.credentials.
Idempotent re-runs keep role/db and do not rotate the password.
2026-06-11 15:51:04 +02:00
16 changed files with 1726 additions and 42 deletions
+47
View File
@@ -0,0 +1,47 @@
name: CI
# K-114 (nexus-hub): Mini-CI für proxmox-scripts — Syntax, build.func-Source-
# Pflicht und Validierungs-Unit-Tests. Läuft auf dem instanzweiten Runner
# (Label homelab, ohne Deploy-Rechte — PR #6 / ct/runner.sh).
on:
push:
branches: [main]
pull_request:
jobs:
lint:
name: Shell-Lint (bash -n, source-check, Validierungs-Tests)
runs-on: homelab
steps:
- name: Checkout
uses: actions/checkout@v4
- name: bash -n über alle Scripts
run: |
status=0
for f in ct/*.sh install/*.sh lib/*.func tests/*.sh; do
if bash -n "$f"; then
echo "ok $f"
else
echo "SYNTAX $f" >&2
status=1
fi
done
exit "$status"
- name: build.func-Source-Check (jedes ct/*.sh)
run: bash tests/check_ct_source.sh
- name: Validierungs-Unit-Tests (lib/build.func)
run: bash tests/test_validation.sh
- name: shellcheck (falls auf dem Runner installiert)
run: |
if command -v shellcheck >/dev/null 2>&1; then
# -S warning: Style-Hinweise nicht blockierend; externe Sources
# (curl|source) kann shellcheck nicht folgen.
shellcheck -S warning -e SC1090,SC1091 ct/*.sh install/*.sh tests/*.sh
else
echo "shellcheck nicht installiert — übersprungen (dokumentiert, K-114)"
fi
+73 -14
View File
@@ -1,47 +1,106 @@
# luki-net / proxmox-scripts # luki-net / proxmox-scripts
Community-script-style installers for LXC services in my Proxmox VE homelab. Each script creates an unprivileged Debian 12 LXC and installs one specific app, with sensible defaults and interactive prompts. Community-script-style installers for LXC services in the luki-net Proxmox VE homelab. Each script creates an unprivileged Debian 12 LXC and installs one specific app — sensible defaults, interactive prompts, env-overridable for non-interactive runs.
Inspired by [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE), but minimal, self-hosted and tailored to my stack. Inspired by [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE), but minimal, self-hosted and tailored to this stack.
## Available scripts ## Script catalog
| App | Description | One-liner | | App | What it provisions | Status |
|-----|-------------|-----------| |-----|--------------------|--------|
| [devpi](ct/devpi.sh) | Private PyPI cache / mirror — saves time on CUDA/torch rebuilds | `bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/devpi.sh)"` | | [devpi](ct/devpi.sh) | Private PyPI cache/mirror — saves time on CUDA/torch rebuilds | ✅ stable |
| [webapp](ct/webapp.sh) | Next.js site with deploy-as-code via a self-hosted Gitea Actions runner (host mode, no inbound port) | `bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/webapp.sh)"` | | [webapp](ct/webapp.sh) | Next.js site with deploy-as-code via a repo-scoped Gitea Actions runner (host mode, no inbound port) | ✅ stable |
| [nexus](ct/nexus.sh) | App LXC for [nexus](https://gitea.luki-net.org/l.kirchner/nexus-hub) (Family Knowledge Hub): host-mode runner (label `nexus`, CI + deploy), service skeleton, `/opt/nexus` layout. Runtime is provisioned/extended via [`install/nexus-runtime.sh`](install/nexus-runtime.sh) (idempotent, re-runnable) | ✅ in production |
| [nexus-db](ct/nexus-db.sh) | PostgreSQL 16 + pgvector for nexus — least-privilege role, pg_hba allowlist (only the nexus LXC), DSN handed over via credentials file | ✅ in production |
| [authentik](ct/authentik.sh) | Central homelab IdP (official Docker Compose via LXC nesting) — headless bootstrap admin **and** API token for agent-driven blueprint configuration, blueprints mount, permanent auth domain (WebAuthn RP-ID) | ✅ in production |
| [runner](ct/runner.sh) | General **instance-wide** Gitea Actions runner (label `homelab`) — Docker for throwaway CI test containers, deliberately **no** sudoers/deploy rights | ✅ in production |
Run any one-liner on a Proxmox VE host as root:
```bash
bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/<app>.sh)"
```
## Usage ## Usage
Run any one-liner on a Proxmox VE host as root (Web UI → Node → Shell works fine). You'll be prompted for CTID, hostname, disk, RAM, CPU, bridge, storage and IP. Defaults are sane. Interactive prompts cover CTID, hostname, disk, RAM, CPU, bridge/VLAN, storage, IP/gateway/DNS — plus app-specific values. Defaults are sane. Non-interactive override via env vars:
Non-interactive override via env vars:
```bash ```bash
CTID=200 HOSTNAME=devpi DISK_SIZE=30 RAM=4096 CORES=4 IPCFG=dhcp \ CTID=200 HOSTNAME=devpi DISK_SIZE=30 RAM=4096 CORES=4 IPCFG=dhcp \
bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/devpi.sh)" bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/devpi.sh)"
``` ```
All defaults (`DEFAULT_HOSTNAME`, `DEFAULT_DISK`, …) are settable per-call via env vars as well. All defaults (`DEFAULT_HOSTNAME`, `DEFAULT_DISK`, …) and app config values are settable per call via env vars.
Every input is validated (digits-only for CTID/disk/cores/RAM/VLAN, IP/CIDR/gateway format, hostname/storage charsets). Invalid interactive input re-prompts — including pasted values with invisible control/non-ASCII bytes, which are rejected rather than silently stripped. Env-provided values are validated too and abort the run when malformed (no re-prompt loop in non-interactive use).
## The pattern
Two files per app, both sourcing the shared libs via `curl`:
- **`ct/<app>.sh`** runs on the PVE host: prompts → unprivileged LXC → pushes a config env file into the container → bootstraps the installer. Apps that need Docker (authentik, runner) enable `nesting+keyctl` automatically. The standard prompts include an **SSH root login choice** (`SSH_ROOT_LOGIN`, default yes for homelab convenience; `no` keeps the Debian key-only default) — applied inside the container as an sshd drop-in by `configure_ssh_root_login`.
- **`install/<app>-install.sh`** runs inside the LXC: packages, unprivileged app user, secrets generated on-host (never printed), systemd units, a `/root/<app>.credentials` notes file — then shreds the bootstrap env. Idempotent where it matters: re-runs skip what exists. `setup_base_apt` also fixes the bare-template **locale situation**: `C.UTF-8` is exported up front (glibc built-in, covers the first apt run without perl warnings), then `en_US.UTF-8` is generated and set as the system default.
**DB installers** carry one extra rule: a PostgreSQL cluster/database freezes its encoding at initdb / `CREATE DATABASE` time and it can never be changed afterwards. A C (non-UTF-8) locale yields a `SQL_ASCII` cluster — psycopg3 then hands text back as bytes and SQLAlchemy crashes. So the pattern (helpers `ensure_utf8_locale_active` + `assert_db_encoding_utf8` in `lib/install.func`) is: make a UTF-8 locale **active** before the server package runs initdb, create the database **explicitly** with `TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE/LC_CTYPE 'en_US.UTF-8'` (never inherit the cluster default), and **verify** `pg_encoding_to_char` returns `UTF8` before finishing — a wrong encoding aborts the install (it's DB damage, see wiki → Lessons). Maintenance examples use `su - postgres -c …`, not `sudo` — these minimal LXCs have no sudo.
Shared libs: [`lib/build.func`](lib/build.func) (host-side: prompts, LXC create, bootstrap) and [`lib/install.func`](lib/install.func) (in-container: apt, users, systemd, http-wait).
## Security conventions
- Unprivileged LXCs only; app processes run as dedicated system users.
- Least privilege everywhere: narrow sudoers (exact-match commands — sudoers compares **verbatim incl. arguments**), DB allowlists, LAN-only binds for inference ports.
- Secrets are generated on the target host and live in `0600` files — never in the repo, the wiki or chat logs.
- **Runner separation:** the nexus runner is repo-scoped and lives on the production LXC *because* it holds deploy rights; the general `runner` LXC is instance-wide *because* it holds none. Don't mix these scopes.
### CIS Tier-A hardening
[`install/cis-tierA.sh`](install/cis-tierA.sh) applies the Tier-A baseline to an existing Debian 12 LXC: SSH drop-in, `login.defs` aging + YESCRYPT, pwquality/faillock, PAM (pwquality, pwhistory, faillock, `nullok` removed) and auditd rules. It is idempotent, backs everything up to `/root/cis-hardening-backup-<ts>/`, gates the SSH restart on `sshd -t` and rolls PAM back if a referenced module is missing.
```bash
pct push <id> install/cis-tierA.sh /root/cis-tierA.sh
pct exec <id> -- bash /root/cis-tierA.sh
```
Applied to authentik first (2026-07-24), then to 11 further containers. The PVE hosts and the Wazuh manager VM stay unhardened on purpose — they are the break-glass foundation.
Two things worth knowing before touching it:
- **Section 1 rewrites `99-cis-hardening.conf` wholesale on every run.** Host-specific exceptions belong in a separate drop-in that sorts *after* it (e.g. `99-zz-local-forward.conf`), never in that file. A `Match` block extends until the next `Match` — across `Include` file boundaries — so such a drop-in has to stay alphabetically last.
- **Forwarding is denied per option, not via `DisableForwarding`.** Both are equivalent in effect, but `DisableForwarding` overrides every other forwarding option *and* does not appear in `sshd -T` output. A denied port-forward then reports `administratively prohibited` while `sshd -T` cheerfully claims `allowtcpforwarding yes`, which costs hours to diagnose.
Caveat: auditd is a no-op in unprivileged LXCs (the host owns the audit subsystem); the script probes for it and removes the package again if it cannot load rules. Together with the Section-1 partition/kernel checks that are equally N/A in a container, the achievable SCA score stays well below 100 %.
## Contributing
**All changes go through a pull request with cross-review** (Claude Code ↔ Codex, or a human) — no direct pushes to `main`. This rule exists because of two real incidents: a pasted VLAN tag carrying an invisible non-UTF-8 byte broke `pct create` mid-run, and the "missing `source build.func`" bug shipped twice — caught in review on the nexus-db PR, but reaching production via an un-reviewed authentik commit (see wiki → Lessons).
CI (`.gitea/workflows/ci.yml`, instance-wide `homelab` runner from [ct/runner.sh](ct/runner.sh)) enforces on every PR: `bash -n` over all scripts, the "every `ct/*.sh` sources `build.func`" check ([tests/check_ct_source.sh](tests/check_ct_source.sh)) and the validation unit tests ([tests/test_validation.sh](tests/test_validation.sh)); shellcheck runs when available on the runner.
Build new app prompts on `prompt_validated`/`require_valid` from `lib/build.func` instead of bare `read`. How to add a script: [docs/adding-a-script.md](docs/adding-a-script.md).
## Repo layout ## Repo layout
``` ```
. .
├── ct/ # Host-side scripts, one per app ├── ct/ # Host-side scripts, one per app
├── install/ # In-container installers, one per app ├── install/ # In-container installers (+ nexus-runtime.sh re-provisioner,
│ # cis-tierA.sh hardening pass)
├── lib/ ├── lib/
│ ├── build.func # Shared host-side helpers (prompts, LXC create, bootstrap) │ ├── build.func # Shared host-side helpers (prompts, LXC create, bootstrap)
│ └── install.func # Shared in-container helpers (apt, systemd, users, http-wait) │ └── install.func # Shared in-container helpers (apt, systemd, users, http-wait)
├── tests/
│ ├── test_validation.sh # Unit tests for the input validation helpers
│ └── check_ct_source.sh # Every ct/*.sh must source build.func
├── docs/ ├── docs/
│ └── adding-a-script.md │ └── adding-a-script.md
├── README.md ├── README.md
└── LICENSE └── LICENSE
``` ```
## Adding a new script ## Related
See [docs/adding-a-script.md](docs/adding-a-script.md). Two files per app, both source the shared libs via `curl`. - [nexus-hub](https://gitea.luki-net.org/l.kirchner/nexus-hub) — Family Knowledge Hub (main consumer of nexus/nexus-db/authentik/runner)
- [Wiki](https://gitea.luki-net.org/luki-net/proxmox-scripts/wiki) — per-app runbook pointers, conventions, lessons learned
## License ## License
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Authentik — zentraler Homelab-IdP (nexus ADR-0003)
#
# Creates an unprivileged Debian 12 LXC with nesting enabled that runs the
# official Authentik docker-compose stack (server, worker, postgres, redis).
#
# Automation-friendly by design (nexus-hub K-102):
# - bootstrap admin password AND API token are generated headlessly
# (-> /root/authentik.credentials) so an agent can apply blueprints via
# API without ever touching the UI
# - optional dedicated SSH public key for agent access (Claude Code)
# - blueprints dir mounted at /opt/authentik/blueprints (compose override)
#
# Manual steps that remain AFTER this script (by design):
# 1. NPMplus: proxy host auth.<domain> -> http://<LXC-IP>:9000
# (WebSockets ON; the auth domain is PERMANENT — WebAuthn RP-ID!)
# 2. Passkey enrollment of the human admin account
#
# Run on a Proxmox VE host:
# bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/authentik.sh)"
set -euo pipefail
APP="authentik"
APP_DESCRIPTION="Authentik IdP (Docker-Compose) — Passkeys, TOTP, OIDC für nexus & Homelab"
APP_PORT="${APP_PORT:-9000}"
LIB_URL="${LIB_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib}"
INSTALL_SCRIPT_URL="${INSTALL_SCRIPT_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/install/authentik-install.sh}"
source <(curl -fsSL "$LIB_URL/build.func")
# LXC defaults (server+worker+postgres+redis brauchen Luft)
DEFAULT_HOSTNAME="authentik"
DEFAULT_DISK="20"
DEFAULT_CORES="2"
DEFAULT_RAM="4096"
prompt_app_config() {
echo
echo "── Authentik configuration ─────────────────────────────────"
if [[ -z "${AUTH_DOMAIN:-}" ]]; then
read -rp "Auth-Domain (dauerhaft! WebAuthn-RP-ID), z. B. auth.luki-net.org: " AUTH_DOMAIN
fi
[[ -n "${AUTH_DOMAIN:-}" ]] || { msg_err "AUTH_DOMAIN ist Pflicht"; exit 1; }
if [[ -z "${CLAUDE_SSH_PUBKEY:-}" ]]; then
read -rp "SSH-Public-Key für Agent-Zugang (leer = überspringen): " CLAUDE_SSH_PUBKEY || true
fi
# Authentik-Version: leer = Default des offiziellen Compose-Files
AUTHENTIK_TAG="${AUTHENTIK_TAG:-}"
echo " → domain: $AUTH_DOMAIN port: $APP_PORT tag: ${AUTHENTIK_TAG:-compose-default}"
}
push_app_config() {
msg_info "Pushing config into container..."
local tmpf; tmpf=$(mktemp)
cat >"$tmpf" <<EOF
AUTH_DOMAIN='$AUTH_DOMAIN'
APP_PORT='$APP_PORT'
AUTHENTIK_TAG='$AUTHENTIK_TAG'
CLAUDE_SSH_PUBKEY='${CLAUDE_SSH_PUBKEY:-}'
EOF
pct push "$CTID" "$tmpf" /root/authentik.deploy.env --perms 600
rm -f "$tmpf"
}
# Docker im unprivilegierten LXC braucht nesting+keyctl — vor dem Bootstrap setzen.
enable_nesting() {
msg_info "Enabling nesting+keyctl features (Docker in unprivileged LXC)..."
pct set "$CTID" --features nesting=1,keyctl=1
pct reboot "$CTID"
# warten bis der Container wieder antwortet
for _ in $(seq 1 30); do
pct exec "$CTID" -- true >/dev/null 2>&1 && break
sleep 2
done
msg_ok "Container restarted with nesting enabled"
}
print_app_summary() {
cat <<EOF
Authentik: http://$IP_CT:$APP_PORT (UI nach erstem Start, dauert 1–2 min)
Credentials/API-Token: /root/authentik.credentials (im LXC; akadmin + Bootstrap-Token)
Blueprints: /opt/authentik/blueprints (gemountet; Agent legt YAMLs ab,
Quelle versioniert in nexus-hub infra/authentik/)
⚠️ JETZT MANUELL (dauerhaft — WebAuthn-RP-ID):
NPMplus: Proxy Host $AUTH_DOMAIN → http://$IP_CT:$APP_PORT (WebSockets: ON)
Danach: Agent (Claude Code) per SSH übernimmt Blueprints/OIDC-Provider;
zuletzt Passkey-Enrollment des menschlichen Admin-Accounts über $AUTH_DOMAIN.
Logs: pct exec $CTID -- docker compose -f /opt/authentik/docker-compose.yml logs -f
EOF
}
trap _on_error ERR
preflight_pve
show_header "$APP" "$APP_DESCRIPTION"
prompt_lxc_config
prompt_app_config
resolve_debian_template
create_lxc
enable_nesting
push_app_config
bootstrap_install_script "$INSTALL_SCRIPT_URL"
print_summary
Executable
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# nexus-db — PostgreSQL 16 (+pgvector) for nexus (Family Knowledge Hub)
#
# Creates an unprivileged Debian 12 LXC that:
# - runs PostgreSQL 16 from the PGDG repo with the pgvector extension
# - hosts database `nexus` owned by a least-privilege role `nexus`
# - accepts connections ONLY from the nexus app LXC (pg_hba allowlist);
# every other host is rejected
#
# Companion card: nexus-hub K-102. Run on a Proxmox VE host:
# bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/nexus-db.sh)"
set -euo pipefail
APP="nexus-db"
APP_DESCRIPTION="PostgreSQL 16 + pgvector for nexus (access restricted to the nexus LXC)"
LIB_URL="${LIB_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib}"
INSTALL_SCRIPT_URL="${INSTALL_SCRIPT_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/install/nexus-db-install.sh}"
# LXC defaults (DB only: small CPU, RAM matters for shared_buffers/cache)
DEFAULT_HOSTNAME="nexus-db"
DEFAULT_DISK="16"
DEFAULT_CORES="2"
DEFAULT_RAM="4096"
DEFAULT_DB_NAME="nexus"
DEFAULT_DB_USER="nexus"
DEFAULT_DB_PORT="5432"
source <(curl -fsSL "$LIB_URL/build.func")
# ── app-specific prompts (host TTY; each skipped if the var is preset) ───────
prompt_app_config() {
echo
echo "── nexus-db configuration ───────────────────────────────────"
# The ONLY host that may connect (pg_hba allowlist) — the nexus app LXC.
if [[ -z "${NEXUS_APP_IP:-}" ]]; then
read -rp "IP of the nexus app LXC (sole allowed client): " NEXUS_APP_IP
fi
[[ -n "${NEXUS_APP_IP:-}" ]] || { msg_err "NEXUS_APP_IP is required (pg_hba allowlist)"; exit 1; }
DB_NAME="${DB_NAME:-$DEFAULT_DB_NAME}"
DB_USER="${DB_USER:-$DEFAULT_DB_USER}"
DB_PORT="${DB_PORT:-$DEFAULT_DB_PORT}"
echo " → database: $DB_NAME role: $DB_USER port: $DB_PORT"
echo " → allowed client: $NEXUS_APP_IP/32 (everything else is rejected)"
}
# ── push gathered config into the container for the installer to consume ─────
push_app_config() {
msg_info "Pushing db config into container..."
local tmpf; tmpf=$(mktemp)
cat >"$tmpf" <<EOF
NEXUS_APP_IP='$NEXUS_APP_IP'
DB_NAME='$DB_NAME'
DB_USER='$DB_USER'
DB_PORT='$DB_PORT'
EOF
pct push "$CTID" "$tmpf" /root/nexus-db.deploy.env --perms 600
rm -f "$tmpf"
}
# ── trailing summary ─────────────────────────────────────────────────────────
print_app_summary() {
local pg_state
pg_state=$(pct exec "$CTID" -- systemctl is-active postgresql 2>/dev/null | tr -d '\r\n')
cat <<EOF
PostgreSQL 16: $IP_CT:$DB_PORT — $pg_state
Database: $DB_NAME (owner: $DB_USER, extension: vector)
Allowed client: $NEXUS_APP_IP/32 — all other hosts are rejected
Credentials + DSN: /root/nexus-db.credentials (inside the LXC)
Smoke test FROM THE NEXUS LXC (uses the DSN from the credentials file):
psql "postgresql://$DB_USER:<password>@$IP_CT:$DB_PORT/$DB_NAME" -c "SELECT extname FROM pg_extension;"
Negative test from any OTHER host (must fail):
psql "postgresql://$DB_USER:<password>@$IP_CT:$DB_PORT/$DB_NAME" -c "SELECT 1;"
Logs: pct exec $CTID -- journalctl -u postgresql -f
EOF
}
# ── orchestrate ───────────────────────────────────────────────────────────────
trap _on_error ERR
preflight_pve
show_header "$APP" "$APP_DESCRIPTION"
prompt_lxc_config
prompt_app_config
resolve_debian_template
create_lxc
push_app_config
bootstrap_install_script "$INSTALL_SCRIPT_URL"
print_summary
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Allgemeiner Gitea-Actions-Runner — instanzweit, OHNE Deploy-Rechte
#
# Motivation (nexus K-114-Blocker): Der nexus-Runner läuft auf dem
# Produktions-LXC und besitzt sudoers-Deploy-Rechte — er darf deshalb NICHT
# instanzweit registriert werden (jedes Repo könnte sonst Workflows auf der
# Produktionsmaschine ausführen). Dieser LXC ist die saubere Trennung:
# - instanzweite Registrierung (Site Administration → Actions → Runners)
# - Label "homelab:host" (Deploy-Jobs bleiben auf "nexus")
# - KEINE sudoers-Regeln, kein Zugriff auf Produktions-Verzeichnisse
# - Docker via Nesting für Wegwerf-Test-Container (z. B. pgvector in CI)
#
# Sicherheitsmodell: siehe Kopfkommentar in install/runner-install.sh —
# instanzweit + docker-Gruppe heißt: jedes Repo der Instanz kann diesen LXC
# kontrollieren. Akzeptiert, WEIL er nichts besitzt. Nicht auf privilegierte
# LXCs übertragen.
#
# Run on a Proxmox VE host:
# bash -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/runner.sh)"
set -euo pipefail
APP="runner"
APP_DESCRIPTION="Allgemeiner Gitea-Actions-Runner (instanzweit, Label homelab, Docker, ohne Deploy-Rechte)"
LIB_URL="${LIB_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib}"
INSTALL_SCRIPT_URL="${INSTALL_SCRIPT_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/install/runner-install.sh}"
source <(curl -fsSL "$LIB_URL/build.func")
DEFAULT_HOSTNAME="runner"
DEFAULT_DISK="30"
DEFAULT_CORES="4"
DEFAULT_RAM="6144"
# ── Validierung (Review-Finding 80): Werte wandern in die Deploy-Env und in
# systemd/argv — strikte Zeichenklassen, Re-Prompt statt Abbruch.
# Bewusst LOKALE Validatoren: die Libs werden zur Laufzeit von main geladen,
# dieses Script muss aber unabhängig vom Merge-Stand der K-114-Helfer
# (prompt_validated/require_valid) funktionieren. Semantik ist identisch;
# Konsolidierung auf die build.func-Helfer ist als Follow-up notiert. ──────
_valid_url() { [[ "$1" =~ ^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; }
_valid_token() { [[ "$1" =~ ^[A-Za-z0-9_-]{16,128}$ ]]; }
_valid_word() { [[ "$1" =~ ^[A-Za-z0-9._:,-]+$ ]]; }
_prompt_until_valid() { # var prompt default validator secret(0|1)
local __var="$1" __prompt="$2" __default="$3" __validator="$4" __secret="${5:-0}" __val
while true; do
if [[ "$__secret" == "1" ]]; then
read -rsp "$__prompt" __val || { echo; msg_err "Eingabe abgebrochen (EOF)"; exit 1; }
echo
else
read -rp "$__prompt" __val || { echo; msg_err "Eingabe abgebrochen (EOF)"; exit 1; }
fi
__val="${__val:-$__default}"
if [[ -n "$__val" ]] && "$__validator" "$__val"; then
printf -v "$__var" '%s' "$__val"
return 0
fi
msg_warn "Ungültige Eingabe — bitte erneut (kein Paste mit Sonderzeichen)."
done
}
prompt_app_config() {
echo
echo "── Runner configuration ─────────────────────────────────────"
# env-präsetzte Werte werden validiert (Abbruch bei ungültig — K-114-Konvention),
# interaktive Eingaben re-prompten bis gültig.
if [[ -n "${GITEA_INSTANCE_URL:-}" ]]; then
_valid_url "$GITEA_INSTANCE_URL" || { msg_err "GITEA_INSTANCE_URL (env) ungültig"; exit 1; }
else
_prompt_until_valid GITEA_INSTANCE_URL \
"Gitea instance URL [https://gitea.luki-net.org]: " \
"https://gitea.luki-net.org" _valid_url 0
fi
# WICHTIG: den INSTANZWEITEN Token verwenden
# (Site Administration → Actions → Runners → Create new runner),
# NICHT den Repo-Token — sonst wiederholt sich der K-114-Scope-Blocker.
if [[ -n "${RUNNER_TOKEN:-}" ]]; then
_valid_token "$RUNNER_TOKEN" || { msg_err "RUNNER_TOKEN (env) ungültig (16–128 Zeichen [A-Za-z0-9_-])"; exit 1; }
else
_prompt_until_valid RUNNER_TOKEN \
"INSTANZWEITER Runner-Registration-Token: " \
"" _valid_token 1
fi
RUNNER_NAME="${RUNNER_NAME:-$CT_HOSTNAME}"
RUNNER_LABELS="${RUNNER_LABELS:-homelab:host}"
RUNNER_VERSION="${RUNNER_VERSION:-0.2.13}"
NODE_MAJOR="${NODE_MAJOR:-22}"
_valid_word "$RUNNER_NAME" || { msg_err "RUNNER_NAME ungültig"; exit 1; }
_valid_word "$RUNNER_LABELS" || { msg_err "RUNNER_LABELS ungültig"; exit 1; }
[[ "$RUNNER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { msg_err "RUNNER_VERSION ungültig"; exit 1; }
[[ "$NODE_MAJOR" =~ ^[0-9]+$ ]] || { msg_err "NODE_MAJOR ungültig"; exit 1; }
echo " → instance: $GITEA_INSTANCE_URL"
echo " → runner: $RUNNER_NAME labels: $RUNNER_LABELS (act_runner $RUNNER_VERSION, host mode, scope: INSTANZ)"
}
push_app_config() {
msg_info "Pushing runner config into container..."
local tmpf; tmpf=$(mktemp)
# Werte sind oben strikt validiert (keine Quotes/Whitespace möglich) —
# damit ist die env-Datei frei von Quoting-/Injection-Fallen (vgl. Finding 76).
cat >"$tmpf" <<EOF
GITEA_INSTANCE_URL=$GITEA_INSTANCE_URL
RUNNER_TOKEN=$RUNNER_TOKEN
RUNNER_NAME=$RUNNER_NAME
RUNNER_LABELS=$RUNNER_LABELS
RUNNER_VERSION=$RUNNER_VERSION
NODE_MAJOR=$NODE_MAJOR
EOF
pct push "$CTID" "$tmpf" /root/runner.deploy.env --perms 600
rm -f "$tmpf"
}
# Docker im unprivilegierten LXC braucht nesting+keyctl (Test-Container in CI).
enable_nesting() {
msg_info "Enabling nesting+keyctl features (Docker für CI-Test-Container)..."
pct set "$CTID" --features nesting=1,keyctl=1
pct reboot "$CTID"
for _ in $(seq 1 30); do
pct exec "$CTID" -- true >/dev/null 2>&1 && break
sleep 2
done
msg_ok "Container restarted with nesting enabled"
}
print_app_summary() {
local runner_state
runner_state=$(pct exec "$CTID" -- systemctl is-active act-runner.service 2>/dev/null | tr -d '\r\n')
cat <<EOF
Gitea-Actions-Runner: $RUNNER_NAME [$RUNNER_LABELS] — $runner_state
Scope: INSTANZWEIT — bedient alle Repos der Instanz
Mode: host (Docker verfügbar für Test-Container)
Sicherheit: kein sudoers, keine Deploy-Rechte, keine Produktions-Mounts
(Modell: siehe install/runner-install.sh Kopfkommentar)
Verify: $GITEA_INSTANCE_URL → Site Administration → Actions → Runners
Workflows anderer Repos nutzen: runs-on: ${RUNNER_LABELS%%:*}
(Deploy-Jobs von nexus bleiben auf dem nexus-LXC-Runner, Label "nexus".)
Logs: pct exec $CTID -- journalctl -u act-runner -f
EOF
}
trap _on_error ERR
preflight_pve
show_header "$APP" "$APP_DESCRIPTION"
prompt_lxc_config
prompt_app_config
resolve_debian_template
create_lxc
enable_nesting
push_app_config
bootstrap_install_script "$INSTALL_SCRIPT_URL"
print_summary
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Authentik installer — runs inside the LXC, called by ct/authentik.sh
#
# Installs Docker + the official Authentik docker-compose stack, headless:
# - secrets generated on-host (PG_PASS, AUTHENTIK_SECRET_KEY) — never printed
# - bootstrap admin (akadmin) password + API token generated so that an
# agent can configure everything via API/blueprints without the UI
# - blueprints dir mounted via docker-compose.override.yml
# - optional dedicated SSH key for agent access appended to authorized_keys
#
# Idempotent: re-running keeps existing secrets/.env and only updates images.
set -euo pipefail
LIB_URL="${LIB_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib}"
source <(curl -fsSL "$LIB_URL/install.func")
[[ "$EUID" -eq 0 ]] || { msg_err "Must run as root"; exit 1; }
CONF="/root/authentik.deploy.env"
[[ -f "$CONF" ]] || { msg_err "$CONF not found (host bootstrap incomplete)"; exit 1; }
set -a; . "$CONF"; set +a
: "${AUTH_DOMAIN:?missing AUTH_DOMAIN}"
APP_PORT="${APP_PORT:-9000}"
AK_DIR="/opt/authentik"
ENV_FILE="$AK_DIR/.env"
CRED_FILE="/root/authentik.credentials"
# ── base packages + Docker ────────────────────────────────────────────────────
setup_base_apt ca-certificates curl
configure_ssh_root_login
if ! command -v docker >/dev/null 2>&1; then
msg_info "Installing Docker (get.docker.com)..."
curl -fsSL https://get.docker.com | sh >/dev/null
msg_ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')"
else
msg_warn "Docker already present, skipping"
fi
# ── optional: dedicated agent SSH key ────────────────────────────────────────
if [[ -n "${CLAUDE_SSH_PUBKEY:-}" ]]; then
mkdir -p /root/.ssh && chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys
if ! grep -qF "$CLAUDE_SSH_PUBKEY" /root/.ssh/authorized_keys; then
echo "$CLAUDE_SSH_PUBKEY" >> /root/.ssh/authorized_keys
msg_ok "Agent SSH key installed"
else
msg_warn "Agent SSH key already present"
fi
fi
# ── compose stack ─────────────────────────────────────────────────────────────
mkdir -p "$AK_DIR/blueprints" "$AK_DIR/media" "$AK_DIR/custom-templates" "$AK_DIR/certs"
cd "$AK_DIR"
if [[ ! -f docker-compose.yml ]]; then
msg_info "Fetching official Authentik compose file..."
curl -fsSL -o docker-compose.yml https://goauthentik.io/docker-compose.yml
msg_ok "docker-compose.yml fetched"
else
msg_warn "docker-compose.yml exists, keeping (idempotent)"
fi
# Override: Blueprints in server+worker mounten, Port-Bind nur auf LXC-Netz nötig?
# Authentik bleibt im LAN hinter NPMplus — Standard-Bind reicht; Blueprints-Mount ergänzen.
if [[ ! -f docker-compose.override.yml ]]; then
cat > docker-compose.override.yml <<EOF
services:
server:
volumes:
- ./blueprints:/blueprints/custom:ro
worker:
volumes:
- ./blueprints:/blueprints/custom:ro
EOF
msg_ok "compose override (custom blueprints mount) written"
fi
# ── secrets / env (idempotent: vorhandene .env bleibt) ────────────────────────
if [[ ! -f "$ENV_FILE" ]]; then
msg_info "Generating secrets (.env)..."
PG_PASS="$(openssl rand -base64 36 | tr -d '\n=/+' | cut -c1-32)"
AK_SECRET="$(openssl rand -base64 60 | tr -d '\n')"
AK_BOOT_PW="$(openssl rand -base64 24 | tr -d '\n=/+' | cut -c1-20)"
AK_BOOT_TOKEN="$(openssl rand -hex 32)"
cat >"$ENV_FILE" <<EOF
PG_PASS=$PG_PASS
AUTHENTIK_SECRET_KEY=$AK_SECRET
AUTHENTIK_BOOTSTRAP_PASSWORD=$AK_BOOT_PW
AUTHENTIK_BOOTSTRAP_TOKEN=$AK_BOOT_TOKEN
AUTHENTIK_BOOTSTRAP_EMAIL=admin@$AUTH_DOMAIN
COMPOSE_PORT_HTTP=$APP_PORT
${AUTHENTIK_TAG:+AUTHENTIK_TAG=$AUTHENTIK_TAG}
# E-Mail-Versand bewusst unkonfiguriert (Familien-Setup; bei Bedarf nachziehen):
# AUTHENTIK_EMAIL__HOST=...
EOF
chmod 600 "$ENV_FILE"
cat >"$CRED_FILE" <<EOF
Authentik — zentraler Homelab-IdP (nexus ADR-0003)
URL (LAN): http://$(hostname -I | awk '{print $1}'):$APP_PORT
URL (final): https://$AUTH_DOMAIN (nach NPMplus-Eintrag; Domain ist DAUERHAFT)
Bootstrap-Admin: akadmin
Passwort: $AK_BOOT_PW
API-Token: $AK_BOOT_TOKEN
-> für Agent-Automation (Blueprints/OIDC via API). Nach Abschluss der
Einrichtung rotieren oder widerrufen; menschlicher Admin nutzt eigenen
Account mit Passkey, NICHT akadmin.
Blueprints: $AK_DIR/blueprints (Quelle: nexus-hub infra/authentik/)
Stack: cd $AK_DIR && docker compose ps|logs|pull
EOF
chmod 600 "$CRED_FILE"
msg_ok "Secrets + credentials written ($CRED_FILE)"
else
msg_warn ".env exists — keeping existing secrets (idempotent)"
fi
# ── start ─────────────────────────────────────────────────────────────────────
msg_info "Pulling images & starting Authentik (first start takes 1–2 min)..."
docker compose pull -q
docker compose up -d
# Warten bis der Server antwortet (Healthcheck)
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:$APP_PORT/-/health/live/" >/dev/null 2>&1; then
msg_ok "Authentik is up (http://127.0.0.1:$APP_PORT)"
break
fi
sleep 5
done
curl -fsS "http://127.0.0.1:$APP_PORT/-/health/live/" >/dev/null 2>&1 || \
msg_warn "Authentik antwortet noch nicht — 'docker compose logs -f' prüfen (Migrationslauf kann dauern)"
shred -u "$CONF" 2>/dev/null || rm -f "$CONF"
apt_cleanup
msg_ok "authentik installation finished"
+163
View File
@@ -0,0 +1,163 @@
#!/bin/bash
# CIS Tier-A hardening for a Debian 12 LXC. Idempotent, backs up everything,
# gates the SSH restart on `sshd -t`, auto-rolls-back PAM on sanity failure.
# Recovery path if anything breaks: `pct exec <id> -- bash` from the PVE host.
#
# Rollout: copy into the target LXC and run as root, e.g.
# pct push <id> install/cis-tierA.sh /root/cis-tierA.sh
# pct exec <id> -- bash /root/cis-tierA.sh
# First applied to authentik on 2026-07-24, then to 11 further containers.
#
# NOT hardened on purpose (break-glass foundation): the PVE hosts pve-gamer /
# pve-i5 and the Wazuh manager VM. See the wiki for the break-glass chain.
#
# SSH: section 1 rewrites /etc/ssh/sshd_config.d/99-cis-hardening.conf WHOLESALE
# on every run. Host-specific exceptions therefore do NOT belong in that file —
# put them in a separate drop-in that sorts AFTER it, e.g.
# 99-zz-local-forward.conf. Mind that a Match block extends until the next
# Match, across Include file boundaries, so such a drop-in must stay last.
#
# Forwarding is denied per option (AllowTcpForwarding / AllowAgentForwarding /
# AllowStreamLocalForwarding / X11Forwarding), not via DisableForwarding. Both
# are equivalent in effect, but DisableForwarding overrides every other
# forwarding option AND is invisible in `sshd -T` output — which makes a denied
# port-forward practically undiagnosable. See the wiki entry on that.
set -u
TS=$(date +%Y%m%d-%H%M%S)
BK=/root/cis-hardening-backup-$TS
mkdir -p "$BK"
export DEBIAN_FRONTEND=noninteractive
say(){ echo "[cis] $*"; }
########## 1. SSH hardening (drop-in, validated) ##########
SSHD=/etc/ssh/sshd_config.d/99-cis-hardening.conf
grep -q "Include /etc/ssh/sshd_config.d" /etc/ssh/sshd_config || echo "Include /etc/ssh/sshd_config.d/*.conf" > /tmp/_noinc
[ -f "$SSHD" ] && cp "$SSHD" "$BK/" 2>/dev/null
printf '%s\n' "Warning: Authorized access only. All activity is monitored." > /etc/issue.net
cat > "$SSHD" <<'EOF'
PermitRootLogin prohibit-password
MaxAuthTries 4
LoginGraceTime 60
ClientAliveInterval 15
ClientAliveCountMax 3
Banner /etc/issue.net
MaxStartups 10:30:60
AllowTcpForwarding no
AllowAgentForwarding no
AllowStreamLocalForwarding no
X11Forwarding no
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
EOF
if sshd -t 2>/tmp/sshderr; then
systemctl restart ssh 2>/dev/null || systemctl restart sshd 2>/dev/null
say "SSH: drop-in applied + restarted (sshd -t OK, active=$(systemctl is-active ssh 2>/dev/null || systemctl is-active sshd 2>/dev/null))"
else
rm -f "$SSHD"; say "SSH: sshd -t FAILED -> reverted ($(cat /tmp/sshderr))"
fi
########## 2. login.defs (password aging + hashing) ##########
cp /etc/login.defs "$BK/"
setdef(){ if grep -qE "^\s*$1\b" /etc/login.defs; then sed -i -E "s|^\s*$1\b.*|$1 $2|" /etc/login.defs; else echo "$1 $2" >> /etc/login.defs; fi; }
setdef PASS_MAX_DAYS 365
setdef PASS_MIN_DAYS 1
setdef PASS_WARN_AGE 7
setdef ENCRYPT_METHOD YESCRYPT
useradd -D -f 30 2>/dev/null
say "login.defs: aging + YESCRYPT set"
########## 3. pwquality ##########
apt-get install -y libpam-pwquality >/dev/null 2>&1
[ -f /etc/security/pwquality.conf ] && cp /etc/security/pwquality.conf "$BK/"
cat > /etc/security/pwquality.conf <<'EOF'
minlen = 14
minclass = 4
maxrepeat = 3
dictcheck = 1
enforcing = 1
EOF
[ -f /etc/security/faillock.conf ] && cp /etc/security/faillock.conf "$BK/"
cat > /etc/security/faillock.conf <<'EOF'
deny = 5
unlock_time = 900
fail_interval = 900
EOF
say "pwquality.conf + faillock.conf written"
########## 4. PAM module enablement (backup + sanity + rollback) ##########
CA=/etc/pam.d/common-auth
CP=/etc/pam.d/common-password
cp "$CA" "$BK/"; cp "$CP" "$BK/"
# 5.3.3.4.1 remove nullok
sed -i 's/[[:space:]]*nullok//g' "$CA" "$CP"
# common-password: full, correctly-formed pwquality + pwhistory lines before pam_unix
grep -q pam_pwquality.so "$CP" || sed -i '0,/^password[[:space:]].*pam_unix.so/s//password requisite pam_pwquality.so retry=3\n&/' "$CP"
grep -q pam_pwhistory.so "$CP" || sed -i '0,/^password[[:space:]].*pam_unix.so/s//password required pam_pwhistory.so remember=5 use_authtok\n&/' "$CP"
grep -qE 'pam_unix.so.*use_authtok' "$CP" || sed -i -E 's/(^password[[:space:]].*pam_unix.so.*)/\1 use_authtok/' "$CP"
# common-auth: faillock preauth/authfail/authsucc (full lines, idempotent)
if ! grep -q pam_faillock.so "$CA"; then
sed -i '1i auth required pam_faillock.so preauth' "$CA"
awk 'BEGIN{d=0}{print} (/pam_unix.so/ && d==0){print "auth [default=die] pam_faillock.so authfail"; print "auth sufficient pam_faillock.so authsucc"; d=1}' "$CA" > "$CA.tmp" && mv "$CA.tmp" "$CA"
fi
# sanity: every referenced module must exist; pam_unix + pam_deny must remain
MODDIR=$(dirname "$(find /lib /usr/lib -name pam_unix.so 2>/dev/null | head -1)")
ok=1
for m in $(grep -hoE 'pam_[a-z_]+\.so' "$CA" "$CP" | sort -u); do
[ -f "$MODDIR/$m" ] || { say "PAM sanity: missing $m"; ok=0; }
done
grep -q pam_unix.so "$CA" && grep -q pam_unix.so "$CP" || ok=0
if [ "$ok" != "1" ]; then
cp "$BK/common-auth" "$CA"; cp "$BK/common-password" "$CP"
say "PAM: sanity FAILED -> rolled back common-auth/common-password"
else
say "PAM: pwquality/pwhistory/faillock enabled, nullok removed (sanity OK)"
fi
########## 5. auditd (probe LXC support) ##########
apt-get install -y auditd audispd-plugins >/dev/null 2>&1
AUOK=0
if auditctl -l >/dev/null 2>&1 && auditctl -a always,exit -F arch=b64 -S adjtimex -k _probe 2>/dev/null; then
auditctl -d always,exit -F arch=b64 -S adjtimex -k _probe 2>/dev/null; AUOK=1
fi
if [ "$AUOK" = "1" ]; then
cp -n /etc/audit/rules.d/audit.rules "$BK/" 2>/dev/null
cat > /etc/audit/rules.d/cis.rules <<'EOF'
-w /etc/group -p wa -k identity
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/security/opasswd -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
-w /var/log/sudo.log -p wa -k sudo_log
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
-w /etc/hosts -p wa -k system-locale
-w /etc/network/ -p wa -k system-locale
-w /var/log/wtmp -p wa -k session
-w /var/log/btmp -p wa -k session
-w /var/run/utmp -p wa -k session
-w /var/log/lastlog -p wa -k logins
-w /var/run/faillock/ -p wa -k logins
-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts
-a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>=1000 -F auid!=4294967295 -k delete
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat,chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod
-a always,exit -F arch=b64 -S init_module,delete_module,finit_module -k modules
-w /usr/sbin/usermod -p x -k usermod
EOF
systemctl enable auditd >/dev/null 2>&1
augenrules --load >/dev/null 2>&1
systemctl restart auditd 2>/dev/null || service auditd restart 2>/dev/null
say "auditd: FUNCTIONAL in this LXC -> CIS rules loaded ($(auditctl -l 2>/dev/null | wc -l) rules)"
else
systemctl disable --now auditd >/dev/null 2>&1
apt-get purge -y auditd audispd-plugins >/dev/null 2>&1
say "auditd: NOT supported in this LXC (host owns audit subsystem) -> N/A, removed"
fi
say "DONE. Backup: $BK"
+1
View File
@@ -19,6 +19,7 @@ DEVPI_PORT="3141"
# ── packages + user + dirs ─────────────────────────────────────────────────── # ── packages + user + dirs ───────────────────────────────────────────────────
setup_base_apt python3 python3-venv python3-pip setup_base_apt python3 python3-venv python3-pip
configure_ssh_root_login
create_system_user "$DEVPI_USER" "$DEVPI_HOME" create_system_user "$DEVPI_USER" "$DEVPI_HOME"
mkdir -p "$DEVPI_DATA" mkdir -p "$DEVPI_DATA"
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# nexus-db installer — runs inside the LXC, called by ct/nexus-db.sh
#
# PostgreSQL 16 from the PGDG repo, pgvector extension, database `nexus`
# with a least-privilege role, network access restricted to the nexus app
# LXC via pg_hba. Idempotent: safe to re-run (existing role/db/extension
# are kept, the password is NOT rotated on re-run).
set -euo pipefail
APP="nexus-db"
LIB_URL="${LIB_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib}"
source <(curl -fsSL "$LIB_URL/install.func")
[[ "$EUID" -eq 0 ]] || { msg_err "Must run as root"; exit 1; }
# ── load config pushed in by the host script ─────────────────────────────────
CONF="/root/nexus-db.deploy.env"
[[ -f "$CONF" ]] || { msg_err "$CONF not found (host bootstrap incomplete)"; exit 1; }
set -a; . "$CONF"; set +a
: "${NEXUS_APP_IP:?missing NEXUS_APP_IP}"
DB_NAME="${DB_NAME:-nexus}"
DB_USER="${DB_USER:-nexus}"
DB_PORT="${DB_PORT:-5432}"
# Values land verbatim in SQL and pg_hba.conf — accept only safe shapes.
[[ "$DB_NAME" =~ ^[a-z_][a-z0-9_]*$ ]] || { msg_err "DB_NAME must be a plain lowercase identifier: $DB_NAME"; exit 1; }
[[ "$DB_USER" =~ ^[a-z_][a-z0-9_]*$ ]] || { msg_err "DB_USER must be a plain lowercase identifier: $DB_USER"; exit 1; }
[[ "$DB_PORT" =~ ^[0-9]{2,5}$ ]] || { msg_err "DB_PORT must be numeric: $DB_PORT"; exit 1; }
[[ "$NEXUS_APP_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ || "$NEXUS_APP_IP" =~ ^[0-9a-fA-F:]+$ ]] \
|| { msg_err "NEXUS_APP_IP must be a single host IP: $NEXUS_APP_IP"; exit 1; }
PG_MAJOR=16
CRED_FILE="/root/nexus-db.credentials"
# ── packages: PGDG repo + PostgreSQL 16 + pgvector ────────────────────────────
setup_base_apt curl ca-certificates gnupg lsb-release
configure_ssh_root_login
if [[ ! -f /etc/apt/sources.list.d/pgdg.sources ]] && [[ ! -f /etc/apt/sources.list.d/pgdg.list ]]; then
msg_info "Adding PGDG apt repo..."
apt-get install -y -qq postgresql-common >/dev/null
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >/dev/null
msg_ok "PGDG repo added"
else
msg_warn "PGDG repo already present, skipping"
fi
# The server package runs initdb for the `main` cluster on install — its
# encoding is frozen there. Make a UTF-8 locale active for THIS process first
# so the cluster is never created as SQL_ASCII (issue #8: a pre-fix LXC was
# provisioned under LANG=C and ended up SQL_ASCII).
ensure_utf8_locale_active en_US.UTF-8
msg_info "Installing PostgreSQL $PG_MAJOR + pgvector..."
apt-get install -y -qq "postgresql-$PG_MAJOR" "postgresql-$PG_MAJOR-pgvector" >/dev/null
msg_ok "PostgreSQL $(psql --version | awk '{print $3}') installed"
PG_CONF_DIR="/etc/postgresql/$PG_MAJOR/main"
PG_CONF="$PG_CONF_DIR/postgresql.conf"
PG_HBA="$PG_CONF_DIR/pg_hba.conf"
# ── network exposure: listen on all interfaces, gate via pg_hba ───────────────
if ! grep -q "^listen_addresses = '\*'" "$PG_CONF"; then
msg_info "Configuring listen_addresses + port $DB_PORT..."
sed -i "s/^#\?listen_addresses\s*=.*/listen_addresses = '*'/" "$PG_CONF"
sed -i "s/^#\?port\s*=.*/port = $DB_PORT/" "$PG_CONF"
msg_ok "postgresql.conf updated"
else
msg_warn "listen_addresses already configured, skipping"
fi
# ── pg_hba: ONLY the nexus LXC may connect over the network ──────────────────
# Strategy: replace the default file with an explicit allowlist. Local
# UNIX-socket access stays peer-authenticated for the postgres superuser
# (maintenance), the nexus role may connect from exactly one IP, everyone
# else hits the final reject rule (defense-in-depth on top of "no other
# rule matches").
HBA_MARKER="# managed by nexus-db-install.sh"
if ! grep -q "$HBA_MARKER" "$PG_HBA"; then
msg_info "Writing restrictive pg_hba.conf..."
cp -a "$PG_HBA" "$PG_HBA.dist"
cat >"$PG_HBA" <<EOF
$HBA_MARKER — change via card, not by hand (nexus-hub K-102)
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
local all all peer
host $DB_NAME $DB_USER $NEXUS_APP_IP/32 scram-sha-256
host all all 0.0.0.0/0 reject
host all all ::/0 reject
EOF
msg_ok "pg_hba.conf restricted to $NEXUS_APP_IP/32"
else
msg_warn "pg_hba.conf already managed, skipping"
fi
systemctl enable postgresql >/dev/null 2>&1
systemctl restart postgresql
# ── role + database + extension (idempotent, password kept on re-run) ─────────
run_psql() { runuser -u postgres -- psql -v ON_ERROR_STOP=1 -qAt "$@"; }
# Re-run safety (issue #8): if the database already exists, verify its
# encoding BEFORE touching roles/passwords. An old SQL_ASCII database must
# abort the run with NO side effects — not after rotating credentials.
if [[ "$(run_psql -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")" == "1" ]]; then
assert_db_encoding_utf8 "$DB_NAME"
fi
if [[ "$(run_psql -c "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")" != "1" ]]; then
msg_info "Creating role $DB_USER + database $DB_NAME..."
DB_PASS="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
run_psql -c "CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS' NOSUPERUSER NOCREATEDB NOCREATEROLE"
msg_ok "Role $DB_USER created (least privilege)"
elif [[ ! -f "$CRED_FILE" ]]; then
# Recovery: role exists but the generated password was never persisted
# (first run died between CREATE ROLE and credentials write). Rotate so
# the credentials file is authoritative again.
msg_warn "Role $DB_USER exists but $CRED_FILE is missing — rotating password"
DB_PASS="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
run_psql -c "ALTER ROLE $DB_USER PASSWORD '$DB_PASS'"
msg_ok "Password rotated"
else
DB_PASS=""
msg_warn "Role $DB_USER already exists, password unchanged"
fi
if [[ "$(run_psql -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")" != "1" ]]; then
# Explicit encoding/collation from template0 — never inherit the cluster
# default, which may be SQL_ASCII if initdb ran under a broken locale
# (issue #8). template0 is required to override LC_COLLATE/LC_CTYPE.
run_psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0"
# Only the owner may connect — no PUBLIC access.
run_psql -c "REVOKE CONNECT ON DATABASE $DB_NAME FROM PUBLIC"
# Verify what we just created (the pre-existing case was already checked
# before the role block, issue #8).
assert_db_encoding_utf8 "$DB_NAME"
msg_ok "Database $DB_NAME created (UTF8, owner $DB_USER, PUBLIC revoked)"
else
msg_warn "Database $DB_NAME already exists, skipping creation"
fi
# pgvector: CREATE EXTENSION needs superuser; installed now (per ADR-0002:
# "Extension ab Tag 1 installiert, ungenutzt bis Phase 2").
run_psql -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS vector" >/dev/null
msg_ok "Extension vector available in $DB_NAME"
# ── credentials / DSN summary ─────────────────────────────────────────────────
IP_SELF="$(hostname -I | awk '{print $1}')"
if [[ -n "$DB_PASS" ]]; then
cat >"$CRED_FILE" <<EOF
nexus-db — PostgreSQL $PG_MAJOR for nexus (Family Knowledge Hub)
Host: $IP_SELF:$DB_PORT
Database: $DB_NAME
Role: $DB_USER (NOSUPERUSER NOCREATEDB NOCREATEROLE, sole owner)
Password: $DB_PASS
DSN for /etc/nexus/env on the nexus LXC (NEXUS_DATABASE_URL):
postgresql+psycopg://$DB_USER:$DB_PASS@$IP_SELF:$DB_PORT/$DB_NAME
Encoding: UTF8 (LC_COLLATE/LC_CTYPE en_US.UTF-8) — verified at install time.
Access policy (pg_hba): only $NEXUS_APP_IP/32 may connect; all other
hosts are rejected. Local socket stays peer-auth for maintenance (these
minimal LXCs have no sudo — use su, not sudo):
pct exec <CTID> -- su - postgres -c "psql -d $DB_NAME"
EOF
chmod 600 "$CRED_FILE"
msg_ok "Credentials written to $CRED_FILE (chmod 600)"
else
msg_warn "Re-run detected: $CRED_FILE untouched (password not rotated)"
fi
apt_cleanup
msg_ok "$APP installation finished"
+1
View File
@@ -46,6 +46,7 @@ run_user() { runuser -u "$APP_USER" -- env HOME="$APP_HOME" "$@"; }
# ── packages: git + rsync + sudo ; Node.js via NodeSource ───────────────────── # ── packages: git + rsync + sudo ; Node.js via NodeSource ─────────────────────
setup_base_apt git rsync sudo setup_base_apt git rsync sudo
configure_ssh_root_login
NODE_HAVE="$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo 0)" NODE_HAVE="$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo 0)"
if [[ "$NODE_HAVE" != "$NODE_MAJOR" ]]; then if [[ "$NODE_HAVE" != "$NODE_MAJOR" ]]; then
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# Runner installer — runs inside the LXC, called by ct/runner.sh
#
# Allgemeiner, INSTANZWEITER Gitea-Actions-Runner ohne Deploy-Rechte:
# - Node.js (für actions/checkout im Host-Mode) + git + rsync
# - Docker (für Wegwerf-Test-Container in CI, z. B. pgvector)
# - act_runner als unprivilegierter User "runner" in der docker-Gruppe
# - systemd-Unit act-runner.service (Requires=docker.service)
#
# SICHERHEITSMODELL (Review-Finding 88, bewusst akzeptiert & dokumentiert):
# docker-Gruppe == de-facto root IN DIESEM LXC. Da der Runner instanzweit
# ist, kann jedes Repo der Gitea-Instanz via Workflow den Runner-Container
# vollständig kontrollieren. Das ist hier akzeptiert, weil (a) alle Repos
# der Instanz vom selben Admin (Lutz) stammen — kein Multi-Tenant — und
# (b) dieser LXC GENAU DESHALB nichts besitzt: keine sudoers, keine
# Produktions-Mounts, keine Secrets außer dem (geshredderten) Reg-Token.
# Bei Öffnung der Instanz für Dritte: Docker rootless oder eigener Runner
# pro Vertrauenszone. NICHT auf LXCs mit Deploy-Rechten übertragen.
#
# Idempotent: erneuter Lauf (auch OHNE /root/runner.deploy.env) überspringt
# Vorhandenes und provisioniert nur nach — Registrierung braucht die env-Datei
# nur beim Erstlauf (Finding 90).
set -euo pipefail
LIB_URL="${LIB_URL:-https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/lib}"
source <(curl -fsSL "$LIB_URL/install.func")
[[ "$EUID" -eq 0 ]] || { msg_err "Must run as root"; exit 1; }
APP_USER="runner"
APP_HOME="/opt/runner"
RUNNER_DIR="$APP_HOME/data"
# ── Konfiguration laden (optional bei Re-Run, Finding 90) ─────────────────────
CONF="/root/runner.deploy.env"
if [[ -f "$CONF" ]]; then
set -a; . "$CONF"; set +a
fi
RUNNER_NAME="${RUNNER_NAME:-$(hostname)}"
RUNNER_LABELS="${RUNNER_LABELS:-homelab:host}"
RUNNER_VERSION="${RUNNER_VERSION:-0.2.13}"
NODE_MAJOR="${NODE_MAJOR:-22}"
# ── Validierung (Finding 80): Werte landen in Shell-Fragmenten/systemd —
# strikte Zeichenklassen statt Vertrauen. Re-Runs ohne CONF validieren
# nur, was gesetzt ist; Registrierungs-Pflichtwerte prüft der Reg-Block. ──
valid_url() { [[ "$1" =~ ^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; }
valid_token() { [[ "$1" =~ ^[A-Za-z0-9_-]{16,128}$ ]]; }
valid_token_word() { [[ "$1" =~ ^[A-Za-z0-9._:,-]+$ ]]; }
[[ -z "${GITEA_INSTANCE_URL:-}" ]] || valid_url "$GITEA_INSTANCE_URL" \
|| { msg_err "GITEA_INSTANCE_URL ungültig: nur http(s)://host[:port]"; exit 1; }
[[ -z "${RUNNER_TOKEN:-}" ]] || valid_token "$RUNNER_TOKEN" \
|| { msg_err "RUNNER_TOKEN ungültig (erwartet 16–128 Zeichen [A-Za-z0-9_-])"; exit 1; }
valid_token_word "$RUNNER_NAME" || { msg_err "RUNNER_NAME enthält unzulässige Zeichen"; exit 1; }
valid_token_word "$RUNNER_LABELS" || { msg_err "RUNNER_LABELS enthält unzulässige Zeichen"; exit 1; }
[[ "$RUNNER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { msg_err "RUNNER_VERSION ungültig"; exit 1; }
[[ "$NODE_MAJOR" =~ ^[0-9]+$ ]] || { msg_err "NODE_MAJOR ungültig"; exit 1; }
# Finding 85 (Re-Review): act_runner schreibt .runner ins CWD — run_user wechselt
# deshalb hart ins RUNNER_DIR (env -C), sonst landet die Registrierung in / und
# der unprivilegierte User darf dort nicht schreiben.
run_user() { runuser -u "$APP_USER" -- env -C "$RUNNER_DIR" HOME="$APP_HOME" "$@"; }
# ── Pakete: git/rsync, Node (checkout-Action), Docker (Test-Container),
# Build-Werkzeuge (Compiler-Sprachen in CI) ─────────────────────────────────
#
# make/gcc/protobuf: Der Runner faehrt Host-Mode, also gibt es kein Image, das
# Werkzeuge mitbringt — was hier nicht liegt, hat kein Job. Konkret gemessen an
# l.kirchner/patchmgr (Go):
# make "make: command not found" in jedem Gate-Job
# gcc ohne gefundenen C-Compiler bleibt cgo aus (go env
# meldet dann CGO_ENABLED=0), und "go test -race" ist
# nicht baubar
# protobuf-compiler protoc fuer die Codegenerierung
# libprotobuf-dev liefert /usr/include/google/protobuf/*.proto; ohne die
# Includes scheitert protoc trotz vorhandenem Binary
#
# ACHTUNG protoc: Das bindet jedes Repo der Instanz an die protoc-Fassung der
# Distribution (Debian 12: 3.21.x). Anders als make/gcc ist protoc ein
# Codegenerator — ein Distro-Upgrade aendert erzeugten Code fuer alle Nutzer
# gleichzeitig. Wer eine eigene Fassung braucht, pinnt sie im Projekt
# (Release-Tarball, buf, oder Docker — der Runner hat Docker) statt sie hier
# zu heben.
#
# zstd: kein Werkzeug fuer Jobs, sondern fuer den Nachlauf. `actions/cache`
# — und damit auch `actions/setup-go`, das darauf aufsetzt — packt seinen
# Cache mit `zstdmt`, wenn zstd vorhanden ist, und faellt sonst auf
# einkerniges gzip zurueck. Der Unterschied ist am Archivnamen zu sehen:
# `cache.tzst` gegen `cache.tgz`.
#
# Gemessen am 02./03.09.2026 in l.kirchner/patchmgr, CI-Lauf 1957: Der Cache
# aus GOMODCACHE und GOCACHE ist dort 2 bis 5 GB gross. Auf runner-gpu (hat
# zstd) heisst er `cache.tzst`; auf runner-01/02/03 `cache.tgz`, und
# `go test -race (stable)` verbrachte damit **605 Sekunden** im Nachlauf bei
# 41 Sekunden Arbeit. Ueber alle acht Jobs waren es 2312 von 3146 Sekunden.
#
# Das ersetzt nicht die Frage, ob dieser Cache ueberhaupt gebraucht wird — im
# Host-Mode mit dauerhaftem Zuhause ueberleben beide Verzeichnisse ohnehin
# zwischen den Jobs, und patchmgr schaltet ihn deshalb ab. Aber solange
# irgendein Repo der Instanz ihn nutzt, soll er nicht einkernig komprimiert
# werden. Belege: .specs/reports/ci-laufzeit-und-cache-2026-09-02.md dort.
#
# Bewusst NICHT installiert: sudo. Ein Workflow soll auf diesem Runner nichts
# nachinstallieren koennen — was gebraucht wird, steht hier.
#
# Go selbst gehoert nicht hierher: Projekte holen es ueber actions/setup-go,
# weil CI-Matrizen mehrere Fassungen fahren.
setup_base_apt git rsync ca-certificates curl \
make gcc protobuf-compiler libprotobuf-dev zstd
NODE_HAVE="$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo 0)"
if [[ "$NODE_HAVE" != "$NODE_MAJOR" ]]; then
msg_info "Installing Node.js ${NODE_MAJOR}.x (NodeSource)..."
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >/dev/null
apt-get install -y -qq nodejs >/dev/null
msg_ok "Node $(node -v) installed"
else
msg_warn "Node $(node -v) already present, skipping"
fi
if ! command -v docker >/dev/null 2>&1; then
msg_info "Installing Docker (get.docker.com)..."
curl -fsSL https://get.docker.com | sh >/dev/null
msg_ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')"
else
msg_warn "Docker already present, skipping"
fi
# ── act_runner binary ─────────────────────────────────────────────────────────
if [[ ! -x /usr/local/bin/act_runner ]]; then
ARCH="$(dpkg --print-architecture)"
case "$ARCH" in amd64|arm64) ;; *) msg_err "unsupported arch: $ARCH"; exit 1 ;; esac
msg_info "Downloading act_runner $RUNNER_VERSION ($ARCH)..."
curl -fsSL "https://dl.gitea.com/act_runner/${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-${ARCH}" \
-o /usr/local/bin/act_runner
chmod +x /usr/local/bin/act_runner
msg_ok "act_runner $(/usr/local/bin/act_runner --version 2>/dev/null | head -n1)"
else
msg_warn "act_runner already present, skipping download"
fi
# ── User (docker-Gruppe VOR dem Daemon-Start — Lesson aus nexus K-103:
# Gruppenmitgliedschaften werden beim Prozessstart eingefroren) ─────────────
create_system_user "$APP_USER" "$APP_HOME"
usermod -aG docker "$APP_USER"
mkdir -p "$RUNNER_DIR"
chown -R "$APP_USER:$APP_USER" "$APP_HOME"
# ── Registrierung (idempotent; braucht CONF-Werte nur beim Erstlauf) ──────────
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
: "${GITEA_INSTANCE_URL:?missing GITEA_INSTANCE_URL (Erstlauf braucht /root/runner.deploy.env)}"
: "${RUNNER_TOKEN:?missing RUNNER_TOKEN (Erstlauf braucht /root/runner.deploy.env)}"
msg_info "Registering runner '$RUNNER_NAME' [$RUNNER_LABELS] with $GITEA_INSTANCE_URL (instance scope)..."
# Werte sind oben strikt validiert (keine Quotes/Whitespace möglich) —
# Übergabe als argv an runuser, keine erneute Shell-Interpolation (vgl. Finding 76).
# CWD = RUNNER_DIR via run_user (Finding 85).
run_user /usr/local/bin/act_runner register \
--no-interactive \
--config /dev/null \
--instance "$GITEA_INSTANCE_URL" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--labels "$RUNNER_LABELS" \
2>&1 | sed "s#$RUNNER_TOKEN#<redacted>#g" || { msg_err "Registrierung fehlgeschlagen"; exit 1; }
# Belt-and-suspenders: falls eine künftige act_runner-Version doch ins HOME schreibt
if [[ -f "$APP_HOME/.runner" && ! -f "$RUNNER_DIR/.runner" ]]; then
mv "$APP_HOME/.runner" "$RUNNER_DIR/.runner"
fi
[[ -f "$RUNNER_DIR/.runner" ]] || { msg_err ".runner nach Registrierung nicht gefunden"; exit 1; }
chown -R "$APP_USER:$APP_USER" "$RUNNER_DIR"
msg_ok "Runner registered"
else
msg_warn "Runner already registered (.runner exists), skipping"
fi
# ── SSH-Root-Login-Policy anwenden, falls vom Host-Script übergeben
# (Funktion existiert in install.func ab K-114/PR #5 — guarded Aufruf,
# damit dieser Branch vor und nach dem Merge funktioniert) ────────────────
if declare -F configure_ssh_root_login >/dev/null 2>&1; then
configure_ssh_root_login
fi
# ── systemd-Unit (bewusst KEINE sudoers-Datei; Findings 82 + 70) ──────────────
cat >/etc/systemd/system/act-runner.service <<EOF
[Unit]
Description=Gitea Actions runner (instance-wide, label ${RUNNER_LABELS%%:*}, no deploy rights)
After=network-online.target docker.service
Wants=network-online.target
# Finding 82: ohne Docker keine Jobs annehmen — harte Kopplung
Requires=docker.service
[Service]
Type=simple
User=$APP_USER
Group=$APP_USER
WorkingDirectory=$RUNNER_DIR
Environment=HOME=$APP_HOME
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/usr/local/bin/act_runner daemon
Restart=on-failure
RestartSec=5
# Finding 70: Basis-Härtung (docker-CLI über Socket bleibt funktional)
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now act-runner.service
msg_ok "act-runner.service installed + started"
CRED_FILE="/root/runner.credentials"
cat >"$CRED_FILE" <<EOF
Allgemeiner Gitea-Actions-Runner (instanzweit)
Instance: ${GITEA_INSTANCE_URL:-<bestehende Registrierung, siehe $RUNNER_DIR/.runner>}
Name/Label: $RUNNER_NAME [$RUNNER_LABELS]
User: $APP_USER (docker-Gruppe, KEIN sudo)
Workdir: $RUNNER_DIR
Logs: journalctl -u act-runner -f
Repos nutzen ihn mit: runs-on: ${RUNNER_LABELS%%:*}
SICHERHEITSMODELL: docker-Gruppe == de-facto root in DIESEM LXC; instanzweit
heißt: jedes Repo der Instanz kann den Runner-LXC kontrollieren. Akzeptiert,
weil Single-Admin-Instanz und dieser LXC nichts besitzt (keine sudoers, keine
Produktions-Mounts). Deploy-Jobs gehören NICHT hierher — die bleiben auf dem
repo-scoped nexus-Runner. Trennung beibehalten.
EOF
chmod 600 "$CRED_FILE"
if [[ -f "$CONF" ]]; then
shred -u "$CONF" 2>/dev/null || rm -f "$CONF"
fi
apt_cleanup
msg_ok "runner installation finished"
+1
View File
@@ -44,6 +44,7 @@ run_user() { runuser -u "$APP_USER" -- env HOME="$APP_HOME" "$@"; }
# ── packages: git + rsync + sudo ; Node.js via NodeSource ──────────────────── # ── packages: git + rsync + sudo ; Node.js via NodeSource ────────────────────
setup_base_apt git rsync sudo setup_base_apt git rsync sudo
configure_ssh_root_login
NODE_HAVE="$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo 0)" NODE_HAVE="$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo 0)"
if [[ "$NODE_HAVE" != "$NODE_MAJOR" ]]; then if [[ "$NODE_HAVE" != "$NODE_MAJOR" ]]; then
+194 -26
View File
@@ -97,55 +97,204 @@ apply_network_profile() {
return 0 return 0
} }
# ── input validation (K-114) ─────────────────────────────────────────────────
# Real incident 2026-06-11: a pasted VLAN tag carried an invisible non-UTF-8
# byte and broke `pct create` deep in the run. Policy: trim CR/edge whitespace,
# but NEVER silently strip junk inside a value — embedded control/non-ASCII
# bytes fail validation and trigger a visible re-prompt.
# Trim \r and leading/trailing whitespace (edges only).
sanitize_input() {
local s="${1-}"
s="${s//$'\r'/}"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
# True if the value contains only printable ASCII (no control/non-ASCII
# bytes). Byte-exact via tr: delete all printable ASCII — anything left
# over is junk.
is_clean_ascii() {
# Newline/Tab zuerst explizit ablehnen — $(…) strippt trailing newlines,
# die der tr-Pfad sonst übersehen würde (Review-Finding K-114).
[[ "$1" == *$'\n'* || "$1" == *$'\t'* ]] && return 1
local leftover
leftover="$(printf '%s' "$1" | LC_ALL=C tr -d '\40-\176')"
[[ -z "$leftover" ]]
}
is_uint() { is_clean_ascii "$1" && [[ "$1" =~ ^[0-9]+$ ]]; }
# 10#: führende Nullen nicht als Oktal werten ("08" wäre sonst ein
# Arithmetik-Fehler statt einer sauberen Ablehnung/Annahme).
is_vlan_tag() { is_uint "$1" && (( 10#$1 >= 1 && 10#$1 <= 4094 )); }
is_token() { is_clean_ascii "$1" && [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; }
is_hostname() { is_clean_ascii "$1" && [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; }
is_ipv4() {
is_clean_ascii "$1" && [[ "$1" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
local o
for o in "${BASH_REMATCH[@]:1:4}"; do (( 10#$o <= 255 )) || return 1; done
return 0
}
is_cidr() {
[[ "$1" =~ ^([0-9.]+)/([0-9]{1,2})$ ]] || return 1
# BASH_REMATCH retten — is_ipv4 nutzt selbst =~ und überschreibt es.
local _ip="${BASH_REMATCH[1]}" _prefix="${BASH_REMATCH[2]}"
is_ipv4 "$_ip" && (( 10#$_prefix >= 1 && 10#$_prefix <= 32 ))
}
is_ipcfg() { [[ "$1" == "dhcp" ]] || is_cidr "$1"; }
# Ja/Nein-Antworten (Prompts wie "… erlauben? [Y/n]"). Akzeptiert
# deutsch/englisch, normalize_yesno macht daraus kanonisch yes|no.
is_yesno() { is_clean_ascii "$1" && [[ "${1,,}" =~ ^(y|yes|j|ja|n|no|nein)$ ]]; }
normalize_yesno() {
case "${1,,}" in
y|yes|j|ja) printf 'yes' ;;
*) printf 'no' ;;
esac
}
# Space/comma-separated list of IPv4s (DNS prompt). Gesamtstring zuerst
# prüfen — die Wort-Splittung würde eingebettete Newlines sonst verstecken.
is_ipv4_list() {
is_clean_ascii "$1" || return 1
local item
for item in ${1//,/ }; do is_ipv4 "$item" || return 1; done
[[ -n "$1" ]]
}
# prompt_validated VARNAME PROMPT VALIDATOR [DEFAULT] [allow_empty]
# Reads into VARNAME (nameref, no subshell), sanitizes, applies the default on
# empty input and re-prompts until the validator passes. allow_empty=yes lets
# an empty value through (e.g. "no VLAN").
prompt_validated() {
# Schutz vor zirkulärem nameref (Review-Finding): interne Namen tabu.
[[ "$1" == _pv_* || "$1" == _rv_* ]] && { msg_err "prompt_validated: reserved variable name '$1'"; return 2; }
local -n _pv_ref="$1"
local _pv_prompt="$2" _pv_validator="$3" _pv_default="${4-}" _pv_allow_empty="${5:-no}"
local _pv_value
while true; do
if ! read -rp "$_pv_prompt" _pv_value; then
# EOF (kein TTY / stdin erschöpft): kein Endlos-Loop, sauber raus —
# unter set -e bricht der Caller damit kontrolliert ab.
msg_err "No input available for prompt: ${_pv_prompt%% *}"
return 1
fi
_pv_value="$(sanitize_input "$_pv_value")"
if [[ -z "$_pv_value" && -n "$_pv_default" ]]; then
_pv_value="$_pv_default"
fi
if [[ -z "$_pv_value" ]]; then
if [[ "$_pv_allow_empty" == "yes" ]]; then _pv_ref=""; return 0; fi
msg_warn "A value is required."
continue
fi
if "$_pv_validator" "$_pv_value"; then
_pv_ref="$_pv_value"
return 0
fi
if ! is_clean_ascii "$_pv_value"; then
msg_warn "Input contains invisible/non-ASCII characters — please re-type (do not paste)."
else
msg_warn "Invalid value: '$_pv_value' — please retry."
fi
done
}
# Validate an env-provided value (non-interactive: abort instead of re-prompt).
require_valid() {
[[ "$1" == _pv_* || "$1" == _rv_* ]] && { msg_err "require_valid: reserved variable name '$1'"; return 2; }
local -n _rv_ref="$1"
local _rv_validator="$2" _rv_label="$3"
_rv_ref="$(sanitize_input "$_rv_ref")"
"$_rv_validator" "$_rv_ref" || { msg_err "$_rv_label invalid: '$_rv_ref'"; exit 1; }
}
# ── prompts ────────────────────────────────────────────────────────────────── # ── prompts ──────────────────────────────────────────────────────────────────
# Each prompt is skipped if the corresponding variable is already set in env. # Each prompt is skipped if the corresponding variable is already set in env
# (env values are still validated — abort on invalid, no silent use).
# VLAN_TAG and NAMESERVER use ${VAR+x} so that explicitly setting them to "" # VLAN_TAG and NAMESERVER use ${VAR+x} so that explicitly setting them to ""
# via env skips the prompt (= "no VLAN" / "inherit DNS from host"). # via env skips the prompt (= "no VLAN" / "inherit DNS from host").
prompt_lxc_config() { prompt_lxc_config() {
if [[ -z "${CTID:-}" ]]; then if [[ -z "${CTID:-}" ]]; then
read -rp "Container ID [auto]: " CTID # Eigener Loop statt prompt_validated: leer = auto (pvesh nextid),
[[ -z "${CTID:-}" ]] && CTID=$(pvesh get /cluster/nextid) # ungültig = Re-Prompt (Review-Finding: vorher Abbruch statt Re-Prompt).
while true; do
if ! read -rp "Container ID [auto]: " CTID; then
msg_err "No input available for prompt: Container ID"
return 1
fi
CTID="$(sanitize_input "$CTID")"
if [[ -z "$CTID" ]]; then
CTID=$(pvesh get /cluster/nextid)
break
fi
is_uint "$CTID" && break
msg_warn "Invalid value: '$CTID' — please retry (digits only)."
done
else
require_valid CTID is_uint "Container ID"
fi fi
echo " → CTID: $CTID" echo " → CTID: $CTID"
if [[ -z "${CT_HOSTNAME:-}" ]]; then if [[ -z "${CT_HOSTNAME:-}" ]]; then
read -rp "Hostname [$DEFAULT_HOSTNAME]: " CT_HOSTNAME prompt_validated CT_HOSTNAME "Hostname [$DEFAULT_HOSTNAME]: " is_hostname "$DEFAULT_HOSTNAME"
CT_HOSTNAME="${CT_HOSTNAME:-$DEFAULT_HOSTNAME}" else
require_valid CT_HOSTNAME is_hostname "Hostname"
fi fi
if [[ -z "${DISK_SIZE:-}" ]]; then if [[ -z "${DISK_SIZE:-}" ]]; then
read -rp "Disk size in GB [$DEFAULT_DISK]: " DISK_SIZE prompt_validated DISK_SIZE "Disk size in GB [$DEFAULT_DISK]: " is_uint "$DEFAULT_DISK"
DISK_SIZE="${DISK_SIZE:-$DEFAULT_DISK}" else
require_valid DISK_SIZE is_uint "Disk size"
fi fi
if [[ -z "${CORES:-}" ]]; then if [[ -z "${CORES:-}" ]]; then
read -rp "vCPU cores [$DEFAULT_CORES]: " CORES prompt_validated CORES "vCPU cores [$DEFAULT_CORES]: " is_uint "$DEFAULT_CORES"
CORES="${CORES:-$DEFAULT_CORES}" else
require_valid CORES is_uint "vCPU cores"
fi fi
if [[ -z "${RAM:-}" ]]; then if [[ -z "${RAM:-}" ]]; then
read -rp "RAM in MB [$DEFAULT_RAM]: " RAM prompt_validated RAM "RAM in MB [$DEFAULT_RAM]: " is_uint "$DEFAULT_RAM"
RAM="${RAM:-$DEFAULT_RAM}" else
require_valid RAM is_uint "RAM"
fi fi
if [[ -z "${BRIDGE:-}" ]]; then if [[ -z "${BRIDGE:-}" ]]; then
read -rp "Bridge [$DEFAULT_BRIDGE]: " BRIDGE prompt_validated BRIDGE "Bridge [$DEFAULT_BRIDGE]: " is_token "$DEFAULT_BRIDGE"
BRIDGE="${BRIDGE:-$DEFAULT_BRIDGE}" else
require_valid BRIDGE is_token "Bridge"
fi fi
if [[ -z "${VLAN_TAG+x}" ]]; then if [[ -z "${VLAN_TAG+x}" ]]; then
read -rp "VLAN tag (empty for none): " VLAN_TAG # The 2026-06-11 incident prompt: junk bytes re-prompt, empty = no VLAN.
prompt_validated VLAN_TAG "VLAN tag (empty for none): " is_vlan_tag "" yes
elif [[ -n "${VLAN_TAG:-}" ]]; then
require_valid VLAN_TAG is_vlan_tag "VLAN tag"
fi fi
if [[ -z "${TEMPLATE_STORAGE:-}" ]]; then if [[ -z "${TEMPLATE_STORAGE:-}" ]]; then
read -rp "Template storage [$DEFAULT_TEMPLATE_STORAGE]: " TEMPLATE_STORAGE prompt_validated TEMPLATE_STORAGE \
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-$DEFAULT_TEMPLATE_STORAGE}" "Template storage [$DEFAULT_TEMPLATE_STORAGE]: " is_token "$DEFAULT_TEMPLATE_STORAGE"
else
require_valid TEMPLATE_STORAGE is_token "Template storage"
fi fi
if [[ -z "${ROOTFS_STORAGE:-}" ]]; then if [[ -z "${ROOTFS_STORAGE:-}" ]]; then
read -rp "Rootfs storage [$DEFAULT_ROOTFS_STORAGE]: " ROOTFS_STORAGE prompt_validated ROOTFS_STORAGE \
ROOTFS_STORAGE="${ROOTFS_STORAGE:-$DEFAULT_ROOTFS_STORAGE}" "Rootfs storage [$DEFAULT_ROOTFS_STORAGE]: " is_token "$DEFAULT_ROOTFS_STORAGE"
else
require_valid ROOTFS_STORAGE is_token "Rootfs storage"
fi fi
if [[ -z "${IPCFG:-}" ]]; then if [[ -z "${IPCFG:-}" ]]; then
read -rp "Network: IP/CIDR or 'dhcp' [dhcp]: " IPCFG prompt_validated IPCFG "Network: IP/CIDR or 'dhcp' [dhcp]: " is_ipcfg "dhcp"
IPCFG="${IPCFG:-dhcp}" else
require_valid IPCFG is_ipcfg "Network (IP/CIDR or dhcp)"
fi fi
GATEWAY="${GATEWAY:-}" GATEWAY="${GATEWAY:-}"
if [[ "$IPCFG" != "dhcp" && -z "$GATEWAY" ]]; then if [[ "$IPCFG" != "dhcp" ]]; then
read -rp "Gateway: " GATEWAY if [[ -z "$GATEWAY" ]]; then
prompt_validated GATEWAY "Gateway: " is_ipv4
else
require_valid GATEWAY is_ipv4 "Gateway"
fi
fi fi
# DNS is driven by the VLAN tag via the network profile (lib/networks.conf), # DNS is driven by the VLAN tag via the network profile (lib/networks.conf),
# so the right resolvers get set even with DHCP. Precedence: # so the right resolvers get set even with DHCP. Precedence:
@@ -155,17 +304,34 @@ prompt_lxc_config() {
# 4. DHCP, no profile → inherit from host # 4. DHCP, no profile → inherit from host
apply_network_profile apply_network_profile
if [[ -n "${NAMESERVER+x}" ]]; then if [[ -n "${NAMESERVER+x}" ]]; then
: # explicit override from env, leave untouched # Explizites Override aus env: "" = inherit bleibt erlaubt, aber ein
# gesetzter Wert wird validiert (Review-Finding: lief vorher ungeprüft
# bis in pct create).
if [[ -n "${NAMESERVER:-}" ]]; then
require_valid NAMESERVER is_ipv4_list "DNS server"
fi
elif [[ -n "$PROFILE_DNS" ]]; then elif [[ -n "$PROFILE_DNS" ]]; then
NAMESERVER="$PROFILE_DNS" NAMESERVER="$PROFILE_DNS"
msg_info "DNS for VLAN ${VLAN_TAG:-none} (${PROFILE_SUBNET:-?}): $NAMESERVER" msg_info "DNS for VLAN ${VLAN_TAG:-none} (${PROFILE_SUBNET:-?}): $NAMESERVER"
elif [[ "$IPCFG" != "dhcp" ]]; then elif [[ "$IPCFG" != "dhcp" ]]; then
local default_ns="${DEFAULT_NAMESERVER:-$GATEWAY}" local default_ns="${DEFAULT_NAMESERVER:-$GATEWAY}"
read -rp "DNS server [$default_ns] (empty = inherit from PVE host): " NAMESERVER prompt_validated NAMESERVER \
NAMESERVER="${NAMESERVER:-$default_ns}" "DNS server [$default_ns] (empty = inherit from PVE host): " \
is_ipv4_list "$default_ns" yes
else else
msg_warn "No network profile for VLAN ${VLAN_TAG:-none}; DHCP DNS will be inherited." msg_warn "No network profile for VLAN ${VLAN_TAG:-none}; DHCP DNS will be inherited."
fi fi
# SSH-Root-Login (Default: ja, Homelab-Komfort). Umgesetzt wird das im
# Install-Pfad per sshd-Drop-in (configure_ssh_root_login, lib/install.func);
# bootstrap_install_script reicht den normalisierten Wert in den Container.
if [[ -z "${SSH_ROOT_LOGIN:-}" ]]; then
prompt_validated SSH_ROOT_LOGIN "SSH-Root-Login erlauben? [Y/n]: " is_yesno "y"
else
require_valid SSH_ROOT_LOGIN is_yesno "SSH root login (y/n)"
fi
SSH_ROOT_LOGIN="$(normalize_yesno "$SSH_ROOT_LOGIN")"
echo " → SSH root login: $SSH_ROOT_LOGIN"
} }
# ── template ───────────────────────────────────────────────────────────────── # ── template ─────────────────────────────────────────────────────────────────
@@ -263,7 +429,9 @@ bootstrap_install_script() {
pct exec "$CTID" -- bash -c "apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null" pct exec "$CTID" -- bash -c "apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null"
msg_info "Running installer ($url)..." msg_info "Running installer ($url)..."
pct exec "$CTID" -- bash -c "curl -fsSL '$url' -o /root/${APP}-install.sh && bash /root/${APP}-install.sh" # SSH_ROOT_LOGIN ist durch normalize_yesno kanonisch yes|no — als Env in
# den Container durchreichen (configure_ssh_root_login wertet es aus).
pct exec "$CTID" -- bash -c "curl -fsSL '$url' -o /root/${APP}-install.sh && SSH_ROOT_LOGIN='${SSH_ROOT_LOGIN:-yes}' bash /root/${APP}-install.sh"
} }
# ── summary ────────────────────────────────────────────────────────────────── # ── summary ──────────────────────────────────────────────────────────────────
+102 -2
View File
@@ -15,10 +15,14 @@ msg_warn() { echo -e "${YELLOW}[!]${NC} $*"; }
msg_err() { echo -e "${RED}[✗]${NC} $*" >&2; } msg_err() { echo -e "${RED}[✗]${NC} $*" >&2; }
# ── apt ────────────────────────────────────────────────────────────────────── # ── apt ──────────────────────────────────────────────────────────────────────
# Always installs: ca-certificates curl openssl tzdata gnupg # Always installs: ca-certificates curl openssl tzdata gnupg locales
# Additional packages can be passed as args. # Additional packages can be passed as args.
setup_base_apt() { setup_base_apt() {
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
# C.UTF-8 ist in glibc eingebaut und damit schon VOR dem locales-Paket
# verfügbar — deckt den ersten apt/dpkg-Lauf ab (keine perl-Warnungen
# "Setting locale failed" mehr, LXC-Templates kommen ohne Locale).
export LANG=C.UTF-8 LC_ALL=C.UTF-8
msg_info "Updating apt index..." msg_info "Updating apt index..."
apt-get update -qq apt-get update -qq
if [[ $# -gt 0 ]]; then if [[ $# -gt 0 ]]; then
@@ -27,17 +31,113 @@ setup_base_apt() {
msg_info "Installing base packages..." msg_info "Installing base packages..."
fi fi
apt-get install -y -qq \ apt-get install -y -qq \
ca-certificates curl openssl tzdata gnupg \ ca-certificates curl openssl tzdata gnupg locales \
"$@" \ "$@" \
>/dev/null >/dev/null
setup_locales
msg_ok "apt setup complete" msg_ok "apt setup complete"
} }
# en_US.UTF-8 generieren und systemweit als Default setzen; C.UTF-8 braucht
# keine Generierung (glibc-built-in). Idempotent: sed greift nur auf die
# auskommentierte Zeile, locale-gen/update-locale sind re-run-sicher.
setup_locales() {
msg_info "Generating locales (en_US.UTF-8; C.UTF-8 built-in)..."
sed -i 's/^# *en_US\.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
locale-gen >/dev/null
update-locale LANG=en_US.UTF-8
msg_ok "Default locale: en_US.UTF-8"
}
apt_cleanup() { apt_cleanup() {
apt-get autoremove -y -qq >/dev/null || true apt-get autoremove -y -qq >/dev/null || true
apt-get autoclean -qq >/dev/null || true apt-get autoclean -qq >/dev/null || true
} }
# ── database installers: UTF-8 locale before initdb ──────────────────────────
# Pattern for every DB installer. A PostgreSQL cluster/database freezes its
# encoding at initdb / CREATE DATABASE time and it cannot be changed later —
# a C (non-UTF-8) locale yields a SQL_ASCII cluster. psycopg3 then returns
# text as bytes and SQLAlchemy crashes on server-version detection; the app
# reports "db: unreachable". So: GENERATE the UTF-8 locale AND make it active
# for THIS process before the server package runs its automatic initdb, then
# fail loudly if it is not actually available (generating alone is not enough
# — the locale must be active when initdb runs).
# Generates+activates a UTF-8 locale (default en_US.UTF-8); the argument
# honours other UTF-8 locales consistently (match, locale.gen line and the
# exported value all derive from it). Matching normalises case and dashes so
# the canonical `en_US.UTF-8` matches `locale -a`'s `en_US.utf8`.
ensure_utf8_locale_active() {
local loc="${1:-en_US.UTF-8}"
local norm; norm="$(printf '%s' "$loc" | tr 'A-Z' 'a-z' | tr -d '-')"
_locale_present() { locale -a 2>/dev/null | tr 'A-Z' 'a-z' | tr -d '-' | grep -qx "$norm"; }
msg_info "Ensuring $loc is generated and active (DB encoding is frozen at initdb)..."
if ! _locale_present; then
# Uncomment the matching `# <loc> UTF-8` line, then generate.
sed -i "s/^# *${loc} UTF-8/${loc} UTF-8/" /etc/locale.gen
locale-gen >/dev/null
fi
if ! _locale_present; then
msg_err "Locale $loc not available after locale-gen — refusing to continue (initdb would create a SQL_ASCII cluster)"
return 1
fi
# Activate for the current process so any automatic initdb during the
# server package install inherits a UTF-8 locale, not the bare-template C.
export LANG="$loc" LC_ALL="$loc"
msg_ok "Locale active for initdb: LANG=$LANG"
}
# Post-install guard: a database MUST be UTF8. Encoding is irreversible, so a
# wrong value is database damage — abort with a clear, actionable message
# instead of shipping a broken cluster. Uses argv-clean `runuser ... psql`
# with a quoted :'db' literal binding (robust regardless of caller); the
# credentials/README docs use `su - postgres -c` for hand maintenance (these
# minimal LXCs have no sudo).
assert_db_encoding_utf8() {
local db="$1" enc
enc="$(runuser -u postgres -- psql -X -qAt -v db="$db" \
-c "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = :'db'")"
if [[ "$enc" != "UTF8" ]]; then
msg_err "Database '$db' has encoding '${enc:-<not found>}', expected UTF8."
msg_err "Encoding is frozen at creation time — this is DB damage, not cosmetic."
msg_err "Fix: regenerate the locale (locale-gen en_US.UTF-8) and recreate the DB"
msg_err " with: CREATE DATABASE $db ... TEMPLATE template0 ENCODING 'UTF8'"
msg_err " LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8';"
return 1
fi
msg_ok "Encoding check: database '$db' is UTF8"
}
# ── ssh ──────────────────────────────────────────────────────────────────────
# SSH-Root-Login gemäß Host-Prompt (prompt_lxc_config setzt SSH_ROOT_LOGIN,
# bootstrap_install_script reicht es als Env durch; Default: yes).
# yes → PermitRootLogin yes (Passwort-Login mit dem generierten Root-Passwort)
# no → PermitRootLogin prohibit-password (Debian-Default, nur SSH-Key)
# Umsetzung als Drop-in, damit Paket-Updates von sshd_config nicht kollidieren.
configure_ssh_root_login() {
local choice="${SSH_ROOT_LOGIN:-yes}" value
case "$choice" in
yes) value="yes" ;;
no) value="prohibit-password" ;;
*) msg_err "SSH_ROOT_LOGIN must be yes|no, got: '$choice'"; return 1 ;;
esac
if [[ ! -d /etc/ssh/sshd_config.d ]]; then
if [[ "$choice" == "no" ]]; then
msg_warn "openssh-server not installed — nothing to configure (root login stays off)"
return 0
fi
msg_info "Installing openssh-server..."
apt-get install -y -qq openssh-server >/dev/null
fi
msg_info "Configuring SSH root login: PermitRootLogin $value"
printf 'PermitRootLogin %s\n' "$value" >/etc/ssh/sshd_config.d/zz-root-login.conf
systemctl reload ssh 2>/dev/null || systemctl restart ssh 2>/dev/null \
|| msg_warn "ssh.service not active yet — config applies on first start"
msg_ok "SSH root login: $choice"
}
# ── users / dirs ───────────────────────────────────────────────────────────── # ── users / dirs ─────────────────────────────────────────────────────────────
create_system_user() { create_system_user() {
local user="$1" home="$2" local user="$1" home="$2"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# K-114: Jedes ct/*.sh MUSS lib/build.func sourcen — der Bug "Helfer nicht
# gesourct, Skript stirbt erst mitten im Lauf" ist zweimal real passiert
# (nexus-db: im Review gefangen; authentik: erst in Produktion).
# Aufruf: tests/check_ct_source.sh [verzeichnis] (Default: ct/)
#
# Bewusst eng: akzeptiert wird NUR die curl-Prozesssubstitutions-Form
# `source <(curl ... build.func)` — das ist der Repo-Vertrag für ct/-Scripts
# (Review-Triage K-114). Lokales Sourcen gehört nicht in ct/*.sh.
set -euo pipefail
DIR="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ct}"
status=0
shopt -s nullglob
scripts=("$DIR"/*.sh)
if [[ ${#scripts[@]} -eq 0 ]]; then
echo "no scripts found in $DIR" >&2
exit 1
fi
for script in "${scripts[@]}"; do
if grep -Eq 'source[[:space:]]+<\(curl[^)]*build\.func' "$script"; then
echo "ok $script"
else
echo "FEHLT $script — sourct lib/build.func nicht" >&2
status=1
fi
done
exit "$status"
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
# K-114: Unit-Tests für die Input-Validierung in lib/build.func.
# Läuft ohne PVE (sourct nur die Helfer). Aufruf: bash tests/test_validation.sh
set -u
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "$REPO_ROOT/lib/build.func"
PASS=0
FAIL=0
ok() { PASS=$((PASS + 1)); echo "ok - $1"; }
nok() { FAIL=$((FAIL + 1)); echo "NOT OK - $1"; }
assert_true() { # assert_true "desc" cmd args...
local desc="$1"; shift
if "$@"; then ok "$desc"; else nok "$desc"; fi
}
assert_false() {
local desc="$1"; shift
if "$@"; then nok "$desc"; else ok "$desc"; fi
}
# ── Repro des Vorfalls vom 2026-06-11: VLAN-Tag mit unsichtbarem Byte ────────
JUNK_VLAN="$(printf '2\x800')" # "20" mit eingebettetem Non-UTF-8-Byte
assert_false "VLAN-Repro: '2<0x80>0' wird abgelehnt (kein silent strip)" is_vlan_tag "$JUNK_VLAN"
assert_false "is_clean_ascii erkennt eingebettetes Junk-Byte" is_clean_ascii "$JUNK_VLAN"
# Re-Prompt-Verhalten: erste Eingabe ist der Junk-Wert, zweite ist sauber —
# prompt_validated muss die zweite liefern (AC: Re-Prompt statt pct-Fehler).
reprompt_result="$(
printf '%s\n20\n' "$JUNK_VLAN" | {
VLAN=""
prompt_validated VLAN "VLAN tag (empty for none): " is_vlan_tag "" yes >/dev/null 2>&1
printf '%s' "$VLAN"
}
)"
if [[ "$reprompt_result" == "20" ]]; then
ok "prompt_validated re-promptet bei Junk und akzeptiert dann '20'"
else
nok "prompt_validated re-promptet bei Junk (got: '$reprompt_result')"
fi
# ── sanitize_input: CR + Rand-Whitespace weg, Inhalt unangetastet ─────────────
[[ "$(sanitize_input $' 42\r\n')" == "42" ]] && ok "sanitize_input trimmt CR/Whitespace" \
|| nok "sanitize_input trimmt CR/Whitespace"
[[ "$(sanitize_input "$JUNK_VLAN")" == "$JUNK_VLAN" ]] && ok "sanitize_input strippt KEIN eingebettetes Junk (Validator soll es sehen)" \
|| nok "sanitize_input lässt eingebettetes Junk unangetastet"
# ── numerische Validatoren ────────────────────────────────────────────────────
assert_true "is_uint akzeptiert 8" is_uint "8"
assert_false "is_uint lehnt '8 ' mit Junk ab" is_uint "$(printf '8\x01')"
assert_false "is_uint lehnt 'abc' ab" is_uint "abc"
assert_false "is_uint lehnt leeren Wert ab" is_uint ""
assert_true "is_vlan_tag akzeptiert 20" is_vlan_tag "20"
assert_false "is_vlan_tag lehnt 0 ab" is_vlan_tag "0"
assert_false "is_vlan_tag lehnt 5000 ab" is_vlan_tag "5000"
# ── Netz-Validatoren ──────────────────────────────────────────────────────────
assert_true "is_ipv4 akzeptiert 10.11.20.66" is_ipv4 "10.11.20.66"
assert_false "is_ipv4 lehnt 10.11.20.666 ab" is_ipv4 "10.11.20.666"
assert_false "is_ipv4 lehnt 10.11.20 ab" is_ipv4 "10.11.20"
assert_true "is_cidr akzeptiert 10.11.20.5/24" is_cidr "10.11.20.5/24"
assert_false "is_cidr lehnt 10.11.20.5/33 ab" is_cidr "10.11.20.5/33"
assert_false "is_cidr lehnt 10.11.20.5 ohne Maske" is_cidr "10.11.20.5"
assert_true "is_ipcfg akzeptiert dhcp" is_ipcfg "dhcp"
assert_true "is_ipcfg akzeptiert CIDR" is_ipcfg "192.168.0.7/24"
assert_false "is_ipcfg lehnt 'static' ab" is_ipcfg "static"
assert_true "is_ipv4_list akzeptiert Liste" is_ipv4_list "1.1.1.1, 8.8.8.8"
assert_false "is_ipv4_list lehnt Hostnamen ab" is_ipv4_list "dns.local"
# ── Namen/Token ───────────────────────────────────────────────────────────────
assert_true "is_hostname akzeptiert nexus-db" is_hostname "nexus-db"
assert_false "is_hostname lehnt '-bad' ab" is_hostname "-bad"
assert_false "is_hostname lehnt 'a b' ab" is_hostname "a b"
assert_true "is_token akzeptiert local-lvm" is_token "local-lvm"
assert_false "is_token lehnt 'a;b' ab" is_token "a;b"
# ── Ja/Nein (SSH-Root-Login-Prompt) ──────────────────────────────────────────
assert_true "is_yesno akzeptiert y" is_yesno "y"
assert_true "is_yesno akzeptiert Ja" is_yesno "Ja"
assert_true "is_yesno akzeptiert NO" is_yesno "NO"
assert_true "is_yesno akzeptiert nein" is_yesno "nein"
assert_false "is_yesno lehnt 'maybe' ab" is_yesno "maybe"
assert_false "is_yesno lehnt leeren Wert ab" is_yesno ""
[[ "$(normalize_yesno "J")" == "yes" && "$(normalize_yesno "nein")" == "no" ]] \
&& ok "normalize_yesno kanonisiert J→yes, nein→no" \
|| nok "normalize_yesno kanonisiert J→yes, nein→no"
# SSH-Root-Login-Prompt: leere Eingabe = Default Y → normalisiert yes;
# explizites 'n' → no.
ssh_default="$(
printf '\n' | {
SSH_ROOT_LOGIN=""
prompt_validated SSH_ROOT_LOGIN "SSH-Root-Login erlauben? [Y/n]: " is_yesno "y" >/dev/null 2>&1
normalize_yesno "$SSH_ROOT_LOGIN"
}
)"
[[ "$ssh_default" == "yes" ]] && ok "SSH-Root-Login: leere Eingabe → Default yes" \
|| nok "SSH-Root-Login Default (got: '$ssh_default')"
ssh_no="$(
printf 'n\n' | {
SSH_ROOT_LOGIN=""
prompt_validated SSH_ROOT_LOGIN "SSH-Root-Login erlauben? [Y/n]: " is_yesno "y" >/dev/null 2>&1
normalize_yesno "$SSH_ROOT_LOGIN"
}
)"
[[ "$ssh_no" == "no" ]] && ok "SSH-Root-Login: 'n' → no" \
|| nok "SSH-Root-Login 'n' (got: '$ssh_no')"
# ── require_valid: env-Werte werden sanitisiert + geprüft ────────────────────
CHECKVAL=$' 7\r'
require_valid CHECKVAL is_uint "Testwert" && [[ "$CHECKVAL" == "7" ]] \
&& ok "require_valid sanitisiert env-Wert (CR weg)" \
|| nok "require_valid sanitisiert env-Wert"
( CHECKBAD="$JUNK_VLAN"; require_valid CHECKBAD is_uint "Testwert" ) >/dev/null 2>&1 \
&& nok "require_valid bricht bei Junk-env-Wert ab" \
|| ok "require_valid bricht bei Junk-env-Wert ab (exit != 0)"
# ── Negativ-Beweis: build.func-Source-Check schlägt bei Präparat an ──────────
TMPDIR_CT="$(mktemp -d)"
cat >"$TMPDIR_CT/broken.sh" <<'EOF'
#!/usr/bin/env bash
# absichtlich ohne source build.func (Repro des zweimal aufgetretenen Bugs)
APP="broken"
prompt_lxc_config
EOF
if bash "$REPO_ROOT/tests/check_ct_source.sh" "$TMPDIR_CT" >/dev/null 2>&1; then
nok "check_ct_source.sh erkennt fehlendes 'source build.func'"
else
ok "check_ct_source.sh erkennt fehlendes 'source build.func'"
fi
rm -rf "$TMPDIR_CT"
# Positiv: das echte ct/-Verzeichnis ist sauber.
if bash "$REPO_ROOT/tests/check_ct_source.sh" "$REPO_ROOT/ct" >/dev/null 2>&1; then
ok "alle ct/*.sh sourcen build.func"
else
nok "alle ct/*.sh sourcen build.func"
fi
# ── Review-Findings K-114: Oktal, Newline, EOF, Nameref-Guard ────────────────
assert_true "is_vlan_tag akzeptiert '08' (kein Oktal-Fehler)" is_vlan_tag "08"
assert_true "is_cidr akzeptiert /08 (kein Oktal-Fehler)" is_cidr "10.0.0.1/08"
assert_false "is_clean_ascii lehnt eingebettetes Newline ab" is_clean_ascii $'1.1.1.1\n8.8.8.8'
assert_false "is_ipv4_list lehnt Newline-Liste ab" is_ipv4_list $'1.1.1.1\n8.8.8.8'
# EOF statt Eingabe: prompt_validated darf nicht endlos loopen.
( printf '' | { V=""; prompt_validated V "Wert: " is_uint; } ) >/dev/null 2>&1
rc=$?
[[ "$rc" -ne 0 ]] && ok "prompt_validated bricht bei EOF ab (rc=$rc)" \
|| nok "prompt_validated bricht bei EOF ab"
# Reservierte Namen → Guard statt zirkulärem nameref.
( _pv_ref=""; prompt_validated _pv_ref "x: " is_uint ) >/dev/null 2>&1
[[ $? -eq 2 ]] && ok "prompt_validated weist reservierte Variablennamen ab" \
|| nok "prompt_validated weist reservierte Variablennamen ab"
# CTID-Re-Prompt: ungültig → erneut fragen, leer wäre auto (hier: gültige Zahl).
ctid_out="$(
printf 'abc\n123\n' | env -u CTID bash -c "
source '$REPO_ROOT/lib/build.func'
CTID=''
while true; do
read -rp 'Container ID [auto]: ' CTID || exit 1
CTID=\"\$(sanitize_input \"\$CTID\")\"
[[ -z \"\$CTID\" ]] && exit 1
is_uint \"\$CTID\" && break
done
printf '%s' \"\$CTID\""
)"
[[ "$ctid_out" == "123" ]] && ok "CTID-Loop re-promptet bei 'abc' und nimmt '123'" \
|| nok "CTID-Loop re-promptet (got: '$ctid_out')"
# ── Dry-Run: prompt_lxc_config komplett aus env, ohne PVE/TTY ────────────────
smoke_out="$(
env CTID=999 CT_HOSTNAME=smoke DISK_SIZE=8 CORES=2 RAM=1024 BRIDGE=vmbr0 \
VLAN_TAG=20 TEMPLATE_STORAGE=local ROOTFS_STORAGE=local-lvm \
IPCFG=10.11.20.99/24 GATEWAY=10.11.20.1 NAMESERVER="" SSH_ROOT_LOGIN=J \
NET_PROFILES_FILE="$REPO_ROOT/lib/networks.conf" \
bash -c "source '$REPO_ROOT/lib/build.func' && prompt_lxc_config >/dev/null && echo SMOKE-OK:\$SSH_ROOT_LOGIN"
)" || true
[[ "$smoke_out" == *SMOKE-OK:yes* ]] \
&& ok "prompt_lxc_config Dry-Run mit validen env-Werten läuft durch (SSH_ROOT_LOGIN J→yes)" \
|| nok "prompt_lxc_config Dry-Run (got: '$smoke_out')"
smoke_bad="$(
env CTID="$(printf '9\x809')" CT_HOSTNAME=smoke DISK_SIZE=8 CORES=2 RAM=1024 BRIDGE=vmbr0 \
VLAN_TAG=20 TEMPLATE_STORAGE=local ROOTFS_STORAGE=local-lvm IPCFG=dhcp NAMESERVER="" \
NET_PROFILES_FILE="$REPO_ROOT/lib/networks.conf" \
bash -c "source '$REPO_ROOT/lib/build.func' && prompt_lxc_config >/dev/null && echo SMOKE-OK" 2>/dev/null
)" || true
[[ "$smoke_bad" == *SMOKE-OK* ]] \
&& nok "prompt_lxc_config bricht bei Junk-CTID aus env ab" \
|| ok "prompt_lxc_config bricht bei Junk-CTID aus env ab"
echo
echo "passed=$PASS failed=$FAIL"
[[ "$FAIL" -eq 0 ]]