Archived
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898b8239cc | ||
|
|
b269a5d616 | ||
|
|
2909db5d04 | ||
|
|
7a038b534f | ||
|
|
c832418aed | ||
|
|
2d5472a59d | ||
|
|
04d1e63f0a |
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$''',
|
||||
]
|
||||
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
|
||||
File renamed without changes.
@@ -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"
|
||||
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.
File renamed without changes.
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