claude-bot 5130b82639
CI / Shell-Lint (bash -n, source-check, Validierungs-Tests) (pull_request) Successful in 2s
security: version the CIS Tier-A hardening pass
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
2026-05-21 23:05:38 +02:00
2026-05-21 23:01:20 +02:00

luki-net / proxmox-scripts

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, but minimal, self-hosted and tailored to this stack.

Script catalog

App What it provisions Status
devpi Private PyPI cache/mirror — saves time on CUDA/torch rebuilds ✅ stable
webapp Next.js site with deploy-as-code via a repo-scoped Gitea Actions runner (host mode, no inbound port) ✅ stable
nexus App LXC for nexus (Family Knowledge Hub): host-mode runner (label nexus, CI + deploy), service skeleton, /opt/nexus layout. Runtime is provisioned/extended via install/nexus-runtime.sh (idempotent, re-runnable) ✅ in production
nexus-db PostgreSQL 16 + pgvector for nexus — least-privilege role, pg_hba allowlist (only the nexus LXC), DSN handed over via credentials file ✅ in production
authentik 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 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 -c "$(curl -fsSL https://gitea.luki-net.org/luki-net/proxmox-scripts/raw/branch/main/ct/<app>.sh)"

Usage

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:

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)"

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 (host-side: prompts, LXC create, bootstrap) and 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 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.

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) enforces on every PR: bash -n over all scripts, the "every ct/*.sh sources build.func" check (tests/check_ct_source.sh) and the validation unit tests (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.

Repo layout

.
├── ct/                 # Host-side scripts, one per app
├── install/            # In-container installers (+ nexus-runtime.sh re-provisioner,
│                       #   cis-tierA.sh hardening pass)
├── lib/
│   ├── build.func      # Shared host-side helpers (prompts, LXC create, bootstrap)
│   └── 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/
│   └── adding-a-script.md
├── README.md
└── LICENSE
  • nexus-hub — Family Knowledge Hub (main consumer of nexus/nexus-db/authentik/runner)
  • Wiki — per-app runbook pointers, conventions, lessons learned

License

MIT

S
Description
Community-script-style installers for LXC services in a Proxmox VE homelab. Shared libs + one folder per app.
Readme MIT
245 KiB
Languages
Shell 100%