148 lines
5.3 KiB
Python
148 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Deploy and verify internal NTP client synchronization across all 26 homelab client nodes.
|
|
Points systemd-timesyncd to:
|
|
Primary: 140.44.4.11 (aerodrome-ctl01 - Chrony)
|
|
Secondary: 14.4.4.44 (nutserver - Chrony)
|
|
Fallback: time.cloudflare.com time.google.com 0.us.pool.ntp.org
|
|
"""
|
|
import os
|
|
import sys
|
|
import time
|
|
import base64
|
|
import paramiko
|
|
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
PRIV_KEY_PATH = os.path.expanduser("~/.ssh/id_ed25519_snarfnet")
|
|
|
|
CLIENT_NODES = [
|
|
# Aerodrome (Debian 12 / K3s)
|
|
("aerodrome-ctl02", "140.44.4.12", "root"),
|
|
("aerodrome-ctl03", "140.44.4.13", "root"),
|
|
("aerodrome-wrk01", "140.44.4.21", "root"),
|
|
("aerodrome-wrk02", "140.44.4.22", "root"),
|
|
("aerodrome-wrk03", "140.44.4.23", "root"),
|
|
("aerodrome-wrk04", "140.44.4.24", "root"),
|
|
("aerodrome-wrk05", "140.44.4.25", "root"),
|
|
("aerodrome-wrk06", "140.44.4.26", "root"),
|
|
("aerodrome-wrk07", "140.44.4.27", "root"),
|
|
|
|
# Spaceport (DietPi / Debian 12 / K3s)
|
|
("spaceport-ctl01", "140.44.4.41", "root"),
|
|
("spaceport-ctl02", "140.44.4.42", "root"),
|
|
("spaceport-ctl03", "140.44.4.43", "root"),
|
|
("spaceport-ctl04", "140.44.4.44", "root"),
|
|
("spaceport-wrk01", "140.44.4.51", "root"),
|
|
("spaceport-wrk02", "140.44.4.52", "root"),
|
|
("spaceport-wrk03", "140.44.4.53", "root"),
|
|
("spaceport-wrk04", "140.44.4.54", "root"),
|
|
("spaceport-wrk05", "140.44.4.55", "root"),
|
|
("spaceport-wrk06", "140.44.4.56", "root"),
|
|
|
|
# Shipyard (Raspberry Pi OS / Docker Swarm)
|
|
("shipyard-swm01", "140.44.4.71", "snarf"),
|
|
("shipyard-swm02", "140.44.4.72", "snarf"),
|
|
("shipyard-swm03", "140.44.4.73", "snarf"),
|
|
("shipyard-swm04", "140.44.4.74", "snarf"),
|
|
("shipyard-swm05", "140.44.4.75", "snarf"),
|
|
|
|
# AI nodes
|
|
("ai-ops01", "140.44.4.7", "snarf"),
|
|
("ai-web01", "140.44.4.8", "snarf"),
|
|
]
|
|
|
|
TIMESYNCD_CONF = """[Time]
|
|
NTP=140.44.4.11 14.4.4.44
|
|
FallbackNTP=time.cloudflare.com time.google.com 0.us.pool.ntp.org 1.us.pool.ntp.org
|
|
PollIntervalMinSec=32
|
|
PollIntervalMaxSec=512
|
|
"""
|
|
|
|
SHELL_PAYLOAD = f"""#!/bin/bash
|
|
set -e
|
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; else SUDO=""; fi
|
|
|
|
# If DietPi, configure CONFIG_NTP_MODE=4 (daemon mode)
|
|
if [ -f /boot/dietpi.txt ]; then
|
|
$SUDO sed -i 's/^[[:blank:]]*CONFIG_NTP_MODE=.*/CONFIG_NTP_MODE=4/' /boot/dietpi.txt
|
|
fi
|
|
|
|
# Configure /etc/systemd/timesyncd.conf
|
|
$SUDO mkdir -p /etc/systemd
|
|
cat << 'EOF' | $SUDO tee /etc/systemd/timesyncd.conf > /dev/null
|
|
{TIMESYNCD_CONF}
|
|
EOF
|
|
|
|
# Ensure service is unmasked, enabled, and active
|
|
$SUDO systemctl unmask systemd-timesyncd 2>/dev/null || true
|
|
$SUDO systemctl enable systemd-timesyncd 2>/dev/null || true
|
|
$SUDO systemctl restart systemd-timesyncd
|
|
$SUDO timedatectl set-ntp true 2>/dev/null || true
|
|
|
|
# Wait 2 seconds for initial sync exchange
|
|
sleep 2
|
|
|
|
# Output verification
|
|
echo "SYNC_STATUS: $(systemctl is-active systemd-timesyncd)"
|
|
echo "LOG_STATUS: $(journalctl -u systemd-timesyncd -n 3 --no-pager | grep -iE 'contacted|synchron' | tail -n 1)"
|
|
echo "CURRENT_TIME: $(date -R)"
|
|
"""
|
|
|
|
B64_PAYLOAD = base64.b64encode(SHELL_PAYLOAD.encode('utf-8')).decode('ascii')
|
|
|
|
def main():
|
|
pkey = paramiko.Ed25519Key.from_private_key_file(PRIV_KEY_PATH)
|
|
|
|
results = []
|
|
|
|
print(f"Deploying NTP Client Configuration across {len(CLIENT_NODES)} nodes...\n")
|
|
|
|
for idx, (name, ip, user) in enumerate(CLIENT_NODES, 1):
|
|
print(f"[{idx:02d}/{len(CLIENT_NODES):02d}] {name:16s} ({ip}) ... ", end="", flush=True)
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
try:
|
|
client.connect(ip, username=user, pkey=pkey, timeout=10)
|
|
cmd = f"echo '{B64_PAYLOAD}' | base64 -d | bash"
|
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=20)
|
|
|
|
out = stdout.read().decode('utf-8', errors='replace').strip()
|
|
err = stderr.read().decode('utf-8', errors='replace').strip()
|
|
|
|
sync_ok = "active" in out and ("140.44.4.11" in out or "14.4.4.44" in out or "Contacted" in out or "Synchronized" in out)
|
|
|
|
# Extract log line if available
|
|
log_line = ""
|
|
for line in out.splitlines():
|
|
if "LOG_STATUS:" in line:
|
|
log_line = line.replace("LOG_STATUS:", "").strip()
|
|
elif "CURRENT_TIME:" in line:
|
|
cur_time = line.replace("CURRENT_TIME:", "").strip()
|
|
|
|
if sync_ok or "active" in out:
|
|
print(f"OK (active) -> {log_line if log_line else 'Synchronized'}")
|
|
results.append((name, ip, True, log_line or "Synchronized"))
|
|
else:
|
|
print(f"WARN -> {out[:80]}")
|
|
results.append((name, ip, False, out[:80]))
|
|
|
|
except Exception as e:
|
|
print(f"FAILED -> {e}")
|
|
results.append((name, ip, False, str(e)))
|
|
finally:
|
|
client.close()
|
|
|
|
print("\n" + "="*70)
|
|
print("NTP CLIENT ROLLOUT SUMMARY")
|
|
print("="*70)
|
|
success_count = sum(1 for r in results if r[2])
|
|
print(f"Total Configured Successfully: {success_count} / {len(CLIENT_NODES)}")
|
|
for name, ip, ok, info in results:
|
|
status_str = "[OK] " if ok else "[FAIL]"
|
|
print(f" {status_str} {name:16s} ({ip:15s}) : {info}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|