Author SHA1 Message Date
beatzaplentyandClaude Sonnet 4.6 afabcbbeff feat(raspberrypi): add Raspberry Pi configuration section
Secret Scan / Scan for secrets and sensitive config (push) Failing after 5s
Documents and scripts to reproduce the IPA integration on the Pi
(raspberrypi.tail13f623.ts.net, Debian 12 bookworm):

- setup-ipa-sudo.sh: writes /etc/sudoers.d/ipa-admins granting
  %admins NOPASSWD:ALL (same IPA admins group as pbs/pdm/pve1)
- setup-docker-ipa-gid.sh: pins local docker group GID to 50010
  via groupmod --non-unique so IPA docker-access group membership
  alone grants docker socket access (mirrors NixOS lib.mkForce approach)
- README.md + CLAUDE.md: quick-start, current status, guardrails

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:47:30 +10:00
beatzaplenty a4d907e2eb Merge pull request 'docs(freeipa): update IPs and remove Pi-hole references' (#5) from worktree-debian-full-sweep into main
Secret Scan / Scan for secrets and sensitive config (push) Failing after 3s
Reviewed-on: #5
2026-07-28 05:26:40 +00:00
beatzaplentyandClaude Sonnet 4.6 761fe9e194 docs(freeipa): update IPs and remove Pi-hole references
Secret Scan / Scan for secrets and sensitive config (push) Failing after 4s
Secret Scan / Scan for secrets and sensitive config (pull_request) Failing after 3s
- IPA now runs at 192.168.2.253 (previously shown as .138 in install docs)
- cloud-init nameserver and nmcli DNS during install use .254 (gateway)
  since IPA isn't running yet at that point
- --forwarder updated to 192.168.2.254 (LAN gateway, Pi-hole is gone)
- Remove Step 7 (Pi-hole DNS forwarding config) from install.md
- Delete obsolete freeipa/docs/pihole-dns.md and configure-pihole-dns.sh
- Update CLAUDE.md section count and freeipa/ guidance entry
- Update freeipa/README.md IP and DNS forwarding description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 15:25:22 +10:00
beatzaplentyandClaude Sonnet 4.6 0c15de3329 proxmox: add IPA sudo and local backdoor scripts, update bootstrap notes
Secret Scan / Scan for secrets and sensitive config (push) Failing after 3s
- setup-ipa-sudo.sh: grants %admins group NOPASSWD sudo after ipa-client-install;
  writes admins-proxmox (pvesh/qm/pct) only when those binaries are present,
  so the same script works on PBS/PDM as well as PVE hosts
- create-local-backdoor.sh: creates a local 'pveadmin' account with SSH key
  and NOPASSWD sudo as an emergency fallback when IPA/SSSD is unavailable;
  password set via BACKDOOR_PASS env var or prompted interactively
- bootstrap.sh: appended post-IPA-enrollment reminder to the final checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 13:32:30 +10:00
beatzaplenty f54e00ebc8 Merge pull request 'feat(freeipa): add domain-controller setup — Rocky Linux 9 + FreeIPA 4.13' (#5) from feat/freeipa-domain-controller into main
Secret Scan / Scan for secrets and sensitive config (push) Failing after 4s
Reviewed-on: #5
2026-07-26 21:38:46 +00:00
beatzaplentyandClaude Sonnet 4.6 89cc8c8cc3 feat(freeipa): add domain-controller setup — Rocky Linux 9 + FreeIPA 4.13
Installs and documents the FreeIPA identity management server at
domain-controller.sweet.home (VMID 108, pve1). Provides Kerberos,
LDAP, and integrated DNS for the SWEET.HOME realm.

New section: freeipa/
- docs/install.md: full step-by-step reproduction procedure including
  Proxmox VM prep (Rocky Linux 9 GenericCloud, SeaBIOS, cloud-init),
  swap setup, static IP, /etc/hosts fix, ipa-server-install flags
- docs/pihole-dns.md: how to configure Pi-hole to forward sweet.home
  queries to the FreeIPA BIND instance
- scripts/install.sh: idempotent install script with pre-flight checks;
  reads passwords from env or interactive prompt (never commits them)
- scripts/configure-pihole-dns.sh: idempotent Pi-hole forwarder setup
- scripts/verify.sh: read-only health check (13 checks, 0 side effects)
- CLAUDE.md: host guardrails for domain-controller

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Jbvxx4xbHVcx1NkK3vtmK
2026-07-27 07:22:05 +10:00
beatzaplenty a6a419bad7 updated structure
Secret Scan / Scan for secrets and sensitive config (push) Failing after 4s
2026-07-24 06:22:31 +10:00
beatzaplentyandClaude Sonnet 4.6 5361a82634 Merge branch 'beszel-firewall': allow beszel agent port 45876
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 06:09:49 +10:00
beatzaplentyandClaude Sonnet 4.6 57ef1fd7be proxmox: allow beszel agent port 45876 from mgmt network
Adds an inbound ACCEPT rule for TCP 45876 so the beszel hub
can poll the agent running on pve1. Scoped to +mgmt (192.168.2.0/24)
to stay consistent with the existing default-deny policy.

Rule applied live on pve1 via pve-firewall restart (2026-07-24).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 06:09:25 +10:00
beatzaplenty 3bf44ffcde Merge pull request 'docs: update documentation for debian-configuration repo' (#4) from worktree-update-docs into main
Secret Scan / Scan for secrets and sensitive config (push) Failing after 3s
Merge PR #4: docs update for debian-configuration rename
2026-07-23 02:35:53 +00:00
beatzaplentyandClaude Sonnet 4.6 4aadef8e97 docs: update documentation for debian-configuration repo rename
Secret Scan / Scan for secrets and sensitive config (push) Failing after 3s
Secret Scan / Scan for secrets and sensitive config (pull_request) Failing after 3s
- Root README.md: new, describes overall repo layout (proxmox/, pihole/,
  scripts/) and secret-scanning workflow
- Root CLAUDE.md: new, cross-section guardrails and instructions for
  adding new machine types; references proxmox/CLAUDE.md and pihole/
  section-specific rules
- pihole/README.md: new, covers scripts usage, notable DHCP/PXE config,
  EFI VM requirements (VirtIO RNG, Secure Boot), and secrets handling
- proxmox/README.md: add repo-context note pointing back to root README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRzqNDrbnYR22ZgZj1Bg3s
2026-07-23 12:35:30 +10:00
beatzaplenty 898b8239cc Merge pull request 'Restructure: move proxmox/ into subfolder, add pihole/ section with config and CI' (#3) from worktree-debian-restructure into main
Secret Scan / Scan for secrets and sensitive config (push) Failing after 3s
Merge PR #3: restructure into proxmox/ + pihole/ with CI secret scanning
2026-07-23 02:33:53 +00:00
beatzaplentyandClaude Sonnet 4.6 b269a5d616 ci: add secret scanning for GitHub, Gitea, and local use
Secret Scan / Scan for secrets and sensitive config (push) Failing after 4s
Secret Scan / Scan for secrets and sensitive config (pull_request) Failing after 3s
- .gitleaks.toml — extends gitleaks defaults with Pi-hole-specific rules
  for pwhash/totp_secret/app_pwhash; allowlists known-safe patterns
- .github/workflows/secret-scan.yml — GitHub Actions (full history scan)
- .gitea/workflows/secret-scan.yml — Gitea Actions (identical workflow)
- scripts/check-secrets.sh — shared runner used by both CI and local;
  supports --staged-only for pre-commit hook use; falls back to Docker
  if gitleaks isn't on PATH
- scripts/install-hooks.sh — installs pre-commit hook pointing at above
- pihole/sanitize-config.sh — redacts pwhash/totp_secret/app_pwhash
  in pihole.toml in-place before the file is committed
- pihole/pull-config.sh — updated to call sanitize-config.sh
  automatically after every pull so the repo stays clean by default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRzqNDrbnYR22ZgZj1Bg3s
2026-07-23 12:21:34 +10:00
beatzaplentyandClaude Sonnet 4.6 2909db5d04 restructure: move proxmox/ into subfolder, add pihole/ section
All existing content moved from repo root into proxmox/ to make room
for other Debian machine configs. Adds pihole/ with:

- config/pihole.toml — snapshot of current Pi-hole v6 config
- config/dnsmasq.d/99-ipxe-chainload.conf — custom PXE DHCP rules
  (EFI/BIOS iPXE chainload, fixed tag-specificity bug for UEFI boot)
- pull-config.sh <source-host> <dest-dir> — pull live config to disk
- apply-config.sh <source-dir> <dest-host> — push config to a Pi-hole

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRzqNDrbnYR22ZgZj1Bg3s
2026-07-23 12:16:35 +10:00
beatzaplentyandClaude Sonnet 4.6 7a038b534f add Linux admin user setup as part of node hardening
New script setup-linux-admin-user.sh creates the Linux system user,
installs an SSH authorized key, and adds the user to the sudo group.
Integrated into bootstrap.sh before harden-ssh.sh so key-based access
is in place before password authentication is disabled.

bootstrap.sh now accepts ADMIN_USER and ADMIN_SSH_KEY env vars to run
user setup and setup-admin-sudo.sh automatically at the right point.
audit.sh checks that at least one non-root user has an authorized key.
docs/04-security-hardening.md updated with the new steps and ordering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:02:26 +10:00
beatzaplentyandClaude Sonnet 4.6 c832418aed setup-admin-sudo: add nix to NOPASSWD list
Single-user Nix on PVE nodes is owned by root. Add the fixed Nix binary
path (/nix/var/nix/profiles/default/bin/nix) to the NOPASSWD sudoers
rule so non-root SSH users can run nix build from create-proxmox-resource.sh.
Gracefully skips with a warning if Nix isn't installed yet (run after
codex-setup.sh bootstraps the node).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 09:49:53 +10:00
beatzaplentyandClaude Sonnet 4.6 2d5472a59d add setup-admin-sudo.sh for passwordless Proxmox tool access
The nixos flake's create-proxmox-resource.sh runs pvesh/qm/pct over
non-interactive SSH, which requires NOPASSWD sudo for those binaries.
pvesh moved from /usr/sbin to /usr/bin in PVE 8.x, so the script
resolves paths at runtime rather than hardcoding them, preventing the
silent NOPASSWD-miss that caused ipcc_send_rec errors.

- scripts/setup-admin-sudo.sh: new script, takes username, writes
  /etc/sudoers.d/<user>-proxmox with correct resolved paths and
  validates it with visudo -c before leaving it in place
- scripts/bootstrap.sh: add setup-admin-sudo.sh to post-steps list
- scripts/audit.sh: check that a *-proxmox sudoers file exists with
  NOPASSWD for all three tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 09:20:48 +10:00
beatzaplenty 04d1e63f0a Updated environment 2026-07-21 23:14:20 +00:00
84 changed files with 7435 additions and 219 deletions
@@ -0,0 +1,28 @@
name: Secret Scan
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
secret-scan:
name: Scan for secrets and sensitive config
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for gitleaks git-log scan
- name: Install gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Run secret scan
run: bash scripts/check-secrets.sh
@@ -0,0 +1,28 @@
name: Secret Scan
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
secret-scan:
name: Scan for secrets and sensitive config
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for gitleaks git-log scan
- name: Install gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Run secret scan
run: bash scripts/check-secrets.sh
@@ -0,0 +1,41 @@
# Gitleaks configuration for debian-configuration repo.
# Extends the default ruleset with Pi-hole-specific secret patterns.
# https://github.com/gitleaks/gitleaks
title = "debian-configuration secret scan"
[extend]
useDefault = true
# ── Custom rules ───────────────────────────────────────────────────────────────
[[rules]]
id = "pihole-pwhash"
description = "Pi-hole password hash (pihole.toml webserver.api.pwhash)"
regex = '''pwhash\s*=\s*"[^"]{10,}"'''
tags = ["pihole", "password"]
[[rules]]
id = "pihole-totp-secret"
description = "Pi-hole 2FA TOTP secret"
regex = '''totp_secret\s*=\s*"[^"]{10,}"'''
tags = ["pihole", "2fa"]
[[rules]]
id = "pihole-app-pwhash"
description = "Pi-hole app password hash"
regex = '''app_pwhash\s*=\s*"[^"]{10,}"'''
tags = ["pihole", "password"]
# ── Allowlist ──────────────────────────────────────────────────────────────────
[allowlist]
description = "Known-safe patterns in this repo"
regexes = [
# TLS cert path reference — not the key itself
'''cert\s*=\s*"/etc/pihole/tls\.pem"''',
]
paths = [
# Example/template files are intentionally non-live
'''\.example$''',
]
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Apply a Pi-hole configuration directory to a destination Pi-hole instance.
#
# Usage: apply-config.sh <source-dir> <dest-host>
# source-dir Local directory containing config files (as written by pull-config.sh)
# dest-host SSH-reachable hostname or IP of the destination Pi-hole
#
# Example:
# ./apply-config.sh ./config root@pihole-new
# ./apply-config.sh /backup/pihole-20260723 root@192.168.2.101
#
# The destination Pi-hole must already have Pi-hole v6 installed.
# pihole-FTL is restarted at the end to apply the new config.
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <source-dir> <dest-host>" >&2
echo " source-dir Local directory with config files (from pull-config.sh)" >&2
echo " dest-host SSH target for the destination Pi-hole (e.g. root@pihole)" >&2
exit 1
}
[[ $# -eq 2 ]] || usage
SOURCE="$1"
DEST="$2"
[[ -d "${SOURCE}" ]] || { echo "Error: source directory '${SOURCE}' not found" >&2; exit 1; }
[[ -f "${SOURCE}/pihole.toml" ]] || { echo "Error: '${SOURCE}/pihole.toml' not found — is this a valid config dir?" >&2; exit 1; }
echo "Applying Pi-hole config from ${SOURCE}${DEST}"
# ── Verify destination is running Pi-hole ──────────────────────────────────────
ssh "${DEST}" "command -v pihole-FTL >/dev/null 2>&1 || { echo 'pihole-FTL not found on destination'; exit 1; }"
# ── pihole.toml ────────────────────────────────────────────────────────────────
echo " pihole.toml"
ssh "${DEST}" "cp /etc/pihole/pihole.toml /etc/pihole/pihole.toml.pre-apply 2>/dev/null || true"
scp "${SOURCE}/pihole.toml" "${DEST}:/etc/pihole/pihole.toml"
ssh "${DEST}" "chown pihole:pihole /etc/pihole/pihole.toml; chmod 640 /etc/pihole/pihole.toml"
# ── custom dnsmasq drop-ins ────────────────────────────────────────────────────
if [[ -d "${SOURCE}/dnsmasq.d" ]] && [[ -n "$(ls "${SOURCE}/dnsmasq.d/"*.conf 2>/dev/null)" ]]; then
echo " dnsmasq.d/ (custom drop-ins)"
for f in "${SOURCE}/dnsmasq.d/"*.conf; do
name="$(basename "$f")"
echo " ${name}"
scp "${f}" "${DEST}:/etc/dnsmasq.d/${name}"
done
fi
# ── Restart pihole-FTL ─────────────────────────────────────────────────────────
echo " Restarting pihole-FTL"
ssh "${DEST}" "systemctl restart pihole-FTL"
sleep 2
ssh "${DEST}" "systemctl is-active pihole-FTL"
echo "Done. Config applied to ${DEST}."
echo "Note: gravity (blocklists) is not transferred — run 'pihole updateGravity' on ${DEST} to rebuild."
@@ -0,0 +1,20 @@
# Detect iPXE clients (already running iPXE).
dhcp-match=set:ipxe,175
dhcp-userclass=set:ipxe,iPXE
# Detect UEFI x86_64 clients by client architecture.
dhcp-match=set:efi64,option:client-arch,7
dhcp-match=set:efi64,option:client-arch,9
# Boot file selection — more positive tags = higher priority.
# EFI iPXE (2 tags): already running iPXE on EFI, chain to HTTP menu.
dhcp-boot=tag:ipxe,tag:efi64,http://192.168.2.247/boot.ipxe
# BIOS iPXE (1 tag): already running iPXE, chain to HTTP menu.
dhcp-boot=tag:ipxe,http://192.168.2.247/boot.ipxe
# EFI non-iPXE (1 tag): send the EFI iPXE binary.
dhcp-boot=tag:efi64,ipxe.efi,,192.168.2.247
# BIOS/legacy fallback (0 tags): send the BIOS iPXE binary.
dhcp-boot=undionly.kpxe,,192.168.2.247
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Pull Pi-hole configuration from a running instance to a local directory.
# Sensitive fields (password hashes, TOTP secrets) are redacted automatically.
#
# Usage: pull-config.sh <source-host> <dest-dir>
# source-host SSH-reachable hostname or IP of the source Pi-hole
# dest-dir Local directory to write config into (created if absent)
#
# Example:
# ./pull-config.sh root@pihole ./config
# ./pull-config.sh root@192.168.2.253 /backup/pihole-$(date +%Y%m%d)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
usage() {
echo "Usage: $(basename "$0") <source-host> <dest-dir>" >&2
echo " source-host SSH target for the source Pi-hole (e.g. root@pihole)" >&2
echo " dest-dir Local directory to write config files into" >&2
exit 1
}
[[ $# -eq 2 ]] || usage
SOURCE="$1"
DEST="$2"
echo "Pulling Pi-hole config from ${SOURCE}${DEST}"
mkdir -p "${DEST}/dnsmasq.d"
# ── pihole.toml ────────────────────────────────────────────────────────────────
echo " pihole.toml"
ssh "${SOURCE}" "cat /etc/pihole/pihole.toml" > "${DEST}/pihole.toml"
"${SCRIPT_DIR}/sanitize-config.sh" "${DEST}/pihole.toml"
# ── custom dnsmasq drop-ins ────────────────────────────────────────────────────
# Pi-hole manages its own generated config; we only capture user-added files.
echo " dnsmasq.d/ (custom drop-ins)"
ssh "${SOURCE}" "ls /etc/dnsmasq.d/*.conf 2>/dev/null || true" | while read -r f; do
name="$(basename "$f")"
echo " ${name}"
ssh "${SOURCE}" "cat '${f}'" > "${DEST}/dnsmasq.d/${name}"
done
# ── DHCP static leases ─────────────────────────────────────────────────────────
echo " dhcp.leases"
ssh "${SOURCE}" "cat /etc/pihole/dhcp.leases 2>/dev/null || true" > "${DEST}/dhcp.leases"
echo "Done. Config written to ${DEST}/"
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Redact sensitive fields from a Pi-hole pihole.toml before committing.
# Called automatically by pull-config.sh; can also be run manually.
#
# Usage: sanitize-config.sh <pihole.toml>
# Edits the file in-place, replacing sensitive field values with "".
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <pihole.toml>" >&2
exit 1
}
[[ $# -eq 1 ]] || usage
FILE="$1"
[[ -f "$FILE" ]] || { echo "Error: file not found: $FILE" >&2; exit 1; }
REDACTED=0
redact_field() {
local field="$1"
# Match lines like: pwhash = "some-value" and blank the value
if grep -qE "^\s+${field}\s*=\s*\"[^\"]{1,}\"" "$FILE"; then
sed -i -E "s|^(\s+${field}\s*=\s*)\"[^\"]*\"|\1\"\"|" "$FILE"
echo " redacted: ${field}"
REDACTED=$((REDACTED + 1))
fi
}
echo "Sanitizing $(basename "$FILE")..."
redact_field "pwhash"
redact_field "app_pwhash"
redact_field "totp_secret"
if [[ $REDACTED -eq 0 ]]; then
echo " (nothing to redact)"
else
echo " ${REDACTED} field(s) redacted."
fi
@@ -0,0 +1,84 @@
# CLAUDE.md
Guidance for Claude Code working in this repo. IMPORTANT: these
instructions OVERRIDE any default behavior and must be followed exactly
as written.
## Repo purpose
Base configuration/hardening toolset and planning docs for Proxmox VE
hosts (`scripts/`, `config/`, `docs/`) — see `README.md` and
`docs/00-overview.md`. Scripts in this repo are meant to be run **on**
the target Proxmox host itself (as root), not orchestrated remotely.
## Two Proxmox nodes: `pve1` (production) and `pve-test` (sandbox)
Two SSH-reachable Proxmox nodes exist on the LAN. They are **not
interchangeable** — see `docs/05-node-roles.md` for full background on
what each one is and why.
### `pve1` (production — off-limits to Claude by default)
A real, live Proxmox node hosting production VMs/containers (see
`docs/05-node-roles.md` for the current guest list) — not a sandbox, and
not Claude's to touch by default.
- **Off-limits at all times unless the operator has given explicit,
same-session instructions to act on this specific host.** That
authorization is scoped to the task it was given for — don't carry it
forward to unrelated later work in the same conversation, and never
assume it from a previous session.
- **Read-only for existing state is always fine, authorization or not.**
SSH in (or use `pvesm`, `qm list`, `pct list`, `qm config`, `pct
config`, the Proxmox API, etc.) to inspect config, storage, and any
existing VM/container freely.
- **Never** modify, stop, restart, delete, reconfigure, or create
anything on this node (`qm set`, `pct set`, `qm destroy`, `pct
destroy`, `qm stop`, `pct stop`, `qm create`, `pct create`, snapshot
operations, storage changes, running any script in this repo against
it, etc.) without that explicit go-ahead. Use `pve-test` for anything
exploratory instead.
- If a guest on `pve1` is HA-managed, be aware of the self-fence hazard
described in `docs/05-node-roles.md`'s cluster-teardown section before
doing anything that could cost the node quorum.
### `pve-test` (sandbox — Claude's default target)
A separate node set aside for testing — safe to create, interrogate, and
destroy scratch VMs/containers on without asking first.
- **Test VMs/containers are allowed, but must be torn down.** Anything
created this way must be destroyed again in the same session, before
ending the task. Use an obviously-scratch VMID/name.
- **Node-level config is still not yours to change by default.**
Creating/destroying your own scratch guests is fine; Proxmox host
config, storage pools, and networking on `pve-test` itself need the
operator's explicit go-ahead too, same as on `pve1` — the "sandbox"
status covers guest-level experimentation, not the host's own
identity. (`pve-test`'s current network config, `docs/06-pve-test-wifi-network.md`,
was applied under exactly that kind of explicit, same-session
authorization — it's not a standing invitation to keep changing it
further without asking again.)
- **Never re-cluster `pve-test` with `pve1`** without the operator
explicitly asking for it and being aware of the wifi/corosync
incompatibility in `docs/06-pve-test-wifi-network.md` — the two were
deliberately de-clustered for this reason once already.
## Safety rules
- Any script in `scripts/` that isn't `audit.sh` (read-only) makes real
changes when run for real. Don't run one against `pve1`, or against
`pve-test`'s node-level config, without the same-session go-ahead
described above. Running a script *against pve-test's own guests* — a
scratch VM/CT you created for this task — doesn't need separate
permission.
- Do not commit secrets: SSH private keys, wifi passphrases, sops/age
keys, or PVE credentials. `scripts/setup-wifi-bond-network.sh` takes
the SSID/passphrase via environment variables for exactly this reason
— never hardcode them into the script or a committed config file.
- Live changes to a node's own management network (the interface/bridge
carrying the SSH session you're using) can strand the box — see the
"what went wrong once" section in `docs/06-pve-test-wifi-network.md`
before touching `pve-test`'s networking again. Prefer applying via
`ifreload -a` over raw `ip link` surgery, and arm an auto-revert
watchdog first when acting without someone physically at the console.
@@ -0,0 +1,55 @@
# Proxmox Configuration
Base configuration and hardening toolset for Proxmox VE hosts, plus planning
docs for eventually growing this into a 3-node HA/Ceph cluster. See
`docs/00-overview.md` for the staging: **Stage 1** (base config/hardening,
applies to any host — active) vs. **Stage 2** (multi-node HA/Ceph — future,
deferred).
## Goals
- Stage 1: a reusable, idempotent base-hardening toolset (`scripts/`) that
can be run against any new Proxmox host — repo/updates, SSH, firewall, PVE
user/access hardening — verified with `scripts/audit.sh`.
- Stage 2 (future): 3-node cluster, quorum via corosync, HA-managed VMs
backed by Ceph. Needs dedicated hardware node 1 (`pve1`, an ASUS PN53 mini
PC) doesn't have — see `docs/01-hardware-node1.md`.
See `CLAUDE.md` for the guardrails Claude Code follows when working
against these hosts (`pve1` is production and off-limits by default;
`pve-test` is the sandbox).
## Repo layout
- `docs/` — planning docs: hardware layout, storage migration, networking,
security hardening, node roles. Read `docs/00-overview.md` first, then
`docs/05-node-roles.md` for what `pve1`/`pve-test` actually are.
- `scripts/` — scripts to apply configuration on a node (SSH hardening, repo
switch, firewall, updates, wifi/bond networking, etc). Idempotent, safe
to re-run. `scripts/bootstrap.sh` runs the full Stage 1 sequence end to
end; `scripts/audit.sh` verifies it (read-only).
`scripts/setup-wifi-bond-network.sh` reproduces `pve-test`'s wifi
network (see `docs/06-pve-test-wifi-network.md`). `scripts/lib/` holds
shared helpers (`common.sh`) sourced by the other scripts.
- `config/` — reference config files/snippets to drop onto a node (firewall
rules, sshd config, etc.).
## Quick start (Stage 1, on a fresh node)
```
MGMT_CIDR=192.168.2.0/24 ./scripts/bootstrap.sh
./scripts/create-admin-user.sh <username>
# then enable 2FA for that user + root@pam via the web UI
./scripts/audit.sh
```
## Status
`pve1` built (ASUS PN53 mini PC, ZFS mirror boot+VM storage, single
2.5GbE NIC) and already running production VMs/CTs. Stage 1 base
hardening applied and verified (`scripts/audit.sh` all green): enterprise
repos removed, SSH key-only + fail2ban, unattended security upgrades,
PVE firewall (mgmt-only), subscription nag disabled, named admin user
(`wayne@pve`) created. Remaining manual step: enable 2FA/TOTP for
`wayne@pve` and `root@pam` via the web UI. Stage 2 (cluster/Ceph) not
started — needs nodes 2/3 on hardware that can actually support it.
@@ -16,24 +16,31 @@ Then run `scripts/audit.sh` to verify. Order matters (matches
| # | Item | Script | Manual step required? |
|---|------|--------|------------------------|
| 1 | Remove enterprise repos, switch to no-subscription | `switch-to-no-subscription-repo.sh` | no |
| 2 | SSH: key-only root login + fail2ban | `harden-ssh.sh` | no (requires an `authorized_keys` already in place — script warns if missing) |
| 3 | Unattended security upgrades, no auto-reboot | `setup-unattended-upgrades.sh` | no |
| 4 | PVE firewall, default-deny, mgmt-only SSH/8006 | `deploy-firewall.sh` | needs `MGMT_CIDR` set |
| 5 | Disable subscription nag (cosmetic) | `disable-subscription-nag.sh` | no |
| 6 | Named PVE admin user, Administrator role | `create-admin-user.sh <username>` | yes — pick the username, change the generated password on first login |
| 7 | 2FA/TOTP on that user and `root@pam` | — | yes — web UI only: Datacenter → Permissions → Two Factor, or user menu → TFA |
| 8 | Verify everything above | `audit.sh` | no |
| 2 | Linux admin user with SSH key + sudo access | `setup-linux-admin-user.sh <user> <pubkey>` | yes — choose a username and provide your SSH public key. Run this **before** SSH hardening or pass `ADMIN_USER`/`ADMIN_SSH_KEY` to `bootstrap.sh` so it runs automatically at the right point |
| 3 | Passwordless sudo for pvesh/qm/pct | `setup-admin-sudo.sh <username>` | yes — same username as step 2 |
| 4 | SSH: key-only root login + fail2ban | `harden-ssh.sh` | no — step 2 ensures authorized_keys are in place first |
| 5 | Unattended security upgrades, no auto-reboot | `setup-unattended-upgrades.sh` | no |
| 6 | PVE firewall, default-deny, mgmt-only SSH/8006 | `deploy-firewall.sh` | needs `MGMT_CIDR` set |
| 7 | Disable subscription nag (cosmetic) | `disable-subscription-nag.sh` | no |
| 8 | Named PVE admin user, Administrator role | `create-admin-user.sh <username>` | yes — pick the username, change the generated password on first login |
| 9 | 2FA/TOTP on that user and `root@pam` | — | yes — web UI only: Datacenter → Permissions → Two Factor, or user menu → TFA |
| 10 | Verify everything above | `audit.sh` | no |
## Linux/SSH layer
- A named Linux system user (created by `setup-linux-admin-user.sh`) with an
SSH authorized key and `sudo` group membership is the primary SSH login
account. Root SSH is locked to key-only after `harden-ssh.sh` runs; the
Linux admin user is how you get shell access day-to-day without using root.
- `setup-admin-sudo.sh` adds a narrower, password-free sudoers rule for the
Proxmox management tools (`pvesh`, `qm`, `pct`) specifically — needed for
non-interactive automation scripts that SSH in and run these tools without a
TTY.
- `PermitRootLogin prohibit-password` in `sshd_config` — root can only
log in via SSH key, never password. Kills most brute-force attempts.
- fail2ban jail for SSH on top of that.
- Restrict SSH to the management VLAN/trusted IPs via the Proxmox
firewall (see `03-networking.md`) rather than exposing broadly.
- A separate Linux sudo user isn't strictly required for day-to-day PVE
admin (the PVE permission system below governs that), but worth adding
if multiple people SSH into the box directly, for accountability.
## PVE/web layer (the one that actually matters day-to-day)
@@ -181,45 +181,6 @@ a real outage**: SSH (`ssh root@pve-test.sweet.home`) → web UI via `curl
only then worry about `ping` specifically, and check
`/etc/pve/firewall/cluster.fw` before blaming the network.
## Confirmed limitation: no wireless client on the same AP can reach pve-test
**This is not an AP-isolation toggle** — tested and ruled out (router:
TP-Link BE9300, AP Isolation confirmed unchecked). Two independent
wireless devices (a laptop and a phone) on the same SSID pve-test uses
(`nbn-fttp-net-5G`) were both completely unable to reach it — not
intermittent, not slow, no ARP entry ever resolves (`arp -a` on Windows /
`ip neigh` on Linux/Mac shows nothing at all, not a stale entry). Every
wired device tested (this repo's own Claude session, `pve1`) reaches it
fine, every time.
Root cause: pve-test's wifi connection uses 4-address (WDS) framing to
make bridging possible at all (see above) — that's what lets the AP
forward frames from arbitrary MACs (VM traffic, the bridge's own
identity) instead of only the wifi card's own hardware MAC. This
router's firmware evidently handles wired↔pve-test forwarding for that
4addr peer correctly, but doesn't correctly forward *wireless-client*
broadcast/ARP traffic to it — an asymmetric gap in the AP's own 4addr
handling, not a policy setting. Likely not fixable short of a firmware
update from TP-Link (if one ever improves 4addr/WDS handling), possibly
not fixable on this hardware at all.
**Practical implication**: anything on `vmbr0` — pve-test itself, or any
VM/CT created on it — is unreachable from a wireless client on this AP,
full stop, for *any* protocol, not just ICMP. ARP resolution is a
prerequisite for sending any IP packet at all (TCP included); since ARP
itself never resolves for a wireless peer here, the web UI (8006) fails
identically to ping, not just ping — confirmed live, not theoretical.
This includes VMs on pve-test too, since they ride the same bridge/wifi
uplink as the host. **Wired access is the only reliable path to
pve-test and anything running on it** — including reaching a VM's
console through the Proxmox web UI, which still requires the accessing
device to reach pve-test's own IP on port 8006 first, so it inherits the
same requirement.
Don't waste time on ARP flushes, firewall rules, or bonding config for
this specific symptom (no ARP entry from a wireless peer, wired fine) —
none of those are the cause.
## Known limitation history
`nic0` (the bond's backup slave) initially had no cable physically
@@ -58,11 +58,6 @@ if [ -f /etc/pve/firewall/cluster.fw ] && grep -qi '^policy_in:\s*DROP' /etc/pve
else
audit_fail "cluster.fw missing or does not default-deny inbound"
fi
if [ -f /etc/pve/firewall/cluster.fw ] && grep -qi 'icmp-type echo-request' /etc/pve/firewall/cluster.fw 2>/dev/null; then
audit_pass "cluster.fw allows ICMP echo-request from mgmt (ping works)"
else
audit_fail "cluster.fw does not allow ping from mgmt - see docs/04-security-hardening.md firewall section"
fi
# --- unattended-upgrades ---
if dpkg -s unattended-upgrades >/dev/null 2>&1 && systemctl is-enabled --quiet unattended-upgrades 2>/dev/null; then
@@ -74,6 +69,22 @@ if [ -f /var/run/reboot-required ]; then
audit_warn "a reboot is pending (/var/run/reboot-required) - schedule one"
fi
# --- Linux admin user with SSH key (for non-root SSH login) ---
LINUX_ADMIN_OK=0
for auth_file in /home/*/.ssh/authorized_keys; do
[ -f "$auth_file" ] || continue
# Must have at least one non-comment, non-empty key line.
if grep -qE '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) ' "$auth_file" 2>/dev/null; then
LINUX_ADMIN_OK=1
break
fi
done
if [ "$LINUX_ADMIN_OK" -eq 1 ]; then
audit_pass "a non-root Linux user has an SSH authorized key"
else
audit_fail "no non-root Linux user has an authorized SSH key (run setup-linux-admin-user.sh)"
fi
# --- named admin user (not just root@pam) ---
if pveum user list --output-format json 2>/dev/null | grep -q '"userid":"[^"]*@pve"'; then
audit_pass "a named @pve admin user exists (root@pam is not the only account)"
@@ -81,6 +92,23 @@ else
audit_fail "no named @pve user found - root@pam is the only account"
fi
# --- passwordless sudo for pvesh/qm/pct ---
# The nixos flake's create-proxmox-resource.sh runs pvesh/qm/pct over
# non-interactive SSH, so the admin user needs NOPASSWD for these tools.
SUDO_OK=0
for f in /etc/sudoers.d/*-proxmox; do
[ -f "$f" ] || continue
if grep -qE 'NOPASSWD:.*pvesh' "$f" && grep -qE 'NOPASSWD:.*\bqm\b' "$f" && grep -qE 'NOPASSWD:.*\bpct\b' "$f"; then
SUDO_OK=1
break
fi
done
if [ "$SUDO_OK" -eq 1 ]; then
audit_pass "admin user has NOPASSWD sudo for pvesh/qm/pct"
else
audit_fail "no sudoers file grants NOPASSWD for pvesh/qm/pct (run setup-admin-sudo.sh <username>)"
fi
# --- time sync ---
if timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qx 'yes'; then
audit_pass "clock is NTP-synchronized"
@@ -0,0 +1,67 @@
#!/bin/bash
# Stage 1 base config + hardening, end to end, for a single fresh PVE host.
# Runs the individual scripts in order. Idempotent - safe to re-run.
#
# If ADMIN_USER and ADMIN_SSH_KEY are set, a Linux system user is created
# with SSH key access and sudo before SSH hardening runs - so key-based
# login is in place before password auth is disabled. If they are not set,
# a reminder is printed at the end to run setup-linux-admin-user.sh manually
# (but do this BEFORE disconnecting, since password auth will be disabled).
#
# Usage:
# MGMT_CIDR=192.168.2.0/24 ./bootstrap.sh
# MGMT_CIDR=192.168.2.0/24 ADMIN_USER=wayne ADMIN_SSH_KEY="ssh-ed25519 ..." ./bootstrap.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if [ -z "${MGMT_CIDR:-}" ]; then
echo "MGMT_CIDR is not set. Example: MGMT_CIDR=192.168.2.0/24 $0" >&2
exit 1
fi
STEP=0
next_step() { STEP=$((STEP + 1)); echo; echo "=== ${STEP}: $* ==="; }
next_step "remove enterprise repos, switch to no-subscription"
"${SCRIPT_DIR}/switch-to-no-subscription-repo.sh"
# Create the Linux admin user before SSH hardening so that authorized_keys
# is in place before password auth is disabled.
if [ -n "${ADMIN_USER:-}" ] && [ -n "${ADMIN_SSH_KEY:-}" ]; then
next_step "Linux admin user '${ADMIN_USER}' + SSH key + sudo group"
"${SCRIPT_DIR}/setup-linux-admin-user.sh" "$ADMIN_USER" "$ADMIN_SSH_KEY"
next_step "passwordless sudo for pvesh/qm/pct (${ADMIN_USER})"
"${SCRIPT_DIR}/setup-admin-sudo.sh" "$ADMIN_USER"
else
echo
echo "WARNING: ADMIN_USER / ADMIN_SSH_KEY not set -- skipping Linux user setup."
echo " Run setup-linux-admin-user.sh and setup-admin-sudo.sh BEFORE disconnecting"
echo " from this session, since the next step disables password authentication."
fi
next_step "SSH hardening (key-only root login + fail2ban)"
"${SCRIPT_DIR}/harden-ssh.sh"
next_step "unattended security upgrades"
"${SCRIPT_DIR}/setup-unattended-upgrades.sh"
next_step "PVE firewall (mgmt-only SSH/8006)"
MGMT_CIDR="$MGMT_CIDR" "${SCRIPT_DIR}/deploy-firewall.sh"
next_step "disable subscription nag (cosmetic)"
"${SCRIPT_DIR}/disable-subscription-nag.sh"
echo
echo "=== Base hardening applied. Remaining manual/deliberate steps: ==="
if [ -z "${ADMIN_USER:-}" ]; then
echo " - ${SCRIPT_DIR}/setup-linux-admin-user.sh <username> <ssh-pubkey>"
echo " - ${SCRIPT_DIR}/setup-admin-sudo.sh <username> (NOPASSWD for pvesh/qm/pct)"
fi
echo " - ${SCRIPT_DIR}/create-admin-user.sh <username> (PVE web UI account)"
echo " - Enable 2FA/TOTP for that user and root@pam via the web UI"
echo " - ${SCRIPT_DIR}/audit.sh (verify everything above)"
@@ -0,0 +1,61 @@
#!/bin/bash
# Grant a named admin user passwordless sudo for Proxmox management tools
# (pvesh, qm, pct) and the Nix package manager so that scripts in the
# nixos flake repo can run these over non-interactive SSH without a TTY.
#
# Nix is included because single-user Nix installations (common on PVE
# hosts bootstrapped via codex-setup.sh) are owned by root; non-root
# users can't touch the Nix store lock without sudo.
#
# Idempotent - safe to re-run (rewrites if paths have changed). Run as
# root on the PVE host.
#
# Usage: ./setup-admin-sudo.sh <username>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
USERNAME="${1:-}"
if [ -z "$USERNAME" ]; then
echo "Usage: $0 <username>" >&2
exit 1
fi
# Resolve actual binary paths at script time -- they differ across Proxmox
# versions (pvesh moved from /usr/sbin to /usr/bin in PVE 8.x) and the
# sudoers rule must match the real path or sudo will fall back to
# prompting for a password.
resolve_bin() {
command -v "$1" 2>/dev/null || { echo "ERROR: $1 not found on PATH" >&2; exit 1; }
}
PVESH="$(resolve_bin pvesh)"
QM="$(resolve_bin qm)"
PCT="$(resolve_bin pct)"
# Nix installs to a fixed path regardless of which user bootstrapped it.
NIX_BIN="/nix/var/nix/profiles/default/bin/nix"
if [ ! -x "$NIX_BIN" ]; then
echo "WARNING: $NIX_BIN not found -- Nix may not be installed yet." >&2
echo " Re-run this script after running codex-setup.sh on the node." >&2
NIX_BIN=""
fi
SUDOERS_FILE="/etc/sudoers.d/${USERNAME}-proxmox"
NIX_ENTRY="${NIX_BIN:+, ${NIX_BIN}}"
CONTENT="${USERNAME} ALL=(root) NOPASSWD: ${PVESH}, ${QM}, ${PCT}${NIX_ENTRY}"
write_if_changed "$SUDOERS_FILE" "$CONTENT"
# visudo -c validates the file we just wrote before we walk away.
if visudo -c -f "$SUDOERS_FILE" >/dev/null 2>&1; then
chmod 0440 "$SUDOERS_FILE"
echo "Sudoers rule for ${USERNAME} is valid and in place."
echo " ${CONTENT}"
else
echo "ERROR: sudoers validation failed -- removing bad file." >&2
rm -f "$SUDOERS_FILE"
exit 1
fi
@@ -0,0 +1,82 @@
#!/bin/bash
# Create a Linux system user for SSH access and sudo, and install their
# authorized SSH public key. Run this before harden-ssh.sh so that
# key-based access is in place before password authentication is disabled.
#
# Idempotent - safe to re-run. Run as root on the PVE host.
#
# Usage:
# ./setup-linux-admin-user.sh <username> <ssh-public-key>
# ./setup-linux-admin-user.sh <username> --key-file <path-to-.pub>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
USERNAME="${1:-}"
if [ -z "$USERNAME" ]; then
echo "Usage: $0 <username> <ssh-public-key>" >&2
echo " $0 <username> --key-file <path-to-.pub>" >&2
exit 1
fi
shift
SSH_KEY=""
if [ "${1:-}" = "--key-file" ]; then
KEY_FILE="${2:-}"
[ -z "$KEY_FILE" ] && { echo "ERROR: --key-file requires a path." >&2; exit 1; }
[ -f "$KEY_FILE" ] || { echo "ERROR: key file not found: $KEY_FILE" >&2; exit 1; }
SSH_KEY="$(cat "$KEY_FILE")"
else
SSH_KEY="${1:-}"
fi
if [ -z "$SSH_KEY" ]; then
echo "ERROR: an SSH public key is required (key string or --key-file <path>)." >&2
exit 1
fi
if ! echo "$SSH_KEY" | grep -qE '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) [A-Za-z0-9+/=]'; then
echo "ERROR: argument doesn't look like a valid SSH public key." >&2
exit 1
fi
# --- create Linux user if missing ---
if id "$USERNAME" >/dev/null 2>&1; then
echo "User '${USERNAME}' already exists - skipping useradd."
else
useradd --create-home --shell /bin/bash "$USERNAME"
echo "Created Linux user '${USERNAME}'."
fi
# --- sudo group membership ---
if id -nG "$USERNAME" | grep -qw sudo; then
echo "User '${USERNAME}' is already in the sudo group."
else
usermod --append --groups sudo "$USERNAME"
echo "Added '${USERNAME}' to the sudo group."
fi
# --- install authorized SSH key ---
HOME_DIR="$(getent passwd "$USERNAME" | cut -d: -f6)"
SSH_DIR="${HOME_DIR}/.ssh"
AUTH_FILE="${SSH_DIR}/authorized_keys"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
touch "$AUTH_FILE"
chmod 600 "$AUTH_FILE"
chown -R "${USERNAME}:${USERNAME}" "$SSH_DIR"
if grep -qF "$SSH_KEY" "$AUTH_FILE" 2>/dev/null; then
echo "SSH key is already present in ${AUTH_FILE}."
else
echo "$SSH_KEY" >> "$AUTH_FILE"
echo "Installed SSH key in ${AUTH_FILE}."
fi
echo
echo "Linux user '${USERNAME}' is ready for SSH key-based login with sudo access."
echo "Next: run setup-admin-sudo.sh ${USERNAME} to grant NOPASSWD for pvesh/qm/pct."
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Scan the repo for secrets and sensitive config values.
# Runs via CI (GitHub/Gitea Actions) and locally as a pre-commit check.
#
# Usage: scripts/check-secrets.sh [--staged-only]
# --staged-only Only check files staged for commit (for pre-commit hook use)
#
# Requires gitleaks on PATH, or falls back to Docker if available.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
set -euo pipefail
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
STAGED_ONLY=false
FAILURES=0
for arg in "$@"; do
[[ "$arg" == "--staged-only" ]] && STAGED_ONLY=true
done
cd "$REPO_ROOT"
# ── Resolve gitleaks binary ────────────────────────────────────────────────────
if command -v gitleaks &>/dev/null; then
GITLEAKS="gitleaks"
elif command -v docker &>/dev/null; then
GITLEAKS="docker run --rm -v ${REPO_ROOT}:/repo zricethezav/gitleaks:latest"
# Adjust paths for docker context
REPO_ROOT="/repo"
else
echo "ERROR: gitleaks not found. Install it or ensure Docker is available." >&2
echo " https://github.com/gitleaks/gitleaks#installing" >&2
exit 1
fi
echo "=== Secret scan ==="
if [[ "$STAGED_ONLY" == "true" ]]; then
# Pre-commit mode: scan only staged content
echo "Mode: staged files only"
if ! $GITLEAKS protect --staged --config="${REPO_ROOT}/.gitleaks.toml" --source="${REPO_ROOT}" 2>&1; then
FAILURES=$((FAILURES + 1))
fi
else
# CI mode: scan full git history
echo "Mode: full git history"
if ! $GITLEAKS detect --config="${REPO_ROOT}/.gitleaks.toml" --source="${REPO_ROOT}" 2>&1; then
FAILURES=$((FAILURES + 1))
fi
fi
# ── Pi-hole specific checks ────────────────────────────────────────────────────
echo ""
echo "=== Pi-hole config checks ==="
PIHOLE_TOML="${REPO_ROOT}/pihole/config/pihole.toml"
if [[ -f "$PIHOLE_TOML" ]]; then
# Check that known sensitive fields are empty
for field in pwhash totp_secret app_pwhash; do
value=$(grep -E "^\s+${field}\s*=" "$PIHOLE_TOML" | sed 's/.*=\s*"\(.*\)".*/\1/' | tr -d '[:space:]' || true)
if [[ -n "$value" && "$value" != '""' ]]; then
echo "FAIL: pihole.toml contains a non-empty '${field}' — run pihole/sanitize-config.sh before committing" >&2
FAILURES=$((FAILURES + 1))
else
echo " OK: ${field} is empty"
fi
done
else
echo " (pihole/config/pihole.toml not present, skipping Pi-hole checks)"
fi
# ── Summary ────────────────────────────────────────────────────────────────────
echo ""
if [[ $FAILURES -gt 0 ]]; then
echo "FAILED: ${FAILURES} issue(s) found. Fix before committing." >&2
exit 1
else
echo "All checks passed."
fi
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Install git hooks that run the secret scan before every commit.
# Run once after cloning: bash scripts/install-hooks.sh
set -euo pipefail
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
HOOK="${REPO_ROOT}/.git/hooks/pre-commit"
cat > "$HOOK" << 'HOOK'
#!/usr/bin/env bash
exec "$(git rev-parse --show-toplevel)/scripts/check-secrets.sh" --staged-only
HOOK
chmod +x "$HOOK"
echo "Installed pre-commit hook → ${HOOK}"
echo "The secret scan will run automatically before every commit."
+28
View File
@@ -0,0 +1,28 @@
name: Secret Scan
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
secret-scan:
name: Scan for secrets and sensitive config
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for gitleaks git-log scan
- name: Install gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Run secret scan
run: bash scripts/check-secrets.sh
+28
View File
@@ -0,0 +1,28 @@
name: Secret Scan
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
secret-scan:
name: Scan for secrets and sensitive config
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for gitleaks git-log scan
- name: Install gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Run secret scan
run: bash scripts/check-secrets.sh
+41
View File
@@ -0,0 +1,41 @@
# Gitleaks configuration for debian-configuration repo.
# Extends the default ruleset with Pi-hole-specific secret patterns.
# https://github.com/gitleaks/gitleaks
title = "debian-configuration secret scan"
[extend]
useDefault = true
# ── Custom rules ───────────────────────────────────────────────────────────────
[[rules]]
id = "pihole-pwhash"
description = "Pi-hole password hash (pihole.toml webserver.api.pwhash)"
regex = '''pwhash\s*=\s*"[^"]{10,}"'''
tags = ["pihole", "password"]
[[rules]]
id = "pihole-totp-secret"
description = "Pi-hole 2FA TOTP secret"
regex = '''totp_secret\s*=\s*"[^"]{10,}"'''
tags = ["pihole", "2fa"]
[[rules]]
id = "pihole-app-pwhash"
description = "Pi-hole app password hash"
regex = '''app_pwhash\s*=\s*"[^"]{10,}"'''
tags = ["pihole", "password"]
# ── Allowlist ──────────────────────────────────────────────────────────────────
[allowlist]
description = "Known-safe patterns in this repo"
regexes = [
# TLS cert path reference — not the key itself
'''cert\s*=\s*"/etc/pihole/tls\.pem"''',
]
paths = [
# Example/template files are intentionally non-live
'''\.example$''',
]
+45 -72
View File
@@ -1,84 +1,57 @@
# CLAUDE.md
Guidance for Claude Code working in this repo. IMPORTANT: these
instructions OVERRIDE any default behavior and must be followed exactly
as written.
Guidance for Claude Code working in this repo. These instructions
OVERRIDE any default behaviour and must be followed exactly.
## Repo purpose
Base configuration/hardening toolset and planning docs for Proxmox VE
hosts (`scripts/`, `config/`, `docs/`) — see `README.md` and
`docs/00-overview.md`. Scripts in this repo are meant to be run **on**
the target Proxmox host itself (as root), not orchestrated remotely.
Configuration toolsets and docs for Wayne's Debian-based LAN machines.
Four sections currently exist: `proxmox/` (Proxmox VE hosts),
`pihole/` (Pi-hole DNS/DHCP), `freeipa/` (FreeIPA identity management),
and `raspberrypi/` (Raspberry Pi 4). Each has its own README.
## Two Proxmox nodes: `pve1` (production) and `pve-test` (sandbox)
## Safety rules (apply everywhere in this repo)
Two SSH-reachable Proxmox nodes exist on the LAN. They are **not
interchangeable** — see `docs/05-node-roles.md` for full background on
what each one is and why.
- **Never commit secrets.** SSH private keys, passwords, hashes, TOTP
seeds, API tokens, or wifi passphrases must not appear in committed
files. The CI pipeline (`scripts/check-secrets.sh`) enforces this on
every push; the pre-commit hook (`scripts/install-hooks.sh`) catches
it locally before it reaches the remote.
- **`pihole/pull-config.sh` auto-sanitises** sensitive fields from
`pihole.toml` on every pull. If you write config to `pihole/config/`
by any other means, run `pihole/sanitize-config.sh` on the result
before committing.
- **Scripts that SSH into live machines make real changes.** Don't run
`pihole/apply-config.sh` against a production Pi-hole, or any script
in `proxmox/scripts/` against `pve1`, without an explicit same-session
go-ahead from the operator. The section-specific CLAUDE.md files
spell out the per-host guardrails in detail.
### `pve1` (production — off-limits to Claude by default)
## Section-specific guidance
A real, live Proxmox node hosting production VMs/containers (see
`docs/05-node-roles.md` for the current guest list) — not a sandbox, and
not Claude's to touch by default.
- **Proxmox:** see `proxmox/CLAUDE.md` — covers `pve1` (production,
off-limits by default) vs. `pve-test` (sandbox, Claude's default
target), per-host authorisation scope, and network-surgery hazards.
- **Pi-hole:** `pihole/` has no live-host guardrails beyond the secret
rules above. `apply-config.sh` is the only script that touches a live
host; treat its `<dest-host>` argument as production unless you're
explicitly testing on a throwaway instance.
- **FreeIPA:** `freeipa/` documents and scripts for the FreeIPA identity
management server (`domain-controller.sweet.home`, VMID 108 on `pve1`).
All scripts that SSH into the server are production operations — treat
them as off-limits without an explicit same-session go-ahead.
- **Raspberry Pi:** see `raspberrypi/CLAUDE.md` — production host running
live services; same write-authorisation rules as `pve1`. Bootstrap access
via local `raspi` user (NOPASSWD sudo). IPA-enrolled; docker GID pinned
to 50010 to match IPA `docker-access` group.
- **Off-limits at all times unless the operator has given explicit,
same-session instructions to act on this specific host.** That
authorization is scoped to the task it was given for — don't carry it
forward to unrelated later work in the same conversation, and never
assume it from a previous session.
- **Read-only for existing state is always fine, authorization or not.**
SSH in (or use `pvesm`, `qm list`, `pct list`, `qm config`, `pct
config`, the Proxmox API, etc.) to inspect config, storage, and any
existing VM/container freely.
- **Never** modify, stop, restart, delete, reconfigure, or create
anything on this node (`qm set`, `pct set`, `qm destroy`, `pct
destroy`, `qm stop`, `pct stop`, `qm create`, `pct create`, snapshot
operations, storage changes, running any script in this repo against
it, etc.) without that explicit go-ahead. Use `pve-test` for anything
exploratory instead.
- If a guest on `pve1` is HA-managed, be aware of the self-fence hazard
described in `docs/05-node-roles.md`'s cluster-teardown section before
doing anything that could cost the node quorum.
## Adding a new machine type
### `pve-test` (sandbox — Claude's default target)
Create a new top-level directory (e.g. `nginx/`, `wireguard/`) with:
- `README.md` — purpose, quick-start, and current status
- `CLAUDE.md` — host-specific guardrails (which hosts are production,
what requires explicit authorisation, what must never be committed)
- `scripts/` and/or `config/` as needed
A separate node set aside for testing — safe to create, interrogate, and
destroy scratch VMs/containers on without asking first.
- **Test VMs/containers are allowed, but must be torn down.** Anything
created this way must be destroyed again in the same session, before
ending the task. Use an obviously-scratch VMID/name.
- **Node-level config is still not yours to change by default.**
Creating/destroying your own scratch guests is fine; Proxmox host
config, storage pools, and networking on `pve-test` itself need the
operator's explicit go-ahead too, same as on `pve1` — the "sandbox"
status covers guest-level experimentation, not the host's own
identity. (`pve-test`'s current network config, `docs/06-pve-test-wifi-network.md`,
was applied under exactly that kind of explicit, same-session
authorization — it's not a standing invitation to keep changing it
further without asking again.)
- **Never re-cluster `pve-test` with `pve1`** without the operator
explicitly asking for it and being aware of the wifi/corosync
incompatibility in `docs/06-pve-test-wifi-network.md` — the two were
deliberately de-clustered for this reason once already.
## Safety rules
- Any script in `scripts/` that isn't `audit.sh` (read-only) makes real
changes when run for real. Don't run one against `pve1`, or against
`pve-test`'s node-level config, without the same-session go-ahead
described above. Running a script *against pve-test's own guests* — a
scratch VM/CT you created for this task — doesn't need separate
permission.
- Do not commit secrets: SSH private keys, wifi passphrases, sops/age
keys, or PVE credentials. `scripts/setup-wifi-bond-network.sh` takes
the SSID/passphrase via environment variables for exactly this reason
— never hardcode them into the script or a committed config file.
- Live changes to a node's own management network (the interface/bridge
carrying the SSH session you're using) can strand the box — see the
"what went wrong once" section in `docs/06-pve-test-wifi-network.md`
before touching `pve-test`'s networking again. Prefer applying via
`ifreload -a` over raw `ip link` surgery, and arm an auto-revert
watchdog first when acting without someone physically at the console.
Update the root `README.md` layout table and this file's
"Section-specific guidance" list when you do.
+56 -48
View File
@@ -1,55 +1,63 @@
# Proxmox Configuration
# debian-configuration
Base configuration and hardening toolset for Proxmox VE hosts, plus planning
docs for eventually growing this into a 3-node HA/Ceph cluster. See
`docs/00-overview.md` for the staging: **Stage 1** (base config/hardening,
applies to any host — active) vs. **Stage 2** (multi-node HA/Ceph — future,
deferred).
Configuration, hardening toolsets, and operational docs for Wayne's
Debian-based LAN machines. Each subdirectory covers a different host or
service type.
## Goals
- Stage 1: a reusable, idempotent base-hardening toolset (`scripts/`) that
can be run against any new Proxmox host — repo/updates, SSH, firewall, PVE
user/access hardening — verified with `scripts/audit.sh`.
- Stage 2 (future): 3-node cluster, quorum via corosync, HA-managed VMs
backed by Ceph. Needs dedicated hardware node 1 (`pve1`, an ASUS PN53 mini
PC) doesn't have — see `docs/01-hardware-node1.md`.
See `CLAUDE.md` for the guardrails Claude Code follows when working
against these hosts (`pve1` is production and off-limits by default;
`pve-test` is the sandbox).
## Repo layout
- `docs/` — planning docs: hardware layout, storage migration, networking,
security hardening, node roles. Read `docs/00-overview.md` first, then
`docs/05-node-roles.md` for what `pve1`/`pve-test` actually are.
- `scripts/` — scripts to apply configuration on a node (SSH hardening, repo
switch, firewall, updates, wifi/bond networking, etc). Idempotent, safe
to re-run. `scripts/bootstrap.sh` runs the full Stage 1 sequence end to
end; `scripts/audit.sh` verifies it (read-only).
`scripts/setup-wifi-bond-network.sh` reproduces `pve-test`'s wifi
network (see `docs/06-pve-test-wifi-network.md`). `scripts/lib/` holds
shared helpers (`common.sh`) sourced by the other scripts.
- `config/` — reference config files/snippets to drop onto a node (firewall
rules, sshd config, etc.).
## Quick start (Stage 1, on a fresh node)
## Layout
```
MGMT_CIDR=192.168.2.0/24 ./scripts/bootstrap.sh
./scripts/create-admin-user.sh <username>
# then enable 2FA for that user + root@pam via the web UI
./scripts/audit.sh
proxmox/ Proxmox VE hosts (pve1 production, pve-test sandbox)
pihole/ Pi-hole DNS/DHCP (config snapshots, pull/apply scripts)
freeipa/ FreeIPA identity management server (domain-controller.sweet.home)
raspberrypi/ Raspberry Pi 4 (Debian 12, IPA-enrolled, Docker host)
scripts/ Repo-wide scripts (secret scanning, git hook installer)
```
## Status
## Sections
`pve1` built (ASUS PN53 mini PC, ZFS mirror boot+VM storage, single
2.5GbE NIC) and already running production VMs/CTs. Stage 1 base
hardening applied and verified (`scripts/audit.sh` all green): enterprise
repos removed, SSH key-only + fail2ban, unattended security upgrades,
PVE firewall (mgmt-only), subscription nag disabled, named admin user
(`wayne@pve`) created. Remaining manual step: enable 2FA/TOTP for
`wayne@pve` and `root@pam` via the web UI. Stage 2 (cluster/Ceph) not
started — needs nodes 2/3 on hardware that can actually support it.
### `proxmox/`
Base configuration and hardening toolset for Proxmox VE hosts. See
`proxmox/README.md` for goals, quick-start, and current status.
### `pihole/`
Pi-hole v6 configuration management. Stores a sanitised snapshot of the
live config and provides scripts to pull from or push to a running
instance. See `pihole/README.md` for usage.
### `freeipa/`
FreeIPA 4.x identity management server running on Rocky Linux 9
(`domain-controller.sweet.home`, VMID 108 on pve1). Provides Kerberos,
LDAP, and integrated DNS for the `sweet.home` realm. See
`freeipa/README.md` for the quick-start and `freeipa/docs/install.md`
for the full reproduction procedure.
### `raspberrypi/`
Raspberry Pi 4 running Debian 12 bookworm (`raspberrypi.tail13f623.ts.net`,
reachable from LAN via Tailscale MagicDNS). IPA-enrolled; runs Traefik,
Uptime Kuma, CrowdSec, and Beszel. See `raspberrypi/README.md` for
setup scripts and current status.
## Secret scanning
All commits are scanned for secrets by a CI pipeline that runs on both
GitHub Actions and Gitea Actions. The same scan can be run locally:
```bash
# One-time setup — installs a pre-commit git hook
bash scripts/install-hooks.sh
# Manual run against the full git history
bash scripts/check-secrets.sh
# Requires gitleaks on PATH; falls back to Docker if available
# https://github.com/gitleaks/gitleaks#installing
```
`pihole/pull-config.sh` automatically redacts sensitive fields
(`pwhash`, `totp_secret`, `app_pwhash`) from `pihole.toml` before
writing it to disk, so the repo stays clean by default.
+33
View File
@@ -0,0 +1,33 @@
# freeipa/CLAUDE.md
Host-specific guardrails for the `domain-controller` FreeIPA server.
## Host: `domain-controller.sweet.home`
- **VMID 108 on `pve1.sweet.home`** — this is production infrastructure.
Treat it the same as any other pve1 guest: no changes without explicit
same-session operator authorisation.
- **Read-only is always fine**: SSH in as `wayne`, inspect IPA state with
`ipa *` commands or `kinit admin && ipa ...`, check service status with
`ipactl status` — none of that needs authorisation.
- **Never modify FreeIPA topology, replicas, or the LDAP DIT directly**
without the operator's go-ahead. That means no `ipa user-del`, no
`ipa-replica-manage`, no `ldapmodify` against the live directory.
- **Do not commit secrets.** The Directory Manager password and the `admin`
Kerberos password must not appear in any file in this repo. Scripts that
need them must read from environment variables or prompt interactively.
- The `admin` password and Directory Manager password were generated at
install time and stored only in the operator's password manager — not in
this repo. See `README.md` for how to retrieve/reset them.
## What is safe to run automatically
- `scripts/verify.sh` — read-only health check, no side effects.
- `scripts/configure-pihole-dns.sh` — idempotent DNS forwarder setup in
Pi-hole; safe to re-run.
## What requires operator go-ahead
- `scripts/install.sh` — destructive if run against an already-provisioned
host. Always check first with `ipactl status`.
- Any `ipa-replica-install` or `ipa-server-upgrade` invocation.
+88
View File
@@ -0,0 +1,88 @@
# FreeIPA — domain-controller.sweet.home
FreeIPA 4.x identity management server providing Kerberos, LDAP, and
integrated DNS for the `sweet.home` LAN. Runs on Rocky Linux 9 in a
Proxmox VM (VMID 108 on `pve1.sweet.home`).
## Quick status
| Item | Value |
|------|-------|
| Host | `domain-controller.sweet.home` |
| IP | `192.168.2.253` (static) |
| Realm | `SWEET.HOME` |
| Domain | `sweet.home` |
| IPA version | 4.13.x (Rocky Linux 9) |
| Web UI | `https://domain-controller.sweet.home/ipa/ui/` |
| VMID | 108 on `pve1.sweet.home` |
| OS | Rocky Linux 9 (GenericCloud image) |
## What it provides
- **Kerberos KDC** — SSO tickets for the `SWEET.HOME` realm
- **LDAP directory** — centralised user/group/host store (389-ds)
- **Integrated DNS** — authoritative for `sweet.home` and primary LAN
resolver for all hosts, forwarding everything else to the LAN gateway
- **CA** — self-signed CA issuing certs for IPA services
- **Web UI** — at `https://domain-controller.sweet.home/ipa/ui/`
## First-time use
```bash
# SSH to the server
ssh wayne@domain-controller
# Get a Kerberos ticket as admin
kinit admin
# List IPA users
ipa user-find
# Add a user
ipa user-add jdoe --first=John --last=Doe --password
# Check service health
ipactl status
```
## Credentials
The `admin` Kerberos password and Directory Manager password were
generated at install time. They are **not stored in this repo** — keep
them in your password manager.
- **admin** — used for day-to-day IPA management (`kinit admin`)
- **Directory Manager** — low-level LDAP root, rarely needed
To reset the admin password (requires being logged in as admin):
```bash
kinit admin
ipa passwd admin
```
## Ports required (firewalld)
FreeIPA's firewalld config is applied by `ipa-server-install` automatically.
The following ports must be reachable from LAN clients:
| Port | Proto | Service |
|------|-------|---------|
| 80 | TCP | HTTP (redirect to HTTPS) |
| 443 | TCP | HTTPS / Web UI |
| 389 | TCP | LDAP |
| 636 | TCP | LDAPS |
| 88 | TCP+UDP | Kerberos |
| 464 | TCP+UDP | Kerberos password change |
| 53 | TCP+UDP | DNS |
## Reproducing this setup
See `docs/install.md` for the full step-by-step install procedure,
or run `scripts/install.sh` on a fresh Rocky Linux 9 VM with the
correct hostname and IP already set.
## Backup
The CA certificates (required for replicas) are at `/root/cacert.p12`
on the server, encrypted with the Directory Manager password.
Back these up to a secure location.
+242
View File
@@ -0,0 +1,242 @@
# FreeIPA Install Procedure
Full reproduction guide for `domain-controller.sweet.home`. Tested on
Rocky Linux 9.8 (GenericCloud), VMID 108, `pve1.sweet.home`.
## Prerequisites
- Proxmox node with `local-zfs` storage and internet access from guests
- Rocky Linux 9 GenericCloud image downloaded (see step 1)
- SSH access to Proxmox node as a user with `sudo` for `qm`/`pvesm`
- The operator's SSH public key available to inject via cloud-init
---
## Step 1 — Download Rocky Linux 9 GenericCloud image
On the Proxmox node, download to your ISO/image store:
```bash
wget -O /mnt/pve/server-iso/Rocky-9-GenericCloud.latest.x86_64.qcow2 \
https://download.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2
```
The image is ~617 MB. The Proxmox storage must be configured to accept
both ISO images and disk images (set "Content" to include "Disk image"
in the Proxmox UI for that storage).
---
## Step 2 — Prepare the VM in Proxmox
Use an existing VM or create a new one. The config used for VMID 108:
- **CPU**: 2 cores, x86-64-v2-AES
- **RAM**: 2048 MB
- **Disk**: 32 GB on `local-zfs`
- **BIOS**: SeaBIOS (GenericCloud uses MBR — not UEFI)
- **Network**: virtio on `vmbr0`, firewall enabled
- **QEMU guest agent**: enabled
If rebuilding an existing VM (e.g. replacing a prior OS):
```bash
# On pve1 — stop the VM
sudo qm stop <VMID>
# Remove existing disks from config
sudo qm set <VMID> --delete scsi0,efidisk0
# Switch to SeaBIOS if the VM was UEFI
sudo qm set <VMID> --bios seabios
# Free old disk volumes from storage
sudo pvesm free local-zfs:vm-<VMID>-disk-0
sudo pvesm free local-zfs:vm-<VMID>-disk-1
```
### Import the Rocky image and configure cloud-init
```bash
# Import image as a new disk
sudo qm importdisk <VMID> /mnt/pve/server-iso/Rocky-9-GenericCloud.latest.x86_64.qcow2 local-zfs
# Check what disk name was assigned
sudo qm config <VMID> # look for unused0: local-zfs:vm-<VMID>-disk-N
# Attach as scsi0 (adjust disk name from above)
sudo qm set <VMID> --scsi0 local-zfs:vm-<VMID>-disk-0,iothread=1
# Resize to 32 GB
sudo qm disk resize <VMID> scsi0 32G
# Add cloud-init drive
sudo qm set <VMID> --ide2 local-zfs:cloudinit
# Write SSH public key to a temp file
echo 'ssh-rsa AAAA... wayne@stream' > /tmp/admin-key.pub
# (use the key from variables.nix adminSshKey)
# Configure cloud-init
# Use the LAN gateway as temporary DNS — IPA itself will be the DNS at
# 192.168.2.253, but it's not running yet at this point in the install.
sudo qm set <VMID> \
--ciuser wayne \
--sshkeys /tmp/admin-key.pub \
--ipconfig0 ip=dhcp \
--nameserver 192.168.2.254 \
--searchdomain sweet.home
# Set boot order
sudo qm set <VMID> --boot order=scsi0
# Start VM
sudo qm start <VMID>
```
### Note on SSH key mismatch
The GenericCloud image injects the cloud-init SSH key on first boot.
If you need to add an additional key (e.g. from a different machine)
after first boot, mount the disk via nbd while the VM is stopped:
```bash
sudo qm stop <VMID>
sudo qemu-nbd --connect=/dev/nbd1 --format=raw /dev/zvol/rpool/data/vm-<VMID>-disk-0
# wait 2s, then:
sudo mount /dev/nbd1p4 /mnt/vm # p4 is the root partition on Rocky 9 GenericCloud
sudo tee -a /mnt/vm/home/wayne/.ssh/authorized_keys <<< 'ssh-ed25519 AAAA... extra-key'
sudo umount /mnt/vm
sudo qemu-nbd --disconnect /dev/nbd1
sudo qm start <VMID>
```
Rocky 9 GenericCloud partition layout: `p1`=BIOS boot (2M),
`p2`=EFI (100M), `p3`=/boot (1G), `p4`=/ (rest).
---
## Step 3 — First-boot system preparation
SSH in as `wayne` once cloud-init has completed (usually 6090 s):
```bash
ssh wayne@<VM-IP>
```
### Add swap (required — FreeIPA needs headroom beyond 1.7 GB RAM)
```bash
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 status=progress
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap defaults 0 0' | sudo tee -a /etc/fstab
```
### Set static IP via NetworkManager
```bash
CON=$(nmcli -t -f NAME con show --active | head -1)
sudo nmcli con mod "$CON" \
ipv4.method manual \
ipv4.addresses 192.168.2.253/24 \
ipv4.gateway 192.168.2.254 \
ipv4.dns 192.168.2.254 \
ipv4.dns-search sweet.home
# Note: using the gateway as DNS here — after IPA installs it becomes the
# authoritative resolver at 192.168.2.253. Clients should then point to
# 192.168.2.253 for sweet.home resolution.
sudo nmcli con up "$CON"
```
### Fix /etc/hosts (cloud-init maps FQDN to 127.0.0.1 — IPA requires real IP)
```bash
sudo sed -i '/domain-controller/d' /etc/hosts
echo '192.168.2.253 domain-controller.sweet.home domain-controller' \
| sudo tee -a /etc/hosts
# Prevent cloud-init from resetting this on reboot
sudo sed -i 's/manage_etc_hosts: true/manage_etc_hosts: false/' \
/etc/cloud/cloud.cfg
```
---
## Step 4 — Install FreeIPA packages
```bash
sudo dnf install -y ipa-server ipa-server-dns
```
This pulls ~200 packages including 389-ds, Dogtag PKI, BIND, and MIT
Kerberos. Takes 510 minutes depending on mirror speed.
---
## Step 5 — Run the unattended install
Generate strong passwords (min 8 chars; store them in your password manager):
```bash
DM_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
ADMIN_PASS=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)
echo "Directory Manager: $DM_PASS"
echo "IPA Admin: $ADMIN_PASS"
# Save both in your password manager NOW before proceeding
```
Run the installer (takes 1520 minutes):
```bash
sudo ipa-server-install \
--realm=SWEET.HOME \
--domain=sweet.home \
--hostname=domain-controller.sweet.home \
--ds-password="$DM_PASS" \
--admin-password="$ADMIN_PASS" \
--setup-dns \
--forwarder=192.168.2.254 \
--no-dnssec-validation \
--no-ntp \
--unattended
```
Key flags:
- `--setup-dns` — install BIND as IPA's authoritative DNS for `sweet.home`
- `--forwarder=192.168.2.254` — forward non-sweet.home queries to the LAN gateway
- `--no-dnssec-validation` — skip DNSSEC (home lab has no DNSSEC chain)
- `--no-ntp` — Proxmox handles time sync for guests; don't install chrony
---
## Step 6 — Verify
```bash
# All services should show RUNNING
ipactl status
# Get a Kerberos ticket and confirm
echo "$ADMIN_PASS" | kinit admin
klist
# Check DNS SRV records are in place
dig +short _kerberos._udp.sweet.home SRV @127.0.0.1
# Expected: 0 100 88 domain-controller.sweet.home.
```
---
## Step 7 — Back up the CA certificate
```bash
# On domain-controller (encrypted with Directory Manager password)
ls -lh /root/cacert.p12
# Copy to a safe location
scp root@domain-controller:/root/cacert.p12 ~/backups/ipa-cacert.p12
```
This file is required if you ever set up a replica or need to
re-issue service certificates.
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# FreeIPA server install script for domain-controller.sweet.home
#
# Run this on a fresh Rocky Linux 9 VM that already has:
# - Correct hostname: domain-controller.sweet.home
# - Static IP: 192.168.2.138/24
# - Gateway: 192.168.2.254
# - DNS: 192.168.2.253 (Pi-hole)
# - sudo access for the current user
#
# The script will prompt for passwords if not set via environment:
# IPA_DM_PASSWORD Directory Manager password (store in password manager)
# IPA_ADMIN_PASSWORD IPA admin Kerberos password (store in password manager)
#
# Full procedure: see docs/install.md
set -euo pipefail
IPA_REALM="SWEET.HOME"
IPA_DOMAIN="sweet.home"
IPA_HOSTNAME="domain-controller.sweet.home"
IPA_IP="192.168.2.138"
IPA_DNS_FORWARDER="192.168.2.253"
# ── Password handling ────────────────────────────────────────────────────────
if [[ -z "${IPA_DM_PASSWORD:-}" ]]; then
read -r -s -p "Directory Manager password (min 8 chars): " IPA_DM_PASSWORD
echo
fi
if [[ -z "${IPA_ADMIN_PASSWORD:-}" ]]; then
read -r -s -p "IPA admin password (min 8 chars): " IPA_ADMIN_PASSWORD
echo
fi
if [[ ${#IPA_DM_PASSWORD} -lt 8 || ${#IPA_ADMIN_PASSWORD} -lt 8 ]]; then
echo "ERROR: passwords must be at least 8 characters" >&2
exit 1
fi
# ── Pre-flight checks ────────────────────────────────────────────────────────
echo "==> Checking hostname..."
actual_fqdn=$(hostname -f)
if [[ "$actual_fqdn" != "$IPA_HOSTNAME" ]]; then
echo "ERROR: hostname -f returned '$actual_fqdn', expected '$IPA_HOSTNAME'" >&2
exit 1
fi
echo "==> Checking /etc/hosts entry..."
if ! grep -q "$IPA_IP $IPA_HOSTNAME" /etc/hosts; then
echo "ERROR: /etc/hosts does not have '$IPA_IP $IPA_HOSTNAME'" >&2
echo "Fix: sudo sed -i '/$IPA_HOSTNAME/d' /etc/hosts && echo '$IPA_IP $IPA_HOSTNAME ${IPA_HOSTNAME%%.*}' | sudo tee -a /etc/hosts" >&2
exit 1
fi
echo "==> Checking Python FQDN resolution..."
resolved=$(python3 -c "import socket; print(socket.gethostbyname('$IPA_HOSTNAME'))" 2>/dev/null || true)
if [[ "$resolved" != "$IPA_IP" ]]; then
echo "ERROR: $IPA_HOSTNAME resolves to '$resolved', expected '$IPA_IP'" >&2
exit 1
fi
echo "==> Checking internet connectivity..."
if ! ping -c1 -W5 8.8.8.8 >/dev/null 2>&1; then
echo "ERROR: no internet connectivity (needed for package install)" >&2
exit 1
fi
# ── Swap ─────────────────────────────────────────────────────────────────────
if ! swapon --show | grep -q .; then
echo "==> Creating 2 GB swap file (FreeIPA needs headroom)..."
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 status=progress
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap defaults 0 0' | sudo tee -a /etc/fstab
else
echo "==> Swap already configured, skipping."
fi
# ── Packages ─────────────────────────────────────────────────────────────────
echo "==> Installing FreeIPA server packages..."
sudo dnf install -y ipa-server ipa-server-dns
# ── Install ──────────────────────────────────────────────────────────────────
echo "==> Running ipa-server-install (1520 min)..."
sudo ipa-server-install \
--realm="$IPA_REALM" \
--domain="$IPA_DOMAIN" \
--hostname="$IPA_HOSTNAME" \
--ds-password="$IPA_DM_PASSWORD" \
--admin-password="$IPA_ADMIN_PASSWORD" \
--setup-dns \
--forwarder="$IPA_DNS_FORWARDER" \
--no-dnssec-validation \
--no-ntp \
--unattended
# ── Verify ───────────────────────────────────────────────────────────────────
echo "==> Verifying services..."
sudo ipactl status
echo "==> Verifying Kerberos ticket..."
echo "$IPA_ADMIN_PASSWORD" | kinit admin
klist
echo "==> Verifying DNS SRV records..."
dig +short _kerberos._udp."$IPA_DOMAIN" SRV @127.0.0.1
echo ""
echo "======================================================================"
echo "Setup complete. Next steps:"
echo ""
echo " 1. Save passwords to your password manager (if not already done)."
echo ""
echo " 2. Configure Pi-hole to forward $IPA_DOMAIN DNS to $IPA_IP:"
echo " bash scripts/configure-pihole-dns.sh <pihole-host>"
echo ""
echo " 3. Back up the CA certificates:"
echo " scp root@$IPA_HOSTNAME:/root/cacert.p12 ~/backups/ipa-cacert.p12"
echo " (Encrypted with the Directory Manager password)"
echo ""
echo " 4. Access the Web UI at:"
echo " https://$IPA_HOSTNAME/ipa/ui/"
echo "======================================================================"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Read-only health check for the FreeIPA server.
# Run locally on domain-controller or remotely:
# ssh wayne@domain-controller 'bash -s' < scripts/verify.sh
#
# Exit code 0 = all checks passed, non-zero = something is wrong.
set -uo pipefail
PASS=0
FAIL=0
check() {
local label="$1"
shift
if "$@" >/dev/null 2>&1; then
echo " OK $label"
(( PASS++ )) || true
else
echo "FAIL $label"
(( FAIL++ )) || true
fi
}
echo "=== FreeIPA health check: $(hostname -f) ==="
echo ""
echo "--- Services ---"
check "ipactl status" sudo ipactl status
check "dirsrv running" systemctl is-active dirsrv.target
check "krb5kdc running" systemctl is-active krb5kdc
check "named running" systemctl is-active named
check "httpd running" systemctl is-active httpd
check "pki-tomcatd running" systemctl is-active pki-tomcatd.target
echo ""
echo "--- DNS ---"
check "A record: domain-controller.sweet.home" dig +short domain-controller.sweet.home A @127.0.0.1
check "SRV: _kerberos._udp.sweet.home" dig +short _kerberos._udp.sweet.home SRV @127.0.0.1
check "SRV: _ldap._tcp.sweet.home" dig +short _ldap._tcp.sweet.home SRV @127.0.0.1
check "TXT: _kerberos.sweet.home" dig +short _kerberos.sweet.home TXT @127.0.0.1
echo ""
echo "--- LDAP ---"
check "LDAP port 389 open" bash -c "exec 3<>/dev/tcp/127.0.0.1/389"
check "LDAPS port 636 open" bash -c "exec 3<>/dev/tcp/127.0.0.1/636"
echo ""
echo "--- Kerberos ---"
check "KDC port 88 open" bash -c "exec 3<>/dev/tcp/127.0.0.1/88"
echo ""
echo "--- HTTP ---"
check "IPA HTTP redirect" curl -sk -o /dev/null -w "%{http_code}" http://localhost/ | grep -qE "^(301|302|200)"
check "IPA HTTPS UI" curl -sk -o /dev/null -w "%{http_code}" https://localhost/ipa/ui/ | grep -q "200"
echo ""
if [[ $FAIL -eq 0 ]]; then
echo "All $PASS checks passed."
else
echo "$FAIL check(s) FAILED, $PASS passed."
exit 1
fi
+67
View File
@@ -0,0 +1,67 @@
# pihole
Pi-hole v6 configuration management for `pihole.sweet.home`.
## What's here
```
config/
pihole.toml Pi-hole v6 main config (sanitised snapshot)
dnsmasq.d/
99-ipxe-chainload.conf Custom DHCP rules for EFI/BIOS iPXE PXE boot
pull-config.sh Pull live config from a Pi-hole to a local dir
apply-config.sh Apply a local config dir to a Pi-hole instance
sanitize-config.sh Redact sensitive fields from pihole.toml in-place
```
## Usage
### Snapshot the live config
```bash
# Pulls pihole.toml + custom dnsmasq.d/ drop-ins, auto-redacts sensitive fields
./pull-config.sh root@pihole ./config
```
### Apply config to a new Pi-hole
```bash
# Destination must have Pi-hole v6 already installed
./apply-config.sh ./config root@pihole-new
```
Both scripts take `<source> <destination>` as positional arguments.
Source/destination is an SSH target (e.g. `root@pihole`, `root@192.168.2.253`)
for the live side and a local directory path for the config side.
### Restore blocklists after applying
`apply-config.sh` transfers config only — not the gravity database.
Run this on the destination after applying to rebuild blocklists:
```bash
ssh root@pihole-new "pihole updateGravity"
```
## Notable config
### `dnsmasq.d/99-ipxe-chainload.conf`
Enables architecture-aware PXE boot via DHCP:
| Client | Vendor class | Boot file served |
|---|---|---|
| EFI iPXE already running | arch 7 + option 175 | `http://.../boot.ipxe` |
| BIOS iPXE already running | arch 0 + option 175 | `http://.../boot.ipxe` |
| EFI, no iPXE yet | `PXEClient:Arch:00007` | `ipxe.efi` via TFTP |
| BIOS/legacy, no iPXE yet | `PXEClient:Arch:00000` | `undionly.kpxe` via TFTP |
Proxmox EFI VMs also require a **VirtIO RNG device** (gives OVMF
enough entropy to complete PXE negotiation) and **Secure Boot disabled**
(`pre-enrolled-keys=0` on the EFI disk, or disabled in UEFI setup).
## Secrets
`pihole.toml` is stored with `pwhash`, `totp_secret`, and `app_pwhash`
redacted to empty strings. `pull-config.sh` does this automatically.
To redact manually: `./sanitize-config.sh config/pihole.toml`.
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Apply a Pi-hole configuration directory to a destination Pi-hole instance.
#
# Usage: apply-config.sh <source-dir> <dest-host>
# source-dir Local directory containing config files (as written by pull-config.sh)
# dest-host SSH-reachable hostname or IP of the destination Pi-hole
#
# Example:
# ./apply-config.sh ./config root@pihole-new
# ./apply-config.sh /backup/pihole-20260723 root@192.168.2.101
#
# The destination Pi-hole must already have Pi-hole v6 installed.
# pihole-FTL is restarted at the end to apply the new config.
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <source-dir> <dest-host>" >&2
echo " source-dir Local directory with config files (from pull-config.sh)" >&2
echo " dest-host SSH target for the destination Pi-hole (e.g. root@pihole)" >&2
exit 1
}
[[ $# -eq 2 ]] || usage
SOURCE="$1"
DEST="$2"
[[ -d "${SOURCE}" ]] || { echo "Error: source directory '${SOURCE}' not found" >&2; exit 1; }
[[ -f "${SOURCE}/pihole.toml" ]] || { echo "Error: '${SOURCE}/pihole.toml' not found — is this a valid config dir?" >&2; exit 1; }
echo "Applying Pi-hole config from ${SOURCE}${DEST}"
# ── Verify destination is running Pi-hole ──────────────────────────────────────
ssh "${DEST}" "command -v pihole-FTL >/dev/null 2>&1 || { echo 'pihole-FTL not found on destination'; exit 1; }"
# ── pihole.toml ────────────────────────────────────────────────────────────────
echo " pihole.toml"
ssh "${DEST}" "cp /etc/pihole/pihole.toml /etc/pihole/pihole.toml.pre-apply 2>/dev/null || true"
scp "${SOURCE}/pihole.toml" "${DEST}:/etc/pihole/pihole.toml"
ssh "${DEST}" "chown pihole:pihole /etc/pihole/pihole.toml; chmod 640 /etc/pihole/pihole.toml"
# ── custom dnsmasq drop-ins ────────────────────────────────────────────────────
if [[ -d "${SOURCE}/dnsmasq.d" ]] && [[ -n "$(ls "${SOURCE}/dnsmasq.d/"*.conf 2>/dev/null)" ]]; then
echo " dnsmasq.d/ (custom drop-ins)"
for f in "${SOURCE}/dnsmasq.d/"*.conf; do
name="$(basename "$f")"
echo " ${name}"
scp "${f}" "${DEST}:/etc/dnsmasq.d/${name}"
done
fi
# ── Restart pihole-FTL ─────────────────────────────────────────────────────────
echo " Restarting pihole-FTL"
ssh "${DEST}" "systemctl restart pihole-FTL"
sleep 2
ssh "${DEST}" "systemctl is-active pihole-FTL"
echo "Done. Config applied to ${DEST}."
echo "Note: gravity (blocklists) is not transferred — run 'pihole updateGravity' on ${DEST} to rebuild."
@@ -0,0 +1,20 @@
# Detect iPXE clients (already running iPXE).
dhcp-match=set:ipxe,175
dhcp-userclass=set:ipxe,iPXE
# Detect UEFI x86_64 clients by client architecture.
dhcp-match=set:efi64,option:client-arch,7
dhcp-match=set:efi64,option:client-arch,9
# Boot file selection — more positive tags = higher priority.
# EFI iPXE (2 tags): already running iPXE on EFI, chain to HTTP menu.
dhcp-boot=tag:ipxe,tag:efi64,http://192.168.2.247/boot.ipxe
# BIOS iPXE (1 tag): already running iPXE, chain to HTTP menu.
dhcp-boot=tag:ipxe,http://192.168.2.247/boot.ipxe
# EFI non-iPXE (1 tag): send the EFI iPXE binary.
dhcp-boot=tag:efi64,ipxe.efi,,192.168.2.247
# BIOS/legacy fallback (0 tags): send the BIOS iPXE binary.
dhcp-boot=undionly.kpxe,,192.168.2.247
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Pull Pi-hole configuration from a running instance to a local directory.
# Sensitive fields (password hashes, TOTP secrets) are redacted automatically.
#
# Usage: pull-config.sh <source-host> <dest-dir>
# source-host SSH-reachable hostname or IP of the source Pi-hole
# dest-dir Local directory to write config into (created if absent)
#
# Example:
# ./pull-config.sh root@pihole ./config
# ./pull-config.sh root@192.168.2.253 /backup/pihole-$(date +%Y%m%d)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
usage() {
echo "Usage: $(basename "$0") <source-host> <dest-dir>" >&2
echo " source-host SSH target for the source Pi-hole (e.g. root@pihole)" >&2
echo " dest-dir Local directory to write config files into" >&2
exit 1
}
[[ $# -eq 2 ]] || usage
SOURCE="$1"
DEST="$2"
echo "Pulling Pi-hole config from ${SOURCE}${DEST}"
mkdir -p "${DEST}/dnsmasq.d"
# ── pihole.toml ────────────────────────────────────────────────────────────────
echo " pihole.toml"
ssh "${SOURCE}" "cat /etc/pihole/pihole.toml" > "${DEST}/pihole.toml"
"${SCRIPT_DIR}/sanitize-config.sh" "${DEST}/pihole.toml"
# ── custom dnsmasq drop-ins ────────────────────────────────────────────────────
# Pi-hole manages its own generated config; we only capture user-added files.
echo " dnsmasq.d/ (custom drop-ins)"
ssh "${SOURCE}" "ls /etc/dnsmasq.d/*.conf 2>/dev/null || true" | while read -r f; do
name="$(basename "$f")"
echo " ${name}"
ssh "${SOURCE}" "cat '${f}'" > "${DEST}/dnsmasq.d/${name}"
done
# ── DHCP static leases ─────────────────────────────────────────────────────────
echo " dhcp.leases"
ssh "${SOURCE}" "cat /etc/pihole/dhcp.leases 2>/dev/null || true" > "${DEST}/dhcp.leases"
echo "Done. Config written to ${DEST}/"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Redact sensitive fields from a Pi-hole pihole.toml before committing.
# Called automatically by pull-config.sh; can also be run manually.
#
# Usage: sanitize-config.sh <pihole.toml>
# Edits the file in-place, replacing sensitive field values with "".
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <pihole.toml>" >&2
exit 1
}
[[ $# -eq 1 ]] || usage
FILE="$1"
[[ -f "$FILE" ]] || { echo "Error: file not found: $FILE" >&2; exit 1; }
REDACTED=0
redact_field() {
local field="$1"
# Match lines like: pwhash = "some-value" and blank the value
if grep -qE "^\s+${field}\s*=\s*\"[^\"]{1,}\"" "$FILE"; then
sed -i -E "s|^(\s+${field}\s*=\s*)\"[^\"]*\"|\1\"\"|" "$FILE"
echo " redacted: ${field}"
REDACTED=$((REDACTED + 1))
fi
}
echo "Sanitizing $(basename "$FILE")..."
redact_field "pwhash"
redact_field "app_pwhash"
redact_field "totp_secret"
if [[ $REDACTED -eq 0 ]]; then
echo " (nothing to redact)"
else
echo " ${REDACTED} field(s) redacted."
fi
+2
View File
@@ -0,0 +1,2 @@
.claude/settings.local.json
*.swp
+84
View File
@@ -0,0 +1,84 @@
# CLAUDE.md
Guidance for Claude Code working in this repo. IMPORTANT: these
instructions OVERRIDE any default behavior and must be followed exactly
as written.
## Repo purpose
Base configuration/hardening toolset and planning docs for Proxmox VE
hosts (`scripts/`, `config/`, `docs/`) — see `README.md` and
`docs/00-overview.md`. Scripts in this repo are meant to be run **on**
the target Proxmox host itself (as root), not orchestrated remotely.
## Two Proxmox nodes: `pve1` (production) and `pve-test` (sandbox)
Two SSH-reachable Proxmox nodes exist on the LAN. They are **not
interchangeable** — see `docs/05-node-roles.md` for full background on
what each one is and why.
### `pve1` (production — off-limits to Claude by default)
A real, live Proxmox node hosting production VMs/containers (see
`docs/05-node-roles.md` for the current guest list) — not a sandbox, and
not Claude's to touch by default.
- **Off-limits at all times unless the operator has given explicit,
same-session instructions to act on this specific host.** That
authorization is scoped to the task it was given for — don't carry it
forward to unrelated later work in the same conversation, and never
assume it from a previous session.
- **Read-only for existing state is always fine, authorization or not.**
SSH in (or use `pvesm`, `qm list`, `pct list`, `qm config`, `pct
config`, the Proxmox API, etc.) to inspect config, storage, and any
existing VM/container freely.
- **Never** modify, stop, restart, delete, reconfigure, or create
anything on this node (`qm set`, `pct set`, `qm destroy`, `pct
destroy`, `qm stop`, `pct stop`, `qm create`, `pct create`, snapshot
operations, storage changes, running any script in this repo against
it, etc.) without that explicit go-ahead. Use `pve-test` for anything
exploratory instead.
- If a guest on `pve1` is HA-managed, be aware of the self-fence hazard
described in `docs/05-node-roles.md`'s cluster-teardown section before
doing anything that could cost the node quorum.
### `pve-test` (sandbox — Claude's default target)
A separate node set aside for testing — safe to create, interrogate, and
destroy scratch VMs/containers on without asking first.
- **Test VMs/containers are allowed, but must be torn down.** Anything
created this way must be destroyed again in the same session, before
ending the task. Use an obviously-scratch VMID/name.
- **Node-level config is still not yours to change by default.**
Creating/destroying your own scratch guests is fine; Proxmox host
config, storage pools, and networking on `pve-test` itself need the
operator's explicit go-ahead too, same as on `pve1` — the "sandbox"
status covers guest-level experimentation, not the host's own
identity. (`pve-test`'s current network config, `docs/06-pve-test-wifi-network.md`,
was applied under exactly that kind of explicit, same-session
authorization — it's not a standing invitation to keep changing it
further without asking again.)
- **Never re-cluster `pve-test` with `pve1`** without the operator
explicitly asking for it and being aware of the wifi/corosync
incompatibility in `docs/06-pve-test-wifi-network.md` — the two were
deliberately de-clustered for this reason once already.
## Safety rules
- Any script in `scripts/` that isn't `audit.sh` (read-only) makes real
changes when run for real. Don't run one against `pve1`, or against
`pve-test`'s node-level config, without the same-session go-ahead
described above. Running a script *against pve-test's own guests* — a
scratch VM/CT you created for this task — doesn't need separate
permission.
- Do not commit secrets: SSH private keys, wifi passphrases, sops/age
keys, or PVE credentials. `scripts/setup-wifi-bond-network.sh` takes
the SSID/passphrase via environment variables for exactly this reason
— never hardcode them into the script or a committed config file.
- Live changes to a node's own management network (the interface/bridge
carrying the SSH session you're using) can strand the box — see the
"what went wrong once" section in `docs/06-pve-test-wifi-network.md`
before touching `pve-test`'s networking again. Prefer applying via
`ifreload -a` over raw `ip link` surgery, and arm an auto-revert
watchdog first when acting without someone physically at the console.
+57
View File
@@ -0,0 +1,57 @@
> Part of the [debian-configuration](../README.md) repo — see the root README for repo-wide layout and secret-scanning setup.
# Proxmox Configuration
Base configuration and hardening toolset for Proxmox VE hosts, plus planning
docs for eventually growing this into a 3-node HA/Ceph cluster. See
`docs/00-overview.md` for the staging: **Stage 1** (base config/hardening,
applies to any host — active) vs. **Stage 2** (multi-node HA/Ceph — future,
deferred).
## Goals
- Stage 1: a reusable, idempotent base-hardening toolset (`scripts/`) that
can be run against any new Proxmox host — repo/updates, SSH, firewall, PVE
user/access hardening — verified with `scripts/audit.sh`.
- Stage 2 (future): 3-node cluster, quorum via corosync, HA-managed VMs
backed by Ceph. Needs dedicated hardware node 1 (`pve1`, an ASUS PN53 mini
PC) doesn't have — see `docs/01-hardware-node1.md`.
See `CLAUDE.md` for the guardrails Claude Code follows when working
against these hosts (`pve1` is production and off-limits by default;
`pve-test` is the sandbox).
## Repo layout
- `docs/` — planning docs: hardware layout, storage migration, networking,
security hardening, node roles. Read `docs/00-overview.md` first, then
`docs/05-node-roles.md` for what `pve1`/`pve-test` actually are.
- `scripts/` — scripts to apply configuration on a node (SSH hardening, repo
switch, firewall, updates, wifi/bond networking, etc). Idempotent, safe
to re-run. `scripts/bootstrap.sh` runs the full Stage 1 sequence end to
end; `scripts/audit.sh` verifies it (read-only).
`scripts/setup-wifi-bond-network.sh` reproduces `pve-test`'s wifi
network (see `docs/06-pve-test-wifi-network.md`). `scripts/lib/` holds
shared helpers (`common.sh`) sourced by the other scripts.
- `config/` — reference config files/snippets to drop onto a node (firewall
rules, sshd config, etc.).
## Quick start (Stage 1, on a fresh node)
```
MGMT_CIDR=192.168.2.0/24 ./scripts/bootstrap.sh
./scripts/create-admin-user.sh <username>
# then enable 2FA for that user + root@pam via the web UI
./scripts/audit.sh
```
## Status
`pve1` built (ASUS PN53 mini PC, ZFS mirror boot+VM storage, single
2.5GbE NIC) and already running production VMs/CTs. Stage 1 base
hardening applied and verified (`scripts/audit.sh` all green): enterprise
repos removed, SSH key-only + fail2ban, unattended security upgrades,
PVE firewall (mgmt-only), subscription nag disabled, named admin user
(`wayne@pve`) created. Remaining manual step: enable 2FA/TOTP for
`wayne@pve` and `root@pam` via the web UI. Stage 2 (cluster/Ceph) not
started — needs nodes 2/3 on hardware that can actually support it.
@@ -0,0 +1,47 @@
# Example cluster-wide firewall rules for /etc/pve/firewall/cluster.fw
#
# Stage 1 (single host, current): only the mgmt IPSET applies. Applied
# automatically by scripts/deploy-firewall.sh, which fills in <MGMT_CIDR>.
#
# Stage 2 (future cluster/Ceph): the corosync and Ceph rules below are
# commented out placeholders. Uncomment and fill in <COROSYNC_CIDR> /
# <CEPH_CIDR> when nodes 2/3 join and those networks actually exist -
# leaving them active on a single node with no corosync/Ceph traffic is
# just dead config, and a literal `<COROSYNC_CIDR>` is invalid syntax if
# left uncommented and unfilled.
#
# Copy to /etc/pve/firewall/cluster.fw and edit before enabling (or use
# scripts/deploy-firewall.sh).
[OPTIONS]
enable: 1
policy_in: DROP
policy_out: ACCEPT
[IPSET mgmt]
<MGMT_CIDR>
[RULES]
# Web UI + SSH only from the management network
IN ACCEPT -source +mgmt -p tcp -dport 8006 -log nolog
IN ACCEPT -source +mgmt -p tcp -dport 22 -log nolog
# ICMP echo (ping) from the management network - diagnostic convenience
# only, nothing else depends on it. Without this, policy_in DROP silently
# eats ping while SSH/web UI keep working - looks like an outage during
# troubleshooting when the host is actually fine. See
# docs/06-pve-test-wifi-network.md for a case this caused real confusion.
IN ACCEPT -source +mgmt -p icmp -icmp-type echo-request -log nolog
# Beszel monitoring agent - the hub polls the agent on this port
IN ACCEPT -source +mgmt -p tcp -dport 45876 -log nolog
# Stage 2: Corosync (cluster quorum) - uncomment once node 2/3 join and
# the corosync network/VLAN exists.
# IN ACCEPT -source <COROSYNC_CIDR> -p udp -dport 5404:5405 -log nolog
# Stage 2: Ceph (uncomment once Ceph is live; ports: mon 3300,6789,
# osd/mgr/mds 6800-7300)
# IN ACCEPT -source <CEPH_CIDR> -p tcp -dport 3300 -log nolog
# IN ACCEPT -source <CEPH_CIDR> -p tcp -dport 6789 -log nolog
# IN ACCEPT -source <CEPH_CIDR> -p tcp -dport 6800:7300 -log nolog
+116
View File
@@ -0,0 +1,116 @@
# Overview & Roadmap
## Staging
This repo now targets two distinct stages, in order:
- **Stage 1 (active)** — base configuration and hardening for a single
Proxmox host, applicable to *any* node regardless of eventual cluster
plans: repo/updates, SSH, firewall, PVE user/access hardening. This is
what `scripts/bootstrap.sh`, `scripts/audit.sh`, and
`04-security-hardening.md` cover, and what's being built out against
node 1 (`pve1`, an ASUS PN53 mini PC) right now.
- **Stage 2 (future)** — the multi-node HA/Ceph cluster described below
and in `01-hardware-node1.md` / `02-storage-zfs-ceph.md` /
`03-networking.md`. Deliberately deferred: `pve1`'s hardware (2 NVMe
already merged into one ZFS mirror used for both boot and VM storage, a
single 2.5GbE NIC) can't support the separate boot/Ceph disks or
bonded/segregated networking those docs assume. Revisit once dedicated
cluster hardware (nodes 2/3) is actually being bought and provisioned;
until then treat the content below as a target design, not a
description of `pve1`.
## Background
Old hardware required disabling KVM hardware virtualization for VMs to
start at all (falls back to software emulation — slow). This is a
host/BIOS-level issue, not a Proxmox limitation. Confirmed not present on
`pve1` (ASUS PN53, Ryzen 7 7735HS): AMD-V and IOMMU both show enabled at
boot (`dmesg | grep -i iommu`), no workaround needed. If this hardware line
is reused for nodes 2/3, this should hold there too, but re-verify per
node before assuming it.
## Stage 1: base config & hardening (active)
Applies to `pve1` now, and to every future node regardless of whether it
ever joins the Stage 2 cluster. Covered by `04-security-hardening.md` and
`scripts/bootstrap.sh` / `scripts/audit.sh`:
1. Fresh PVE install; confirm VT-x/AMD-V + IOMMU per the note above.
2. Switch off the enterprise repos, onto no-subscription
(`scripts/switch-to-no-subscription-repo.sh`).
3. SSH hardening: key-only root login + fail2ban
(`scripts/harden-ssh.sh`).
4. Unattended security upgrades, no auto-reboot
(`scripts/setup-unattended-upgrades.sh`).
5. PVE datacenter firewall, default-deny, mgmt-only SSH/8006
(`scripts/deploy-firewall.sh`).
6. Named PVE admin user (Administrator role) + 2FA, `root@pam` reserved
for emergencies (`scripts/create-admin-user.sh`, then manual TOTP
enrollment via the web UI).
7. Verify with `scripts/audit.sh`.
`pve1`'s actual disk/network layout (single ZFS mirror for boot + VMs, one
2.5GbE NIC) is documented as-is in `01-hardware-node1.md` — Stage 1 doesn't
require or assume the split-disk/multi-NIC layout Stage 2 wants.
## Stage 2: HA cluster + Ceph (future, deferred)
Everything below this point is the target design for when nodes 2 and 3
are actually being provisioned. Not applicable to `pve1` as it stands.
**`pve-test` existing does not mean node 2 exists.** A second physical
node (`pve-test`) does run alongside `pve1` — see `05-node-roles.md`
but it's a sandbox/test box, not built to this Stage 2 design, and as of
this writing runs on **wifi** networking
(`06-pve-test-wifi-network.md`), which is directly incompatible with
corosync's latency/jitter requirements below. `pve-test` and `pve1` were
briefly clustered and then deliberately de-clustered for exactly this
reason. Don't treat `pve-test` as progress toward Stage 2 without a
deliberate decision to rebuild its networking first.
### End goal
3-node Proxmox VE cluster with HA-managed VMs backed by **Ceph** — true
distributed shared storage, sync replication, near-zero RPO on failover
(see `02-storage-zfs-ceph.md`). Ceph needs 3+ nodes and a fast dedicated
network, so it can't exist until nodes 2 and 3 are up.
Deliberately no intermediate "ZFS + storage replication" HA step. Node 1
runs local ZFS for boot + VM storage with no cluster-wide HA until Ceph
goes live — as soon as nodes 2/3 join, VMs move onto Ceph rather than
adopting ZFS replication as a stopgap. Simpler end state, one storage
model to operate instead of two.
### Cluster fundamentals (apply from node 1 onward)
- 3 nodes minimum for real quorum. If starting with 2, add a QDevice
(small VM or Raspberry Pi) as tie-breaker.
- Dedicated network for corosync (cluster/quorum traffic) — never shared
with VM or storage traffic. Needs low, consistent latency (well under
5ms); jitter matters more than bandwidth.
- All nodes on the same PVE version, NTP-synced, SSH reachable between
nodes.
- Set VM CPU type to a portable type (e.g. `x86-64-v2-AES` or `kvm64`)
rather than `host` if nodes will ever have different CPUs — needed for
clean live migration.
### Rollout sequence (Stage 2, once dedicated cluster hardware exists)
1. Build node 1 per `01-hardware-node1.md`'s target design — fresh PVE
install on a dedicated ZFS boot mirror, Ceph-earmarked disks left idle
(or as a temporary local ZFS pool, to be wiped later — see
`02-storage-zfs-ceph.md`). Note: this assumes hardware with enough
disks/NICs to separate boot, Ceph, and network roles — `pve1` does not
have this and stays on Stage 1 only unless rebuilt on different
hardware.
2. Migrate VMs onto the new node via `vzdump` → copy backups →
`qmrestore` (converts disks to ZVOLs). No HA yet — single node.
3. Stage 1 base hardening already applied; layer on Stage 2 networking
(`03-networking.md`) before joining a cluster.
4. Add nodes 2 and 3 identically (same disk/network layout).
5. Join cluster, stand up dedicated corosync network.
6. Wipe the Ceph-earmarked disks (if used as temporary ZFS) and
initialize Ceph across all 3 nodes.
7. Migrate VMs from local ZFS onto Ceph-backed storage, then configure HA
groups.
+92
View File
@@ -0,0 +1,92 @@
# Node 1 Hardware Layout
## pve1 as built (current reality)
`pve1` is an ASUS PN53 mini PC (Ryzen 7 7735HS, 32GB RAM), not the
dedicated-server hardware the target design below assumes:
- **Disks**: 2x 2TB NVMe (Crucial CT2000E100SSD8), both in a single ZFS
mirror (`rpool`) that serves as both the boot pool and VM storage
(`local-zfs` = `rpool/data`). No spare disks to earmark for Ceph — the
chassis only has 2 NVMe slots.
- **Network**: one physical NIC (Realtek RTL8125, 2.5GbE), bridged as
`vmbr0`. No second NIC for a dedicated corosync/Ceph link. (An unused
`nic1` stanza in `/etc/network/interfaces` is a leftover from the
installer template — there is no second NIC on this hardware.)
This is sufficient and correct for **Stage 1** (see `00-overview.md`) —
base config and hardening don't need split disks or multiple NICs. It is
*not* sufficient for **Stage 2** (Ceph/HA) as designed below without
either different hardware or a materially different plan (e.g.
USB/Thunderbolt-attached OSD storage, which trades away the
enterprise-SSD/PLP guidance below — not recommended, revisit when
actually provisioning nodes 2/3). Treat everything from here down as the
Stage 2 target design for purpose-built hardware, not a description of
`pve1`.
## Target design (Stage 2, future dedicated hardware)
Build node 1 so nodes 2/3 are drop-in identical later — don't re-architect
disks or network when the cluster grows.
## Disks — two roles, physically separate devices
1. **Boot/OS pool (`rpool`)** — 2x small SSDs (240-480GB plenty), ZFS
mirror. Proxmox itself only. Never share with Ceph OSDs.
2. **Future Ceph OSD disks** — must end up as raw, unformatted devices —
no ZFS/RAID/LVM underneath (Ceph does its own replication; anything
underneath just doubles copy-on-write/checksumming and hurts
performance). Use enterprise SATA/NVMe SSDs with power-loss protection
(PLP) — matters far more for Ceph write latency than for general ZFS
use. Ceph needs 3 nodes minimum to go live, so on node 1 these disks
either sit idle or run as a temporary local ZFS pool (all VMs live
here until nodes 2/3 exist), to be wiped and handed to Ceph once the
cluster can actually run it. See `02-storage-zfs-ceph.md`.
No permanent local-ZFS "replicated tier" — once Ceph is live, it's the
only HA storage; local ZFS is boot pool + this temporary pre-Ceph staging
role, not an ongoing parallel tier. Avoid consumer QLC SSDs for either
role — Ceph punishes it on latency, ZFS on sync writes/scrub.
## Networking — cable and provision for the final topology now
Logically separate networks (ideally separate NICs/VLANs):
- **Management** — web UI / SSH
- **Corosync** — cluster quorum traffic, low-latency, unshared
- **Ceph public** — VM-to-OSD traffic
- **Ceph cluster/backend** — OSD-to-OSD replication (heaviest load)
Practical layout: 2x 10/25GbE bonded or split — one pair for Ceph, one
for mgmt + corosync + VM traffic, with corosync on its own VLAN even if
sharing a physical NIC. Get switch/cabling right on node 1 so nodes 2/3
are identical drops.
## CPU / RAM sizing
Size for the end state, not day one — RAM is the hardest thing to
retrofit. Budget covers:
- OS + ZFS ARC (ZFS wants RAM, not just disk)
- Ceph OSD daemons — realistically 3-5GB per OSD once running
- Actual VM workloads
Roughly a core per OSD on top of what VMs need. If OSDs won't be active
for a while, that's headway, but buy for 3 nodes' worth of eventual OSD
load.
## Backup target (PBS)
Keep it off the Ceph/compute nodes if possible — its failure domain
should be independent of the cluster. Modest separate machine or NAS:
ZFS mirror or raidz2, ECC RAM if possible, capacity for retention policy.
If it has to run as a VM inside the cluster short-term, that's a known
compromise, not the end state.
## Node 1 install sequence
1. Install Proxmox VE fresh onto the ZFS boot mirror.
2. Provision the Ceph-earmarked disks as a temporary local ZFS pool and
run all VMs from it (or leave idle if VMs aren't moving over yet).
3. Once nodes 2/3 join and Ceph goes live: wipe this pool, hand the disks
to Ceph, migrate VMs onto Ceph-backed storage.
+66
View File
@@ -0,0 +1,66 @@
# Storage: LVM-thin → ZFS → Ceph
**Stage 2 (future).** Describes the target storage model once dedicated
cluster hardware exists. `pve1`'s current single ZFS mirror (boot + VM
storage combined, see `01-hardware-node1.md`) is the Stage 1 end state for
now, not an intermediate step being actively migrated from.
## Why move off LVM-thin
Neither ZFS nor LVM-thin is shared storage — both are node-local. HA needs
a VM's disk reachable from more than one node so it can restart elsewhere
on host failure. LVM-thin has no answer for that. Ceph does, natively.
## Storage model: single node → 3-node cluster
No intermediate "ZFS + replication" HA step. The plan is deliberately a
single storage model at the end (Ceph), not two to operate long-term:
- **Single node (node 1 only)**: local ZFS pool, no cluster-wide HA. This
is a temporary state, not a design to build tooling around.
- **3 nodes with Ceph live**: VMs run on Ceph-backed storage — true
distributed, synchronous storage across all nodes, near-zero RPO on
failover. Needs 3+ nodes and a fast dedicated network (see
`03-networking.md`), which is exactly why it can't exist before then.
## Migration plan (old hardware → new hardware)
Don't convert the old LVM-thin box in place. Rebuild fresh on new
hardware with ZFS from the installer (mirror if 2+ disks), then move VMs:
1. On the old host: `vzdump` each VM to a backup file (external drive,
NFS share, or PBS if available).
2. Copy backups to the new host.
3. `qmrestore` onto the new ZFS storage — disks land as ZVOLs.
Alternative if both hosts can see each other on the network: temporarily
cluster them and use the GUI "Migrate" with a storage move (offline only
— live migration doesn't cross storage types).
## Standing up Ceph once nodes 2 and 3 exist
1. Wipe the temporary local ZFS pool on the Ceph-earmarked disks (see
`01-hardware-node1.md`) on all 3 nodes — they need to end up raw,
unformatted.
2. Install the Ceph packages on all 3 nodes (`pveceph install`) and
initialize the cluster (`pveceph init`), using the dedicated Ceph
network from `03-networking.md`.
3. Create Ceph monitors and managers (3 mons for quorum, matching node
count).
4. Create OSDs directly on the raw disks on each node — no ZFS/RAID
underneath.
5. Create a Ceph pool sized for your VM storage needs (replica count,
typically 3 for full redundancy across 3 nodes).
6. Add the pool as PVE storage (RBD), then migrate VMs from local ZFS
onto it — offline migration if crossing storage types, or storage
migration via the GUI.
7. Configure HA groups once VMs are on Ceph-backed storage.
## Notes
- Ceph RAM/CPU overhead is real — budget per `01-hardware-node1.md`
(roughly 3-5GB RAM and a core per OSD, on top of VM workloads).
- Enterprise SSDs with power-loss protection (PLP) matter far more here
than for plain ZFS — Ceph write latency is sensitive to it.
- Once Ceph is live, local ZFS remains only for each node's boot pool —
it's not a fallback tier for VM storage going forward.
+66
View File
@@ -0,0 +1,66 @@
# Networking
## pve1 as built (Stage 1, current)
Single NIC (2.5GbE), single bridge `vmbr0` on the flat LAN
(`192.168.2.0/24`), no VLANs. There is no corosync or Ceph traffic to
separate yet — this node isn't clustered. Segmentation for Stage 1 is
done at the firewall, not the network: `scripts/deploy-firewall.sh`
restricts SSH (22) and the web UI (8006) to the management CIDR via the
PVE datacenter firewall (default-deny inbound otherwise). That's
sufficient until Stage 2 needs actual separate physical/VLAN paths for
corosync and Ceph traffic — see below.
## pve-test as built (sandbox, current)
Different node, different design, not a Stage 1/2 example to generalize
from: `pve-test` runs `vmbr0` bridged over a wifi NIC in 4addr client mode
(active-backup bonded with a wired NIC as an automatic fallback). Full
detail, including why this is normally impossible and how it was
validated before trusting it with the management IP, in
`06-pve-test-wifi-network.md`. This is intentionally a one-off for a
standalone sandbox box — never extend it to a node that's clustered or
Ceph-connected (see the Stage 2 note in `00-overview.md`).
## Target design (Stage 2, future cluster)
## Required separation
Keep these on logically separate networks/VLANs, ideally separate NICs:
- **Management** — web UI (8006), SSH
- **Corosync** — cluster quorum. Low, *consistent* latency (well under
5ms) matters more than bandwidth. Never share with VM/storage traffic.
- **Ceph public** — VM-to-OSD traffic (once Ceph is live)
- **Ceph cluster/backend** — OSD-to-OSD replication, heaviest load
## Practical layout
2x 10/25GbE bonded or split:
- Link pair A → Ceph (public + backend, or split further if 4 NICs
available)
- Link pair B → management + corosync + VM traffic, with corosync on its
own VLAN even when sharing a physical NIC with the rest
## Cluster join requirements
- All nodes reachable to each other on SSH (22) and the corosync network
- Same PVE version across nodes
- NTP-synced clocks
## Firewall
Proxmox's built-in firewall operates at datacenter and node level.
Default-deny, then whitelist:
- SSH from the management network/VLAN only
- Web UI (8006) from the management network/VLAN only
- Corosync ports between cluster nodes
- Ceph ports between cluster nodes (once Ceph is live)
Enforce the network separation above at the firewall — corosync and Ceph
traffic shouldn't be reachable from the VM network even if they end up
sharing a physical link.
See `config/pve-firewall/` for a starting rule set.
+112
View File
@@ -0,0 +1,112 @@
# Security Hardening
Stage 1 (see `00-overview.md`) — applies to any Proxmox host, independent
of cluster plans. Proxmox has no `sudo` out of the box — everything
defaults to root. That's the install default, not the recommended end
state. Two layers to harden separately.
## Checklist / script mapping
Run `scripts/bootstrap.sh` for everything except the admin user (needs a
username decision) and 2FA enrollment (must be done interactively via the
web UI — there's no safe way to script TOTP secret generation over SSH).
Then run `scripts/audit.sh` to verify. Order matters (matches
`bootstrap.sh`):
| # | Item | Script | Manual step required? |
|---|------|--------|------------------------|
| 1 | Remove enterprise repos, switch to no-subscription | `switch-to-no-subscription-repo.sh` | no |
| 2 | Linux admin user with SSH key + sudo access | `setup-linux-admin-user.sh <user> <pubkey>` | yes — choose a username and provide your SSH public key. Run this **before** SSH hardening or pass `ADMIN_USER`/`ADMIN_SSH_KEY` to `bootstrap.sh` so it runs automatically at the right point |
| 3 | Passwordless sudo for pvesh/qm/pct | `setup-admin-sudo.sh <username>` | yes — same username as step 2 |
| 4 | SSH: key-only root login + fail2ban | `harden-ssh.sh` | no — step 2 ensures authorized_keys are in place first |
| 5 | Unattended security upgrades, no auto-reboot | `setup-unattended-upgrades.sh` | no |
| 6 | PVE firewall, default-deny, mgmt-only SSH/8006 | `deploy-firewall.sh` | needs `MGMT_CIDR` set |
| 7 | Disable subscription nag (cosmetic) | `disable-subscription-nag.sh` | no |
| 8 | Named PVE admin user, Administrator role | `create-admin-user.sh <username>` | yes — pick the username, change the generated password on first login |
| 9 | 2FA/TOTP on that user and `root@pam` | — | yes — web UI only: Datacenter → Permissions → Two Factor, or user menu → TFA |
| 10 | Verify everything above | `audit.sh` | no |
## Linux/SSH layer
- A named Linux system user (created by `setup-linux-admin-user.sh`) with an
SSH authorized key and `sudo` group membership is the primary SSH login
account. Root SSH is locked to key-only after `harden-ssh.sh` runs; the
Linux admin user is how you get shell access day-to-day without using root.
- `setup-admin-sudo.sh` adds a narrower, password-free sudoers rule for the
Proxmox management tools (`pvesh`, `qm`, `pct`) specifically — needed for
non-interactive automation scripts that SSH in and run these tools without a
TTY.
- `PermitRootLogin prohibit-password` in `sshd_config` — root can only
log in via SSH key, never password. Kills most brute-force attempts.
- fail2ban jail for SSH on top of that.
- Restrict SSH to the management VLAN/trusted IPs via the Proxmox
firewall (see `03-networking.md`) rather than exposing broadly.
## PVE/web layer (the one that actually matters day-to-day)
- Keep `root@pam` for emergencies only.
- Create a named user (e.g. `wayne@pve`) with the Administrator role for
routine cluster management — `create-admin-user.sh` does this, or
Datacenter → Permissions → Users manually.
- Enable 2FA (TOTP or hardware key) on both that account and `root@pam`:
Datacenter → Permissions → Realms/Users.
- For API integrations (monitoring, automation, Terraform, etc.), issue
scoped API tokens with least-privilege roles (e.g. `PVEAuditor` or a
custom role) — never hand out root credentials.
## Firewall
Default-deny at datacenter/node level, whitelist only what's needed (see
`03-networking.md` for the specifics). Template in
`config/pve-firewall/cluster.fw.example`, applied by
`scripts/deploy-firewall.sh`.
**ICMP echo (ping) is explicitly allowed from the management network**,
alongside SSH/8006 — not required for anything to function, but without
it `policy_in: DROP` silently eats ping while SSH/web UI keep working.
That split (ping dead, everything else fine) reads exactly like a real
outage mid-troubleshooting; see `06-pve-test-wifi-network.md` for a case
this caused genuine confusion after a network change. If you ever debug
"can't ping but can SSH", check this firewall before assuming the
network itself is broken.
**This file is cluster-wide, not per-node**: `cluster.fw` lives in
`/etc/pve/firewall/` — shared via pmxcfs across every node in a cluster.
A node that joins a cluster inherits whatever's already there, and (per
`docs/05-node-roles.md`) keeps its own local copy after leaving. Don't
assume a node's firewall state matches what `deploy-firewall.sh` was
last run with directly against it — check `pve-firewall status` /
`/etc/pve/firewall/cluster.fw` on the actual node.
## Repos and updates
Fresh installs point at the enterprise repo, which fails on `apt update`
without a subscription. `scripts/switch-to-no-subscription-repo.sh`
removes the enterprise sources entirely (renamed `.disabled`, not just
commented out) and switches to the no-subscription repo — handles both
the legacy `.list` format and the deb822 `.sources` format current
installers write. Keep the host patched — hypervisor CVEs are high-value
targets; `scripts/setup-unattended-upgrades.sh` automates security
patches (deliberately no auto-reboot on a hypervisor — check
`/var/run/reboot-required` and reboot during a planned window).
The web UI's "No valid subscription" popup and dashboard indicator are
cosmetic upsell, not a security control, but with no subscription they'll
nag on every login — `scripts/disable-subscription-nag.sh` patches
`proxmox-widget-toolkit`'s JS to suppress them, and installs an apt
`Post-Invoke` hook that reapplies the patch automatically after every
`apt`/`dpkg` run, since a `proxmox-widget-toolkit` package upgrade
overwrites the patched file.
## Misc
- Management interface on a network you trust, not the same broadcast
domain as guest VM traffic.
- If the web UI is ever needed outside the LAN, put it behind a VPN —
don't port-forward 8006 directly.
## Further reading / not yet automated here
- CIS Benchmark for Proxmox VE
- Community PVE hardening guides (kernel parameters, audit logging,
storage encryption)
+83
View File
@@ -0,0 +1,83 @@
# Node Roles: pve1 and pve-test
Two Proxmox VE hosts exist on the LAN. They are **not interchangeable**
different purpose, different risk tolerance, different guardrails. See
`CLAUDE.md` for the actual rules Claude follows; this doc is the
background/context those rules assume.
## pve1 (production)
ASUS PN53 mini PC (Ryzen 7 7735HS, 32GB RAM) — see `01-hardware-node1.md`
for the full hardware layout. Stage 1 base hardening applied and verified
(`04-security-hardening.md`).
Runs real production workloads:
- `server-nixos` (VM) and LXC containers `pihole`, `claude`, `nix-cache`,
`docker`, `pdm`, `pxe-boot` — DNS, this Claude Code environment itself,
the LAN's Nix binary cache, etc.
Off-limits to Claude by default — see `CLAUDE.md`.
## pve-test (sandbox)
A separate physical node kept for testing. Has a capable GPU (earmarked
for future GPU workloads — transcoding, local inference, etc. — nothing
built on it yet as of this writing). No production guests run here.
Safe for Claude to create/destroy scratch VMs and containers on without
asking — see `CLAUDE.md` for the exact boundary (node-level config is
still not free-for-all even here).
### History: briefly clustered with pve1, then deliberately separated
On 2026-07-21/22, pve-test was joined to pve1 in a 2-node Proxmox cluster
(corosync + shared `/etc/pve`) for a period, then the cluster was
**intentionally destroyed** at the operator's explicit request — not a
failure or accident. Rationale: the operator wanted pve-test moved onto
wifi networking (see `06-pve-test-wifi-network.md`), and wifi is
fundamentally unsuitable for corosync's latency/jitter requirements (token
timeouts, flapping membership). Rather than fight that, the cluster was
torn down first, then wifi was configured on the now-standalone node.
Teardown procedure used (safe to repeat if pve-test or pve1 is ever
reclustered and needs separating again):
1. Un-manage any HA-assigned resources first if HA is in use
(`ha-manager remove <sid>` per resource) — **critical**: if a node has
HA-managed guests and its watchdog is still armed when it loses
quorum, HA's self-fence behavior can force-reboot it. Removing the
resource assignments first (not stopping the guests — just
un-managing them) prevents this.
2. Stop `pve-ha-lrm`/`pve-ha-crm` on all nodes once no resources remain
assigned, confirm via `ha-manager status` that fencing is no longer
armed.
3. From a node that will remain in the cluster: `pvecm delnode <name>`
for the node being removed. In a 2-node cluster with no QDevice, this
can itself cause the *remaining* node to transiently lose quorum
(killing a node's live corosync membership drops total votes below
expected votes before the nodelist file can be rewritten to match) —
this is expected, not a failure; proceed to step 4 regardless.
4. On **each** node being separated (including one you just ran `delnode`
from, if it's also being decoupled): `systemctl stop pve-cluster
corosync`, `pmxcfs -l` (local mode, bypasses the quorum requirement
for local writes), remove `/etc/pve/corosync.conf` and
`/etc/corosync/*`, `killall pmxcfs`, `systemctl start pve-cluster`.
This is the official "separate a node without reinstalling" procedure
and doesn't touch any running VM/CT — they're independent OS
processes, unaffected by corosync/pmxcfs state either way.
5. Optional cosmetic cleanup: the node(s) staying up may retain a stale
`/etc/pve/nodes/<removed-node>/` directory (cached VM/CT configs from
when they were clustered) — safe to `rm -rf` once separation is
confirmed; it's dead data, not a live reference to anything.
Guests were never at risk during this procedure (confirmed: all 7 of
pve1's guests ran continuously throughout) — the only real hazard is the
HA/watchdog self-fence path in step 1, and losing corosync-provided quorum
temporarily blocking `/etc/pve` writes (not guest execution) in step 3.
### Current state: standalone, wifi-primary networking
pve-test now runs entirely independently of pve1, on the network design
documented in `06-pve-test-wifi-network.md`. **Do not re-cluster
pve-test while it's on wifi** — see that doc for why.
+191
View File
@@ -0,0 +1,191 @@
# pve-test's Wifi-Primary Network
Non-standard, deliberately chosen against normal Proxmox guidance (a
hypervisor's management network is not supposed to be wireless). Applies
**only** to `pve-test` as a standalone sandbox node — see
`05-node-roles.md` for why this and cluster membership don't mix, and
never apply this pattern to a node that's joined (or might join) a
cluster.
## Why this works at all
Wifi (802.11) normally cannot be bridged the way Ethernet can: an access
point only accepts frames whose source MAC matches the MAC that
associated with it. A Linux bridge with a wifi port as a member sends
frames tagged with whatever MAC actually generated them (the bridge's own
MAC, a VM's MAC, etc.) — none of which match the wifi card's hardware
MAC, so the AP silently drops them. This isn't a Linux/Proxmox limitation,
it's how WiFi association works.
**4addr (WDS) client mode** is the exception: if both the wifi driver and
the AP support it, the client tags frames with a 4th address field
carrying the original MAC, and the AP forwards them like a real Ethernet
segment. Most consumer routers do **not** support this (it's common on
OpenWrt/DD-WRT/enterprise APs, rare on stock ISP hardware) — it has to be
confirmed empirically, per-AP, before trusting it with a management IP.
pve-test's card (Intel AX200, `iwlwifi` driver, interface `wlp3s0`)
supports 4addr client-side generically (this is a `mac80211` core
feature, not driver-specific). The home AP it associates to
(`nbn-fttp-net-5G`) was confirmed to honor it — see validation method
below.
## Validate before ever touching a live management IP
Don't add the wifi NIC straight into your real bridge to "see if it
works" — if 4addr isn't actually honored, you lose access to the box with
no diagnostic trail (association succeeds; only the *data plane* silently
fails). Test in an isolated scratch namespace first:
```bash
# assumes wlp3s0 already associated with 4addr on (see systemd unit below)
ip link add testbr0 type bridge && ip link set testbr0 up
ip link set wlp3s0 master testbr0 && ip link set wlp3s0 up
ip netns add wifitest
ip link add veth-host type veth peer name veth-ns
ip link set veth-host master testbr0 && ip link set veth-host up
ip link set veth-ns netns wifitest
ip netns exec wifitest ip link set veth-ns address 02:11:22:33:44:55 # deliberately NOT the wifi card's MAC
ip netns exec wifitest ip link set veth-ns up
ip netns exec wifitest ip link set lo up
ip netns exec wifitest dhclient -v -1 veth-ns # real DHCP lease = AP forwards foreign MACs = 4addr genuinely works
```
If that gets a lease (or at minimum a ping reply from the gateway) from a
MAC that was never the one that associated, the AP is forwarding
arbitrary-MAC frames — real bridge mode will work. If not, don't proceed
to bridge mode; fall back to a routed/NAT design instead (wifi gets its
own IP via DHCP, VMs NAT out through it — never covered here since it
wasn't needed, but keep this fallback in mind if reproducing on different
hardware/AP).
Tear the test namespace/bridge down afterwards (`ip netns del wifitest;
ip link del veth-host; ip link set wlp3s0 nomaster; ip link del testbr0`)
— it's scratch, not part of the real config.
## Final architecture
```
wlp3s0 (wifi, 4addr client mode) ─┐
├─ bond0 (active-backup, wlp3s0 primary) ─ vmbr0 (192.168.2.251/24)
nic0 (wired, LAN backup) ─┘
```
- **`wpa-4addr-<iface>.service`** (systemd unit, `Before=network-pre.target`) —
sets 4addr mode and starts `wpa_supplicant` *before* ifupdown2 processes
`/etc/network/interfaces`. Deliberately not using `wpasupplicant`'s own
`wpa-conf` ifupdown2 hook integration — its ordering relative to a
custom `pre-up iw ... set 4addr on` line is implementation-specific and
not worth gambling on for something this hard to debug if it's wrong.
The systemd unit gives full, deterministic control over sequencing
instead.
- **`bond0`** — active-backup bonding, `wlp3s0` as `bond-primary`,
`nic0` as backup, `bond-miimon 100`. Bonding a wifi interface works here
because `mac80211`/`iwlwifi` properly reports carrier state
(`netif_carrier_on/off`) on association/disassociation, which is what
bonding's `miimon` (with the default `use_carrier=1`) actually watches
— it doesn't require a "real" MII-capable NIC.
- **`vmbr0`** — unchanged IP (`192.168.2.251/24`), bridged over `bond0`
instead of directly over a physical NIC.
Reproduce with `scripts/setup-wifi-bond-network.sh` — see its header
comment for usage and required env vars (SSID/passphrase, management
address/gateway). It stages `/etc/network/interfaces` and the systemd
unit but does **not** auto-apply (`ifreload -a`) — see the next section
for why that step needs care, not automation.
## Applying a live bridge-port change: what went wrong once, and what worked
Moving `vmbr0`'s underlying port live — while SSH'd in over the very
address that lives on that bridge — is inherently risky: if the new port
doesn't actually pass traffic, you lose the connection you're using to
fix it.
**What failed**: doing the port swap via raw `ip link set nic0 nomaster`
+ `ip link set nic0 down` (with `wlp3s0` already added as a second bridge
member) caused an extended outage that needed a physical power-cycle to
recover — `nic0` never came back up on its own even well past a 60s
auto-revert watchdog's deadline. Importantly, the *host itself* never
crashed — `journalctl -b -1` showed completely normal operation (a
`pvestatd` polling loop, no gaps, no panic/OOM/watchdog trigger) right up
until a keyboard was plugged in and it was manually reset. So the failure
was specifically in the live network transition, not the OS — root cause
unconfirmed, but plausibly bridge FDB/MAC-identity handling when a port
carrying an already-live IP is swapped out via raw `ip link` rather than
a coordinated reconfiguration.
**What worked**: writing the final state into `/etc/network/interfaces`
and applying with `ifreload -a` (Proxmox's own supported hot-reload path)
instead. Same end state, same risk window, but ifupdown2 evidently
sequences the transition in a way raw `ip link` surgery didn't. This
succeeded on the first retry using this method.
**Either way, expect ~30-60 seconds of apparent breakage even on a
successful change** — the upstream switch/AP needs to relearn which port
`192.168.2.251`'s MAC now lives behind. Don't judge success/failure
before that window passes; a `ping`/SSH failure at the 5-10s mark is not
yet a sign anything is wrong.
**Recommended safety net for any future live change here**: arm a
backgrounded auto-revert-on-timeout before applying, e.g.:
```bash
cp /etc/network/interfaces /etc/network/interfaces.bak
cat > /root/revert.sh <<'EOF'
#!/bin/bash
cp /etc/network/interfaces.bak /etc/network/interfaces
ifreload -a
EOF
chmod +x /root/revert.sh
setsid nohup bash -c "sleep 45 && /root/revert.sh" >/root/revert.log 2>&1 < /dev/null &
disown
# now apply the real change, e.g.: ifreload -a
# once confirmed working: pkill -f "sleep 45 && /root/revert.sh"
```
This only self-heals if the box stays responsive enough for the
backgrounded job to run to completion — it is not a substitute for
physical/console access being available, just a way to avoid needing it
for the common case.
## Troubleshooting: "can't ping it, web UI won't load" isn't necessarily an outage
Two independent things can make `pve-test` look dead when it isn't:
1. **Stale ARP after any change to `vmbr0`'s active port.** `vmbr0`'s MAC
address follows whichever interface is currently active in `bond0`
(`wlp3s0`'s MAC while wifi is primary, `nic0`'s if it fails over) — it
is *not* fixed. Other devices on the LAN (including your own
workstation) that cached the old MAC before a change won't notice
until their ARP entry naturally expires or gets flushed
(`ip neigh flush <ip>` / `arp -d <ip>`). Symptom: intermittent or
totally dead connectivity from one specific device while others (or
the host itself) are fine.
2. **ICMP ping is blocked by the management firewall, unrelated to
network health.** See `04-security-hardening.md`'s firewall section —
`cluster.fw`'s `policy_in: DROP` only explicitly allows TCP 22/8006
(now also ICMP echo-request, after this was hit) from the mgmt
network. Before that rule was added, `ping` failed 100% *even with a
perfectly correct, fresh ARP entry and full SSH/web UI access* —
nothing to do with wifi, bonding, or the network being down. This is
exactly what happened once: alarming "can't ping, web UI not loading"
turned out to be a stale-ARP moment (which resolved itself) plus a
pre-existing firewall policy (ping was never going to work,
regardless of wifi).
**When `pve-test` seems unreachable, check in this order before assuming
a real outage**: SSH (`ssh root@pve-test.sweet.home`) → web UI via `curl
-sk -o /dev/null -w '%{http_code}' https://pve-test.sweet.home:8006/`
only then worry about `ping` specifically, and check
`/etc/pve/firewall/cluster.fw` before blaming the network.
## Known limitation history
`nic0` (the bond's backup slave) initially had no cable physically
connected when this was first set up — the bond was correctly configured
but inert until a cable was plugged in. As of this writing a cable is
connected and the backup slave is live (confirm with `cat
/proc/net/bonding/bond0`) — if reproducing this setup, verify the same
before assuming LAN failover will actually work.
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# Read-only Stage 1 base-hardening audit. Checks the current state of a PVE
# host against the checklist in docs/04-security-hardening.md and prints
# PASS/FAIL per item. Exits non-zero if anything fails, so it can gate CI or
# be run periodically as a compliance check. Makes no changes.
#
# Usage: ./audit.sh (run as root on the PVE host)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
AUDIT_FAIL=0
# --- apt repos: no enabled enterprise source ---
ENTERPRISE_ENABLED=0
for f in /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do
[ -f "$f" ] || continue
grep -qi 'enterprise.proxmox.com' "$f" 2>/dev/null && ENTERPRISE_ENABLED=1
done
if [ "$ENTERPRISE_ENABLED" -eq 0 ]; then
audit_pass "no enabled enterprise apt repo"
else
audit_fail "an enterprise apt repo is still enabled (needs a subscription to update)"
fi
# --- SSH ---
SSHD_T="$(sshd -T 2>/dev/null)"
if echo "$SSHD_T" | grep -qiE '^permitrootlogin (prohibit-password|without-password)'; then
audit_pass "sshd: PermitRootLogin prohibit-password (key-only)"
else
audit_fail "sshd: PermitRootLogin is not key-only (prohibit-password/without-password)"
fi
if echo "$SSHD_T" | grep -qi '^passwordauthentication no'; then
audit_pass "sshd: PasswordAuthentication no"
else
audit_fail "sshd: PasswordAuthentication is not disabled"
fi
# --- fail2ban ---
if systemctl is-active --quiet fail2ban 2>/dev/null; then
audit_pass "fail2ban is active"
else
audit_fail "fail2ban is not active"
fi
# --- PVE firewall ---
FW_STATUS="$(pve-firewall status 2>/dev/null || true)"
if echo "$FW_STATUS" | grep -qi '^Status: enabled'; then
audit_pass "pve-firewall is enabled"
else
audit_fail "pve-firewall is not enabled (status: ${FW_STATUS:-unknown})"
fi
if [ -f /etc/pve/firewall/cluster.fw ] && grep -qi '^policy_in:\s*DROP' /etc/pve/firewall/cluster.fw 2>/dev/null; then
audit_pass "cluster.fw has default-deny inbound policy"
else
audit_fail "cluster.fw missing or does not default-deny inbound"
fi
# --- unattended-upgrades ---
if dpkg -s unattended-upgrades >/dev/null 2>&1 && systemctl is-enabled --quiet unattended-upgrades 2>/dev/null; then
audit_pass "unattended-upgrades installed and enabled"
else
audit_fail "unattended-upgrades not installed/enabled"
fi
if [ -f /var/run/reboot-required ]; then
audit_warn "a reboot is pending (/var/run/reboot-required) - schedule one"
fi
# --- Linux admin user with SSH key (for non-root SSH login) ---
LINUX_ADMIN_OK=0
for auth_file in /home/*/.ssh/authorized_keys; do
[ -f "$auth_file" ] || continue
# Must have at least one non-comment, non-empty key line.
if grep -qE '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) ' "$auth_file" 2>/dev/null; then
LINUX_ADMIN_OK=1
break
fi
done
if [ "$LINUX_ADMIN_OK" -eq 1 ]; then
audit_pass "a non-root Linux user has an SSH authorized key"
else
audit_fail "no non-root Linux user has an authorized SSH key (run setup-linux-admin-user.sh)"
fi
# --- named admin user (not just root@pam) ---
if pveum user list --output-format json 2>/dev/null | grep -q '"userid":"[^"]*@pve"'; then
audit_pass "a named @pve admin user exists (root@pam is not the only account)"
else
audit_fail "no named @pve user found - root@pam is the only account"
fi
# --- passwordless sudo for pvesh/qm/pct ---
# The nixos flake's create-proxmox-resource.sh runs pvesh/qm/pct over
# non-interactive SSH, so the admin user needs NOPASSWD for these tools.
SUDO_OK=0
for f in /etc/sudoers.d/*-proxmox; do
[ -f "$f" ] || continue
if grep -qE 'NOPASSWD:.*pvesh' "$f" && grep -qE 'NOPASSWD:.*\bqm\b' "$f" && grep -qE 'NOPASSWD:.*\bpct\b' "$f"; then
SUDO_OK=1
break
fi
done
if [ "$SUDO_OK" -eq 1 ]; then
audit_pass "admin user has NOPASSWD sudo for pvesh/qm/pct"
else
audit_fail "no sudoers file grants NOPASSWD for pvesh/qm/pct (run setup-admin-sudo.sh <username>)"
fi
# --- time sync ---
if timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qx 'yes'; then
audit_pass "clock is NTP-synchronized"
else
audit_fail "clock is not NTP-synchronized"
fi
# --- subscription nag (cosmetic - warn only, never fails the audit) ---
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
if [ -f "$JS_FILE" ] && ! grep -qF "data.status.toLowerCase() !== 'active'" "$JS_FILE"; then
audit_pass "subscription nag patch applied"
else
audit_warn "subscription nag patch not applied (cosmetic only, see scripts/disable-subscription-nag.sh)"
fi
echo
if [ "$AUDIT_FAIL" -eq 0 ]; then
echo "All Stage 1 base-hardening checks passed."
else
echo "One or more checks failed - see FAIL lines above."
fi
exit "$AUDIT_FAIL"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Stage 1 base config + hardening, end to end, for a single fresh PVE host.
# Runs the individual scripts in order. Idempotent - safe to re-run.
#
# If ADMIN_USER and ADMIN_SSH_KEY are set, a Linux system user is created
# with SSH key access and sudo before SSH hardening runs - so key-based
# login is in place before password auth is disabled. If they are not set,
# a reminder is printed at the end to run setup-linux-admin-user.sh manually
# (but do this BEFORE disconnecting, since password auth will be disabled).
#
# Usage:
# MGMT_CIDR=192.168.2.0/24 ./bootstrap.sh
# MGMT_CIDR=192.168.2.0/24 ADMIN_USER=wayne ADMIN_SSH_KEY="ssh-ed25519 ..." ./bootstrap.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if [ -z "${MGMT_CIDR:-}" ]; then
echo "MGMT_CIDR is not set. Example: MGMT_CIDR=192.168.2.0/24 $0" >&2
exit 1
fi
STEP=0
next_step() { STEP=$((STEP + 1)); echo; echo "=== ${STEP}: $* ==="; }
next_step "remove enterprise repos, switch to no-subscription"
"${SCRIPT_DIR}/switch-to-no-subscription-repo.sh"
# Create the Linux admin user before SSH hardening so that authorized_keys
# is in place before password auth is disabled.
if [ -n "${ADMIN_USER:-}" ] && [ -n "${ADMIN_SSH_KEY:-}" ]; then
next_step "Linux admin user '${ADMIN_USER}' + SSH key + sudo group"
"${SCRIPT_DIR}/setup-linux-admin-user.sh" "$ADMIN_USER" "$ADMIN_SSH_KEY"
next_step "passwordless sudo for pvesh/qm/pct (${ADMIN_USER})"
"${SCRIPT_DIR}/setup-admin-sudo.sh" "$ADMIN_USER"
else
echo
echo "WARNING: ADMIN_USER / ADMIN_SSH_KEY not set -- skipping Linux user setup."
echo " Run setup-linux-admin-user.sh and setup-admin-sudo.sh BEFORE disconnecting"
echo " from this session, since the next step disables password authentication."
fi
next_step "SSH hardening (key-only root login + fail2ban)"
"${SCRIPT_DIR}/harden-ssh.sh"
next_step "unattended security upgrades"
"${SCRIPT_DIR}/setup-unattended-upgrades.sh"
next_step "PVE firewall (mgmt-only SSH/8006)"
MGMT_CIDR="$MGMT_CIDR" "${SCRIPT_DIR}/deploy-firewall.sh"
next_step "disable subscription nag (cosmetic)"
"${SCRIPT_DIR}/disable-subscription-nag.sh"
echo
echo "=== Base hardening applied. Remaining manual/deliberate steps: ==="
if [ -z "${ADMIN_USER:-}" ]; then
echo " - ${SCRIPT_DIR}/setup-linux-admin-user.sh <username> <ssh-pubkey>"
echo " - ${SCRIPT_DIR}/setup-admin-sudo.sh <username> (NOPASSWD for pvesh/qm/pct)"
fi
echo " - ${SCRIPT_DIR}/create-admin-user.sh <username> (PVE web UI account)"
echo " - Enable 2FA/TOTP for that user and root@pam via the web UI"
echo " - ${SCRIPT_DIR}/audit.sh (verify everything above)"
echo
echo " If this host will be enrolled in FreeIPA:"
echo " ipa-client-install --domain=sweet.home --realm=SWEET.HOME \\"
echo " --server=domain-controller.sweet.home --mkhomedir --ssh-trust-dns --no-ntp"
echo " ${SCRIPT_DIR}/setup-ipa-sudo.sh (NOPASSWD sudo for IPA admins group)"
echo " ${SCRIPT_DIR}/create-local-backdoor.sh <ssh-pubkey> (emergency local account)"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Create a named PVE admin user (Administrator role) so root@pam can be
# reserved for emergencies. Generates a random initial password, printed
# once - change it and enable TOTP on first login (Datacenter -> Permissions
# -> Two Factor, or the user icon menu in the top right).
#
# Idempotent - if the user already exists, does nothing (won't reset an
# existing password). Run as root on the PVE host.
#
# Usage: ./create-admin-user.sh <username> (realm is always @pve)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
USERNAME="${1:-}"
if [ -z "$USERNAME" ]; then
echo "Usage: $0 <username>" >&2
exit 1
fi
USERID="${USERNAME}@pve"
if pveum user list --output-format json 2>/dev/null | grep -q "\"${USERID}\""; then
echo "${USERID} already exists - not touching password or role. Skipping."
exit 0
fi
PASSWORD="$(openssl rand -base64 24)"
pveum user add "$USERID" --password "$PASSWORD" --comment "Named admin account, created by create-admin-user.sh"
pveum acl modify / --users "$USERID" --roles Administrator
echo
echo "Created ${USERID} with the Administrator role."
echo "Initial password (shown once - not logged anywhere): ${PASSWORD}"
echo
echo "Next steps (do these before relying on this account):"
echo " 1. Log in as ${USERID} and change the password."
echo " 2. Enable TOTP/2FA for ${USERID} (and for root@pam)."
echo " 3. Reserve root@pam for emergencies only from here on."
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# Create a local 'pveadmin' account as an emergency backdoor for when
# IPA/SSSD is unavailable. The account authenticates by SSH key only
# (password auth is disabled by harden-ssh.sh); the password set here
# is for physical console access only.
#
# Idempotent - safe to re-run. If the account already exists, the SSH key
# is refreshed but the password and account are left unchanged. Run as root.
#
# Usage:
# ./create-local-backdoor.sh <ssh-public-key>
# ./create-local-backdoor.sh --key-file <path-to-.pub>
#
# Set BACKDOOR_PASS env var to supply the console password non-interactively;
# otherwise you will be prompted.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
USERNAME="pveadmin"
SSH_KEY=""
if [ "${1:-}" = "--key-file" ]; then
KEY_FILE="${2:-}"
[ -z "$KEY_FILE" ] && { echo "ERROR: --key-file requires a path." >&2; exit 1; }
[ -f "$KEY_FILE" ] || { echo "ERROR: key file not found: $KEY_FILE" >&2; exit 1; }
SSH_KEY="$(cat "$KEY_FILE")"
else
SSH_KEY="${1:-}"
fi
if [ -z "$SSH_KEY" ]; then
echo "Usage: $0 <ssh-public-key>" >&2
echo " $0 --key-file <path-to-.pub>" >&2
exit 1
fi
if ! echo "$SSH_KEY" | grep -qE '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) [A-Za-z0-9+/=]'; then
echo "ERROR: argument doesn't look like a valid SSH public key." >&2
exit 1
fi
if id "$USERNAME" >/dev/null 2>&1; then
echo "User '${USERNAME}' already exists -- not modifying account or password."
else
useradd --create-home --shell /bin/bash "$USERNAME"
echo "Created Linux user '${USERNAME}'."
if [ -n "${BACKDOOR_PASS:-}" ]; then
printf '%s:%s\n' "$USERNAME" "$BACKDOOR_PASS" | chpasswd
echo "Console password set."
else
echo "Set a console password for '${USERNAME}' (used for physical console access only):"
passwd "$USERNAME"
fi
fi
HOME_DIR="$(getent passwd "$USERNAME" | cut -d: -f6)"
SSH_DIR="${HOME_DIR}/.ssh"
AUTH_FILE="${SSH_DIR}/authorized_keys"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
chown "${USERNAME}:${USERNAME}" "$SSH_DIR"
if grep -qF "$SSH_KEY" "$AUTH_FILE" 2>/dev/null; then
echo "SSH key already present in ${AUTH_FILE}."
else
printf '%s\n' "$SSH_KEY" >> "$AUTH_FILE"
echo "Installed SSH key in ${AUTH_FILE}."
fi
chmod 600 "$AUTH_FILE"
chown "${USERNAME}:${USERNAME}" "$AUTH_FILE"
SUDOERS_FILE="/etc/sudoers.d/${USERNAME}-nopasswd"
write_if_changed "$SUDOERS_FILE" "${USERNAME} ALL=(root) NOPASSWD: ALL"
chmod 0440 "$SUDOERS_FILE"
visudo -c >/dev/null
echo "Sudoers rule for '${USERNAME}' is valid."
echo
echo "'${USERNAME}' is ready: SSH key login, NOPASSWD sudo, console password set."
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Deploy the Proxmox datacenter-level firewall from
# config/pve-firewall/cluster.fw.example, with the management CIDR filled
# in, and enable it. Default-deny inbound; allow SSH/8006 from mgmt only.
#
# Idempotent - safe to re-run. Run as root on the PVE host.
#
# Usage: MGMT_CIDR=192.168.2.0/24 ./deploy-firewall.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if [ -z "${MGMT_CIDR:-}" ]; then
echo "MGMT_CIDR is not set. Example: MGMT_CIDR=192.168.2.0/24 $0" >&2
exit 1
fi
if ! [[ "$MGMT_CIDR" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "MGMT_CIDR '${MGMT_CIDR}' doesn't look like a CIDR (e.g. 192.168.2.0/24)." >&2
exit 1
fi
TEMPLATE="${SCRIPT_DIR}/../config/pve-firewall/cluster.fw.example"
if [ ! -f "$TEMPLATE" ]; then
echo "Template not found: $TEMPLATE" >&2
exit 1
fi
# Corosync/Ceph rules stay commented placeholders until Stage 2 (cluster);
# only the mgmt IPSET is real for a single Stage 1 node.
mkdir -p /etc/pve/firewall
write_if_changed "/etc/pve/firewall/cluster.fw" "$(sed "s|<MGMT_CIDR>|${MGMT_CIDR}|" "$TEMPLATE")"
echo "Validating ruleset..."
pve-firewall compile
echo "Restarting pve-firewall..."
pve-firewall restart
sleep 1
pve-firewall status
echo
echo "Firewall enabled. SSH (22) and the web UI (8006) are now only reachable"
echo "from ${MGMT_CIDR}. If your current SSH session is NOT from that range,"
echo "reconnect and verify access before closing this session."
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Install the subscription-nag patch (lib/nag-patch.sh) as a persistent
# standalone script under /usr/local/sbin, plus an apt Post-Invoke hook that
# re-applies it after every dpkg run - a proxmox-widget-toolkit package
# upgrade overwrites the patched file, so without the hook the patch would
# silently revert on the next `apt upgrade`.
#
# Idempotent - safe to re-run. Run as root on the PVE host.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
INSTALLED="/usr/local/sbin/pve-disable-subscription-nag.sh"
write_if_changed "$INSTALLED" "$(cat "${SCRIPT_DIR}/lib/nag-patch.sh")"
chmod +x "$INSTALLED"
HOOK="/etc/apt/apt.conf.d/85pve-nosubnag"
write_if_changed "$HOOK" 'DPkg::Post-Invoke { "test -x /usr/local/sbin/pve-disable-subscription-nag.sh && /usr/local/sbin/pve-disable-subscription-nag.sh || true"; };'
"$INSTALLED"
echo "Subscription nag patch installed; will reapply automatically after updates."
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Apply baseline SSH hardening to a Proxmox VE node: key-only root login
# + fail2ban. Idempotent - safe to re-run. Run as root on the PVE host.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if ! authorized_keys_present=$(find /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys -type f 2>/dev/null | head -n1); then
authorized_keys_present=""
fi
if [ -z "$authorized_keys_present" ]; then
echo "WARNING: no authorized_keys found for any user yet." >&2
echo "Add your SSH public key before disconnecting, or you'll lock yourself out." >&2
fi
mkdir -p /etc/ssh/sshd_config.d
write_if_changed "/etc/ssh/sshd_config.d/99-hardening.conf" "PermitRootLogin prohibit-password
PasswordAuthentication no"
sshd -t
systemctl reload sshd
echo "sshd reloaded with key-only root login."
if ! dpkg -s fail2ban >/dev/null 2>&1; then
apt-get update
apt-get install -y fail2ban
fi
mkdir -p /etc/fail2ban/jail.d
write_if_changed "/etc/fail2ban/jail.d/sshd.local" "[sshd]
enabled = true
port = ssh
backend = systemd
maxretry = 5
bantime = 1h
findtime = 10m"
systemctl enable --now fail2ban
systemctl restart fail2ban
echo "fail2ban enabled for sshd."
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Shared helpers for proxmox-configuration scripts. Sourced, not executed
# directly:
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "${SCRIPT_DIR}/lib/common.sh"
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "Must run as root." >&2
exit 1
fi
}
# Codename of the running Debian/PVE release, e.g. "trixie".
pve_codename() {
(. /etc/os-release && echo "$VERSION_CODENAME")
}
# backup_file <path>
# Copies an existing file to <path>.bak.<epoch>. No-op if it doesn't exist.
backup_file() {
local path="$1"
if [ -f "$path" ]; then
cp "$path" "${path}.bak.$(date +%s)"
echo "Backed up ${path}"
fi
}
# write_if_changed <path> <content>
# Writes content to path only if it differs from what's already there,
# backing up the previous version first. Prints what happened.
write_if_changed() {
local path="$1" content="$2"
if [ -f "$path" ] && [ "$(cat "$path")" = "$content" ]; then
echo "Already up to date: $path"
return 0
fi
backup_file "$path"
printf '%s\n' "$content" > "$path"
echo "Wrote $path"
}
# --- audit.sh status helpers ---
# Callers should initialize: AUDIT_FAIL=0
audit_pass() { echo "PASS $1"; }
audit_fail() { echo "FAIL $1"; AUDIT_FAIL=1; }
audit_warn() { echo "WARN $1"; }
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Neutralizes the Proxmox "No valid subscription" nag (login popup and the
# dashboard subscription indicator) by patching proxmox-widget-toolkit's
# proxmoxlib.js. Cosmetic only - doesn't create or spoof a subscription
# anywhere except this UI check.
#
# This file is not run from the repo directly - disable-subscription-nag.sh
# installs a copy of it to /usr/local/sbin and wires it into an apt
# Post-Invoke hook, because a proxmox-widget-toolkit package upgrade
# overwrites proxmoxlib.js and reverts the patch. Idempotent: exits quietly
# if already patched or if the file isn't present.
set -euo pipefail
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
[ -f "$JS_FILE" ] || exit 0
PATTERN="data.status.toLowerCase() !== 'active'"
grep -qF "$PATTERN" "$JS_FILE" || exit 0
cp "$JS_FILE" "${JS_FILE}.bak.$(date +%s)"
sed -i "s/${PATTERN}/false/g" "$JS_FILE"
echo "Patched subscription nag in $JS_FILE"
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# Grant a named admin user passwordless sudo for Proxmox management tools
# (pvesh, qm, pct) and the Nix package manager so that scripts in the
# nixos flake repo can run these over non-interactive SSH without a TTY.
#
# Nix is included because single-user Nix installations (common on PVE
# hosts bootstrapped via codex-setup.sh) are owned by root; non-root
# users can't touch the Nix store lock without sudo.
#
# Idempotent - safe to re-run (rewrites if paths have changed). Run as
# root on the PVE host.
#
# Usage: ./setup-admin-sudo.sh <username>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
USERNAME="${1:-}"
if [ -z "$USERNAME" ]; then
echo "Usage: $0 <username>" >&2
exit 1
fi
# Resolve actual binary paths at script time -- they differ across Proxmox
# versions (pvesh moved from /usr/sbin to /usr/bin in PVE 8.x) and the
# sudoers rule must match the real path or sudo will fall back to
# prompting for a password.
resolve_bin() {
command -v "$1" 2>/dev/null || { echo "ERROR: $1 not found on PATH" >&2; exit 1; }
}
PVESH="$(resolve_bin pvesh)"
QM="$(resolve_bin qm)"
PCT="$(resolve_bin pct)"
# Nix installs to a fixed path regardless of which user bootstrapped it.
NIX_BIN="/nix/var/nix/profiles/default/bin/nix"
if [ ! -x "$NIX_BIN" ]; then
echo "WARNING: $NIX_BIN not found -- Nix may not be installed yet." >&2
echo " Re-run this script after running codex-setup.sh on the node." >&2
NIX_BIN=""
fi
SUDOERS_FILE="/etc/sudoers.d/${USERNAME}-proxmox"
NIX_ENTRY="${NIX_BIN:+, ${NIX_BIN}}"
CONTENT="${USERNAME} ALL=(root) NOPASSWD: ${PVESH}, ${QM}, ${PCT}${NIX_ENTRY}"
write_if_changed "$SUDOERS_FILE" "$CONTENT"
# visudo -c validates the file we just wrote before we walk away.
if visudo -c -f "$SUDOERS_FILE" >/dev/null 2>&1; then
chmod 0440 "$SUDOERS_FILE"
echo "Sudoers rule for ${USERNAME} is valid and in place."
echo " ${CONTENT}"
else
echo "ERROR: sudoers validation failed -- removing bad file." >&2
rm -f "$SUDOERS_FILE"
exit 1
fi
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Grant the IPA 'admins' group passwordless sudo on this host.
#
# Writes two files:
# /etc/sudoers.d/admins-nopasswd -- NOPASSWD: ALL for general shell use
# /etc/sudoers.d/admins-proxmox -- NOPASSWD for pvesh/qm/pct (PVE only;
# skipped silently if those binaries
# aren't present, e.g. on PBS/PDM)
#
# Idempotent - safe to re-run. Run as root on the target host after
# ipa-client-install has been completed and SSSD is active.
#
# Usage: ./setup-ipa-sudo.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if [ ! -f /etc/ipa/default.conf ]; then
echo "ERROR: /etc/ipa/default.conf not found -- is this host enrolled in FreeIPA?" >&2
exit 1
fi
write_if_changed /etc/sudoers.d/admins-nopasswd '%admins ALL=(root) NOPASSWD: ALL'
chmod 0440 /etc/sudoers.d/admins-nopasswd
# PVE-specific tools -- pvesh moved from /usr/sbin to /usr/bin in PVE 8.x;
# resolve at script time so the path in the sudoers rule is always correct.
PVESH="$(command -v pvesh 2>/dev/null || true)"
QM="$(command -v qm 2>/dev/null || true)"
PCT="$(command -v pct 2>/dev/null || true)"
if [ -n "$PVESH" ] && [ -n "$QM" ] && [ -n "$PCT" ]; then
write_if_changed /etc/sudoers.d/admins-proxmox \
"%admins ALL=(root) NOPASSWD: ${PVESH}, ${QM}, ${PCT}"
chmod 0440 /etc/sudoers.d/admins-proxmox
echo "Proxmox tools found -- wrote admins-proxmox."
else
echo "pvesh/qm/pct not found -- skipping admins-proxmox (not a PVE host)."
fi
visudo -c >/dev/null
echo "All sudoers files valid. IPA admins group has sudo on this host."
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# Create a Linux system user for SSH access and sudo, and install their
# authorized SSH public key. Run this before harden-ssh.sh so that
# key-based access is in place before password authentication is disabled.
#
# Idempotent - safe to re-run. Run as root on the PVE host.
#
# Usage:
# ./setup-linux-admin-user.sh <username> <ssh-public-key>
# ./setup-linux-admin-user.sh <username> --key-file <path-to-.pub>
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
USERNAME="${1:-}"
if [ -z "$USERNAME" ]; then
echo "Usage: $0 <username> <ssh-public-key>" >&2
echo " $0 <username> --key-file <path-to-.pub>" >&2
exit 1
fi
shift
SSH_KEY=""
if [ "${1:-}" = "--key-file" ]; then
KEY_FILE="${2:-}"
[ -z "$KEY_FILE" ] && { echo "ERROR: --key-file requires a path." >&2; exit 1; }
[ -f "$KEY_FILE" ] || { echo "ERROR: key file not found: $KEY_FILE" >&2; exit 1; }
SSH_KEY="$(cat "$KEY_FILE")"
else
SSH_KEY="${1:-}"
fi
if [ -z "$SSH_KEY" ]; then
echo "ERROR: an SSH public key is required (key string or --key-file <path>)." >&2
exit 1
fi
if ! echo "$SSH_KEY" | grep -qE '^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) [A-Za-z0-9+/=]'; then
echo "ERROR: argument doesn't look like a valid SSH public key." >&2
exit 1
fi
# --- create Linux user if missing ---
if id "$USERNAME" >/dev/null 2>&1; then
echo "User '${USERNAME}' already exists - skipping useradd."
else
useradd --create-home --shell /bin/bash "$USERNAME"
echo "Created Linux user '${USERNAME}'."
fi
# --- sudo group membership ---
if id -nG "$USERNAME" | grep -qw sudo; then
echo "User '${USERNAME}' is already in the sudo group."
else
usermod --append --groups sudo "$USERNAME"
echo "Added '${USERNAME}' to the sudo group."
fi
# --- install authorized SSH key ---
HOME_DIR="$(getent passwd "$USERNAME" | cut -d: -f6)"
SSH_DIR="${HOME_DIR}/.ssh"
AUTH_FILE="${SSH_DIR}/authorized_keys"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
touch "$AUTH_FILE"
chmod 600 "$AUTH_FILE"
chown -R "${USERNAME}:${USERNAME}" "$SSH_DIR"
if grep -qF "$SSH_KEY" "$AUTH_FILE" 2>/dev/null; then
echo "SSH key is already present in ${AUTH_FILE}."
else
echo "$SSH_KEY" >> "$AUTH_FILE"
echo "Installed SSH key in ${AUTH_FILE}."
fi
echo
echo "Linux user '${USERNAME}' is ready for SSH key-based login with sudo access."
echo "Next: run setup-admin-sudo.sh ${USERNAME} to grant NOPASSWD for pvesh/qm/pct."
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Install and configure unattended-upgrades for security patches. Deliberately
# conservative for a hypervisor: security-only origins (Debian security +
# the active PVE repo), no automatic reboot ever - a flag file is left at
# /var/run/reboot-required for you to act on manually.
#
# Idempotent - safe to re-run. Run as root on the PVE host.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if ! dpkg -s unattended-upgrades >/dev/null 2>&1; then
apt-get update
apt-get install -y unattended-upgrades
fi
CODENAME="$(pve_codename)"
write_if_changed "/etc/apt/apt.conf.d/51pve-unattended-upgrades.conf" "// Managed by proxmox-configuration/scripts/setup-unattended-upgrades.sh
Unattended-Upgrade::Origins-Pattern {
\"origin=Debian,codename=${CODENAME},label=Debian-Security\";
\"origin=Debian,codename=${CODENAME}-security,label=Debian-Security\";
\"origin=Proxmox\";
};
// Never auto-reboot a hypervisor. Check /var/run/reboot-required manually
// (or via scripts/audit.sh) and reboot during a planned maintenance window.
Unattended-Upgrade::Automatic-Reboot \"false\";
// Don't remove packages automatically; review before doing so by hand.
Unattended-Upgrade::Remove-Unused-Dependencies \"false\";
Unattended-Upgrade::Remove-Unused-Kernel-Packages \"false\";"
write_if_changed "/etc/apt/apt.conf.d/20auto-upgrades" '// Managed by proxmox-configuration/scripts/setup-unattended-upgrades.sh
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";'
systemctl enable --now unattended-upgrades.service >/dev/null
echo "unattended-upgrades enabled (security-only origins, no auto-reboot)."
echo "Dry run:"
unattended-upgrade --dry-run --debug 2>&1 | tail -20
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# Reproduces pve-test's wifi-primary networking: vmbr0 bridged over a wifi
# NIC in 4addr (WDS) client-bridge mode, active-backup bonded with a wired
# NIC as an automatic LAN fallback. See docs/06-pve-test-wifi-network.md for
# why this exists and how it was validated. Idempotent - safe to re-run.
#
# Requires: a wifi NIC whose driver/AP both support 4addr mode (verify with
# docs/06-pve-test-wifi-network.md's isolated-namespace test *before*
# trusting this against a live management IP - a wifi NIC or AP that
# doesn't support 4addr will associate fine but silently drop bridged
# frames from any MAC other than the card's own).
#
# Usage (run as root on the target PVE host):
# WIFI_SSID="..." WIFI_PASSPHRASE="..." \
# MGMT_ADDR=192.168.2.251/24 MGMT_GATEWAY=192.168.2.254 \
# ./setup-wifi-bond-network.sh
#
# Optional overrides: WIFI_IFACE (default wlp3s0), LAN_IFACE (default nic0)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
WIFI_IFACE="${WIFI_IFACE:-wlp3s0}"
LAN_IFACE="${LAN_IFACE:-nic0}"
for var in WIFI_SSID WIFI_PASSPHRASE MGMT_ADDR MGMT_GATEWAY; do
if [ -z "${!var:-}" ]; then
echo "$var is not set. See usage in this script's header." >&2
exit 1
fi
done
if ! ip link show "$WIFI_IFACE" >/dev/null 2>&1; then
echo "No interface named $WIFI_IFACE on this host. Run 'ip -br link' and set WIFI_IFACE=..." >&2
exit 1
fi
if ! ip link show "$LAN_IFACE" >/dev/null 2>&1; then
echo "No interface named $LAN_IFACE on this host. Run 'ip -br link' and set LAN_IFACE=..." >&2
exit 1
fi
echo "=== 1/4: wifi tooling ==="
apt-get install -y iw wpasupplicant >/dev/null
echo "installed iw, wpasupplicant"
echo
echo "=== 2/4: wpa_supplicant config (SSID: $WIFI_SSID, iface: $WIFI_IFACE) ==="
WPA_CONF="/etc/wpa_supplicant/wpa_supplicant-${WIFI_IFACE}.conf"
wpa_passphrase "$WIFI_SSID" "$WIFI_PASSPHRASE" > "$WPA_CONF"
sed -i '/^\s*#psk=/d' "$WPA_CONF"
chmod 600 "$WPA_CONF"
echo "wrote $WPA_CONF (passphrase hashed, not stored in plaintext)"
echo
echo "=== 3/4: systemd unit to set 4addr mode + start wpa_supplicant ==="
UNIT="/etc/systemd/system/wpa-4addr-${WIFI_IFACE}.service"
write_if_changed "$UNIT" "[Unit]
Description=wpa_supplicant on ${WIFI_IFACE} with 4addr mode enabled
Before=network-pre.target
Wants=network-pre.target
[Service]
Type=simple
ExecStartPre=/sbin/ip link set ${WIFI_IFACE} down
ExecStartPre=/sbin/iw dev ${WIFI_IFACE} set 4addr on
ExecStartPre=/sbin/ip link set ${WIFI_IFACE} up
ExecStart=/sbin/wpa_supplicant -i ${WIFI_IFACE} -c ${WPA_CONF}
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target"
systemctl daemon-reload
systemctl enable --now "wpa-4addr-${WIFI_IFACE}.service"
sleep 5
if ! iw dev "$WIFI_IFACE" link | grep -q "^Connected"; then
echo "WARNING: ${WIFI_IFACE} did not associate to '$WIFI_SSID' within 5s - check:" >&2
echo " systemctl status wpa-4addr-${WIFI_IFACE}.service" >&2
echo " journalctl -u wpa-4addr-${WIFI_IFACE}.service" >&2
exit 1
fi
echo "associated: $(iw dev "$WIFI_IFACE" link | grep '^Connected')"
echo
echo "=== 4/4: /etc/network/interfaces (bond0 active-backup: ${WIFI_IFACE} primary, ${LAN_IFACE} backup) ==="
IFACES_FILE="/etc/network/interfaces"
backup_file "$IFACES_FILE"
cat > "$IFACES_FILE" <<EOF
auto lo
iface lo inet loopback
iface ${LAN_IFACE} inet manual
iface ${WIFI_IFACE} inet manual
auto bond0
iface bond0 inet manual
bond-slaves ${WIFI_IFACE} ${LAN_IFACE}
bond-mode active-backup
bond-miimon 100
bond-primary ${WIFI_IFACE}
bond-updelay 200
bond-downdelay 200
auto vmbr0
iface vmbr0 inet static
address ${MGMT_ADDR}
gateway ${MGMT_GATEWAY}
bridge-ports bond0
bridge-stp off
bridge-fd 0
source /etc/network/interfaces.d/*
EOF
echo "wrote $IFACES_FILE"
echo
echo "Config staged but NOT applied yet - applying it live can drop your"
echo "current management connection for ~30-60s while the switch/AP"
echo "relearns MAC locations (expected, self-resolves; see"
echo "docs/06-pve-test-wifi-network.md). Recommended: run this from the"
echo "physical console, or arm a revert-on-timeout watchdog first, e.g.:"
echo
echo " cp ${IFACES_FILE}.bak.* /tmp/interfaces.orig # pick the backup just made"
echo " (sleep 45 && cp /tmp/interfaces.orig ${IFACES_FILE} && ifreload -a) &"
echo " ifreload -a"
echo " # then kill the backgrounded revert job once you confirm connectivity"
echo
echo "Apply now with: ifreload -a"
echo "Verify after with: cat /proc/net/bonding/bond0 ; ip -4 -br addr show vmbr0"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Remove the Proxmox/Ceph enterprise apt sources (which fail on apt update
# without a paid subscription) and switch to the no-subscription repo.
# Handles both the legacy one-line .list format and the deb822 .sources
# format current PVE installers write.
# Idempotent - safe to re-run. Run as root on the PVE host.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
SOURCES_DIR="/etc/apt/sources.list.d"
# Any sources file pointing at the enterprise host gets moved out of apt's
# way entirely (renamed .disabled) rather than commented out in place -
# apt only reads *.sources/*.list, so this fully removes it from
# consideration while keeping a copy on disk for reference.
for f in "${SOURCES_DIR}"/*.sources "${SOURCES_DIR}"/*.list; do
[ -f "$f" ] || continue
grep -qi 'enterprise.proxmox.com' "$f" 2>/dev/null || continue
mv "$f" "${f}.disabled"
echo "Removed enterprise source (renamed to .disabled): $f"
done
write_if_changed "${SOURCES_DIR}/pve-no-subscription.sources" "Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: $(pve_codename)
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg"
# Clean up a legacy-format no-subscription file from a previous run of an
# older version of this script, to avoid two sources for the same repo.
LEGACY_NOSUB="${SOURCES_DIR}/pve-no-subscription.list"
if [ -f "$LEGACY_NOSUB" ]; then
rm -f "$LEGACY_NOSUB"
echo "Removed superseded $LEGACY_NOSUB"
fi
apt-get update
echo "Repo switched. Review 'apt list --upgradable' before upgrading."
+39
View File
@@ -0,0 +1,39 @@
# raspberrypi CLAUDE.md
Guardrails for Claude Code working on raspberrypi configuration.
## Host
`raspberrypi.tail13f623.ts.net` (100.86.56.87) — Raspberry Pi 4, Debian 12
bookworm (aarch64). Reachable from LAN via Tailscale MagicDNS.
## Production status
The Raspberry Pi is a **production host** running live services (Traefik,
Uptime Kuma, CrowdSec, Beszel agent). Treat it the same as pve1: read-only
inspection is always fine; any script that writes to the host requires
explicit same-session operator go-ahead.
## Bootstrap access
The local `raspi` account has NOPASSWD sudo and the nixos ED25519 key
authorized. Use it to bootstrap IPA sudo rules or make root-level changes
when `wayne` sudo is not yet working.
## IPA integration
- Enrolled in `SWEET.HOME` realm via `ipa-client-install`.
- SSSD resolves IPA groups: `admins (50000)`, `docker-access (50010)`.
- `%admins NOPASSWD:ALL` granted via `/etc/sudoers.d/ipa-admins`
(written by `scripts/setup-ipa-sudo.sh`).
## Docker GID
The local `docker` group GID is pinned to 50010 (`groupmod --non-unique`)
so it matches the IPA `docker-access` group. Members of `docker-access`
in IPA can run docker without any per-host group membership entry.
Applied by `scripts/setup-docker-ipa-gid.sh`.
## What must never be committed
SSH private keys, passwords, API tokens, Tailscale auth keys.
+52
View File
@@ -0,0 +1,52 @@
# raspberrypi
Configuration scripts for `raspberrypi.tail13f623.ts.net` — Raspberry Pi 4
running Debian 12 bookworm (aarch64). Reachable from LAN via Tailscale
MagicDNS (`tail13f623.ts.net`).
## Services
- **Traefik** — reverse proxy (ports 80, 443, 8080)
- **Uptime Kuma** — uptime monitoring
- **CrowdSec** — intrusion detection
- **Beszel agent** — metrics collection
## IPA enrollment
The Pi is enrolled in the `sweet.home` FreeIPA domain. SSSD resolves:
- `admins (GID 50000)` — sudo access
- `docker-access (GID 50010)` — docker socket access
## Setup scripts
Run these **as root** (or via `sudo`) after `ipa-client-install` completes.
### 1. IPA sudo
```bash
sudo ./scripts/setup-ipa-sudo.sh
```
Writes `/etc/sudoers.d/ipa-admins` granting `%admins NOPASSWD:ALL`. After
this, IPA users in the `admins` group can `sudo` without a password.
### 2. Docker GID
```bash
sudo ./scripts/setup-docker-ipa-gid.sh
```
Pins the local `docker` group GID to 50010 to match the IPA `docker-access`
group. Restarts `docker.socket` + `docker.service` to recreate the socket
with the new GID. After this, IPA members of `docker-access` can run docker
without any per-host group membership entry.
## Current status
| Item | Status |
|---|---|
| IPA enrollment | ✅ |
| SSH (wayne) | ✅ via `raspberrypi.tail13f623.ts.net` |
| Sudo (wayne, NOPASSWD) | ✅ `/etc/sudoers.d/ipa-admins` |
| Docker (wayne, via IPA group) | ✅ docker group GID = 50010 |
| Bootstrap sudo | ✅ local `raspi` user, NOPASSWD |
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Shared helpers for raspberrypi scripts. Sourced, not executed directly:
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "${SCRIPT_DIR}/lib/common.sh"
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "Must run as root." >&2
exit 1
fi
}
# backup_file <path>
# Copies an existing file to <path>.bak.<epoch>. No-op if it doesn't exist.
backup_file() {
local path="$1"
if [ -f "$path" ]; then
cp "$path" "${path}.bak.$(date +%s)"
echo "Backed up ${path}"
fi
}
# write_if_changed <path> <content>
# Writes content to path only if it differs from what's already there,
# backing up the previous version first. Prints what happened.
write_if_changed() {
local path="$1" content="$2"
if [ -f "$path" ] && [ "$(cat "$path")" = "$content" ]; then
echo "Already up to date: $path"
return 0
fi
backup_file "$path"
printf '%s\n' "$content" > "$path"
echo "Wrote $path"
}
@@ -0,0 +1,53 @@
#!/bin/bash
# Pin the local 'docker' group GID to match the IPA 'docker-access' group
# (GID 50010) so that IPA group membership alone grants docker socket access.
#
# On Debian, GID 50010 is already present via SSSD (the IPA group), so
# groupmod requires --non-unique to allow the local docker group to share
# it. The docker.socket + docker.service pair must be fully stopped before
# removing the stale socket so systemd recreates it with the new GID.
#
# Idempotent — exits 0 without touching anything if the GID is already 50010.
#
# Run as root after setup-ipa-sudo.sh has been applied and docker is running.
#
# Usage: sudo ./setup-docker-ipa-gid.sh
set -euo pipefail
DOCKER_ACCESS_GID=50010
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if ! command -v docker &>/dev/null; then
echo "ERROR: docker not found -- install Docker before running this script." >&2
exit 1
fi
current_gid=$(getent group docker | cut -d: -f3)
if [ "$current_gid" = "$DOCKER_ACCESS_GID" ]; then
echo "docker group is already GID ${DOCKER_ACCESS_GID} -- nothing to do."
exit 0
fi
echo "Changing docker group GID: ${current_gid} -> ${DOCKER_ACCESS_GID}"
# SSSD exposes GID 50010 via the IPA docker-access group, so groupmod sees
# it as already in use. --non-unique lets the local docker group share it.
groupmod --non-unique -g "${DOCKER_ACCESS_GID}" docker
echo "Restarting docker to recreate socket with new GID..."
systemctl stop docker.service docker.socket
rm -f /var/run/docker.sock
systemctl start docker.socket docker.service
actual_gid=$(stat -c '%g' /var/run/docker.sock)
if [ "$actual_gid" != "$DOCKER_ACCESS_GID" ]; then
echo "ERROR: socket GID is ${actual_gid}, expected ${DOCKER_ACCESS_GID}" >&2
exit 1
fi
echo "Done. Docker socket is now GID ${DOCKER_ACCESS_GID} (docker / docker-access)."
echo "IPA members of the 'docker-access' group can now run docker without explicit local group membership."
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Grant the IPA 'admins' group passwordless sudo on this Raspberry Pi.
#
# Writes /etc/sudoers.d/ipa-admins with NOPASSWD: ALL for the admins group.
# Idempotent — safe to re-run.
#
# Run as root (or via the local 'raspi' user's NOPASSWD sudo) after
# ipa-client-install has been completed and SSSD is active.
#
# Usage: sudo ./setup-ipa-sudo.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if [ ! -f /etc/ipa/default.conf ]; then
echo "ERROR: /etc/ipa/default.conf not found -- is this host enrolled in FreeIPA?" >&2
exit 1
fi
write_if_changed /etc/sudoers.d/ipa-admins '%admins ALL=(ALL) NOPASSWD:ALL'
chmod 0440 /etc/sudoers.d/ipa-admins
visudo -c >/dev/null
echo "Sudoers file valid. IPA admins group has NOPASSWD sudo on this host."
-45
View File
@@ -1,45 +0,0 @@
#!/bin/bash
# Stage 1 base config + hardening, end to end, for a single fresh PVE host.
# Runs the individual scripts in order. Idempotent - safe to re-run.
#
# Does NOT create the named admin user (needs a username decision) - run
# create-admin-user.sh separately afterwards. Run audit.sh at the end to
# verify.
#
# Usage: MGMT_CIDR=192.168.2.0/24 ./bootstrap.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_root
if [ -z "${MGMT_CIDR:-}" ]; then
echo "MGMT_CIDR is not set. Example: MGMT_CIDR=192.168.2.0/24 $0" >&2
exit 1
fi
echo "=== 1/5: remove enterprise repos, switch to no-subscription ==="
"${SCRIPT_DIR}/switch-to-no-subscription-repo.sh"
echo
echo "=== 2/5: SSH hardening (key-only root login + fail2ban) ==="
"${SCRIPT_DIR}/harden-ssh.sh"
echo
echo "=== 3/5: unattended security upgrades ==="
"${SCRIPT_DIR}/setup-unattended-upgrades.sh"
echo
echo "=== 4/5: PVE firewall (mgmt-only SSH/8006) ==="
MGMT_CIDR="$MGMT_CIDR" "${SCRIPT_DIR}/deploy-firewall.sh"
echo
echo "=== 5/5: disable subscription nag (cosmetic) ==="
"${SCRIPT_DIR}/disable-subscription-nag.sh"
echo
echo "=== Base hardening applied. Remaining manual/deliberate steps: ==="
echo " - ${SCRIPT_DIR}/create-admin-user.sh <username>"
echo " - Enable 2FA/TOTP for that user and root@pam via the web UI"
echo " - ${SCRIPT_DIR}/audit.sh (verify everything above)"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Scan the repo for secrets and sensitive config values.
# Runs via CI (GitHub/Gitea Actions) and locally as a pre-commit check.
#
# Usage: scripts/check-secrets.sh [--staged-only]
# --staged-only Only check files staged for commit (for pre-commit hook use)
#
# Requires gitleaks on PATH, or falls back to Docker if available.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
set -euo pipefail
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
STAGED_ONLY=false
FAILURES=0
for arg in "$@"; do
[[ "$arg" == "--staged-only" ]] && STAGED_ONLY=true
done
cd "$REPO_ROOT"
# ── Resolve gitleaks binary ────────────────────────────────────────────────────
if command -v gitleaks &>/dev/null; then
GITLEAKS="gitleaks"
elif command -v docker &>/dev/null; then
GITLEAKS="docker run --rm -v ${REPO_ROOT}:/repo zricethezav/gitleaks:latest"
# Adjust paths for docker context
REPO_ROOT="/repo"
else
echo "ERROR: gitleaks not found. Install it or ensure Docker is available." >&2
echo " https://github.com/gitleaks/gitleaks#installing" >&2
exit 1
fi
echo "=== Secret scan ==="
if [[ "$STAGED_ONLY" == "true" ]]; then
# Pre-commit mode: scan only staged content
echo "Mode: staged files only"
if ! $GITLEAKS protect --staged --config="${REPO_ROOT}/.gitleaks.toml" --source="${REPO_ROOT}" 2>&1; then
FAILURES=$((FAILURES + 1))
fi
else
# CI mode: scan full git history
echo "Mode: full git history"
if ! $GITLEAKS detect --config="${REPO_ROOT}/.gitleaks.toml" --source="${REPO_ROOT}" 2>&1; then
FAILURES=$((FAILURES + 1))
fi
fi
# ── Pi-hole specific checks ────────────────────────────────────────────────────
echo ""
echo "=== Pi-hole config checks ==="
PIHOLE_TOML="${REPO_ROOT}/pihole/config/pihole.toml"
if [[ -f "$PIHOLE_TOML" ]]; then
# Check that known sensitive fields are empty
for field in pwhash totp_secret app_pwhash; do
value=$(grep -E "^\s+${field}\s*=" "$PIHOLE_TOML" | sed 's/.*=\s*"\(.*\)".*/\1/' | tr -d '[:space:]' || true)
if [[ -n "$value" && "$value" != '""' ]]; then
echo "FAIL: pihole.toml contains a non-empty '${field}' — run pihole/sanitize-config.sh before committing" >&2
FAILURES=$((FAILURES + 1))
else
echo " OK: ${field} is empty"
fi
done
else
echo " (pihole/config/pihole.toml not present, skipping Pi-hole checks)"
fi
# ── Summary ────────────────────────────────────────────────────────────────────
echo ""
if [[ $FAILURES -gt 0 ]]; then
echo "FAILED: ${FAILURES} issue(s) found. Fix before committing." >&2
exit 1
else
echo "All checks passed."
fi
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Install git hooks that run the secret scan before every commit.
# Run once after cloning: bash scripts/install-hooks.sh
set -euo pipefail
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
HOOK="${REPO_ROOT}/.git/hooks/pre-commit"
cat > "$HOOK" << 'HOOK'
#!/usr/bin/env bash
exec "$(git rev-parse --show-toplevel)/scripts/check-secrets.sh" --staged-only
HOOK
chmod +x "$HOOK"
echo "Installed pre-commit hook → ${HOOK}"
echo "The secret scan will run automatically before every commit."