Archived
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
143eebcbf3 | ||
|
|
dc4eff97fa | ||
|
|
ba73420350 |
No files matched your search
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
File renamed without changes.
@@ -1,41 +0,0 @@
|
||||
# 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$''',
|
||||
]
|
||||
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,31 +16,24 @@ 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 | 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 |
|
||||
| 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 |
|
||||
|
||||
## 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,6 +181,45 @@ 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
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/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."
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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
@@ -1,51 +0,0 @@
|
||||
#!/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}/"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,67 +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.
|
||||
#
|
||||
# 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)"
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/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."
|
||||
@@ -58,6 +58,11 @@ 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
|
||||
@@ -69,22 +74,6 @@ 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)"
|
||||
@@ -92,23 +81,6 @@ 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
+45
@@ -0,0 +1,45 @@
|
||||
#!/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)"
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,17 +0,0 @@
|
||||
#!/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."
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user