Archived
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf44ffcde | ||
|
|
4aadef8e97 | ||
|
|
898b8239cc | ||
|
|
b269a5d616 | ||
|
|
2909db5d04 | ||
|
|
7a038b534f | ||
|
|
c832418aed | ||
|
|
2d5472a59d | ||
|
|
04d1e63f0a | ||
|
|
8c5b76cb15 |
No files matched your search
@@ -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,48 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for Claude Code working in this repo. These instructions
|
||||||
|
OVERRIDE any default behaviour and must be followed exactly.
|
||||||
|
|
||||||
|
## Repo purpose
|
||||||
|
|
||||||
|
Configuration toolsets and docs for Wayne's Debian-based LAN machines.
|
||||||
|
Two sections currently exist: `proxmox/` (Proxmox VE hosts) and
|
||||||
|
`pihole/` (Pi-hole DNS/DHCP). Each has its own README.
|
||||||
|
|
||||||
|
## Safety rules (apply everywhere in this repo)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Section-specific guidance
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Adding a new machine type
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Update the root `README.md` layout table and this file's
|
||||||
|
"Section-specific guidance" list when you do.
|
||||||
@@ -1,48 +1,46 @@
|
|||||||
# Proxmox Configuration
|
# debian-configuration
|
||||||
|
|
||||||
Base configuration and hardening toolset for Proxmox VE hosts, plus planning
|
Configuration, hardening toolsets, and operational docs for Wayne's
|
||||||
docs for eventually growing this into a 3-node HA/Ceph cluster. See
|
Debian-based LAN machines. Each subdirectory covers a different host or
|
||||||
`docs/00-overview.md` for the staging: **Stage 1** (base config/hardening,
|
service type.
|
||||||
applies to any host — active) vs. **Stage 2** (multi-node HA/Ceph — future,
|
|
||||||
deferred).
|
|
||||||
|
|
||||||
## Goals
|
## Layout
|
||||||
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## Repo layout
|
|
||||||
|
|
||||||
- `docs/` — planning docs: hardware layout, storage migration, networking,
|
|
||||||
security hardening. Read `docs/00-overview.md` first.
|
|
||||||
- `scripts/` — scripts to apply configuration on a node (SSH hardening, repo
|
|
||||||
switch, firewall, updates, 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/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
|
proxmox/ Proxmox VE hosts (pve1 production, pve-test sandbox)
|
||||||
./scripts/create-admin-user.sh <username>
|
pihole/ Pi-hole DNS/DHCP (config snapshots, pull/apply scripts)
|
||||||
# then enable 2FA for that user + root@pam via the web UI
|
scripts/ Repo-wide scripts (secret scanning, git hook installer)
|
||||||
./scripts/audit.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Status
|
## Sections
|
||||||
|
|
||||||
`pve1` built (ASUS PN53 mini PC, ZFS mirror boot+VM storage, single
|
### `proxmox/`
|
||||||
2.5GbE NIC) and already running production VMs/CTs. Stage 1 base
|
|
||||||
hardening applied and verified (`scripts/audit.sh` all green): enterprise
|
Base configuration and hardening toolset for Proxmox VE hosts. See
|
||||||
repos removed, SSH key-only + fail2ban, unattended security upgrades,
|
`proxmox/README.md` for goals, quick-start, and current status.
|
||||||
PVE firewall (mgmt-only), subscription nag disabled, named admin user
|
|
||||||
(`wayne@pve`) created. Remaining manual step: enable 2FA/TOTP for
|
### `pihole/`
|
||||||
`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.
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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`.
|
||||||
Executable
+60
@@ -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
Executable
+51
@@ -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}/"
|
||||||
Executable
+40
@@ -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
|
||||||
File renamed without changes.
@@ -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,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.
|
||||||
+7
@@ -26,6 +26,13 @@ policy_out: ACCEPT
|
|||||||
IN ACCEPT -source +mgmt -p tcp -dport 8006 -log nolog
|
IN ACCEPT -source +mgmt -p tcp -dport 8006 -log nolog
|
||||||
IN ACCEPT -source +mgmt -p tcp -dport 22 -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
|
||||||
|
|
||||||
# Stage 2: Corosync (cluster quorum) - uncomment once node 2/3 join and
|
# Stage 2: Corosync (cluster quorum) - uncomment once node 2/3 join and
|
||||||
# the corosync network/VLAN exists.
|
# the corosync network/VLAN exists.
|
||||||
# IN ACCEPT -source <COROSYNC_CIDR> -p udp -dport 5404:5405 -log nolog
|
# IN ACCEPT -source <COROSYNC_CIDR> -p udp -dport 5404:5405 -log nolog
|
||||||
@@ -59,6 +59,16 @@ require or assume the split-disk/multi-NIC layout Stage 2 wants.
|
|||||||
Everything below this point is the target design for when nodes 2 and 3
|
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.
|
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
|
### End goal
|
||||||
|
|
||||||
3-node Proxmox VE cluster with HA-managed VMs backed by **Ceph** — true
|
3-node Proxmox VE cluster with HA-managed VMs backed by **Ceph** — true
|
||||||
File renamed without changes.
File renamed without changes.
@@ -11,6 +11,17 @@ PVE datacenter firewall (default-deny inbound otherwise). That's
|
|||||||
sufficient until Stage 2 needs actual separate physical/VLAN paths for
|
sufficient until Stage 2 needs actual separate physical/VLAN paths for
|
||||||
corosync and Ceph traffic — see below.
|
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)
|
## Target design (Stage 2, future cluster)
|
||||||
|
|
||||||
## Required separation
|
## Required separation
|
||||||
@@ -16,24 +16,31 @@ Then run `scripts/audit.sh` to verify. Order matters (matches
|
|||||||
| # | Item | Script | Manual step required? |
|
| # | Item | Script | Manual step required? |
|
||||||
|---|------|--------|------------------------|
|
|---|------|--------|------------------------|
|
||||||
| 1 | Remove enterprise repos, switch to no-subscription | `switch-to-no-subscription-repo.sh` | no |
|
| 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) |
|
| 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 | Unattended security upgrades, no auto-reboot | `setup-unattended-upgrades.sh` | no |
|
| 3 | Passwordless sudo for pvesh/qm/pct | `setup-admin-sudo.sh <username>` | yes — same username as step 2 |
|
||||||
| 4 | PVE firewall, default-deny, mgmt-only SSH/8006 | `deploy-firewall.sh` | needs `MGMT_CIDR` set |
|
| 4 | SSH: key-only root login + fail2ban | `harden-ssh.sh` | no — step 2 ensures authorized_keys are in place first |
|
||||||
| 5 | Disable subscription nag (cosmetic) | `disable-subscription-nag.sh` | no |
|
| 5 | Unattended security upgrades, no auto-reboot | `setup-unattended-upgrades.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 |
|
| 6 | PVE firewall, default-deny, mgmt-only SSH/8006 | `deploy-firewall.sh` | needs `MGMT_CIDR` set |
|
||||||
| 7 | 2FA/TOTP on that user and `root@pam` | — | yes — web UI only: Datacenter → Permissions → Two Factor, or user menu → TFA |
|
| 7 | Disable subscription nag (cosmetic) | `disable-subscription-nag.sh` | no |
|
||||||
| 8 | Verify everything above | `audit.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
|
## 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
|
- `PermitRootLogin prohibit-password` in `sshd_config` — root can only
|
||||||
log in via SSH key, never password. Kills most brute-force attempts.
|
log in via SSH key, never password. Kills most brute-force attempts.
|
||||||
- fail2ban jail for SSH on top of that.
|
- fail2ban jail for SSH on top of that.
|
||||||
- Restrict SSH to the management VLAN/trusted IPs via the Proxmox
|
- Restrict SSH to the management VLAN/trusted IPs via the Proxmox
|
||||||
firewall (see `03-networking.md`) rather than exposing broadly.
|
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)
|
## PVE/web layer (the one that actually matters day-to-day)
|
||||||
|
|
||||||
@@ -54,6 +61,23 @@ Default-deny at datacenter/node level, whitelist only what's needed (see
|
|||||||
`config/pve-firewall/cluster.fw.example`, applied by
|
`config/pve-firewall/cluster.fw.example`, applied by
|
||||||
`scripts/deploy-firewall.sh`.
|
`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
|
## Repos and updates
|
||||||
|
|
||||||
Fresh installs point at the enterprise repo, which fails on `apt update`
|
Fresh installs point at the enterprise repo, which fails on `apt update`
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -69,6 +69,22 @@ if [ -f /var/run/reboot-required ]; then
|
|||||||
audit_warn "a reboot is pending (/var/run/reboot-required) - schedule one"
|
audit_warn "a reboot is pending (/var/run/reboot-required) - schedule one"
|
||||||
fi
|
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) ---
|
# --- named admin user (not just root@pam) ---
|
||||||
if pveum user list --output-format json 2>/dev/null | grep -q '"userid":"[^"]*@pve"'; then
|
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)"
|
audit_pass "a named @pve admin user exists (root@pam is not the only account)"
|
||||||
@@ -76,6 +92,23 @@ else
|
|||||||
audit_fail "no named @pve user found - root@pam is the only account"
|
audit_fail "no named @pve user found - root@pam is the only account"
|
||||||
fi
|
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 ---
|
# --- time sync ---
|
||||||
if timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qx 'yes'; then
|
if timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qx 'yes'; then
|
||||||
audit_pass "clock is NTP-synchronized"
|
audit_pass "clock is NTP-synchronized"
|
||||||
Executable
+67
@@ -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)"
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Executable
+61
@@ -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
|
||||||
Executable
+82
@@ -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."
|
||||||
File renamed without changes.
Executable
+133
@@ -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"
|
||||||
File renamed without changes.
@@ -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)"
|
|
||||||
Executable
+80
@@ -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
|
||||||
Executable
+17
@@ -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."
|
||||||
Reference in new issue
Block a user