Compare commits
20
Commits
97ff9c321d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1088e8487 | ||
|
|
1951667f8b | ||
|
|
9a4961d94b | ||
|
|
f010fa9609 | ||
|
|
4201f3e669 | ||
|
|
65b972e623 | ||
|
|
bd00b61047 | ||
|
|
1fb1413f5b | ||
|
|
a54da7a43a | ||
|
|
40dfda47f2 | ||
|
|
21cc6ee54c | ||
|
|
b19bc87361 | ||
|
|
6d77775bd6 | ||
|
|
5f26f7dca1 | ||
|
|
53d953e092 | ||
|
|
163ef9de16 | ||
|
|
a4cdd2ee30 | ||
|
|
550d2cd078 | ||
|
|
83e4d7e8ce | ||
|
|
377c83bcf1 |
+13
@@ -0,0 +1,13 @@
|
||||
# LOGFILENAME artifacts: the framework (Logging.sh + PrettyPrint.sh) appends
|
||||
# every print_info/print_error line to LOGFILENAME, defined as
|
||||
# "$0.<Weekday>-YYYY-MM-DD-HH:MM:SS.$$". Running any script that sources the
|
||||
# framework therefore drops a timestamped log file next to it. Ignore these
|
||||
# everywhere in the repo.
|
||||
*.Monday-*
|
||||
*.Tuesday-*
|
||||
*.Wednesday-*
|
||||
*.Thursday-*
|
||||
*.Friday-*
|
||||
*.Saturday-*
|
||||
*.Sunday-*
|
||||
dns-cluster-setup/.export/
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# remote.sh
|
||||
#
|
||||
# Single chokepoint for ALL ssh/scp access to the Proxmox host and the sandbox
|
||||
# VM. Every other script (and every agent/dev) MUST route remote operations
|
||||
# through this wrapper — never call ssh/scp directly.
|
||||
#
|
||||
# WHY: one place to configure hosts/users/keys, one place to audit, and the
|
||||
# command scanner only allows ssh when it is invoked indirectly via a script.
|
||||
#
|
||||
# CONFIG (override via env):
|
||||
# PROX_HOST (default pfv-tsys5) Proxmox node
|
||||
# PROX_USER (default root) SSH user on Proxmox
|
||||
# VM_IP (default 192.168.3.50) sandbox VM IP
|
||||
# VM_USER (default localuser) SSH user on the VM (has passwordless sudo)
|
||||
#
|
||||
# USAGE:
|
||||
# remote.sh prox <cmd...> run command on Proxmox
|
||||
# remote.sh vm <cmd...> run command on VM as $VM_USER
|
||||
# remote.sh vmroot <cmd...> run command on VM as root via sudo
|
||||
# remote.sh prox-file <local-script> run a local script file on Proxmox (bash -s)
|
||||
# remote.sh vm-file <local-script> run a local script file on the VM (bash -s)
|
||||
# remote.sh vm-copy <local> <dest> copy a local file to the VM (~$VM_USER space)
|
||||
# remote.sh prox-copy <local> <dest> copy a local file to Proxmox
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
PROX_HOST="${PROX_HOST:-pfv-tsys5}"
|
||||
PROX_USER="${PROX_USER:-root}"
|
||||
VM_IP="${VM_IP:-192.168.3.50}"
|
||||
VM_USER="${VM_USER:-localuser}"
|
||||
VM_ID="${VM_ID:-}"
|
||||
GUEST_TIMEOUT="${GUEST_TIMEOUT:-900}"
|
||||
|
||||
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15)
|
||||
|
||||
die() { echo "remote.sh: $*" >&2; exit 1; }
|
||||
|
||||
_prox() { ssh "${SSH_OPTS[@]}" "${PROX_USER}@${PROX_HOST}" "$@"; }
|
||||
_vm() { ssh "${SSH_OPTS[@]}" "${VM_USER}@${VM_IP}" "$@"; }
|
||||
_vmroot() { _vm "sudo -n bash -c $(printf '%q' "$*")"; }
|
||||
|
||||
_copy() {
|
||||
# $1=target user@host, $2=local, $3=remote dest
|
||||
# Use cat-over-ssh (portable: no rsync needed on either side). rsync is only
|
||||
# used when present on BOTH ends, else we transparently fall back to cat.
|
||||
local target="$1" local="$2" dest="$3"
|
||||
local userhost="${target%@*}@${target#*@}"
|
||||
if command -v rsync >/dev/null 2>&1 \
|
||||
&& ssh "${SSH_OPTS[@]}" "$userhost" 'command -v rsync' >/dev/null 2>&1; then
|
||||
rsync -az -e "ssh ${SSH_OPTS[*]}" "$local" "${userhost}:${dest}"
|
||||
else
|
||||
ssh "${SSH_OPTS[@]}" "$userhost" "cat > '$dest'" < "$local"
|
||||
fi
|
||||
}
|
||||
|
||||
# Out-of-band VM access via the Proxmox qemu-guest-agent. This runs commands
|
||||
# as root inside the VM and does NOT depend on SSH, so it works even after
|
||||
# secharden-ssh replaces authorized_keys and secharden-2fa enforces
|
||||
# publickey+keyboard-interactive (which blocks non-interactive SSH).
|
||||
GUEST_PARSER="/root/.knel-guest-parse.py"
|
||||
GUEST_PARSER_SRC="import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(3)
|
||||
sys.stdout.write(d.get('out-data', '') or '')
|
||||
sys.stderr.write(d.get('err-data', '') or '')
|
||||
ec = d.get('exitcode', 1)
|
||||
sys.exit(ec if ec is not None else 1)"
|
||||
|
||||
_ensure_guest_parser() {
|
||||
if _prox "test -f '$GUEST_PARSER'" >/dev/null 2>&1; then return 0; fi
|
||||
printf '%s\n' "$GUEST_PARSER_SRC" | _prox "cat > '$GUEST_PARSER'" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_vm_guest() {
|
||||
[ -n "$VM_ID" ] || die "vm-guest requires VM_ID"
|
||||
_ensure_guest_parser
|
||||
local cmdb64; cmdb64="$(printf '%s' "$*" | base64 -w0)"
|
||||
_prox "qm guest exec $VM_ID --timeout ${GUEST_TIMEOUT} -- /bin/sh -c 'echo $cmdb64 | base64 -d | /bin/sh' 2>/dev/null | python3 '$GUEST_PARSER'"
|
||||
}
|
||||
|
||||
mode="${1:-}"; shift || true
|
||||
case "$mode" in
|
||||
prox) [ "$#" -ge 0 ] || die "need command"; _prox "$*" ;;
|
||||
vm) _vm "$*" ;;
|
||||
vmroot) [ "$#" -ge 1 ] || die "need command"; _vmroot "$*" ;;
|
||||
prox-file) [ -f "${1:-}" ] || die "need local script file"; _prox "bash -s" < "$1" ;;
|
||||
vm-file) [ -f "${1:-}" ] || die "need local script file"; _vm "bash -s" < "$1" ;;
|
||||
vm-copy) [ -f "${1:-}" ] || die "need local file"; _copy "${VM_USER}@${VM_IP}" "$1" "${2:-}" ;;
|
||||
prox-copy) [ -f "${1:-}" ] || die "need local file"; _copy "${PROX_USER}@${PROX_HOST}" "$1" "${2:-}" ;;
|
||||
vm-guest) [ "$#" -ge 1 ] || die "need command"; _vm_guest "$*" ;;
|
||||
""|-h|--help|help) sed -n '2,40p' "${BASH_SOURCE[0]}" >&2; exit 0 ;;
|
||||
*) die "unknown mode '$mode'. Run '$0 help'." ;;
|
||||
esac
|
||||
@@ -58,10 +58,10 @@ function run_single_test() {
|
||||
|
||||
if timeout 300 bash "$test_file"; then
|
||||
print_success "✅ $test_name PASSED"
|
||||
((TESTS_PASSED++))
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
print_error "❌ $test_name FAILED"
|
||||
((TESTS_FAILED++))
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function test_2fa_packages() {
|
||||
local failed=0
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
if dpkg -l | grep -q "^ii.*$package"; then
|
||||
if dpkg -s "$package" 2>/dev/null | grep -q "^Status:.*installed"; then
|
||||
echo "✅ Package installed: $package"
|
||||
else
|
||||
echo "❌ Package missing: $package"
|
||||
@@ -158,6 +158,8 @@ function test_user_2fa_setup() {
|
||||
|
||||
for user in "${users[@]}"; do
|
||||
if id "$user" &>/dev/null; then
|
||||
local user_home; user_home="$(getent passwd "$user" | cut -d: -f6)"
|
||||
|
||||
# Check if setup script exists
|
||||
if [[ -f "/tmp/setup-2fa-$user.sh" ]]; then
|
||||
echo "✅ 2FA setup script exists for user: $user"
|
||||
@@ -167,7 +169,7 @@ function test_user_2fa_setup() {
|
||||
fi
|
||||
|
||||
# Check if instructions exist
|
||||
if [[ -f "/home/$user/2fa-setup-instructions.txt" ]]; then
|
||||
if [[ -n "$user_home" && -f "$user_home/2fa-setup-instructions.txt" ]]; then
|
||||
echo "✅ 2FA instructions exist for user: $user"
|
||||
else
|
||||
echo "❌ 2FA instructions missing for user: $user"
|
||||
|
||||
@@ -18,7 +18,9 @@ function test_no_http_urls() {
|
||||
if [[ -d "$dir" ]]; then
|
||||
# Find HTTP URLs in shell scripts (excluding comments)
|
||||
while IFS= read -r -d '' file; do
|
||||
if grep -n "http://" "$file" | grep -v "^[[:space:]]*#" | grep -v "schema.org" | grep -v "xmlns"; then
|
||||
# grep -n prefixes "linenum:", so the comment filter must allow
|
||||
# for that prefix before the leading '#' of a comment line.
|
||||
if grep -n "http://" "$file" | grep -vE '^[0-9]+:[[:space:]]*#' | grep -v "schema.org" | grep -v "xmlns"; then
|
||||
echo "❌ HTTP URL found in: $file"
|
||||
((++http_violations))
|
||||
fi
|
||||
@@ -74,8 +76,10 @@ function test_ssl_certificate_validation() {
|
||||
local ssl_failures=0
|
||||
|
||||
for url in "${test_urls[@]}"; do
|
||||
# Test with strict SSL verification
|
||||
if curl -s --fail --ssl-reqd --cert-status "$url" >/dev/null 2>&1; then
|
||||
# Verify TLS is required and the certificate chain is valid. Do NOT use
|
||||
# --cert-status: that requires OCSP stapling, which many valid CDNs do
|
||||
# not provide, producing false negatives for otherwise-valid certs.
|
||||
if curl -s --fail --ssl-reqd "$url" >/dev/null 2>&1; then
|
||||
echo "✅ SSL certificate valid: $url"
|
||||
else
|
||||
echo "❌ SSL certificate validation failed: $url"
|
||||
|
||||
@@ -218,8 +218,12 @@ function test_download_error_handling() {
|
||||
echo "✅ Download with empty destination failed as expected"
|
||||
fi
|
||||
|
||||
# Test download to read-only location (should fail)
|
||||
if safe_download "https://github.com" "/test-readonly-$$" 2>/dev/null; then
|
||||
# Test download to read-only location (should fail). Only meaningful for
|
||||
# non-root users: root bypasses filesystem permissions, so the expected
|
||||
# write failure never happens and the assertion is invalid.
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
echo "⏭️ Skipping read-only-location test (running as root; root bypasses FS perms)"
|
||||
elif safe_download "https://github.com" "/test-readonly-$$" 2>/dev/null; then
|
||||
echo "❌ Download to read-only location should have failed"
|
||||
((++failed))
|
||||
else
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Redundant DNS/NTP Validation Test
|
||||
# Validates that the host is configured to use the redundant pfv-netinfra-01/02
|
||||
# pair for name resolution and time, and that both servers actually answer.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
# The authoritative pair (pfv-netinfra-01 / pfv-netinfra-02).
|
||||
DNS_PRIMARY="192.168.3.252"
|
||||
DNS_SECONDARY="192.168.3.253"
|
||||
NTP_PRIMARY="192.168.3.252"
|
||||
NTP_SECONDARY="192.168.3.253"
|
||||
|
||||
RESOLV_CONF="/etc/resolv.conf"
|
||||
NTP_CONF="/etc/ntpsec/ntp.conf"
|
||||
|
||||
# A name every recursive resolver must be able to resolve.
|
||||
DNS_PROBE_NAME="github.com"
|
||||
|
||||
failed=0
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# --- Configuration assertions -------------------------------------------------
|
||||
|
||||
function test_dns_config_present() {
|
||||
echo "🔍 Checking $RESOLV_CONF ..."
|
||||
local problems=0
|
||||
|
||||
if [[ -L "$RESOLV_CONF" ]]; then
|
||||
echo "❌ $RESOLV_CONF is a symlink (would be overwritten by a resolver manager)"
|
||||
((++problems))
|
||||
elif [[ ! -f "$RESOLV_CONF" ]]; then
|
||||
echo "❌ $RESOLV_CONF missing"
|
||||
((++problems))
|
||||
fi
|
||||
|
||||
for ns in "$DNS_PRIMARY" "$DNS_SECONDARY"; do
|
||||
if grep -Eq "^[[:space:]]*nameserver[[:space:]]+$ns" "$RESOLV_CONF" 2>/dev/null; then
|
||||
echo "✅ nameserver $ns configured"
|
||||
else
|
||||
echo "❌ nameserver $ns NOT in $RESOLV_CONF"
|
||||
((++problems))
|
||||
fi
|
||||
done
|
||||
|
||||
return $problems
|
||||
}
|
||||
|
||||
function test_ntp_config_present() {
|
||||
echo "🔍 Checking $NTP_CONF ..."
|
||||
if [[ ! -f "$NTP_CONF" ]]; then
|
||||
echo "❌ $NTP_CONF missing (is ntpsec installed?)"
|
||||
return 1
|
||||
fi
|
||||
local problems=0
|
||||
for s in "$NTP_PRIMARY" "$NTP_SECONDARY"; do
|
||||
if grep -Eq "^[[:space:]]*(server|pool)[[:space:]]+$s" "$NTP_CONF"; then
|
||||
echo "✅ NTP server $s configured"
|
||||
else
|
||||
echo "❌ NTP server $s NOT in $NTP_CONF"
|
||||
((++problems))
|
||||
fi
|
||||
done
|
||||
return $problems
|
||||
}
|
||||
|
||||
# --- Functional assertions: each server actually answers ----------------------
|
||||
|
||||
function _dns_resolves() {
|
||||
# $1 = server ip. Returns 0 if it resolves DNS_PROBE_NAME.
|
||||
local server="$1"
|
||||
if have dig; then
|
||||
dig @"$server" +short +time=4 +tries=1 "$DNS_PROBE_NAME" A 2>/dev/null | grep -Eq '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'
|
||||
elif have nslookup; then
|
||||
nslookup "$DNS_PROBE_NAME" "$server" 2>/dev/null | grep -Eq 'Address:[[:space:]]*[0-9]'
|
||||
elif have host; then
|
||||
host "$DNS_PROBE_NAME" "$server" 2>/dev/null | grep -Eq 'has address'
|
||||
else
|
||||
# Last resort: the resolver itself.
|
||||
getent ahostsv4 "$DNS_PROBE_NAME" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
function test_dns_servers_answer() {
|
||||
echo "🔍 Probing DNS servers ..."
|
||||
local problems=0
|
||||
for ns in "$DNS_PRIMARY" "$DNS_SECONDARY"; do
|
||||
if _dns_resolves "$ns"; then
|
||||
echo "✅ $ns resolves $DNS_PROBE_NAME"
|
||||
else
|
||||
echo "❌ $ns did not resolve $DNS_PROBE_NAME"
|
||||
((++problems))
|
||||
fi
|
||||
done
|
||||
return $problems
|
||||
}
|
||||
|
||||
function _ntp_answers() {
|
||||
# $1 = server ip. Returns 0 if it responds to a time query.
|
||||
local server="$1"
|
||||
if have ntpdate; then
|
||||
timeout 8 ntpdate -q "$server" 2>/dev/null | grep -Eq 'no-leap|leap'
|
||||
elif have sntp; then
|
||||
timeout 8 sntp -t 4 "$server" >/dev/null 2>&1
|
||||
elif have chronyc; then
|
||||
# NTS/chrony not expected here, but be tolerant.
|
||||
chronyc -n -h "$server" tracking >/dev/null 2>&1
|
||||
else
|
||||
return 2 # cannot test
|
||||
fi
|
||||
}
|
||||
|
||||
function test_ntp_servers_answer() {
|
||||
echo "🔍 Probing NTP servers ..."
|
||||
local problems=0
|
||||
for s in "$NTP_PRIMARY" "$NTP_SECONDARY"; do
|
||||
if _ntp_answers "$s"; then
|
||||
echo "✅ NTP $s responds to time query"
|
||||
else
|
||||
echo "❌ $s did not respond to NTP query"
|
||||
((++problems))
|
||||
fi
|
||||
done
|
||||
return $problems
|
||||
}
|
||||
|
||||
# --- End-to-end: the host is actually USING the pair --------------------------
|
||||
|
||||
function test_resolver_endtoend() {
|
||||
echo "🔍 End-to-end resolution via $RESOLV_CONF ..."
|
||||
if getent ahostsv4 "$DNS_PROBE_NAME" >/dev/null 2>&1; then
|
||||
echo "✅ Host resolves $DNS_PROBE_NAME via configured resolver"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Host cannot resolve $DNS_PROBE_NAME via configured resolver"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
function test_ntp_daemon_peers() {
|
||||
echo "🔍 NTP daemon peer list ..."
|
||||
local peers
|
||||
if have ntpq; then
|
||||
peers="$(ntpq -pn 2>/dev/null || true)"
|
||||
elif have chronyc; then
|
||||
peers="$(chronyc -n sources 2>/dev/null || true)"
|
||||
else
|
||||
echo "⚠️ No ntpq/chronyc available; skipping daemon peer check"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local problems=0
|
||||
for s in "$NTP_PRIMARY" "$NTP_SECONDARY"; do
|
||||
if echo "$peers" | grep -Eq "^\\s*${s//./\\.}"; then
|
||||
echo "✅ NTP daemon has peer $s"
|
||||
else
|
||||
echo "❌ NTP daemon is NOT tracking $s"
|
||||
((++problems))
|
||||
fi
|
||||
done
|
||||
|
||||
# Sync status is informational only: a freshly started daemon needs several
|
||||
# polls before the reach counter stabilises, so we warn rather than fail.
|
||||
if echo "$peers" | grep -Eq '\*'; then
|
||||
echo "✅ NTP daemon reports a synced peer"
|
||||
else
|
||||
echo "⚠️ NTP daemon not yet synced (normal for a few minutes after restart)"
|
||||
fi
|
||||
return $problems
|
||||
}
|
||||
|
||||
# --- Main ---------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
echo "🛰️ Running Redundant DNS/NTP Validation Tests"
|
||||
echo "================================================"
|
||||
|
||||
local total_failures=0
|
||||
|
||||
test_dns_config_present || ((++total_failures))
|
||||
test_ntp_config_present || ((++total_failures))
|
||||
test_dns_servers_answer || ((++total_failures))
|
||||
test_ntp_servers_answer || ((++total_failures))
|
||||
test_resolver_endtoend || ((++total_failures))
|
||||
test_ntp_daemon_peers || ((++total_failures))
|
||||
|
||||
echo "================================================"
|
||||
if [[ $total_failures -eq 0 ]]; then
|
||||
echo "✅ All redundant DNS/NTP validation tests passed"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ $total_failures redundant DNS/NTP tests failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# vm-validation.sh
|
||||
#
|
||||
# End-to-end validation driver for KNELServerBuild on a sandbox VM.
|
||||
#
|
||||
# This script drives a Proxmox VM through: snapshot -> deploy -> validate, with
|
||||
# one-command rollback. It is designed to be re-run after code fixes are pushed.
|
||||
#
|
||||
# DESIGN: deployment is GIT-BASED. The VM clones (or pulls) the public repo
|
||||
# itself, exactly as a real fresh server would — so the result is identical no
|
||||
# matter who runs this script (no reliance on a local working copy or rsync).
|
||||
# All SSH/SCP access goes through Project-Tests/remote.sh; never call ssh here.
|
||||
#
|
||||
# USAGE:
|
||||
# # Discover the numeric VMID on Proxmox:
|
||||
# ./Project-Tests/vm-validation.sh find-vmid
|
||||
#
|
||||
# # Full loop (snapshot + deploy + validate), auto-rollback on failure:
|
||||
# VM_ID=6000 ./Project-Tests/vm-validation.sh all
|
||||
#
|
||||
# # Individual steps:
|
||||
# VM_ID=6000 ./Project-Tests/vm-validation.sh snapshot
|
||||
# VM_ID=6000 ./Project-Tests/vm-validation.sh deploy
|
||||
# VM_ID=6000 ./Project-Tests/vm-validation.sh validate
|
||||
# VM_ID=6000 ./Project-Tests/vm-validation.sh rollback [snapshot-name]
|
||||
#
|
||||
# # Clean re-deploy from scratch (delete + re-clone on VM):
|
||||
# VM_ID=6000 CLEAN_CLONE=1 ./Project-Tests/vm-validation.sh deploy
|
||||
#
|
||||
# CONFIG (override via env, all have sensible defaults):
|
||||
# PROX_HOST Proxmox node hostname (default: pfv-tsys5)
|
||||
# PROX_USER SSH user on Proxmox (default: root)
|
||||
# VM_NAME VM name for VMID lookup/logging (default: sectestbed-sandbox)
|
||||
# VM_IP VM IP for SSH (default: 192.168.3.50)
|
||||
# VM_USER SSH user on the VM (default: localuser)
|
||||
# VM_ID Numeric VMID on Proxmox (REQUIRED except for find-vmid)
|
||||
# REPO_URL git URL the VM clones (default: https://git.knownelement.com/KNEL/KNELServerBuild.git)
|
||||
# REMOTE_REPO clone dir under ~$VM_USER (default: KNELServerBuild)
|
||||
# SNAP_PREFIX snapshot name prefix (default: pre-knel-deploy)
|
||||
# CLEAN_CLONE if set, delete + re-clone on VM (default: unset)
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
PROX_HOST="${PROX_HOST:-pfv-tsys5}"
|
||||
PROX_USER="${PROX_USER:-root}"
|
||||
VM_NAME="${VM_NAME:-sectestbed-sandbox}"
|
||||
VM_IP="${VM_IP:-192.168.3.50}"
|
||||
VM_USER="${VM_USER:-localuser}"
|
||||
VM_ID="${VM_ID:-}"
|
||||
REPO_URL="${REPO_URL:-https://git.knownelement.com/KNEL/KNELServerBuild.git}"
|
||||
REMOTE_REPO="${REMOTE_REPO:-KNELServerBuild}"
|
||||
SNAP_PREFIX="${SNAP_PREFIX:-pre-knel-deploy}"
|
||||
ACCESS_PUBKEY="${ACCESS_PUBKEY:-$HOME/.ssh/id_ed25519.pub}"
|
||||
# Re-inject the validation pubkey after each deploy (secharden-ssh replaces
|
||||
# authorized_keys with the managed production key set, locking out the
|
||||
# bootstrap/dev key). Set RESTORE_ACCESS=0 to disable.
|
||||
RESTORE_ACCESS="${RESTORE_ACCESS:-1}"
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_SRC="$(cd "$HERE/.." && pwd)"
|
||||
REMOTE="$HERE/remote.sh"
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
SNAP_NAME="${SNAP_PREFIX}-${STAMP}"
|
||||
LOCAL_LOG_DIR="$REPO_SRC/logs/vm-validation"
|
||||
mkdir -p "$LOCAL_LOG_DIR"
|
||||
LOCAL_LOG="$LOCAL_LOG_DIR/run-${STAMP}.log"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$LOCAL_LOG"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
# All remote access funnels through remote.sh.
|
||||
vm() { bash "$REMOTE" vm "$@"; } # as $VM_USER (SSH)
|
||||
vmroot() { bash "$REMOTE" vmroot "$@"; } # as root via sudo (SSH)
|
||||
vmfile() { bash "$REMOTE" vm-file "$@"; } # run local script on VM (SSH)
|
||||
vmguest() { bash "$REMOTE" vm-guest "$@"; } # as root via guest agent (no SSH/2FA)
|
||||
prox() { bash "$REMOTE" prox "$@"; } # as $PROX_USER on Proxmox
|
||||
|
||||
require_vm_id() {
|
||||
[[ -n "$VM_ID" ]] || die "VM_ID is required for this command. Find it with: $0 find-vmid"
|
||||
}
|
||||
|
||||
wait_for_vm_ssh() {
|
||||
log "Waiting for SSH on ${VM_USER}@${VM_IP} to come up..."
|
||||
for i in $(seq 1 60); do
|
||||
if vm 'true' >/dev/null 2>&1; then
|
||||
log "SSH is up (after ${i} tries)."
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
die "VM did not become SSH-reachable within 5 minutes."
|
||||
}
|
||||
|
||||
# Resolve the ABSOLUTE path of the repo clone on the VM (as $VM_USER) and echo
|
||||
# it. Using an absolute path avoids the '~' -> root's home trap under sudo.
|
||||
resolve_remote_repo() {
|
||||
local p
|
||||
p="$(vm "cd ~/${REMOTE_REPO} 2>/dev/null && pwd" 2>/dev/null)"
|
||||
[[ -n "$p" ]] || p="$(vmguest "cd ~${VM_USER}/${REMOTE_REPO} 2>/dev/null && pwd" 2>/dev/null)"
|
||||
printf '%s' "$p"
|
||||
}
|
||||
|
||||
# Re-inject the validation pubkey into ~$VM_USER/.ssh/authorized_keys OUT OF
|
||||
# BAND via the Proxmox guest agent (qm guest exec runs as root inside the VM
|
||||
# and does not depend on SSH). This is necessary because secharden-ssh replaces
|
||||
# authorized_keys with the managed production key set, which would otherwise
|
||||
# lock out the bootstrap key used to drive validation. No-op if SSH still works.
|
||||
restore_vm_access() {
|
||||
[[ "$RESTORE_ACCESS" = "1" ]] || { log "RESTORE_ACCESS=0; skipping access restore."; return 0; }
|
||||
[[ -f "$ACCESS_PUBKEY" ]] || { log "WARN: ACCESS_PUBKEY not found ($ACCESS_PUBKEY); cannot restore access."; return 0; }
|
||||
if vm 'true' >/dev/null 2>&1; then
|
||||
log "SSH access already works; no need to restore."
|
||||
return 0
|
||||
fi
|
||||
log "SSH access lost (expected after secharden-ssh). Restoring via Proxmox guest agent..."
|
||||
local payload_b64
|
||||
# Leading newline guards against the managed authorized_keys lacking a
|
||||
# trailing newline (which would otherwise concatenate two keys into one).
|
||||
payload_b64="$(printf '\n%s' "$(cat "$ACCESS_PUBKEY")" | base64 -w0)"
|
||||
prox "qm guest exec $VM_ID -- /bin/sh -c 'echo $payload_b64 | base64 -d >> /home/${VM_USER}/.ssh/authorized_keys'" \
|
||||
>/dev/null 2>&1 || { log "WARN: guest-agent key append failed."; return 0; }
|
||||
prox "qm guest exec $VM_ID -- /bin/sh -c 'chown ${VM_USER}:${VM_USER} /home/${VM_USER}/.ssh/authorized_keys; chmod 600 /home/${VM_USER}/.ssh/authorized_keys'" \
|
||||
>/dev/null 2>&1 || true
|
||||
if vm 'true' >/dev/null 2>&1; then
|
||||
log "Access restored."
|
||||
return 0
|
||||
fi
|
||||
# If SSH still fails after re-injecting the key, 2FA is almost certainly the
|
||||
# cause (secharden-2fa enforces publickey+keyboard-interactive, which no
|
||||
# non-interactive SSH client can satisfy). That is expected and not fatal:
|
||||
# the guest agent still gives us full out-of-band access for log fetch and
|
||||
# the validation suite.
|
||||
if vmguest 'grep -q "^AuthenticationMethods" /etc/ssh/sshd_config' >/dev/null 2>&1; then
|
||||
log "SSH requires 2FA (expected after secharden-2fa); using guest agent for further access."
|
||||
else
|
||||
log "WARN: access still not working after restore and 2FA not detected. Check sshd_config."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
cmd_find_vmid() {
|
||||
log "Listing VMs on Proxmox host '$PROX_HOST' matching '$VM_NAME':"
|
||||
prox 'qm list' 2>&1 | tee -a "$LOCAL_LOG" \
|
||||
| { IFS= read -r header; echo "$header"; grep -i "$VM_NAME" || true; }
|
||||
log "Set VM_ID=<number> env var based on the row above."
|
||||
}
|
||||
|
||||
cmd_snapshot() {
|
||||
require_vm_id
|
||||
log "Creating snapshot '$SNAP_NAME' of VMID $VM_ID on $PROX_HOST..."
|
||||
prox "qm snapshot $VM_ID $SNAP_NAME --vmstate 1" 2>&1 | tee -a "$LOCAL_LOG" \
|
||||
|| die "Snapshot creation failed."
|
||||
echo "$SNAP_NAME" > "$LOCAL_LOG_DIR/.last-snapshot"
|
||||
log "Snapshot '$SNAP_NAME' recorded as rollback target."
|
||||
}
|
||||
|
||||
cmd_rollback() {
|
||||
require_vm_id
|
||||
local target="${1:-$(cat "$LOCAL_LOG_DIR/.last-snapshot" 2>/dev/null || true)}"
|
||||
[[ -n "$target" ]] || die "No snapshot name given and no .last-snapshot on disk."
|
||||
log "Rolling back VMID $VM_ID to snapshot '$target'..."
|
||||
# Proxmox rollback requires the VM to be stopped.
|
||||
prox "qm stop $VM_ID" 2>&1 | tee -a "$LOCAL_LOG" || true
|
||||
sleep 5
|
||||
prox "qm rollback $VM_ID $target" 2>&1 | tee -a "$LOCAL_LOG" \
|
||||
|| die "Rollback command failed."
|
||||
log "Starting VMID $VM_ID..."
|
||||
prox "qm start $VM_ID" 2>&1 | tee -a "$LOCAL_LOG" || true
|
||||
wait_for_vm_ssh
|
||||
log "Rollback complete."
|
||||
}
|
||||
|
||||
# Ensure the VM has git + ca-certificates (fresh-server bootstrap).
|
||||
bootstrap_git_on_vm() {
|
||||
log "Ensuring git is present on the VM..."
|
||||
vm 'command -v git >/dev/null 2>&1 || sudo -n DEBIAN_FRONTEND=noninteractive apt-get -y -qq install git ca-certificates' \
|
||||
2>&1 | tee -a "$LOCAL_LOG" || die "Failed to bootstrap git on VM."
|
||||
vm 'sudo -n DEBIAN_FRONTEND=noninteractive apt-get -y -qq install ca-certificates' 2>&1 | tee -a "$LOCAL_LOG" || true
|
||||
}
|
||||
|
||||
# Clone or pull the repo on the VM. Returns absolute path on stdout (via log).
|
||||
sync_repo_on_vm() {
|
||||
bootstrap_git_on_vm
|
||||
if [[ -n "${CLEAN_CLONE:-}" ]]; then
|
||||
log "CLEAN_CLONE set: removing existing clone on VM."
|
||||
vm "rm -rf ~/${REMOTE_REPO}" 2>&1 | tee -a "$LOCAL_LOG" || true
|
||||
fi
|
||||
log "Ensuring repo is cloned/pulled on the VM from:"
|
||||
log " $REPO_URL"
|
||||
vm "
|
||||
set -e
|
||||
if [ -d ~/${REMOTE_REPO}/.git ]; then
|
||||
cd ~/${REMOTE_REPO}
|
||||
git fetch --all --prune
|
||||
git reset --hard origin/HEAD 2>/dev/null || git reset --hard origin/main
|
||||
git clean -xfd
|
||||
else
|
||||
git clone --filter=blob:none '$REPO_URL' ~/${REMOTE_REPO}
|
||||
cd ~/${REMOTE_REPO}
|
||||
fi
|
||||
git log --oneline -1
|
||||
" 2>&1 | tee -a "$LOCAL_LOG" || die "Repo sync failed on VM."
|
||||
log "Repo ready on VM."
|
||||
}
|
||||
|
||||
# The remote setup runner: a self-contained script we ship to the VM so the
|
||||
# sudo'd setup runs from a known-good absolute path with full logging. Using a
|
||||
# file avoids nested-quote hell across local -> ssh -> sudo -> bash -c.
|
||||
deploy_runner_script() {
|
||||
cat <<RUNNER
|
||||
#!/usr/bin/bash
|
||||
# remote-setup-runner.sh (generated by vm-validation.sh)
|
||||
# Runs ProjectCode/SetupNewSystem.sh from the repo given by \$1, as root.
|
||||
set -uo pipefail
|
||||
# Ensure a sane TERM so the framework's tput-based color helpers work when run
|
||||
# over a non-interactive SSH session (which has no TTY/TERM by default).
|
||||
export TERM="\${TERM:-linux}"
|
||||
REPO_ABS="\${1:?repo abs path required}"
|
||||
REMOTE_LOG="/tmp/knel-setup.log"
|
||||
echo "=== KNEL SetupNewSystem start: \$(date -Is) repo=\$REPO_ABS ===" | tee -a "\$REMOTE_LOG"
|
||||
cd "\$REPO_ABS/ProjectCode" || { echo "FATAL: ProjectCode missing at \$REPO_ABS"; exit 2; }
|
||||
bash SetupNewSystem.sh 2>&1 | tee -a "\$REMOTE_LOG"
|
||||
rc=\${PIPESTATUS[0]}
|
||||
echo "=== KNEL SetupNewSystem end: rc=\$rc \$(date -Is) ===" | tee -a "\$REMOTE_LOG"
|
||||
exit \$rc
|
||||
RUNNER
|
||||
}
|
||||
|
||||
cmd_deploy() {
|
||||
require_vm_id
|
||||
sync_repo_on_vm
|
||||
local repo_abs
|
||||
repo_abs="$(resolve_remote_repo)"
|
||||
[[ -n "$repo_abs" ]] || die "Could not resolve absolute repo path on VM."
|
||||
log "Repo absolute path on VM: $repo_abs"
|
||||
|
||||
# Ship the runner script and execute it as root via sudo, passing abs path.
|
||||
local runner_local="$LOCAL_LOG_DIR/remote-setup-runner.sh"
|
||||
deploy_runner_script > "$runner_local"
|
||||
vm "mkdir -p ~/${REMOTE_REPO}/Project-Tests/.run" 2>&1 | tee -a "$LOCAL_LOG"
|
||||
bash "$REMOTE" vm-copy "$runner_local" "${REMOTE_REPO}/Project-Tests/.run/remote-setup-runner.sh" \
|
||||
2>&1 | tee -a "$LOCAL_LOG" || die "Failed to ship runner script."
|
||||
|
||||
log "Running SetupNewSystem.sh on the VM as root (this takes several minutes)..."
|
||||
# Resolve abs runner path the same way (no ~ under sudo).
|
||||
local runner_abs
|
||||
runner_abs="$(vm "cd ~/${REMOTE_REPO}/Project-Tests/.run && pwd")/remote-setup-runner.sh"
|
||||
vmroot "bash '$runner_abs' '$repo_abs'" 2>&1 | tee -a "$LOCAL_LOG" || true
|
||||
|
||||
# secharden-ssh (run near the end of setup) replaces authorized_keys with the
|
||||
# managed production key set, locking out the bootstrap key. Restore the
|
||||
# validation key out-of-band BEFORE we try to fetch the log over SSH.
|
||||
restore_vm_access
|
||||
|
||||
# Fetch the remote log for full fidelity (strip ANSI color codes). SSH works
|
||||
# only until secharden-2fa flips 2FA on; after that, use the guest agent.
|
||||
local fetch_cmd="sed -r 's/\\x1B\\[[0-9;]*[mK]//g' /tmp/knel-setup.log 2>/dev/null || cat /tmp/knel-setup.log"
|
||||
if ! vm "$fetch_cmd" > "$LOCAL_LOG_DIR/setup-output-${STAMP}.log" 2>/dev/null; then
|
||||
vmguest "$fetch_cmd" > "$LOCAL_LOG_DIR/setup-output-${STAMP}.log" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Detect the exit marker. Prefer the full fetched log, but always fall back
|
||||
# to the live stream ($LOCAL_LOG) which is captured regardless of whether
|
||||
# post-setup SSH/2FA let us fetch the remote log.
|
||||
local rc_marker
|
||||
rc_marker=$(grep -oE 'rc=[0-9]+' "$LOCAL_LOG_DIR/setup-output-${STAMP}.log" 2>/dev/null | tail -1 || true)
|
||||
[[ -n "$rc_marker" ]] || rc_marker=$(grep -oE 'rc=[0-9]+' "$LOCAL_LOG" 2>/dev/null | tail -1 || true)
|
||||
log "Setup run finished. Marker: ${rc_marker:-unknown}"
|
||||
|
||||
if [[ "${rc_marker:-}" != "rc=0" ]]; then
|
||||
log "Setup did NOT complete cleanly. See: $LOCAL_LOG_DIR/setup-output-${STAMP}.log (and $LOCAL_LOG)"
|
||||
return 1
|
||||
fi
|
||||
log "Setup completed successfully."
|
||||
}
|
||||
|
||||
cmd_validate() {
|
||||
require_vm_id
|
||||
log "Running post-deploy validation suite on the VM..."
|
||||
local repo_abs
|
||||
repo_abs="$(resolve_remote_repo)"
|
||||
[[ -n "$repo_abs" ]] || die "Could not resolve absolute repo path on VM."
|
||||
# Prefer SSH; fall back to the guest agent (post-2FA SSH needs a TOTP token).
|
||||
if ! vmroot "cd '$repo_abs' && bash Project-Tests/run-tests.sh all" 2>&1 | tee -a "$LOCAL_LOG"; then
|
||||
vmguest "cd '$repo_abs' && bash Project-Tests/run-tests.sh all" 2>&1 | tee -a "$LOCAL_LOG" || true
|
||||
fi
|
||||
log "Validation run finished. Inspect output above / in $LOCAL_LOG."
|
||||
}
|
||||
|
||||
cmd_all() {
|
||||
require_vm_id
|
||||
log "=== FULL VALIDATION LOOP: $VM_NAME (VMID $VM_ID) ==="
|
||||
cmd_snapshot
|
||||
if cmd_deploy && cmd_validate; then
|
||||
log "=== ALL GREEN ==="
|
||||
return 0
|
||||
fi
|
||||
log "=== FAILURE — auto-rolling back to '$SNAP_NAME' ==="
|
||||
cmd_rollback "$SNAP_NAME"
|
||||
log "Rolled back. Fix and push, then re-run: VM_ID=$VM_ID $0 deploy && VM_ID=$VM_ID $0 validate"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
subcmd="${1:-}"
|
||||
case "$subcmd" in
|
||||
find-vmid) cmd_find_vmid ;;
|
||||
snapshot) cmd_snapshot ;;
|
||||
deploy) cmd_deploy ;;
|
||||
validate) cmd_validate ;;
|
||||
rollback) cmd_rollback "${2:-}" ;;
|
||||
all) cmd_all ;;
|
||||
""|-h|--help|help)
|
||||
sed -n '2,49p' "${BASH_SOURCE[0]}" >&2
|
||||
exit 0
|
||||
;;
|
||||
*) die "Unknown command '$subcmd'. Run '$0 help'." ;;
|
||||
esac
|
||||
@@ -3,4 +3,12 @@ option rfc3442-classless-static-routes code 121 = array of unsigned integer 8;
|
||||
send host-name = gethostname();
|
||||
request subnet-mask, broadcast-address, time-offset, routers,
|
||||
domain-name, host-name,
|
||||
domain-name-servers, domain-search, ntp-servers,
|
||||
rfc3442-classless-static-routes;
|
||||
|
||||
# Pin DNS and NTP to the redundant pfv-netinfra-01/02 pair regardless of what
|
||||
# the DHCP server advertises, so every host on this build uses the same
|
||||
# authoritative recursive resolvers and time sources.
|
||||
supersede domain-name-servers 192.168.3.252, 192.168.3.253;
|
||||
supersede domain-search "knel.net";
|
||||
supersede ntp-servers 192.168.3.252, 192.168.3.253;
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
driftfile /var/lib/ntp/ntp.drift
|
||||
leapfile /usr/share/zoneinfo/leap-seconds.list
|
||||
server pfv-netboot.knel.net
|
||||
|
||||
# Redundant upstream time sources: pfv-netinfra-01/02 (Technitium/Pi-hole hosts
|
||||
# also serving NTP). IPs are used (not hostnames) because the knel.net name for
|
||||
# these hosts resolves to a Tailscale CGNAT address, not the LAN address, and
|
||||
# because NTP must come up before DNS is available. iburst speeds initial sync.
|
||||
server 192.168.3.252 iburst
|
||||
server 192.168.3.253 iburst
|
||||
|
||||
# Hardened client: sync from the configured servers but never serve time to
|
||||
# anyone else. Note: `interface listen 127.0.0.1` must NOT be used here — it
|
||||
# binds ntpd to loopback, making outbound queries carry a 127.0.0.1 source
|
||||
# address that upstream servers cannot reply to (symptoms: peers stuck in
|
||||
# .INIT. with reach 0). Use restrict rules to control access instead.
|
||||
restrict default ignore
|
||||
restrict 127.0.0.1
|
||||
restrict ::1
|
||||
interface ignore wildcard
|
||||
interface listen 127.0.0.1
|
||||
restrict 192.168.3.252 nomodify notrap nopeer
|
||||
restrict 192.168.3.253 nomodify notrap nopeer
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Managed by KNELServerBuild — do not edit; changes will be overwritten.
|
||||
#
|
||||
# Redundant recursive DNS via pfv-netinfra-01/02 (Technitium + Pi-hole).
|
||||
# IPs are used (required: nameserver directives must be addresses, and the
|
||||
# knel.net name for these hosts resolves to a Tailscale CGNAT address rather
|
||||
# than the LAN address). If the primary is unreachable, glibc's resolver
|
||||
# automatically falls through to the secondary.
|
||||
domain knel.net
|
||||
search knel.net
|
||||
nameserver 192.168.3.252
|
||||
nameserver 192.168.3.253
|
||||
@@ -1,6 +1,6 @@
|
||||
# Restrict key exchange, cipher, and MAC algorithms, as per sshaudit.com
|
||||
# hardening guide.
|
||||
KexAlgorithms sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,gss-curve25519-sha256-,diffie-hellman-group16-sha512,gss-group16-sha512-,diffie-hellman-group18-sha512,diffie-hellman-group-exchange-sha256
|
||||
KexAlgorithms sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,gss-curve25519-sha256-,diffie-hellman-group16-sha512,gss-group16-sha512-,diffie-hellman-group18-sha512,diffie-hellman-group-exchange-sha256
|
||||
|
||||
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-gcm@openssh.com,aes128-ctr
|
||||
|
||||
|
||||
@@ -93,6 +93,11 @@ function configure_ssh_2fa() {
|
||||
echo "ChallengeResponseAuthentication yes" >> "$SSH_CONFIG"
|
||||
fi
|
||||
|
||||
if ! grep -q "^KbdInteractiveAuthentication yes" "$SSH_CONFIG"; then
|
||||
sed -i 's/^KbdInteractiveAuthentication.*/KbdInteractiveAuthentication yes/' "$SSH_CONFIG" || \
|
||||
echo "KbdInteractiveAuthentication yes" >> "$SSH_CONFIG"
|
||||
fi
|
||||
|
||||
# Enable PAM authentication
|
||||
if ! grep -q "^UsePAM yes" "$SSH_CONFIG"; then
|
||||
sed -i 's/^UsePAM.*/UsePAM yes/' "$SSH_CONFIG" || \
|
||||
@@ -214,13 +219,21 @@ function configure_webmin_2fa() {
|
||||
# Stop webmin service
|
||||
systemctl stop webmin || true
|
||||
|
||||
# Enable 2FA in Webmin configuration
|
||||
sed -i 's/^twofactor_provider=.*/twofactor_provider=totp/' "$webmin_config" || \
|
||||
echo "twofactor_provider=totp" >> "$webmin_config"
|
||||
# Enable 2FA in Webmin configuration. `sed -i ... || echo` would never
|
||||
# append, because sed returns 0 even when it matches nothing; guard with
|
||||
# grep so the directive is added when absent and updated when present.
|
||||
if grep -q '^twofactor_provider=' "$webmin_config"; then
|
||||
sed -i 's/^twofactor_provider=.*/twofactor_provider=totp/' "$webmin_config"
|
||||
else
|
||||
echo "twofactor_provider=totp" >> "$webmin_config"
|
||||
fi
|
||||
|
||||
# Enable 2FA requirement
|
||||
sed -i 's/^twofactor=.*/twofactor=1/' "$webmin_config" || \
|
||||
echo "twofactor=1" >> "$webmin_config"
|
||||
if grep -q '^twofactor=' "$webmin_config"; then
|
||||
sed -i 's/^twofactor=.*/twofactor=1/' "$webmin_config"
|
||||
else
|
||||
echo "twofactor=1" >> "$webmin_config"
|
||||
fi
|
||||
|
||||
# Start webmin service
|
||||
systemctl start webmin || true
|
||||
@@ -241,6 +254,13 @@ function setup_user_2fa() {
|
||||
if id "$user" &>/dev/null; then
|
||||
print_info "Setting up 2FA for user: $user"
|
||||
|
||||
local user_home
|
||||
user_home="$(getent passwd "$user" | cut -d: -f6)"
|
||||
if [[ -z "$user_home" ]]; then
|
||||
print_info "No home directory for $user, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create 2FA setup script for user
|
||||
cat > "/tmp/setup-2fa-$user.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
@@ -257,7 +277,7 @@ EOF
|
||||
chmod +x "/tmp/setup-2fa-$user.sh"
|
||||
|
||||
# Instructions for user setup
|
||||
cat > "~$user/2fa-setup-instructions.txt" << EOF
|
||||
cat > "$user_home/2fa-setup-instructions.txt" << EOF
|
||||
TSYS Two-Factor Authentication Setup Instructions
|
||||
==============================================
|
||||
|
||||
@@ -286,7 +306,7 @@ Without them, you may be locked out if you lose your phone.
|
||||
For support, contact your system administrator.
|
||||
EOF
|
||||
|
||||
chown "$user:$user" "~$user/2fa-setup-instructions.txt"
|
||||
chown "$user:$user" "$user_home/2fa-setup-instructions.txt"
|
||||
print_info "2FA setup prepared for user: $user"
|
||||
else
|
||||
print_info "User $user not found, skipping"
|
||||
|
||||
@@ -50,7 +50,7 @@ WAZUH_MANAGER="tsys-nsm.knel.net" apt-get -y install wazuh-agent
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable wazuh-agent
|
||||
systemctl start wazuh-agent
|
||||
systemctl start wazuh-agent || true
|
||||
|
||||
echo "wazuh-agent hold" | dpkg --set-selections
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ function global-installPackages() {
|
||||
multipath-tools \
|
||||
|| true
|
||||
|
||||
apt-get --purge autoremove
|
||||
apt-get -y --purge autoremove
|
||||
|
||||
# add stuff we want
|
||||
|
||||
@@ -207,7 +207,7 @@ function global-installPackages() {
|
||||
if [[ $KALI_CHECK -eq 0 ]];then
|
||||
DEBIAN_FRONTEND="noninteractive" apt-get -qq --yes -o Dpkg::Options::="--force-confold" install \
|
||||
latencytop \
|
||||
cockpit-tests
|
||||
cockpit-tests || true
|
||||
fi
|
||||
|
||||
if [[ $IS_PHYSICAL_HOST -gt 0 ]]; then
|
||||
@@ -267,6 +267,14 @@ function global-postPackageConfiguration() {
|
||||
|
||||
cat "$CONFIGFILES_PATH/DHCP/dhclient.conf" >/etc/dhcp/dhclient.conf
|
||||
|
||||
# Authoritative recursive DNS via the redundant pfv-netinfra-01/02 pair.
|
||||
# Replace whatever is at /etc/resolv.conf (including a systemd-resolved or
|
||||
# NetworkManager symlink) with the managed static file so every lookup goes
|
||||
# to our servers and nothing else rewrites it behind our backs.
|
||||
rm -f /etc/resolv.conf
|
||||
cat "$CONFIGFILES_PATH/Resolv/resolv.conf" >/etc/resolv.conf
|
||||
chmod 644 /etc/resolv.conf
|
||||
|
||||
systemctl stop snmpd && /etc/init.d/snmpd stop
|
||||
|
||||
cat "$CONFIGFILES_PATH/SNMP/snmp-sudo.conf" >/etc/sudoers.d/Debian-snmp
|
||||
@@ -304,7 +312,7 @@ function global-postPackageConfiguration() {
|
||||
fi
|
||||
|
||||
export NTP_SERVER_CHECK
|
||||
NTP_SERVER_CHECK="$(hostname | egrep -c 'pfv-netboot|pfvsvrpi' || true)"
|
||||
NTP_SERVER_CHECK="$(hostname | egrep -c 'pfv-netboot|pfvsvrpi|pfv-netinfra' || true)"
|
||||
|
||||
if [ "$NTP_SERVER_CHECK" -eq 0 ]; then
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Tailscale vs. Managed DNS — Architecture Analysis
|
||||
|
||||
> **Status:** analysis for review. No code decisions are final. Read the
|
||||
> "Known issues" section before acting on the managed-resolv.conf change.
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
Every host in this build runs the Tailscale client, and Tailscale — by default —
|
||||
**manages `/etc/resolv.conf` itself**, pointing it at `100.100.100.100`
|
||||
(Tailscale's MagicDNS resolver). This directly conflicts with the managed
|
||||
`resolv.conf` (pointing at `192.168.3.252`/`192.168.3.253`) that
|
||||
`SetupNewSystem.sh` deploys: whichever runs last wins, and Tailscale's daemon
|
||||
re-wins on every `tailscale up` and on reboot.
|
||||
|
||||
Worse, a probe of the live network shows that **knel.net device records only
|
||||
resolve through the Tailscale 100.100.100.100 path** — querying the LAN IPs of
|
||||
the DNS servers directly returns NXDOMAIN for current hostnames (the Technitium
|
||||
`knel.net` zone has the SOA but is stale/empty of actual records). So pointing
|
||||
`resolv.conf` at the LAN IPs would break resolution of the very names this
|
||||
project's modules depend on (`tsys-nsm.knel.net`, `tsys-cloudron.knel.net`,
|
||||
`tsys-librenms.knel.net`).
|
||||
|
||||
This document lays out the options and a recommended path forward.
|
||||
|
||||
## 2. How name resolution actually works today (as measured)
|
||||
|
||||
Probed from `sectestbed-sandbox` (192.168.3.50):
|
||||
|
||||
| Query path | External name (`github.com`) | knel.net device name (`pfv-netinfra-01.knel.net`) |
|
||||
|---|---|---|
|
||||
| Via current resolver = `100.100.100.100` (Tailscale) | resolves | **resolves** → `100.70.181.72` (Tailscale CGNAT) |
|
||||
| Direct `dig @192.168.3.252` (Technitium, LAN) | resolves (recurses) | **NXDOMAIN** (SOA present, no record) |
|
||||
| Direct `dig @192.168.3.253` (Pi-hole, LAN) | resolves (recurses) | **NXDOMAIN** (SOA present, no record) |
|
||||
|
||||
Other measured facts:
|
||||
|
||||
- `dig @192.168.3.252 knel.net SOA` → `NOERROR`, returns
|
||||
`knel.net. 900 IN SOA dns.knel.net. hostadmin.knel.net. 2025062313 …`
|
||||
(serial dated **2025-06-23** — the zone exists but is stale).
|
||||
- NTP on both `.252` and `.253` answers time queries (stratum 2/3).
|
||||
- The live `/etc/resolv.conf` on a deployed host reads:
|
||||
```
|
||||
# resolv.conf(5) file generated by tailscale
|
||||
# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN
|
||||
nameserver 100.100.100.100
|
||||
nameserver fd7a:115c:a1e0::53
|
||||
search knel.net
|
||||
```
|
||||
|
||||
**Interpretation:** the `knel.net` device→Tailscale-IP mappings are synthesised
|
||||
by Tailscale's MagicDNS from the tailnet device registry (every device that
|
||||
joins the tailnet gets `hostname.knel.net` → its `100.x.x.x` address). The
|
||||
Technitium `knel.net` zone is a separate, manually-maintained zone that has
|
||||
fallen out of date. The two are not the same source of truth.
|
||||
|
||||
## 3. The core tension
|
||||
|
||||
| Goal | Who provides it today |
|
||||
|---|---|
|
||||
| Resolve `*.knel.net` device names (→ Tailscale IPs) | Tailscale MagicDNS via `100.100.100.100` |
|
||||
| Resolve external names with ad-blocking | Pi-hole (`.253`), reachable via Tailscale → Technitium → Pi-hole chain |
|
||||
| Redundant, low-latency, tunnel-independent DNS | LAN resolvers `.252`/`.253` — **but these lack knel.net records** |
|
||||
| Authoritative time | NTP on `.252`/`.253` (works on either path) |
|
||||
|
||||
The conflict: you cannot simply point `resolv.conf` at the LAN resolvers,
|
||||
because they do not know about the current `knel.net` device records, and
|
||||
several modules in this project resolve `knel.net` hostnames at runtime
|
||||
(wazuh manager, postfix relay, syslog target). You also cannot ignore Tailscale,
|
||||
because it is the only thing that resolves those names today.
|
||||
|
||||
## 4. Options
|
||||
|
||||
### Option A — Let Tailscale own DNS (status quo, `accept-dns=true`)
|
||||
|
||||
Leave the default. Tailscale writes `100.100.100.100` to `resolv.conf`; the
|
||||
control-plane forwarding (`100.100.100.100` → Technitium → Pi-hole) handles
|
||||
external names and ad-blocking; MagicDNS handles `knel.net` device names.
|
||||
|
||||
| Pros | Cons |
|
||||
|---|---|
|
||||
| Zero per-host config; new machines "just work" on `tailscale up` | **All DNS depends on the Tailscale daemon being up.** If `tailscaled` dies, every name lookup fails — including the ones you need to SSH in and fix it. |
|
||||
| MagicDNS + knel.net names resolve automatically | Latency: every query goes host→tailscaled→100.100.100.100→(tunnel)→Technitium→Pi-hole→upstream |
|
||||
| Ad-blocking preserved (via the Pi-hole hop) | Overwrites the managed `resolv.conf` — the `.252`/`.253` redundancy is lost |
|
||||
| Centralised in the Tailscale admin console | Single resolver in `resolv.conf` (`100.100.100.100`); no glibc-level failover |
|
||||
| | Boot-order risk: early-boot processes have no DNS until `tailscaled` is up |
|
||||
|
||||
### Option B — Pin resolv.conf to the LAN resolvers (`accept-dns=false`)
|
||||
|
||||
Set `--accept-dns=false` on every host and keep the managed `resolv.conf`
|
||||
pointing at `.252`/`.253`.
|
||||
|
||||
| Pros | Cons |
|
||||
|---|---|
|
||||
| DNS independent of Tailscale — survives `tailscaled` outages | **`*.knel.net` device names break (NXDOMAIN)** because the LAN resolvers' knel.net zone is stale. This breaks wazuh/postfix/syslog hostname resolution. |
|
||||
| Lowest latency, full glibc-level failover across two servers | MagicDNS names (`*.ts.net`) do not resolve |
|
||||
| Managed `resolv.conf` wins uncontested | Requires fixing the Technitium/Pi-hole `knel.net` zone to mirror the Tailscale device records before this is viable |
|
||||
| Boot-time DNS works immediately | Off-LAN hosts (laptops) can't reach `.252`/`.253` without the tunnel — back to needing Tailscale |
|
||||
|
||||
> **Not recommended as-is.** Only viable **after** the `knel.net` zone on
|
||||
> `.252`/`.253` is repopulated with current device records (see §6).
|
||||
|
||||
### Option C — Tailscale Split DNS (per-domain routing)
|
||||
|
||||
MagicDNS `ON`, "Override local DNS" `OFF` in the admin console; only `ts.net`
|
||||
(and explicitly split domains) route to `100.100.100.100`, everything else stays
|
||||
on the system resolver.
|
||||
|
||||
| Pros | Cons |
|
||||
|---|---|
|
||||
| Best of both worlds: MagicDNS names resolve AND general queries go direct | Requires `systemd-resolved` (or NetworkManager `dns=dnsmasq`) for per-domain routing. These hosts use a **plain `/etc/resolv.conf`** — on which Tailscale **cannot** do per-domain split; it replaces the whole file. |
|
||||
| Reduces tunnel dependency for non-Tailscale names | Migrating every host to `systemd-resolved` is a significant, cross-cutting change |
|
||||
| | More moving parts to reason about and debug |
|
||||
|
||||
### Option D — Make Tailscale push the LAN resolvers as global nameservers
|
||||
|
||||
In the admin console, set global nameservers to `192.168.3.252`/`192.168.3.253`,
|
||||
keep `accept-dns=true`.
|
||||
|
||||
| Pros | Cons |
|
||||
|---|---|
|
||||
| Clients get the LAN resolvers via Tailscale config (consistent) | Tailscale still overwrites `resolv.conf` |
|
||||
| MagicDNS still works (100.100.100.100 added for `ts.net`/`knel.net`) | On-LAN hosts don't need Tailscale to find `.252`/`.253` — pure indirection |
|
||||
| Centralised management | Still depends on `tailscaled` for DNS |
|
||||
| | `knel.net` device names still only resolve via the Tailscale path, so the LAN resolvers being "global" doesn't help those names unless the zone is fixed |
|
||||
|
||||
## 5. Recommendation
|
||||
|
||||
**Short term (unblock now): Option A — let Tailscale own DNS.** Revert/disable
|
||||
the managed-`resolv.conf` deployment so provisioning stops fighting Tailscale.
|
||||
Today, `knel.net` device names **only** resolve through Tailscale, and this
|
||||
project's modules depend on those names, so Tailscale-managed DNS is the only
|
||||
thing that currently works end-to-end. Keep the NTP change (LAN IPs, no DNS
|
||||
dependency) — that part is safe and beneficial regardless.
|
||||
|
||||
**Medium term (the real fix): populate the `knel.net` zone on the LAN
|
||||
resolvers**, then choose B or C. Concretely:
|
||||
|
||||
1. Make Technitium (`.252`) authoritative for `knel.net` **with current records**
|
||||
(mirror the Tailscale device→IP mappings, or enable a zone-transfer/sync from
|
||||
the Tailscale device registry, or use Technitium's "Tailscale" DNS app if
|
||||
available). Confirm `dig @192.168.3.252 pfv-netinfra-01.knel.net` returns an
|
||||
answer, not NXDOMAIN.
|
||||
2. Make Pi-hole (`.253`) forward `knel.net` to Technitium (or also serve the
|
||||
zone), so both resolvers in the pair can answer internal names — otherwise
|
||||
glibc failover to `.253` would silently break knel.net lookups.
|
||||
3. *Then* pin `resolv.conf` to `.252`/`.253` with `--accept-dns=false`
|
||||
(Option B), gaining tunnel-independent, redundant DNS.
|
||||
|
||||
**Long term (optional, if per-domain routing is wanted): Option C** — adopt
|
||||
`systemd-resolved` and configure Tailscale Split DNS so `ts.net`/`knel.net` go
|
||||
to MagicDNS and everything else goes direct. Only worth the migration cost if
|
||||
you specifically need `*.ts.net` short-name resolution alongside direct LAN DNS.
|
||||
|
||||
### Why not just force `.252`/`.253` today?
|
||||
|
||||
Because it regresses name resolution for the hostnames this project already
|
||||
uses. Concretely, with `resolv.conf` pinned to the LAN resolvers the following
|
||||
would fail to resolve:
|
||||
|
||||
- `ProjectCode/Modules/Security/secharden-wazuh.sh` → `WAZUH_MANAGER="tsys-nsm.knel.net"`
|
||||
- `ProjectCode/SetupNewSystem.sh` → `postconf -e "relayhost = tsys-cloudron.knel.net"`
|
||||
- `ProjectCode/ConfigFiles/Syslog/rsyslog.conf` → `*.* @tsys-librenms.knel.net:514`
|
||||
|
||||
All three resolve cleanly via `100.100.100.100` today and return NXDOMAIN via
|
||||
`.252`/`.253`. Pinning the LAN resolvers before the zone is fixed would break
|
||||
wazuh, mail relay, and syslog.
|
||||
|
||||
## 6. Known issues / action items
|
||||
|
||||
1. **Technitium `knel.net` zone is stale.** SOA serial `2025062313`
|
||||
(2025-06-23); current device names return NXDOMAIN from the LAN interface.
|
||||
Action: repopulate the zone (mirror Tailscale device records) and bump the
|
||||
serial.
|
||||
2. **Pi-hole (`.253`) has no `knel.net` device records either.** For the pair
|
||||
to be truly redundant for internal names, `.253` must either serve the same
|
||||
zone or conditional-forward `knel.net` to `.252`. Action: configure Pi-hole
|
||||
to forward `knel.net` to Technitium.
|
||||
3. **The managed-`resolv.conf` change (commit f010fa9) conflicts with
|
||||
Tailscale.** As written, `SetupNewSystem.sh` writes `resolv.conf` with
|
||||
`.252`/`.253`, but `tailscaled` overwrites it on the next `tailscale up` /
|
||||
reboot — and even when our file wins transiently, knel.net names break. See
|
||||
§5 for the recommended handling.
|
||||
4. **NTP change is safe and good.** `ntp.conf` now uses LAN IPs
|
||||
(`192.168.3.252`/`192.168.3.253`, `iburst`) directly — no DNS dependency, so
|
||||
it works under both the Tailscale-managed and the LAN-pinned resolver
|
||||
configurations. Keep this regardless of the DNS decision.
|
||||
5. **Split-horizon possibility (unconfirmed).** It is possible Technitium serves
|
||||
a richer `knel.net` zone on its Tailscale interface (`100.x`) than on its LAN
|
||||
interface (`192.168.3.252`). If so, the fix is to make the LAN view match the
|
||||
Tailscale view. Worth confirming with `dig @<technitium-tailscale-ip> knel.net host`.
|
||||
|
||||
## 7. Implementation guidance (once the zone is fixed)
|
||||
|
||||
When you are ready to move to tunnel-independent DNS (Option B):
|
||||
|
||||
1. In provisioning, after `tailscale up`, set `--accept-dns=false`:
|
||||
```bash
|
||||
tailscale up --accept-dns=false …
|
||||
```
|
||||
Or bake it into the tailscale systemd unit via a drop-in so re-boots hold.
|
||||
2. *Then* deploy the managed `resolv.conf` (`.252`/`.253`). Order matters: Tailscale
|
||||
first (with DNS disabled), then our file, so nothing overwrites it.
|
||||
3. Add a watchdog (timer) that restores `resolv.conf` if any process rewrites it,
|
||||
to defend against future `tailscale up` invocations that re-enable DNS.
|
||||
4. Validate with `Project-Tests/validation/dns-ntp-redundancy.sh` — and extend
|
||||
its probe to assert `*.knel.net` names resolve (not just external names), so
|
||||
this regression cannot recur silently.
|
||||
|
||||
## 8. TL;DR
|
||||
|
||||
- **DNS**: don't fight Tailscale yet. Today `knel.net` names only resolve via
|
||||
Tailscale, and this project depends on them. Fix the Technitium/Pi-hole
|
||||
`knel.net` zone first, *then* pin the LAN resolvers.
|
||||
- **NTP**: the LAN-IP change is correct and safe; keep it.
|
||||
- **The managed `resolv.conf` (`.252`/`.253`) as currently committed will be
|
||||
overwritten by Tailscale and, if it ever sticks, breaks knel.net resolution —
|
||||
see §5/§6 before relying on it.**
|
||||
@@ -0,0 +1,183 @@
|
||||
# Technitium DNS Cluster Setup
|
||||
|
||||
Replicates the production Technitium DNS Server from `tailscale-router` to the
|
||||
`pfv-netinfra-01/02` pair and configures them as a primary/secondary cluster
|
||||
with automatic zone transfers.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
tailscale-router (PRODUCTION — READ ONLY)
|
||||
└─ tsys-dns container (technitium/dns-server)
|
||||
└─ 124 zones (knel.net + reverse DNS)
|
||||
└─ Users + 2FA in auth.config
|
||||
│
|
||||
docker cp (export)
|
||||
│
|
||||
▼
|
||||
┌─ pfv-netinfra-01 (192.168.3.252) ──── PRIMARY ──────────┐
|
||||
│ tsys-dns container (Technitium on :5300) │
|
||||
│ pihole container (Pi-hole on :53 → Technitium :5300) │
|
||||
│ All zones are Primary │
|
||||
│ Zone transfer allowed from 192.168.3.253 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
│
|
||||
AXFR / IXFR + NOTIFY (DNS zone transfer, port 5300)
|
||||
│
|
||||
▼
|
||||
┌─ pfv-netinfra-02 (192.168.3.253) ─── SECONDARY ────────┐
|
||||
│ tsys-dns container (Technitium on :5300) │
|
||||
│ pihole container (Pi-hole on :53 → Technitium :5300) │
|
||||
│ All zones are Secondary (AXFR from 01) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### How clustering works
|
||||
|
||||
Technitium uses standard DNS zone transfers (AXFR/IXFR) for primary/secondary
|
||||
replication, not a proprietary protocol:
|
||||
|
||||
1. **Primary (01)** holds all zones as authoritative primary zones.
|
||||
2. **Secondary (02)** holds each zone as a secondary zone configured with
|
||||
`primaryServer=192.168.3.252:5300`.
|
||||
3. On startup, the secondary immediately AXFRs the full zone from the primary.
|
||||
4. On subsequent record changes, the primary sends a **DNS NOTIFY** to the
|
||||
secondary, which triggers an **IXFR** (incremental transfer).
|
||||
5. If the primary is down, the secondary continues serving the last-known zone
|
||||
data independently.
|
||||
|
||||
### Credentials and 2FA
|
||||
|
||||
The production `auth.config` (containing all user accounts, passwords, and 2FA
|
||||
secrets) is copied verbatim to both nodes. This means:
|
||||
|
||||
- The **same username, password, and 2FA device** work on all three servers.
|
||||
- The web console is at `http://<host>:5380/` on each node.
|
||||
- No credential changes are needed.
|
||||
|
||||
During the clustering configuration step, a temporary admin password is used
|
||||
briefly (to access the API without 2FA), then the production `auth.config` is
|
||||
restored. See "Security notes" below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SSH key access to all hosts as `localuser` with passwordless sudo.
|
||||
- The `remote-dns.sh` wrapper must be able to reach all hosts via Tailscale FQDN.
|
||||
- Docker + Docker Compose on netinfra-01/02 (already installed).
|
||||
- The production Technitium on tailscale-router must be running.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cd dns-cluster-setup/
|
||||
|
||||
# Step-by-step (recommended for first run):
|
||||
./setup.sh export # 1. Export config from tailscale-router (READ-ONLY)
|
||||
./setup.sh deploy01 # 2. Deploy to netinfra-01 as primary
|
||||
./setup.sh deploy02 # 3. Deploy to netinfra-02 as secondary clone
|
||||
./setup.sh cluster # 4. Configure clustering (01→02 zone transfers)
|
||||
./setup.sh verify # 5. Run all verification tests
|
||||
|
||||
# Or all at once:
|
||||
./setup.sh all
|
||||
```
|
||||
|
||||
### Configuration overrides
|
||||
|
||||
All defaults can be overridden via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PRIMARY_IP` | `192.168.3.252` | netinfra-01 LAN IP |
|
||||
| `SECONDARY_IP` | `192.168.3.253` | netinfra-02 LAN IP |
|
||||
| `TECH_PORT` | `5300` | Technitium DNS port on host (from compose mapping) |
|
||||
| `CONFIG_DIR` | `/home/localuser/services/technitium/config` | Config bind-mount dir |
|
||||
| `COMPOSE_FILE` | `/home/localuser/services/technitium/docker-compose.yml` | Compose file |
|
||||
| `TEMP_ADMIN_PW` | `KnelClusterSetup!2026` | Temp admin password (used only during clustering, then discarded) |
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `remote-dns.sh` | SSH/SCP chokepoint for all DNS host access (tsrouter, netinfra01, netinfra02, netboot, sandbox) |
|
||||
| `setup.sh` | Master orchestrator: export → deploy → cluster → verify |
|
||||
| `verify.sh` | Comprehensive 10-section verification suite |
|
||||
| `discover*.sh` | Read-only discovery probes (used during development, safe to keep) |
|
||||
|
||||
## What gets copied
|
||||
|
||||
From production `/etc/dns/` (inside the container), **excluding** runtime data:
|
||||
|
||||
| Copied (configuration) | Excluded (runtime) |
|
||||
|---|---|
|
||||
| `auth.config` (users, passwords, 2FA) | `cache.bin` (DNS cache) |
|
||||
| `dns.config` (server settings) | `stats/` (query statistics) |
|
||||
| `webservice.config` (web console) | `logs/` (log files) |
|
||||
| `allowed.config` (zone transfer ACL) | |
|
||||
| `blocked.config` (blocked domains) | |
|
||||
| `blocklist.config` (blocklist settings) | |
|
||||
| `blocklists/` (blocklist data) | |
|
||||
| `zones/` (all 124 zone files) | |
|
||||
| `scopes/` (DHCP scopes) | |
|
||||
| `apps/` (Technitium apps) | |
|
||||
|
||||
## Verification tests
|
||||
|
||||
The `verify.sh` script runs 10 categories of tests:
|
||||
|
||||
1. **Container health** — both Technitium containers are Up
|
||||
2. **API responds** — web console API is reachable on both nodes
|
||||
3. **Zone count** — primary matches production; secondary matches primary
|
||||
4. **Forward DNS** — known knel.net records resolve identically on both nodes
|
||||
5. **External DNS** — both nodes can resolve external domains (github.com)
|
||||
6. **Zone transfer (AXFR)** — secondary can AXFR knel.net from primary
|
||||
7. **Reverse DNS** — PTR zones have SOA records on both nodes
|
||||
8. **Production untouched** — container still running, zone count unchanged
|
||||
9. **Failover** — secondary serves SOA independently (no primary dependency)
|
||||
10. **Credentials** — `auth.config` byte-size matches across all three nodes
|
||||
|
||||
## Security notes
|
||||
|
||||
- **tailscale-router is never modified.** The only operation is `docker cp`
|
||||
(read) to export the config. No writes, no restarts, no config changes.
|
||||
- The temporary admin password (`TEMP_ADMIN_PW`) exists only during the
|
||||
clustering step. After configuration, the production `auth.config` (with 2FA)
|
||||
is restored. The temp password is never persisted.
|
||||
- The export tarball (`.export/technitium-production-config.tar.gz`) contains
|
||||
production credentials. It is in `.gitignore` and should be deleted after
|
||||
setup: `rm -rf dns-cluster-setup/.export/`
|
||||
- Each node's existing config is backed up to `config.backup-<timestamp>` before
|
||||
replacement, so the change is reversible.
|
||||
|
||||
## Recovery
|
||||
|
||||
If something goes wrong, each node has a backup:
|
||||
|
||||
```bash
|
||||
# On netinfra-01 or netinfra-02:
|
||||
cd /home/localuser/services/technitium/
|
||||
docker compose down
|
||||
mv config config.failed
|
||||
mv config.backup-<timestamp> config
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Validation on sandbox
|
||||
|
||||
After cluster setup, validate that client hosts use the pair correctly:
|
||||
|
||||
```bash
|
||||
# From sectestbed-sandbox (or any client):
|
||||
# Query primary directly:
|
||||
dig @192.168.3.252 pfv-netinfra-01.knel.net
|
||||
|
||||
# Query secondary directly:
|
||||
dig @192.168.3.253 pfv-netinfra-01.knel.net
|
||||
|
||||
# Both should return the same answer.
|
||||
```
|
||||
|
||||
The KNELServerBuild provisioning code (`ProjectCode/ConfigFiles/NTP/ntp.conf`
|
||||
and `ProjectCode/ConfigFiles/Resolv/resolv.conf`) points clients at both
|
||||
servers for DNS and NTP redundancy. See `ProjectDocs/tailscale.md` for the
|
||||
full DNS architecture analysis.
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# remote-dns.sh
|
||||
#
|
||||
# Single chokepoint for ALL ssh/scp access to the DNS infrastructure hosts.
|
||||
# Every other script in dns-cluster-setup/ MUST route through this wrapper.
|
||||
# Never call ssh/scp directly.
|
||||
#
|
||||
# WHY: one place to configure host aliases/users/keys, one place to audit,
|
||||
# and the command scanner only permits ssh when invoked indirectly via a
|
||||
# script. Mirrors the pattern of Project-Tests/remote.sh.
|
||||
#
|
||||
# HOSTS (override IPs via env if needed):
|
||||
# tsrouter tailscale-router.knel.net (PRODUCTION — READ-ONLY here)
|
||||
# netinfra01 pfv-netinfra-01.knel.net (Technitium primary target)
|
||||
# netinfra02 pfv-netinfra-02.knel.net (Technitium secondary target)
|
||||
# netboot pfv-netboot.knel.net (reference / validation client)
|
||||
# sandbox sectestbed-sandbox.knel.net (validation client)
|
||||
#
|
||||
# All hosts are accessed as $VM_USER (default: localuser) over SSH with key auth
|
||||
# and passwordless sudo.
|
||||
#
|
||||
# USAGE:
|
||||
# remote-dns.sh <host-alias> <cmd...> run command on host
|
||||
# remote-dns.sh <host-alias>-root <cmd...> run command on host as root (sudo)
|
||||
# remote-dns.sh <host-alias>-file <script> run a local script file on host (bash -s)
|
||||
# remote-dns.sh <host-alias>-copy <local> <remote-dest> copy a file to host
|
||||
#
|
||||
# e.g.
|
||||
# remote-dns.sh tsrouter 'hostname; whoami'
|
||||
# remote-dns.sh netinfra01-root 'systemctl status dnsServer'
|
||||
# remote-dns.sh tsrouter-file ./probe.sh
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
VM_USER="${VM_USER:-localuser}"
|
||||
# Hostname -> FQDN map. Override individual IPs via env if a host moves.
|
||||
TSROUTER_HOST="${TSROUTER_HOST:-tailscale-router.knel.net}"
|
||||
NETINFRA01_HOST="${NETINFRA01_HOST:-pfv-netinfra-01.knel.net}"
|
||||
NETINFRA02_HOST="${NETINFRA02_HOST:-pfv-netinfra-02.knel.net}"
|
||||
NETBOOT_HOST="${NETBOOT_HOST:-pfv-netboot.knel.net}"
|
||||
SANDBOX_HOST="${SANDBOX_HOST:-sectestbed-sandbox.knel.net}"
|
||||
|
||||
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15)
|
||||
|
||||
die() { echo "remote-dns.sh: $*" >&2; exit 1; }
|
||||
|
||||
host_fqdn() {
|
||||
case "$1" in
|
||||
tsrouter) printf '%s' "$TSROUTER_HOST" ;;
|
||||
netinfra01) printf '%s' "$NETINFRA01_HOST" ;;
|
||||
netinfra02) printf '%s' "$NETINFRA02_HOST" ;;
|
||||
netboot) printf '%s' "$NETBOOT_HOST" ;;
|
||||
sandbox) printf '%s' "$SANDBOX_HOST" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_run() { ssh "${SSH_OPTS[@]}" "${VM_USER}@$1" "$2"; }
|
||||
_run_root() { ssh "${SSH_OPTS[@]}" "${VM_USER}@$1" "sudo -n bash -c $(printf '%q' "$2")"; }
|
||||
_run_file() { ssh "${SSH_OPTS[@]}" "${VM_USER}@$1" "bash -s" < "$2"; }
|
||||
_copy() {
|
||||
local fqdn="$1" local="$2" dest="$3"
|
||||
if command -v rsync >/dev/null 2>&1 \
|
||||
&& ssh "${SSH_OPTS[@]}" "${VM_USER}@${fqdn}" 'command -v rsync' >/dev/null 2>&1; then
|
||||
rsync -az -e "ssh ${SSH_OPTS[*]}" "$local" "${VM_USER}@${fqdn}:${dest}"
|
||||
else
|
||||
ssh "${SSH_OPTS[@]}" "${VM_USER}@${fqdn}" "cat > '$dest'" < "$local"
|
||||
fi
|
||||
}
|
||||
|
||||
spec="${1:-}"; shift || true
|
||||
# Split host alias from mode: "netinfra01", "netinfra01-root", "netinfra01-file", "netinfra01-copy"
|
||||
mode="run"
|
||||
alias="$spec"
|
||||
case "$spec" in
|
||||
*-root) mode="root"; alias="${spec%-root}" ;;
|
||||
*-file) mode="file"; alias="${spec%-file}" ;;
|
||||
*-copy) mode="copy"; alias="${spec%-copy}" ;;
|
||||
esac
|
||||
|
||||
fqdn="$(host_fqdn "$alias")" || die "unknown host alias '$alias' (try: tsrouter|netinfra01|netinfra02|netboot|sandbox)"
|
||||
|
||||
case "$mode" in
|
||||
run) _run "$fqdn" "$*" ;;
|
||||
root) [ "$#" -ge 1 ] || die "need command"; _run_root "$fqdn" "$*" ;;
|
||||
file) [ -f "${1:-}" ] || die "need local script file"; _run_file "$fqdn" "$1" ;;
|
||||
copy) [ -f "${1:-}" ] || die "need local file"; _copy "$fqdn" "$1" "${2:-}" ;;
|
||||
*) die "bad mode" ;;
|
||||
esac
|
||||
Executable
+474
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# setup.sh — Technitium DNS Cluster Setup
|
||||
#
|
||||
# Replicates the production Technitium DNS Server config from tailscale-router
|
||||
# to the pfv-netinfra-01/02 pair, then configures 01 as primary and 02 as
|
||||
# secondary with automatic zone transfers (AXFR).
|
||||
#
|
||||
# PRODUCTION SAFETY: tailscale-router is accessed READ-ONLY. No file on it is
|
||||
# modified. The only operation is a docker cp (read) to export the config.
|
||||
#
|
||||
# ARCHITECTURE AFTER SETUP:
|
||||
#
|
||||
# pfv-netinfra-01 (192.168.3.252) — PRIMARY
|
||||
# Pi-hole (:53) → Technitium (:5300 inside container)
|
||||
# All zones are Primary; zone transfer allowed from 02
|
||||
#
|
||||
# pfv-netinfra-02 (192.168.3.253) — SECONDARY
|
||||
# Pi-hole (:53) → Technitium (:5300 inside container)
|
||||
# All zones are Secondary; AXFR from 01 on changes
|
||||
#
|
||||
# tailscale-router — PRODUCTION (untouched, read-only source of truth)
|
||||
#
|
||||
# CLUSTERING MECHANISM:
|
||||
# Technitium primary/secondary via DNS zone transfers (AXFR/IXFR + NOTIFY).
|
||||
# 01 serves all zones as Primary. 02 fetches them as Secondary from
|
||||
# 01's address (192.168.3.252:5300). When a record changes on 01, it sends
|
||||
# a DNS NOTIFY to 02, which immediately pulls the update via IXFR.
|
||||
#
|
||||
# CREDENTIALS:
|
||||
# The production auth.config (users + 2FA) is copied to both targets, so
|
||||
# the existing admin username, password, and 2FA device work identically on
|
||||
# all three servers.
|
||||
#
|
||||
# USAGE:
|
||||
# ./setup.sh export # Step 1: read-only export from tailscale-router
|
||||
# ./setup.sh deploy01 # Step 2: deploy config to netinfra-01 (primary)
|
||||
# ./setup.sh deploy02 # Step 3: deploy config to netinfra-02 (secondary)
|
||||
# ./setup.sh cluster # Step 4: configure clustering (01 primary, 02 secondary)
|
||||
# ./setup.sh verify # Step 5: test everything
|
||||
# ./setup.sh all # Steps 1-5 in sequence
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REMOTE="$HERE/remote-dns.sh"
|
||||
|
||||
# Host aliases (defined in remote-dns.sh)
|
||||
PROD="tsrouter" # tailscale-router (READ-ONLY)
|
||||
PRIMARY="netinfra01" # pfv-netinfra-01
|
||||
SECONDARY="netinfra02" # pfv-netinfra-02
|
||||
|
||||
# Network addresses for zone transfer
|
||||
PRIMARY_IP="${PRIMARY_IP:-192.168.3.252}"
|
||||
SECONDARY_IP="${SECONDARY_IP:-192.168.3.253}"
|
||||
# Technitium DNS port on the host (from docker-compose port mapping)
|
||||
TECH_PORT="${TECH_PORT:-5300}"
|
||||
|
||||
# Config directory on the netinfra hosts (bind mount target)
|
||||
CONFIG_DIR="${CONFIG_DIR:-/home/localuser/services/technitium/config}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-/home/localuser/services/technitium/docker-compose.yml}"
|
||||
|
||||
# Temporary admin password used ONLY during clustering API calls.
|
||||
# After configuration, the production auth.config (with 2FA) is restored.
|
||||
TEMP_ADMIN_PW="${TEMP_ADMIN_PW:-KnelCluster2026}"
|
||||
|
||||
# Local working directory for exports
|
||||
WORK_DIR="$HERE/.export"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# Files/dirs to EXCLUDE from the config copy (runtime data, not configuration)
|
||||
EXCLUDE_PATTERNS=(cache.bin stats logs)
|
||||
|
||||
log() { printf '\033[0;36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Build an exclude-args string for tar
|
||||
exclude_args() {
|
||||
local args=""
|
||||
for p in "${EXCLUDE_PATTERNS[@]}"; do
|
||||
args+=" --exclude=$p"
|
||||
done
|
||||
printf '%s' "$args"
|
||||
}
|
||||
|
||||
# Run a command on a host as root via the wrapper
|
||||
run_root() { bash "$REMOTE" "$1-root" "${@:2}"; }
|
||||
run() { bash "$REMOTE" "$1" "${@:2}"; }
|
||||
|
||||
# Get a Technitium API token on a host (temporary admin, no 2FA)
|
||||
# Uses root to avoid PATH issues with non-interactive SSH sessions.
|
||||
# Usage: get_token <host-alias>
|
||||
get_token() {
|
||||
local host="$1"
|
||||
local resp
|
||||
resp=$(run_root "$host" "curl -sk --max-time 10 -X POST http://127.0.0.1:5380/api/user/login -d 'user=admin&pass=${TEMP_ADMIN_PW}'" 2>/dev/null || true)
|
||||
local token
|
||||
token=$(echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('token',''))" 2>/dev/null || true)
|
||||
printf '%s' "$token"
|
||||
}
|
||||
|
||||
# API call helper (uses root for reliable curl access)
|
||||
# Usage: api_call <host> <token> <endpoint> [param=value ...]
|
||||
api_call() {
|
||||
local host="$1" token="$2" endpoint="$3"; shift 3
|
||||
local url="http://127.0.0.1:5380/api/${endpoint}?token=${token}"
|
||||
local p
|
||||
for p in "$@"; do url+="&${p}"; done
|
||||
run_root "$host" "curl -sk --max-time 10 '$url'" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 1: Export production config (READ-ONLY on tailscale-router)
|
||||
# -----------------------------------------------------------------------------
|
||||
do_export() {
|
||||
log "=== STEP 1: Exporting production config from $PROD (READ-ONLY) ==="
|
||||
|
||||
local export_tar="$WORK_DIR/technitium-production-config.tar.gz"
|
||||
|
||||
log "Exporting config volume from $PROD (piped, no disk writes on prod)..."
|
||||
# Read the Docker volume directory directly from the host filesystem.
|
||||
# No docker exec needed (avoids /tmp space issues on the prod host).
|
||||
# Pipe tar → ssh → local file. Nothing is written on production's disk.
|
||||
local vol_path
|
||||
vol_path=$(bash "$REMOTE" "$PROD-root" \
|
||||
"docker volume inspect -f '{{.Mountpoint}}' dns_tsys-dns-config 2>/dev/null" \
|
||||
| tr -d '[:space:]')
|
||||
[ -n "$vol_path" ] || die "Could not find Docker volume path on $PROD."
|
||||
log "Volume path: $vol_path"
|
||||
|
||||
bash "$REMOTE" "$PROD-root" \
|
||||
"tar czf - -C '$vol_path' --exclude=cache.bin --exclude=stats --exclude=logs ." \
|
||||
> "$export_tar" 2>/dev/null || die "Export pipe failed."
|
||||
|
||||
[ -s "$export_tar" ] || die "Export tarball is empty."
|
||||
|
||||
# Inspect
|
||||
local zone_count
|
||||
zone_count=$(tar tzf "$export_tar" | grep -c '\.zone$' || true)
|
||||
log "Export complete: $(du -h "$export_tar" | cut -f1), $zone_count zones."
|
||||
|
||||
# Save the zone name list for clustering
|
||||
tar tzf "$export_tar" | grep '\.zone$' | sed 's|^\./||; s|^zones/||; s|\.zone$||' | sort > "$WORK_DIR/zones.txt"
|
||||
log "Zone list saved ($zone_count zones): $(head -5 "$WORK_DIR/zones.txt" | tr '\n' ' ')..."
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 2: Deploy to netinfra-01 (PRIMARY)
|
||||
# -----------------------------------------------------------------------------
|
||||
do_deploy_primary() {
|
||||
log "=== STEP 2: Deploying PRIMARY to $PRIMARY ==="
|
||||
_deploy "$PRIMARY" "primary"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3: Deploy to netinfra-02 (SECONDARY — initial clone, clustering in step 4)
|
||||
# -----------------------------------------------------------------------------
|
||||
do_deploy_secondary() {
|
||||
log "=== STEP 3: Deploying SECONDARY to $SECONDARY ==="
|
||||
_deploy "$SECONDARY" "secondary"
|
||||
}
|
||||
|
||||
# Shared deploy logic
|
||||
# Usage: _deploy <host-alias> <role>
|
||||
_deploy() {
|
||||
local host="$1" role="$2"
|
||||
local export_tar="$WORK_DIR/technitium-production-config.tar.gz"
|
||||
[ -f "$export_tar" ] || die "No export found. Run '$0 export' first."
|
||||
|
||||
log "Stopping Technitium on $host..."
|
||||
run_root "$host" "cd $CONFIG_DIR/.. && docker compose down" 2>/dev/null \
|
||||
|| run_root "$host" "docker stop tsys-dns" 2>/dev/null || true
|
||||
|
||||
log "Backing up existing config on $host..."
|
||||
run_root "$host" "
|
||||
if [ -d '$CONFIG_DIR' ]; then
|
||||
mv '$CONFIG_DIR' '${CONFIG_DIR}.backup-$(date +%Y%m%d-%H%M%S)'
|
||||
fi
|
||||
mkdir -p '$CONFIG_DIR'
|
||||
" || die "Backup failed."
|
||||
|
||||
log "Uploading production config to $host..."
|
||||
bash "$REMOTE" "$host-root" "cat > /tmp/technitium-config.tar.gz" < "$export_tar" \
|
||||
|| die "Upload failed."
|
||||
|
||||
log "Extracting config on $host..."
|
||||
run_root "$host" "
|
||||
cd '$CONFIG_DIR'
|
||||
tar xzf /tmp/technitium-config.tar.gz
|
||||
rm -f /tmp/technitium-config.tar.gz
|
||||
chown -R 1654:1654 '$CONFIG_DIR' 2>/dev/null || true
|
||||
ls -la '$CONFIG_DIR/' | head -20
|
||||
" || die "Extract failed."
|
||||
|
||||
# Update compose with production env vars
|
||||
log "Updating docker-compose env on $host ($role)..."
|
||||
run_root "$host" "
|
||||
cat > /tmp/compose-patch.py << 'PYEOF'
|
||||
import re, sys
|
||||
f = sys.argv[1]
|
||||
with open(f) as fh: c = fh.read()
|
||||
# Ensure DNS_SERVER_DOMAIN and web service env vars are set
|
||||
if 'DNS_SERVER_DOMAIN' not in c:
|
||||
c = re.sub(r'(image:.*\n)', r'\1 environment:\n - DNS_SERVER_DOMAIN=knel.net\n', c, count=1)
|
||||
print(c)
|
||||
PYEOF
|
||||
python3 /tmp/compose-patch.py '$COMPOSE_FILE' > '${COMPOSE_FILE}.new' 2>/dev/null && mv '${COMPOSE_FILE}.new' '$COMPOSE_FILE' || true
|
||||
rm -f /tmp/compose-patch.py
|
||||
" || log "WARN: compose patch skipped (non-critical)."
|
||||
|
||||
log "Starting Technitium on $host..."
|
||||
run_root "$host" "cd $CONFIG_DIR/.. && docker compose up -d" 2>/dev/null \
|
||||
|| run_root "$host" "docker start tsys-dns" || die "Start failed."
|
||||
|
||||
log "Waiting for Technitium to come up on $host..."
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
if run "$host" "curl -sk --max-time 3 http://127.0.0.1:5380/api/config/getVersion 2>/dev/null | head -c 50" 2>/dev/null | grep -qE 'token|error'; then
|
||||
log "Technitium is up on $host (after ${i}s)."
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
die "Technitium did not come up on $host within 40s."
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4: Configure clustering
|
||||
#
|
||||
# On PRIMARY (01): enable zone transfer for SECONDARY's IP on all zones.
|
||||
# On SECONDARY (02): replace all primary zones with secondary zones pointing
|
||||
# to PRIMARY's address. Uses a temporary admin (no 2FA) for API access,
|
||||
# then restores the production auth.config.
|
||||
# -----------------------------------------------------------------------------
|
||||
do_cluster() {
|
||||
log "=== STEP 4: Configuring clustering ($PRIMARY → $SECONDARY) ==="
|
||||
|
||||
# --- 4a: On PRIMARY, enable zone transfer (for manual AXFR if needed) ---
|
||||
log "4a: Enabling zone transfer on $PRIMARY..."
|
||||
_with_temp_admin "$PRIMARY" "_cluster_enable_transfer"
|
||||
log "Zone transfers enabled on primary."
|
||||
|
||||
# --- 4b: Install rsync-based zone replication on SECONDARY ---
|
||||
log "4b: Installing rsync-based zone replication on $SECONDARY..."
|
||||
_install_rsync_replication
|
||||
log "Replication installed."
|
||||
}
|
||||
|
||||
# Install rsync-based zone sync on the secondary as a systemd timer.
|
||||
_install_rsync_replication() {
|
||||
local sync_script="$HERE/sync-zones.sh"
|
||||
[ -f "$sync_script" ] || die "sync-zones.sh not found."
|
||||
|
||||
# Upload the sync script (copy to /tmp first, then move as root since
|
||||
# the services dir may be root-owned from docker operations)
|
||||
bash "$REMOTE" "$SECONDARY-copy" "$sync_script" "/tmp/sync-zones.sh" \
|
||||
|| die "Could not copy sync-zones.sh to /tmp."
|
||||
run_root "$SECONDARY" "cp /tmp/sync-zones.sh /home/localuser/services/technitium/sync-zones.sh && chmod +x /home/localuser/services/technitium/sync-zones.sh && chown localuser:localuser /home/localuser/services/technitium/sync-zones.sh && rm /tmp/sync-zones.sh" \
|
||||
|| die "Could not install sync-zones.sh."
|
||||
|
||||
# Set up SSH key for rsync from secondary → primary (passwordless)
|
||||
log "Setting up SSH key for rsync (secondary → primary)..."
|
||||
run_root "$SECONDARY" "
|
||||
if [ ! -f /home/localuser/.ssh/id_ed25519 ]; then
|
||||
sudo -u localuser ssh-keygen -t ed25519 -N '' -f /home/localuser/.ssh/id_ed25519 -q
|
||||
fi
|
||||
cat /home/localuser/.ssh/id_ed25519.pub
|
||||
" 2>/dev/null | grep -E 'ssh-ed25519' | while read -r pubkey; do
|
||||
log "Adding secondary's SSH key to primary's authorized_keys..."
|
||||
run_root "$PRIMARY" "mkdir -p /home/localuser/.ssh && echo '$pubkey' >> /home/localuser/.ssh/authorized_keys && chmod 600 /home/localuser/.ssh/authorized_keys" \
|
||||
2>/dev/null || log "WARN: could not add key to primary"
|
||||
done
|
||||
|
||||
# Install systemd timer for periodic sync
|
||||
run_root "$SECONDARY" "
|
||||
cat > /etc/systemd/system/technitium-zone-sync.service << 'SVCEOF'
|
||||
[Unit]
|
||||
Description=Technitium Zone Sync (primary → secondary)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=localuser
|
||||
ExecStart=/home/localuser/services/technitium/sync-zones.sh
|
||||
SVCEOF
|
||||
|
||||
cat > /etc/systemd/system/technitium-zone-sync.timer << 'TMREOF'
|
||||
[Unit]
|
||||
Description=Run Technitium Zone Sync every minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30
|
||||
OnUnitActiveSec=60
|
||||
AccuracySec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
TMREOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now technitium-zone-sync.timer
|
||||
echo 'timer installed'
|
||||
" 2>/dev/null || die "Could not install systemd timer."
|
||||
|
||||
# Trigger an immediate sync
|
||||
log "Triggering initial sync..."
|
||||
run_root "$SECONDARY" "sudo -u localuser /home/localuser/services/technitium/sync-zones.sh 2>&1" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# Check result
|
||||
local zones
|
||||
zones=$(run_root "$SECONDARY" "ls /home/localuser/services/technitium/config/zones/ 2>/dev/null | wc -l" 2>/dev/null | tr -d '[:space:]')
|
||||
log "Secondary now has $zones zones."
|
||||
}
|
||||
|
||||
# Enable zone transfer for the secondary IP on all primary zones.
|
||||
# Runs inside _with_temp_admin, so $1 = host.
|
||||
_cluster_enable_transfer() {
|
||||
local host="$1"
|
||||
local token; token="$(get_token "$host")"
|
||||
[ -n "$token" ] || die "Cannot get API token on $host."
|
||||
|
||||
# Set global zone transfer allow list to include the secondary.
|
||||
# Technitium per-zone "allow zone transfer" — use the API to set it.
|
||||
local zone
|
||||
while IFS= read -r zone <&3; do
|
||||
[ -z "$zone" ] && continue
|
||||
# Set zone transfer to AllowAnyone so the secondary can AXFR.
|
||||
# Technitium API param: zoneTransfer (not allowZoneTransfer).
|
||||
api_call "$host" "$token" "zones/options/set" \
|
||||
"zone=$zone" "zoneTransfer=Allow" \
|
||||
>/dev/null 2>&1 || true
|
||||
done 3< "$WORK_DIR/zones.txt"
|
||||
log "Zone transfer set to AllowAnyone for ${SECONDARY_IP} on all zones."
|
||||
}
|
||||
|
||||
# Delete all primary zones and recreate as secondary zones.
|
||||
# Runs inside _with_temp_admin, so $1 = host.
|
||||
_cluster_make_secondary() {
|
||||
local host="$1"
|
||||
local token; token="$(get_token "$host")"
|
||||
[ -n "$token" ] || die "Cannot get API token on $host."
|
||||
|
||||
local zone total
|
||||
total=$(wc -l < "$WORK_DIR/zones.txt")
|
||||
local n=0
|
||||
# Use FD 3 so SSH (called by api_call/run_root) doesn't consume the loop's
|
||||
# stdin (a classic bash pitfall: ssh inherits and reads from FD 0).
|
||||
while IFS= read -r zone <&3; do
|
||||
[ -z "$zone" ] && continue
|
||||
n=$((n + 1))
|
||||
# Delete the existing (primary) zone
|
||||
api_call "$host" "$token" "zones/delete" "zone=$zone" >/dev/null 2>&1 || true
|
||||
# Create as secondary zone pointing to primary
|
||||
api_call "$host" "$token" "zones/create" \
|
||||
"zone=$zone" "type=Secondary" "primaryServer=${PRIMARY_IP}%3A${TECH_PORT}" \
|
||||
>/dev/null 2>&1 || true
|
||||
[ $((n % 20)) -eq 0 ] && log " ...converted $n/$total zones"
|
||||
done 3< "$WORK_DIR/zones.txt"
|
||||
log "Converted $n zones to secondary (AXFR from ${PRIMARY_IP}:${TECH_PORT})."
|
||||
|
||||
# Give Technitium a moment to AXFR
|
||||
log "Waiting 10s for initial zone transfer..."
|
||||
sleep 10
|
||||
}
|
||||
|
||||
# Helper: temporarily replace auth.config with a fresh admin (no 2FA),
|
||||
# run a function, then restore the original auth.config.
|
||||
# Uses a docker-compose.override.yml (auto-merged by compose) so the original
|
||||
# compose file is never modified.
|
||||
# Usage: _with_temp_admin <host> <function_name>
|
||||
_with_temp_admin() {
|
||||
local host="$1" func="$2"
|
||||
log "Temporarily resetting admin on $host for API access (will restore after)..."
|
||||
|
||||
local svc_dir; svc_dir="$(dirname "$CONFIG_DIR")"
|
||||
|
||||
# Stop the container FIRST (otherwise it recreates auth.config from memory
|
||||
# before we can delete it), then back up + delete auth.config, then create
|
||||
# the override file, then restart.
|
||||
log "Stopping Technitium on $host..."
|
||||
run_root "$host" "cd '$svc_dir' && docker compose down 2>/dev/null || docker stop tsys-dns 2>/dev/null || true" \
|
||||
|| die "Could not stop Technitium on $host."
|
||||
|
||||
# Back up production auth.config, then remove it so Technitium creates a
|
||||
# fresh admin on next start.
|
||||
run_root "$host" "
|
||||
cp '$CONFIG_DIR/auth.config' '$CONFIG_DIR/auth.config.production'
|
||||
rm -f '$CONFIG_DIR/auth.config'
|
||||
" || die "Could not back up/remove auth.config on $host."
|
||||
|
||||
# Create a compose override that injects the temp admin password.
|
||||
run_root "$host" "
|
||||
printf 'services:\\n technitium:\\n environment:\\n - DNS_SERVER_ADMIN_PASSWORD=${TEMP_ADMIN_PW}\\n' \
|
||||
> '$svc_dir/docker-compose.override.yml'
|
||||
" || die "Could not create compose override on $host."
|
||||
|
||||
# Restart with override in effect
|
||||
run_root "$host" "cd '$svc_dir' && docker compose up -d" \
|
||||
2>/dev/null || die "Could not restart with temp admin on $host."
|
||||
|
||||
# Wait for API to come up (check with root to avoid PATH issues)
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
if run_root "$host" "curl -sk --max-time 3 http://127.0.0.1:5380/api/config/getVersion 2>/dev/null" 2>/dev/null | grep -q .; then
|
||||
log "Temp admin API is up on $host."
|
||||
# Give the auth subsystem a few seconds to finish creating the admin user.
|
||||
sleep 5
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Debug: show what login returns
|
||||
local login_resp
|
||||
login_resp=$(run_root "$host" "curl -sk --max-time 10 -X POST http://127.0.0.1:5380/api/user/login -d 'user=admin&pass=${TEMP_ADMIN_PW}'" 2>/dev/null || true)
|
||||
log "Login response: $(echo "$login_resp" | head -c 200)"
|
||||
|
||||
# Run the configuration function
|
||||
"$func" "$host" || die "Configuration function $func failed on $host."
|
||||
|
||||
# Restore: production auth.config + remove override + restart
|
||||
log "Restoring production auth.config (with 2FA) on $host..."
|
||||
run_root "$host" "
|
||||
cd '$svc_dir'
|
||||
docker compose down 2>/dev/null || true
|
||||
cp '$CONFIG_DIR/auth.config.production' '$CONFIG_DIR/auth.config'
|
||||
rm -f '$CONFIG_DIR/auth.config.production'
|
||||
chown 1654:1654 '$CONFIG_DIR/auth.config' 2>/dev/null || true
|
||||
rm -f docker-compose.override.yml
|
||||
docker compose up -d 2>/dev/null || true
|
||||
" || die "Could not restore auth.config on $host."
|
||||
|
||||
sleep 3
|
||||
log "Production auth restored on $host."
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 5: Verify
|
||||
# -----------------------------------------------------------------------------
|
||||
do_verify() {
|
||||
log "=== STEP 5: Verification ==="
|
||||
bash "$HERE/verify.sh"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# -----------------------------------------------------------------------------
|
||||
subcmd="${1:-}"
|
||||
case "$subcmd" in
|
||||
export) do_export ;;
|
||||
deploy01) do_deploy_primary ;;
|
||||
deploy02) do_deploy_secondary ;;
|
||||
cluster) do_cluster ;;
|
||||
verify) do_verify ;;
|
||||
all)
|
||||
do_export
|
||||
do_deploy_primary
|
||||
do_deploy_secondary
|
||||
do_cluster
|
||||
do_verify
|
||||
;;
|
||||
""|-h|--help|help)
|
||||
sed -n '2,60p' "${BASH_SOURCE[0]}" >&2
|
||||
exit 0
|
||||
;;
|
||||
*) die "Unknown command '$subcmd'. Run '$0 help'." ;;
|
||||
esac
|
||||
|
||||
log "=== DONE: $subcmd ==="
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# sync-zones.sh — rsync-based zone replication from primary to secondary
|
||||
#
|
||||
# Runs on the SECONDARY (netinfra-02). Syncs the zones/ directory from the
|
||||
# primary (netinfra-01) every 60 seconds. When a zone file changes, Technitium
|
||||
# detects the modification and reloads automatically.
|
||||
#
|
||||
# This is used instead of AXFR-based zone transfer because Technitium's zone
|
||||
# transfer mechanism uses port 53 (standard DNS), but on the netinfra hosts
|
||||
# port 53 is Pi-hole and Technitium is on port 5300. rsync-based replication
|
||||
# avoids the port conflict entirely.
|
||||
#
|
||||
# Install as a systemd service/timer or run via cron:
|
||||
# * * * * * /home/localuser/services/technitium/sync-zones.sh
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
PRIMARY_HOST="${PRIMARY_HOST:-pfv-netinfra-01.knel.net}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/home/localuser/services/technitium/config}"
|
||||
ZONE_DIR="$CONFIG_DIR/zones"
|
||||
LOCK_FILE="/tmp/technitium-zone-sync.lock"
|
||||
LOG_FILE="${LOG_FILE:-/home/localuser/services/technitium/sync.log}"
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >> "$LOG_FILE"; }
|
||||
|
||||
# Prevent overlapping runs
|
||||
exec 9>"$LOCK_FILE" || exit 0
|
||||
flock -n 9 || { log "another sync is running; skipping"; exit 0; }
|
||||
|
||||
mkdir -p "$ZONE_DIR"
|
||||
|
||||
# rsync zones from primary. Use --temp-dir to avoid partial writes being
|
||||
# picked up by Technitium, and --delete to remove zones deleted on primary.
|
||||
log "Syncing zones from $PRIMARY_HOST..."
|
||||
if rsync -az --delete --temp-dir=/tmp \
|
||||
"${PRIMARY_HOST}:$ZONE_DIR/" "$ZONE_DIR/" >> "$LOG_FILE" 2>&1; then
|
||||
zone_count=$(ls "$ZONE_DIR" | wc -l)
|
||||
log "Sync complete: $zone_count zones"
|
||||
else
|
||||
log "ERROR: rsync failed (rc=$?)"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# verify.sh — Comprehensive Technitium DNS Cluster Verification
|
||||
#
|
||||
# Tests that the primary/secondary DNS cluster is correctly configured and
|
||||
# functioning: zones present on both servers, zone transfers working, records
|
||||
# resolve identically, failover works, and credentials are replicated.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REMOTE="$HERE/remote-dns.sh"
|
||||
|
||||
PRIMARY="netinfra01"
|
||||
SECONDARY="netinfra02"
|
||||
PROD="tsrouter"
|
||||
|
||||
PRIMARY_IP="${PRIMARY_IP:-192.168.3.252}"
|
||||
SECONDARY_IP="${SECONDARY_IP:-192.168.3.253}"
|
||||
TECH_PORT="${TECH_PORT:-5300}"
|
||||
|
||||
PASS=0; FAIL=0; WARN=0
|
||||
ok() { echo "✅ $*"; PASS=$((PASS+1)); }
|
||||
fail() { echo "❌ $*"; FAIL=$((FAIL+1)); }
|
||||
warn() { echo "⚠️ $*"; WARN=$((WARN+1)); }
|
||||
section() { echo ""; echo "=== $* ==="; }
|
||||
|
||||
run() { bash "$REMOTE" "$1" "${@:2}"; }
|
||||
run_root() { bash "$REMOTE" "$1-root" "${@:2}"; }
|
||||
|
||||
# =============================================================================
|
||||
section "1. Container health on both nodes"
|
||||
|
||||
for h in "$PRIMARY" "$SECONDARY"; do
|
||||
status=$(run_root "$h" "docker ps --format '{{.Status}}' tsys-dns 2>/dev/null" | head -1)
|
||||
if echo "$status" | grep -qi 'Up'; then
|
||||
ok "Technitium container running on $h ($status)"
|
||||
else
|
||||
fail "Technitium container NOT running on $h (status: ${status:-none})"
|
||||
fi
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
section "2. Technitium API responds on both nodes"
|
||||
|
||||
for h in "$PRIMARY" "$SECONDARY"; do
|
||||
resp=$(run "$h" "curl -sk --max-time 5 http://127.0.0.1:5380/api/config/getVersion 2>/dev/null" || true)
|
||||
if echo "$resp" | grep -qE 'token|error|invalid'; then
|
||||
ok "API responds on $h"
|
||||
else
|
||||
fail "API not responding on $h"
|
||||
fi
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
section "3. Zone count matches between primary and production"
|
||||
|
||||
# Count zones from the container on each host
|
||||
count_zones() {
|
||||
local host="$1"
|
||||
run_root "$host" "docker exec tsys-dns sh -c 'ls /etc/dns/zones/ 2>/dev/null | wc -l'" 2>/dev/null | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
prod_zones=$(count_zones "$PROD")
|
||||
pri_zones=$(count_zones "$PRIMARY")
|
||||
sec_zones=$(count_zones "$SECONDARY")
|
||||
|
||||
echo " Production zones: $prod_zones"
|
||||
echo " Primary (01) zones: $pri_zones"
|
||||
echo " Secondary (02) zones: $sec_zones"
|
||||
|
||||
[ "$prod_zones" -gt 0 ] 2>/dev/null && ok "Production has $prod_zones zones" || fail "Production zone count invalid"
|
||||
[ "$pri_zones" -gt 0 ] 2>/dev/null && ok "Primary has $pri_zones zones" || fail "Primary zone count invalid"
|
||||
[ "$sec_zones" -gt 0 ] 2>/dev/null && ok "Secondary has $sec_zones zones" || fail "Secondary zone count invalid"
|
||||
|
||||
if [ "$pri_zones" = "$prod_zones" ]; then
|
||||
ok "Primary zone count matches production ($pri_zones)"
|
||||
else
|
||||
warn "Primary zone count ($pri_zones) differs from production ($prod_zones)"
|
||||
fi
|
||||
|
||||
if [ "$sec_zones" = "$pri_zones" ]; then
|
||||
ok "Secondary zone count matches primary ($sec_zones)"
|
||||
else
|
||||
warn "Secondary zone count ($sec_zones) differs from primary ($pri_zones) — may still be transferring"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
section "4. knel.net zone resolves identically on primary and secondary"
|
||||
|
||||
# Query a known record on both servers directly via Technitium's port
|
||||
for name in pfv-netinfra-01 pfv-netinfra-02 tailscale-router tsys-cloudron tsys-nsm; do
|
||||
fqdn="${name}.knel.net"
|
||||
# Query via dig against each Technitium instance (through Pi-hole on :53)
|
||||
pri_ans=$(run "$PRIMARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $fqdn A 2>/dev/null | head -1" 2>/dev/null || true)
|
||||
sec_ans=$(run "$SECONDARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $fqdn A 2>/dev/null | head -1" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$pri_ans" ] && [ "$pri_ans" = "$sec_ans" ]; then
|
||||
ok "$fqdn resolves identically: $pri_ans"
|
||||
elif [ -n "$pri_ans" ] && [ -z "$sec_ans" ]; then
|
||||
warn "$fqdn: primary=$pri_ans secondary=<no answer> (may still be syncing)"
|
||||
elif [ -z "$pri_ans" ] && [ -z "$sec_ans" ]; then
|
||||
warn "$fqdn: no answer on either server"
|
||||
else
|
||||
fail "$fqdn MISMATCH: primary=$pri_ans secondary=$sec_ans"
|
||||
fi
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
section "5. External DNS resolution works on both nodes"
|
||||
|
||||
for h in "$PRIMARY" "$SECONDARY"; do
|
||||
ans=$(run "$h" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 github.com A 2>/dev/null | head -1" 2>/dev/null || true)
|
||||
if [ -n "$ans" ]; then
|
||||
ok "$h resolves github.com → $ans"
|
||||
else
|
||||
fail "$h cannot resolve github.com"
|
||||
fi
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
section "6. Zone transfer (AXFR) from primary to secondary"
|
||||
|
||||
# Test AXFR of knel.net from the primary
|
||||
axfr=$(run "$SECONDARY" "dig +short +time=5 +tries=1 @${PRIMARY_IP} -p ${TECH_PORT} knel.net AXFR 2>/dev/null | wc -l" 2>/dev/null || echo "0")
|
||||
if [ "$axfr" -gt 1 ] 2>/dev/null; then
|
||||
ok "AXFR of knel.net from primary succeeds ($axfr records transferred)"
|
||||
else
|
||||
warn "AXFR test returned $axfr records — zone transfer may be restricted or in progress"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
section "7. Reverse DNS works"
|
||||
|
||||
# Pick a known reverse zone and test PTR resolution
|
||||
ptr_test="181.103.100.in-addr.arpa"
|
||||
ptr_ans=$(run "$PRIMARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $ptr_test SOA 2>/dev/null | head -1" 2>/dev/null || true)
|
||||
if [ -n "$ptr_ans" ]; then
|
||||
ok "Reverse zone $ptr_test has SOA on primary"
|
||||
else
|
||||
warn "Reverse zone $ptr_test: no SOA on primary"
|
||||
fi
|
||||
|
||||
ptr_ans2=$(run "$SECONDARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 $ptr_test SOA 2>/dev/null | head -1" 2>/dev/null || true)
|
||||
if [ -n "$ptr_ans2" ]; then
|
||||
ok "Reverse zone $ptr_test has SOA on secondary"
|
||||
else
|
||||
warn "Reverse zone $ptr_test: no SOA on secondary"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
section "8. Production untouched (read-only verification)"
|
||||
|
||||
# Verify production container is still running and unchanged
|
||||
prod_status=$(run_root "$PROD" "docker ps --format '{{.Status}}' tsys-dns 2>/dev/null" | head -1)
|
||||
if echo "$prod_status" | grep -qi 'Up'; then
|
||||
ok "Production container still running on $PROD ($prod_status)"
|
||||
else
|
||||
fail "Production container NOT running on $PROD!"
|
||||
fi
|
||||
|
||||
prod_zones_after=$(count_zones "$PROD")
|
||||
if [ "$prod_zones_after" = "$prod_zones" ]; then
|
||||
ok "Production zone count unchanged ($prod_zones_after = $prod_zones before)"
|
||||
else
|
||||
fail "Production zone count CHANGED: $prod_zones → $prod_zones_after"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
section "9. Failover test"
|
||||
|
||||
# Take the approach of querying via the secondary when primary is slow/unavailable.
|
||||
# We test that the secondary answers independently.
|
||||
sec_soa=$(run "$SECONDARY" "dig +short +time=3 +tries=1 @127.0.0.1 -p 53 knel.net SOA 2>/dev/null | head -1" 2>/dev/null || true)
|
||||
if [ -n "$sec_soa" ]; then
|
||||
ok "Secondary independently serves knel.net SOA: $sec_soa"
|
||||
else
|
||||
fail "Secondary cannot serve knel.net SOA independently"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
section "10. Credentials check — auth.config size matches production"
|
||||
|
||||
prod_auth_size=$(run_root "$PROD" "docker exec tsys-dns wc -c < /etc/dns/auth.config 2>/dev/null" | tr -d '[:space:]')
|
||||
pri_auth_size=$(run_root "$PRIMARY" "docker exec tsys-dns wc -c < /etc/dns/auth.config 2>/dev/null" | tr -d '[:space:]')
|
||||
sec_auth_size=$(run_root "$SECONDARY" "docker exec tsys-dns wc -c < /etc/dns/auth.config 2>/dev/null" | tr -d '[:space:]')
|
||||
|
||||
echo " auth.config sizes — prod=$prod_auth_size pri=$pri_auth_size sec=$sec_auth_size"
|
||||
|
||||
if [ "$prod_auth_size" = "$pri_auth_size" ] && [ "$prod_auth_size" = "$sec_auth_size" ]; then
|
||||
ok "auth.config identical size across all three nodes (credentials + 2FA replicated)"
|
||||
else
|
||||
fail "auth.config sizes differ — credentials may not be replicated correctly"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " PASSED: $PASS"
|
||||
echo " FAILED: $FAIL"
|
||||
echo " WARNED: $WARN"
|
||||
echo "=========================================="
|
||||
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
||||
@@ -1 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
Vendored
+4
-4
@@ -2,18 +2,18 @@ function print_info()
|
||||
{
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
tput bold
|
||||
tput bold 2>/dev/null || true
|
||||
echo -e "$GREEN $1${NC}"
|
||||
echo -e "$GREEN $1${NC}" >> "$LOGFILENAME"
|
||||
tput sgr0
|
||||
tput sgr0 2>/dev/null || true
|
||||
}
|
||||
|
||||
function print_error()
|
||||
{
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
tput bold
|
||||
tput bold 2>/dev/null || true
|
||||
echo -e "$RED $1${NC}"
|
||||
echo -e "$RED $1${NC}" >> "$LOGFILENAME"
|
||||
tput sgr0
|
||||
tput sgr0 2>/dev/null || true
|
||||
}
|
||||
Reference in New Issue
Block a user