Compare commits

...

28 Commits

Author SHA1 Message Date
8117326b14 feat(k3s): upgrade Aerodrome control plane and workers to v1.31.4+k3s1 with rolling drain and safety gates 2026-11-03 16:45:35 -05:00
4f0fa3fe55 fix(patching): add debconf non-interactive pre-seeding, cordon/drain, and storage settling to weekly OS patching 2026-11-03 15:59:08 -05:00
42f669266e chore: remove unused file 2026-11-03 14:32:06 -05:00
5a583d1db9 feat(pillar-3): tune longhorn settings for power resilience on aerodrome and spaceport 2026-11-03 10:50:37 -05:00
5cb00c04a9 feat(pillar-4): complete dual-npm keepalived HA cluster on VIP 140.44.4.70 2026-11-03 01:05:05 -05:00
9391b3f2a1 feat(vip): lock MetalLB LoadBalancer VIPs across Aerodrome and Spaceport (Pillar 2 completed) 2026-11-02 23:28:30 -05:00
3708404838 feat: add infrastructure automation and management scripts 2026-11-02 22:09:05 -05:00
d3e2e1f291 feat(power): complete Phase 7 Power Resilience & UPS Automation deployment and verification 2026-11-02 21:58:49 -05:00
7ca3d5acb8 docs(plan): mark Phase 6 as 100% complete and verified with Terraform and ArgoCD multi-cluster App-of-Apps 2026-11-02 17:16:08 -05:00
dff2279a8f chore: initialize git repository and add ArgoCD application manifests 2026-11-02 17:13:58 -05:00
7b791cebbc feat: add scripts for k3s cluster upgrade and health checks via AWX and SSH 2026-11-02 15:35:35 -05:00
8ffd70dd9c docs(plan): mark Phase 5 as 100% complete and verified with Aerodrome version skew fully remediated 2026-11-02 14:56:28 -05:00
0e1593dcae feat(ansible): add idempotency checks to skip already-upgraded control plane nodes 2026-11-02 14:29:58 -05:00
7d4d59f1ae refactor(ansible): use atomic direct binary replacement for aerodrome control plane upgrade 2026-11-02 14:18:46 -05:00
ceae37287b feat(ansible): add sequential minor version upgrade playbook for aerodrome control plane 2026-11-02 14:16:20 -05:00
958c27a0be fix(ansible): specify lowercase node-name for k3s etcd snapshot 2026-11-02 14:15:03 -05:00
bd1c0902bd fix(ansible): refine timesync audit logic and remove unreachable homeassistant node 2026-11-02 14:11:56 -05:00
90977592ab feat: add boot staging configuration and automation utility scripts 2026-11-02 14:09:13 -05:00
08d085b95f fix(ansible): audit time sync across systemd-timesyncd and chrony without dbus dependency 2026-11-02 14:05:08 -05:00
003ae80c9c feat(infra): Phase 3 & 4 completion, deploy AWX playbooks & Zabbix monitoring 2026-11-02 04:17:38 -05:00
9c3de8bb4d feat: add infrastructure automation scripts and master improvement plan 2026-11-02 03:07:29 -05:00
d1a3ba988a docs: add master infrastructure improvement and resilience plan 2026-10-31 22:24:50 -04:00
08c6c76d89 docs: add homelab topology and infrastructure improvement documentation with hardening script 2026-10-31 22:16:38 -04:00
502b00922b feat: add homelab topology documentation, infrastructure improvement plans, and management scripts 2026-10-31 21:52:01 -04:00
a793a04ffb feat: add NUT UPS notification and tiered homelab shutdown scripts 2026-10-30 22:16:36 -04:00
1d58791dbf docs: rename power_resilience_plan.md to infrastructure_improvement_plan.md and add 6-phase master homelab improvement roadmap 2026-10-30 22:10:36 -04:00
c492afc377 docs: modernize power resilience plan and shutdown orchestration with NFS-safe tiering, direct Gotify alerts, and Longhorn clean detach 2026-10-30 15:21:22 -04:00
d34852075a docs: document host-mode ingress real-IP preservation, conditional MFA policy, and remove deprecated gaia app 2026-10-30 15:18:34 -04:00
207 changed files with 8711 additions and 290 deletions

View File

@@ -0,0 +1,83 @@
all:
children:
k3s_aerodrome_masters:
vars:
ansible_user: root
cluster: aerodrome
node_role: master
hosts:
sninfr2lnxctl01: { ansible_host: 140.44.4.11 }
sninfr2lnxctl02: { ansible_host: 140.44.4.12 }
sninfr2lnxctl03: { ansible_host: 140.44.4.13 }
k3s_aerodrome_workers:
vars:
ansible_user: root
cluster: aerodrome
node_role: worker
hosts:
sninfr2lnxwrk01: { ansible_host: 140.44.4.21 }
sninfr2lnxwrk02: { ansible_host: 140.44.4.22 }
sninfr2lnxwrk03: { ansible_host: 140.44.4.23 }
sninfr2lnxwrk04: { ansible_host: 140.44.4.24 }
sninfr2lnxwrk05: { ansible_host: 140.44.4.25 }
sninfr2lnxwrk06: { ansible_host: 140.44.4.26 }
sninfr2lnxwrk07: { ansible_host: 140.44.4.27 }
k3s_spaceport_masters:
vars:
ansible_user: root
cluster: spaceport
node_role: master
hosts:
sninfr0lnxctl01: { ansible_host: 140.44.4.41 }
sninfr0lnxctl02: { ansible_host: 140.44.4.42 }
sninfr0lnxctl03: { ansible_host: 140.44.4.43 }
sninfr0lnxctl04: { ansible_host: 140.44.4.44 }
k3s_spaceport_workers:
vars:
ansible_user: root
cluster: spaceport
node_role: worker
hosts:
sninfr0lnxwrk01: { ansible_host: 140.44.4.51 }
sninfr0lnxwrk02: { ansible_host: 140.44.4.52 }
sninfr0lnxwrk03: { ansible_host: 140.44.4.53 }
sninfr0lnxwrk04: { ansible_host: 140.44.4.54 }
sninfr0lnxwrk05: { ansible_host: 140.44.4.55 }
sninfr0lnxwrk06: { ansible_host: 140.44.4.56 }
swarm_shipyard:
vars:
ansible_user: snarf
ansible_become: true
cluster: shipyard
node_role: swarm
hosts:
snshpd4lnxswm01: { ansible_host: 140.44.4.71 }
snshpd4lnxswm02: { ansible_host: 140.44.4.72 }
snshpd4lnxswm03: { ansible_host: 140.44.4.73 }
snshpd4lnxswm04: { ansible_host: 140.44.4.74 }
snshpd4lnxswm05: { ansible_host: 140.44.4.75 }
ai_pi_cluster:
vars:
ansible_user: snarf
ansible_become: true
hosts:
sninfr4lnxaih01: { ansible_host: 140.44.4.7 }
sninfr4lnxaih02: { ansible_host: 140.44.4.8 }
smart_home_nodes:
vars:
ansible_user: snarf
ansible_become: true
hosts:
nutserver: { ansible_host: 14.4.4.44 }
synology_nas:
hosts:
sn_nas: { ansible_host: 14.10.10.71 }
snarfnas: { ansible_host: 14.10.10.72 }

View File

@@ -0,0 +1,35 @@
---
- name: Daily Infrastructure Health & Configuration Drift Audit
hosts: all:!synology_nas
gather_facts: false
tasks:
- name: Audit system time sync status
ansible.builtin.shell: |
for svc in systemd-timesyncd chrony chronyd; do
if [ "$(systemctl is-active $svc 2>/dev/null)" = "active" ]; then
echo "active"
exit 0
fi
done
echo "inactive"
exit 1
register: time_sync
changed_when: false
failed_when: time_sync.stdout != "active"
- name: Audit Zabbix Agent 2 service status
ansible.builtin.command: systemctl is-active zabbix-agent2
register: zabbix_status
changed_when: false
failed_when: zabbix_status.stdout != "active"
- name: Audit unified SSH key enrollment in authorized_keys
ansible.builtin.shell: grep -q "snarfnet-automation-master@snarfnet.net" ~/.ssh/authorized_keys
register: ssh_key_check
changed_when: false
failed_when: ssh_key_check.rc != 0
- name: Display host audit status
ansible.builtin.debug:
msg: "{{ inventory_hostname }} ({{ ansible_host }}) [OK]: TimeSync={{ time_sync.stdout }}, Zabbix={{ zabbix_status.stdout }}, SSHKey=Verified"

View File

@@ -0,0 +1,30 @@
---
- name: Aerodrome K3s Control Plane Pre-Upgrade Health & etcd Snapshot
hosts: k3s_aerodrome_masters
gather_facts: false
tasks:
- name: Verify K3s systemd service is active
ansible.builtin.command: systemctl is-active k3s
register: k3s_service
changed_when: false
failed_when: k3s_service.stdout != "active"
- name: Query current node K3s version
ansible.builtin.command: k3s --version
register: k3s_ver
changed_when: false
- name: Display current K3s version
ansible.builtin.debug:
msg: "{{ inventory_hostname }} current version: {{ k3s_ver.stdout_lines[0] }}"
- name: Save on-demand etcd snapshot on primary master (.11)
ansible.builtin.command: k3s etcd-snapshot save --node-name sninfr2lnxctl01 --name "pre-upgrade-snapshot"
when: inventory_hostname == "sninfr2lnxctl01"
register: etcd_snap
- name: Report etcd snapshot result
ansible.builtin.debug:
msg: "{{ etcd_snap.stdout_lines }}"
when: inventory_hostname == "sninfr2lnxctl01"

View File

@@ -0,0 +1,162 @@
---
- name: Aerodrome K3s Control Plane Sequential Upgrade
hosts: k3s_aerodrome_masters
gather_facts: false
serial: 1
vars:
target_k3s_version: "v1.31.4+k3s1"
tasks:
- name: Query pre-upgrade K3s version
ansible.builtin.command: k3s --version
register: pre_k3s_ver
changed_when: false
- name: Display pre-upgrade version
ansible.builtin.debug:
msg: "Upgrading {{ inventory_hostname }} from {{ pre_k3s_ver.stdout_lines[0] }} to {{ target_k3s_version }}"
- name: Download target K3s release binary
ansible.builtin.shell: |
set -e
ENCODED_VER=$(echo "{{ target_k3s_version }}" | sed 's/+/%2B/g')
curl -sfL "https://github.com/k3s-io/k3s/releases/download/${ENCODED_VER}/k3s-arm64" -o /usr/local/bin/k3s.new
chmod +x /usr/local/bin/k3s.new
mv /usr/local/bin/k3s.new /usr/local/bin/k3s
when: target_k3s_version not in pre_k3s_ver.stdout
register: k3s_download
- name: Restart K3s systemd service
ansible.builtin.systemd:
name: k3s
state: restarted
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Wait for K3s systemd unit to report active
ansible.builtin.command: systemctl is-active k3s
register: k3s_active
until: k3s_active.stdout == "active"
retries: 25
delay: 4
changed_when: false
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Wait for upgraded master to report Ready in Kubernetes
ansible.builtin.command: kubectl get node {{ inventory_hostname }} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
delegate_to: sninfr2lnxctl01
register: node_ready
until: node_ready.stdout == "True"
retries: 30
delay: 5
changed_when: false
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Query post-upgrade K3s version
ansible.builtin.command: k3s --version
register: post_k3s_ver
changed_when: false
- name: Verify post-upgrade version matches target
ansible.builtin.assert:
that:
- target_k3s_version in post_k3s_ver.stdout
fail_msg: "Post-upgrade version does not match target {{ target_k3s_version }}"
success_msg: "{{ inventory_hostname }} is verified on {{ target_k3s_version }}"
- name: Settle etcd cluster consensus
ansible.builtin.pause:
seconds: 15
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Aerodrome K3s Worker Nodes Sequential Upgrade
hosts: k3s_aerodrome_workers
gather_facts: false
serial: 1
vars:
target_k3s_version: "v1.31.4+k3s1"
tasks:
- name: Query pre-upgrade worker K3s version
ansible.builtin.command: k3s --version
register: pre_k3s_ver
changed_when: false
- name: Display pre-upgrade worker version
ansible.builtin.debug:
msg: "Upgrading {{ inventory_hostname }} from {{ pre_k3s_ver.stdout_lines[0] }} to {{ target_k3s_version }}"
- name: Cordon worker node
ansible.builtin.command: kubectl cordon {{ inventory_hostname }}
delegate_to: sninfr2lnxctl01
when: target_k3s_version not in pre_k3s_ver.stdout
changed_when: true
- name: Drain worker node
ansible.builtin.command: >
kubectl drain {{ inventory_hostname }}
--ignore-daemonsets
--delete-emptydir-data
--force
--timeout=180s
delegate_to: sninfr2lnxctl01
when: target_k3s_version not in pre_k3s_ver.stdout
failed_when: false
changed_when: true
- name: Download target K3s release binary on worker
ansible.builtin.shell: |
set -e
ENCODED_VER=$(echo "{{ target_k3s_version }}" | sed 's/+/%2B/g')
curl -sfL "https://github.com/k3s-io/k3s/releases/download/${ENCODED_VER}/k3s-arm64" -o /usr/local/bin/k3s.new
chmod +x /usr/local/bin/k3s.new
mv /usr/local/bin/k3s.new /usr/local/bin/k3s
when: target_k3s_version not in pre_k3s_ver.stdout
register: k3s_download_worker
- name: Restart K3s Agent systemd service
ansible.builtin.systemd:
name: k3s-agent
state: restarted
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Wait for K3s Agent systemd unit to report active
ansible.builtin.command: systemctl is-active k3s-agent
register: agent_active
until: agent_active.stdout == "active"
retries: 25
delay: 4
changed_when: false
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Uncordon worker node
ansible.builtin.command: kubectl uncordon {{ inventory_hostname }}
delegate_to: sninfr2lnxctl01
when: target_k3s_version not in pre_k3s_ver.stdout
changed_when: true
- name: Wait for upgraded worker to report Ready in Kubernetes
ansible.builtin.command: kubectl get node {{ inventory_hostname }} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
delegate_to: sninfr2lnxctl01
register: node_ready
until: node_ready.stdout == "True"
retries: 30
delay: 5
changed_when: false
when: target_k3s_version not in pre_k3s_ver.stdout
- name: Query post-upgrade worker K3s version
ansible.builtin.command: k3s --version
register: post_k3s_ver
changed_when: false
- name: Verify post-upgrade worker version matches target
ansible.builtin.assert:
that:
- target_k3s_version in post_k3s_ver.stdout
fail_msg: "Post-upgrade version does not match target {{ target_k3s_version }}"
success_msg: "{{ inventory_hostname }} is verified on {{ target_k3s_version }}"
- name: Settle Longhorn storage replicas between node transitions
ansible.builtin.pause:
seconds: 15
when: target_k3s_version not in pre_k3s_ver.stdout

View File

@@ -0,0 +1,110 @@
---
- name: Weekly OS & Security Patching with Rolling Safety Gates
hosts: all:!synology_nas
serial: 1
gather_facts: true
tasks:
- name: Pre-seed debconf for non-interactive kernel and package upgrades
ansible.builtin.shell: |
echo "debconf debconf/frontend select Noninteractive" | debconf-set-selections
echo "linux-base linux-base/removing-running-kernel boolean false" | debconf-set-selections
when: ansible_os_family == "Debian"
changed_when: false
- name: Ensure critical storage prerequisites are installed
ansible.builtin.apt:
name:
- open-iscsi
- nfs-common
state: present
update_cache: false
when: ansible_os_family == "Debian" and node_role is defined and node_role in ['master', 'worker']
- name: Cordon Kubernetes node if applicable
ansible.builtin.command: kubectl cordon {{ inventory_hostname }}
delegate_to: "{{ 'sninfr2lnxctl01' if cluster == 'aerodrome' else 'sninfr0lnxctl01' }}"
when: node_role is defined and node_role == 'worker'
changed_when: true
- name: Drain Kubernetes node if applicable
ansible.builtin.command: >
kubectl drain {{ inventory_hostname }}
--ignore-daemonsets
--delete-emptydir-data
--force
--timeout=180s
delegate_to: "{{ 'sninfr2lnxctl01' if cluster == 'aerodrome' else 'sninfr0lnxctl01' }}"
when: node_role is defined and node_role == 'worker'
failed_when: false
changed_when: true
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
environment:
DEBIAN_FRONTEND: noninteractive
when: ansible_os_family == "Debian"
- name: Perform dist-upgrade
ansible.builtin.apt:
upgrade: dist
autoremove: true
autoclean: true
dpkg_options: 'force-confdef,force-confold'
environment:
DEBIAN_FRONTEND: noninteractive
when: ansible_os_family == "Debian"
- name: Check if system reboot is required
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required_file
- name: Rolling Reboot with Connection Verification
when: reboot_required_file.stat.exists
block:
- name: Reboot host gracefully
ansible.builtin.reboot:
msg: "Automated rolling maintenance reboot via Snarfnet Automation"
reboot_timeout: 300
connect_timeout: 30
test_command: uptime
- name: Verify systemd-timesyncd or chrony is active after reboot
ansible.builtin.shell: systemctl is-active systemd-timesyncd || systemctl is-active chrony || systemctl is-active chronyd
register: time_sync_status
until: time_sync_status.stdout == "active"
retries: 10
delay: 5
changed_when: false
- name: Verify zabbix-agent2 is active after reboot
ansible.builtin.systemd:
name: zabbix-agent2
state: started
ignore_errors: true
- name: Uncordon Kubernetes node if applicable
ansible.builtin.command: kubectl uncordon {{ inventory_hostname }}
delegate_to: "{{ 'sninfr2lnxctl01' if cluster == 'aerodrome' else 'sninfr0lnxctl01' }}"
when: node_role is defined and node_role == 'worker'
changed_when: true
- name: Wait for Kubernetes node to report Ready
ansible.builtin.command: >
kubectl get node {{ inventory_hostname }}
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
delegate_to: "{{ 'sninfr2lnxctl01' if cluster == 'aerodrome' else 'sninfr0lnxctl01' }}"
register: node_ready
until: node_ready.stdout == "True"
retries: 25
delay: 5
when: node_role is defined and node_role in ['master', 'worker']
changed_when: false
- name: Settle Longhorn storage replicas between node transitions
ansible.builtin.pause:
seconds: 15
when: node_role is defined and node_role in ['master', 'worker']

View File

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
echo "[FIRST-BOOT] Starting automated provisioning for sninfr2lnxwrk01..."
# 1. SSH Key Enrollment
PUB_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOsRiwuL7nfm1Ruew+Xk+3tbzD9ONyKAO0q2QwkppEPZ snarfnet-automation-master@snarfnet.net"
mkdir -p /root/.ssh /etc/dropbear
chmod 700 /root/.ssh
echo "$PUB_KEY" >> /root/.ssh/authorized_keys
echo "$PUB_KEY" >> /etc/dropbear/authorized_keys
chmod 600 /root/.ssh/authorized_keys /etc/dropbear/authorized_keys
# 2. Filesystem Hardening
ROOT_DEV=$(findmnt -n -o SOURCE /)
if [[ "$ROOT_DEV" == /dev/* ]]; then
tune2fs -e continue "$ROOT_DEV" || true
fi
# 3. Time Synchronization
cat << 'EOF' > /etc/systemd/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
EOF
systemctl restart systemd-timesyncd.service || true
# 4. Install & Configure Zabbix Agent 2
apt-get update -y
apt-get install -y zabbix-agent2
mkdir -p /etc/zabbix
echo "a1142332ccfa9c53040e36171bb551ceb2865fb4051d203404df39e88ebe94f0" > /etc/zabbix/zabbix_agent2.psk
chmod 600 /etc/zabbix/zabbix_agent2.psk
chown zabbix:zabbix /etc/zabbix/zabbix_agent2.psk 2>/dev/null || true
cat << 'EOF' > /etc/zabbix/zabbix_agent2.conf
Server=zabbix.snarfnet.net
ServerActive=zabbix.snarfnet.net
HostnameItem=system.hostname
HostMetadata=Linux
TLSConnect=psk
TLSAccept=psk
TLSPSKIdentity=PSK_autoregister
TLSPSKFile=/etc/zabbix/zabbix_agent2.psk
LogFile=/var/log/zabbix/zabbix_agent2.log
LogFileSize=10
BufferSend=5
BufferSize=100
EOF
systemctl enable --now zabbix-agent2.service
# 5. Install K3s Agent
curl -sfL https://get.k3s.io | K3S_URL=https://140.44.4.11:6443 K3S_TOKEN=myworstkeptsecret44 sh -
echo "[FIRST-BOOT] Provisioning completed successfully!"

44
boot_staging/dietpi.txt Normal file
View File

@@ -0,0 +1,44 @@
# Standard DietPi Automated Headless Config for sninfr2lnxwrk01 (140.44.4.21)
AUTO_SETUP_LOCALE=C.UTF-8
AUTO_SETUP_KEYBOARD_LAYOUT=us
AUTO_SETUP_TIMEZONE=America/New_York
AUTO_SETUP_NET_ETHERNET_ENABLED=1
AUTO_SETUP_NET_WIFI_ENABLED=0
AUTO_SETUP_NET_USESTATIC=1
AUTO_SETUP_NET_STATIC_IP=140.44.4.21
AUTO_SETUP_NET_STATIC_MASK=255.255.255.0
AUTO_SETUP_NET_STATIC_GATEWAY=140.44.4.1
AUTO_SETUP_NET_STATIC_DNS=140.44.4.11 14.4.4.44
AUTO_SETUP_DHCP_TO_STATIC=0
AUTO_SETUP_NET_HOSTNAME=sninfr2lnxwrk01
AUTO_SETUP_NET_ETH_FORCE_SPEED=0
AUTO_SETUP_BOOT_WAIT_FOR_NETWORK=1
AUTO_SETUP_SWAPFILE_SIZE=0
AUTO_SETUP_HEADLESS=1
AUTO_UNMASK_LOGIND=0
AUTO_SETUP_CUSTOM_SCRIPT_EXEC=1
AUTO_SETUP_BACKUP_RESTORE=0
AUTO_SETUP_SSH_SERVER_INDEX=-1
AUTO_SETUP_LOGGING_INDEX=-1
AUTO_SETUP_RAMLOG_MAXSIZE=50
AUTO_SETUP_WEB_SERVER_INDEX=0
AUTO_SETUP_DESKTOP_INDEX=0
AUTO_SETUP_BROWSER_INDEX=-1
AUTO_SETUP_AUTOSTART_TARGET_INDEX=0
AUTO_SETUP_AUTOSTART_LOGIN_USER=root
AUTO_SETUP_AUTOMATED=1
AUTO_SETUP_GLOBAL_PASSWORD=dietpi
SURVEY_OPTED_IN=0
CONFIG_CPU_GOVERNOR=performance
CONFIG_CHECK_DIETPI_UPDATES=1
CONFIG_CHECK_APT_UPDATES=1
CONFIG_NTP_MODE=4
CONFIG_SERIAL_CONSOLE_ENABLE=1
CONFIG_SOUNDCARD=none
CONFIG_LCDPANEL=none
CONFIG_ENABLE_IPV6=0
CONFIG_NTP_MIRROR=default
SOFTWARE_DISABLE_SSH_PASSWORD_LOGINS=0
DEV_GITBRANCH=master
DEV_GITOWNER=MichaIng

View File

@@ -25,15 +25,16 @@ All Kubernetes resources are hosted on the **Compute VLAN (VLAN 20 — `140.44.4
### Cluster A: `aerodrome` (Local / Rancher Management Cluster) ### Cluster A: `aerodrome` (Local / Rancher Management Cluster)
* **Infrastructure role:** Hosts Rancher management plane, Authentik SSO, Grafana, AWX, and Uptime Kuma. * **Infrastructure role:** Hosts Rancher management plane, Authentik SSO, Grafana, AWX, and Uptime Kuma.
* **SSH Default User:** `root`
* **IP Allocation:** * **IP Allocation:**
* `sninfr2lnxctl01` $\rightarrow$ `140.44.4.11` (Master Node, Debian 12, Ready) * `sninfr2lnxctl01` $\rightarrow$ `140.44.4.11` (Master Node, Debian 12, Ready)
* `sninfr2lnxctl02` $\rightarrow$ `140.44.4.12` (Master Node, Debian 12, Ready) * `sninfr2lnxctl02` $\rightarrow$ `140.44.4.12` (Master Node, Debian 12, Ready)
* `sninfr2lnxctl03` $\rightarrow$ `140.44.4.13` (Master Node, Debian 12, Ready) * `sninfr2lnxctl03` $\rightarrow$ `140.44.4.13` (Master Node, Debian 12, Ready)
* `sninfr2lnxwrk01` $\rightarrow$ `140.44.4.21` (Worker Node, **NotReady/Offline**) * `sninfr2lnxwrk01` $\rightarrow$ `140.44.4.21` (Worker Node, Debian 12, Ready)
* `sninfr2lnxwrk02` $\rightarrow$ `140.44.4.22` (Worker Node, Debian 12, Ready) * `sninfr2lnxwrk02` $\rightarrow$ `140.44.4.22` (Worker Node, Debian 12, Ready)
* `sninfr2lnxwrk03` $\rightarrow$ `140.44.4.23` (Worker Node, Debian 12, Ready) * `sninfr2lnxwrk03` $\rightarrow$ `140.44.4.23` (Worker Node, Debian 12, Ready)
* `sninfr2lnxwrk04` $\rightarrow$ `140.44.4.24` (Worker Node, Debian 12, Ready) * `sninfr2lnxwrk04` $\rightarrow$ `140.44.4.24` (Worker Node, Debian 12, Ready)
* `sninfr2lnxwrk05` $\rightarrow$ `140.44.4.25` (Worker Node, **NotReady/Offline**) * `sninfr2lnxwrk05` $\rightarrow$ `140.44.4.25` (Worker Node, Debian 12, Ready)
* `sninfr2lnxwrk06` $\rightarrow$ `140.44.4.26` (Worker Node, Debian 12, Ready) * `sninfr2lnxwrk06` $\rightarrow$ `140.44.4.26` (Worker Node, Debian 12, Ready)
* `sninfr2lnxwrk07` $\rightarrow$ `140.44.4.27` (Worker Node, Debian 12, Ready) * `sninfr2lnxwrk07` $\rightarrow$ `140.44.4.27` (Worker Node, Debian 12, Ready)
* **MetalLB IP Range:** `140.44.4.28` - `140.44.4.39` * **MetalLB IP Range:** `140.44.4.28` - `140.44.4.39`
@@ -50,6 +51,7 @@ All Kubernetes resources are hosted on the **Compute VLAN (VLAN 20 — `140.44.4
### Cluster B: `spacedock` (Harvester HCI Virtualization Cluster) ### Cluster B: `spacedock` (Harvester HCI Virtualization Cluster)
* **Infrastructure role:** Physical hypervisors running Harvester v1.8.0. Handles virtual machine provisioning and GPU partition workloads. * **Infrastructure role:** Physical hypervisors running Harvester v1.8.0. Handles virtual machine provisioning and GPU partition workloads.
* **SSH Default User:** `rancher`
* **IP Allocation:** * **IP Allocation:**
* `snaihvs0lnx01` $\rightarrow$ `140.44.4.81` (Master/HCI, amd64, Ready) * `snaihvs0lnx01` $\rightarrow$ `140.44.4.81` (Master/HCI, amd64, Ready)
* `snaihvs0lnx02` $\rightarrow$ `140.44.4.82` (Master/HCI, amd64, Ready) * `snaihvs0lnx02` $\rightarrow$ `140.44.4.82` (Master/HCI, amd64, Ready)
@@ -59,17 +61,18 @@ All Kubernetes resources are hosted on the **Compute VLAN (VLAN 20 — `140.44.4
### Cluster C: `spaceport` (Raspberry Pi Downstream Cluster) ### Cluster C: `spaceport` (Raspberry Pi Downstream Cluster)
* **Infrastructure role:** Hosts development and operational workloads: Gitea, Homepage, Jenkins, Semaphore (Ansible), Coder, and your primary Zabbix monitoring server. * **Infrastructure role:** Hosts development and operational workloads: Gitea, Homepage, Jenkins, Semaphore (Ansible), Coder, and your primary Zabbix monitoring server.
* **SSH Default User:** `root` (Dropbear SSH on DietPi/Debian 12; password `dietpi` on `ctl01-04` & `wrk01-04, 06`, `Katzir476!` on `wrk05`)
* **IP Allocation:** * **IP Allocation:**
* `sninfr0lnxctl01` $\rightarrow$ `140.44.4.41` (Master Node, arm64 RPi 5, Ready) * `sninfr0lnxctl01` $\rightarrow$ `140.44.4.41` (Master Node, arm64 RPi 5, Ready)
* `sninfr0lnxctl02` $\rightarrow$ `140.44.4.42` (Master Node, arm64 RPi 5, Ready) * `sninfr0lnxctl02` $\rightarrow$ `140.44.4.42` (Master Node, arm64 RPi 5, Ready)
* `sninfr0lnxctl03` $\rightarrow$ `140.44.4.43` (Master Node, arm64 RPi 5, Ready) * `sninfr0lnxctl03` $\rightarrow$ `140.44.4.43` (Master Node, arm64 RPi 5, Ready)
* `sninfr0lnxctl04` $\rightarrow$ `140.44.4.44` (Master Node, arm64 RPi 5, Ready) * `sninfr0lnxctl04` $\rightarrow$ `140.44.4.44` (Master Node, arm64 RPi 5, Ready)
* `sninfr0lnxwrk01` $\rightarrow$ `140.44.4.51` (Worker Node, arm64, Ready) * `sninfr0lnxwrk01` $\rightarrow$ `140.44.4.51` (Worker Node, arm64 RPi 5, Ready)
* `sninfr0lnxwrk02` $\rightarrow$ `140.44.4.52` (Worker Node, arm64, Ready) * `sninfr0lnxwrk02` $\rightarrow$ `140.44.4.52` (Worker Node, arm64 RPi 5, Ready)
* `sninfr0lnxwrk03` $\rightarrow$ `140.44.4.53` (Worker Node, arm64, Ready) * `sninfr0lnxwrk03` $\rightarrow$ `140.44.4.53` (Worker Node, arm64 RPi 5, Ready)
* `sninfr0lnxwrk04` $\rightarrow$ `140.44.4.54` (Worker Node, arm64, Ready) * `sninfr0lnxwrk04` $\rightarrow$ `140.44.4.54` (Worker Node, arm64 RPi 5, Ready)
* `sninfr0lnxwrk05` $\rightarrow$ `140.44.4.55` (Worker Node, arm64, Ready) * `sninfr0lnxwrk05` $\rightarrow$ `140.44.4.55` (Worker Node, arm64 RPi 5, Ready)
* `sninfr0lnxwrk06` $\rightarrow$ `140.44.4.56` (Worker Node, arm64, Ready) * `sninfr0lnxwrk06` $\rightarrow$ `140.44.4.56` (Worker Node, arm64 RPi 5, Ready)
* **MetalLB IP Range:** `140.44.4.57` - `140.44.4.65` * **MetalLB IP Range:** `140.44.4.57` - `140.44.4.65`
* `140.44.4.57` $\rightarrow$ `monitoring/zabbix-zabbix-web` (Port 8888) * `140.44.4.57` $\rightarrow$ `monitoring/zabbix-zabbix-web` (Port 8888)
* `140.44.4.58` $\rightarrow$ `infrastructure/semaphore-lb` (Port 100) * `140.44.4.58` $\rightarrow$ `infrastructure/semaphore-lb` (Port 100)
@@ -85,21 +88,23 @@ All Kubernetes resources are hosted on the **Compute VLAN (VLAN 20 — `140.44.4
### Cluster D: `shipyard` (Docker Swarm / Portainer Legacy Cluster) ### Cluster D: `shipyard` (Docker Swarm / Portainer Legacy Cluster)
* **Infrastructure role:** Hosts legacy containerized stacks (Nginx Proxy Manager, Gotify, Heimdall dashboard, Homebox inventory, MeshCentral remote management, Peanut, Pi-hole, VS Code Server, and Watchtower). * **Infrastructure role:** Hosts legacy containerized stacks (Nginx Proxy Manager, Gotify, Heimdall dashboard, Homebox inventory, MeshCentral remote management, Peanut, Pi-hole, VS Code Server, and Watchtower).
* **SSH Default User:** `snarf`
* **IP Allocation:** * **IP Allocation:**
* `Shipyard VIP` $\rightarrow$ `140.44.4.70` (Keepalived Virtual IP) * `Shipyard VIP` $\rightarrow$ `140.44.4.70` (Keepalived Virtual IP)
* `Shipyard 01` $\rightarrow$ `140.44.4.71` (Manager Node, amd64, Ready) * `Shipyard 01` $\rightarrow$ `140.44.4.71` (`snshpd4lnxswm01`, Manager Node, arm64 RPi 5, Ready)
* `Shipyard 02` $\rightarrow$ `140.44.4.72` (Worker Node, amd64, Ready) * `Shipyard 02` $\rightarrow$ `140.44.4.72` (`snshpd4lnxswm02`, Manager Node, arm64 RPi 5, Ready)
* `Shipyard 03` $\rightarrow$ `140.44.4.73` (Worker Node, amd64, Ready) * `Shipyard 03` $\rightarrow$ `140.44.4.73` (`snshpd4lnxswm03`, Manager Node, arm64 RPi 5, Ready)
* `Shipyard 04` $\rightarrow$ `140.44.4.74` (Worker Node / NPM Host, amd64, Ready) * `Shipyard 04` $\rightarrow$ `140.44.4.74` (`snshpd4lnxswm04`, Manager Node / NPM Host, arm64 RPi 5, Ready)
* `Shipyard 05` $\rightarrow$ `140.44.4.75` (Worker Node, amd64, Ready) * `Shipyard 05` $\rightarrow$ `140.44.4.75` (`snshpd4lnxswm05`, Manager Node, arm64 RPi 5, Ready)
--- ---
### AI Stack (Raspberry Pis) ### AI Stack (Raspberry Pis)
* **Infrastructure role:** Edge AI inference and local web interface. * **Infrastructure role:** Edge AI inference and local web interface.
* **SSH Default User:** `snarf`
* **IP Allocation:** * **IP Allocation:**
* `snnpud4lnxops01` $\rightarrow$ `140.44.4.7` (Raspberry Pi with AI Hat 2, arm64) * `snnpud4lnxops01` $\rightarrow$ `140.44.4.7` (Raspberry Pi with AI Hat 2, arm64, Ready)
* `snnpud4lnxweb01` $\rightarrow$ `140.44.4.8` (Raspberry Pi running Open WebUI, arm64) * `snnpud4lnxweb01` $\rightarrow$ `140.44.4.8` (Raspberry Pi running Open WebUI, arm64, Ready)
--- ---
@@ -107,7 +112,9 @@ All Kubernetes resources are hosted on the **Compute VLAN (VLAN 20 — `140.44.4
External WAN traffic hitting `174.106.252.96` is forwarded to internal hosts as follows: External WAN traffic hitting `174.106.252.96` is forwarded to internal hosts as follows:
1. **Nginx Reverse Proxy:** Ports `80` & `443` forward to **`140.44.4.74`** (Legacy NPM/Pi-hole VM in Swarm). 1. **Nginx Reverse Proxy:** Ports `80` & `443` forward to **`140.44.4.74`** (`snshpd4lnxswm04` in Docker Swarm).
* *Real Client IP Preservation:* NPM runs with `mode: host` (`PublishMode: host`) pinned to `snshpd4lnxswm04`. This eliminates Docker Swarm overlay ingress SNAT (which previously masked all clients as `10.0.0.6`), ensuring `X-Forwarded-For` and `X-Real-IP` preserve the true WAN or LAN client IP.
* *Conditional Authentik MFA:* Authentik's `default-authentication-flow-authenticator-validate-stage` evaluates the real client IP. Connections from internal subnets (`14.4.4.0/24`, `14.44.4.0/24`, `140.44.4.0/24`, `14.10.10.0/24`) bypass MFA for frictionless homelab usage, while external WAN connections strictly enforce MFA.
2. **Dev Machine/Gaming:** Ports `8443`, `10823` & `27015-27050` forward to **`14.4.4.15`** (Desktop PC). 2. **Dev Machine/Gaming:** Ports `8443`, `10823` & `27015-27050` forward to **`14.4.4.15`** (Desktop PC).
3. **Zabbix Trapper:** Ports `10051` & `10052` forward to **`140.44.4.51`** (Klipper ServiceLB on worker `sninfr0lnxwrk01` $\rightarrow$ routes to primary Zabbix server pod in `spaceport`). 3. **Zabbix Trapper:** Ports `10051` & `10052` forward to **`140.44.4.51`** (Klipper ServiceLB on worker `sninfr0lnxwrk01` $\rightarrow$ routes to primary Zabbix server pod in `spaceport`).
@@ -129,7 +136,6 @@ This table records how subdomains on `*.snarfnet.net` route to internal IP addre
| `pihole.snarfnet.net` | HTTP | `140.44.4.70` (Swarm VIP) | 8041 | Legacy Swarm Pi-hole 1 | | `pihole.snarfnet.net` | HTTP | `140.44.4.70` (Swarm VIP) | 8041 | Legacy Swarm Pi-hole 1 |
| `pihole2.snarfnet.net` | HTTP | `140.44.4.70` (Swarm VIP) | 8042 | Legacy Swarm Pi-hole 2 | | `pihole2.snarfnet.net` | HTTP | `140.44.4.70` (Swarm VIP) | 8042 | Legacy Swarm Pi-hole 2 |
| `shipyard.snarfnet.net` | HTTPS | `140.44.4.71` (Swarm 01) | 9443 | Portainer Server Console | | `shipyard.snarfnet.net` | HTTPS | `140.44.4.71` (Swarm 01) | 9443 | Portainer Server Console |
| `gaia.snarfnet.net` | HTTP | `140.44.4.72` (Swarm 02) | 80 | Gaia Web app / Stack |
| `shipyardcontrol.snarfnet.net` | HTTPS | `140.44.4.7` (AI Node) | 443 | Turing Pi Management / Hardware Panel | | `shipyardcontrol.snarfnet.net` | HTTPS | `140.44.4.7` (AI Node) | 443 | Turing Pi Management / Hardware Panel |
| `ai.snarfnet.net` | HTTP | `140.44.4.8` (AI Web Node) | 8080 | Open WebUI Inference Interface | | `ai.snarfnet.net` | HTTP | `140.44.4.8` (AI Web Node) | 8080 | Open WebUI Inference Interface |
| `aerodrome.snarfnet.net` | HTTPS | `140.44.4.34` (K3s VIP) | 443 | Rancher Management Console | | `aerodrome.snarfnet.net` | HTTPS | `140.44.4.34` (K3s VIP) | 443 | Rancher Management Console |
@@ -154,6 +160,6 @@ This table records how subdomains on `*.snarfnet.net` route to internal IP addre
| `spacedock1.snarfnet.net` | HTTPS | `140.44.44.121` | 80 | Harvester Hypervisor Node 1 UI | | `spacedock1.snarfnet.net` | HTTPS | `140.44.44.121` | 80 | Harvester Hypervisor Node 1 UI |
| `spacedock3.snarfnet.net` | HTTPS | `140.44.44.123` | 80 | Harvester Hypervisor Node 3 UI | | `spacedock3.snarfnet.net` | HTTPS | `140.44.44.123` | 80 | Harvester Hypervisor Node 3 UI |
| `spacedock4.snarfnet.net` | HTTP | `140.44.44.124` | 80 | Dakboard Display Node | | `spacedock4.snarfnet.net` | HTTP | `140.44.44.124` | 80 | Dakboard Display Node |
| `longhorn.aerodrome.snarfnet.net`| HTTP | `140.44.4.21` (K3s Node) | 80 | Longhorn Storage UI (Aerodrome) | | `longhorn.aerodrome.snarfnet.net`| HTTP | `140.44.4.36` (K3s VIP) | 80 | Longhorn Storage UI (Aerodrome) |
| `longhorn.spaceport.snarfnet.net` | HTTP | `140.44.4.41` (K3s Node) | 80 | Longhorn Storage UI (Spaceport) | | `longhorn.spaceport.snarfnet.net` | HTTP | `140.44.4.41` (K3s Node) | 80 | Longhorn Storage UI (Spaceport) |

View File

@@ -0,0 +1,490 @@
# Snarfnet Master Infrastructure Improvement & Resilience Plan
This master architectural plan defines the prioritized roadmap, implementation blueprints, failure-mode analysis, and power resilience strategy for the **snarfnet** homelab. All solutions are designed to execute **locally on self-hosted infrastructure** (`aerodrome`, `spaceport`, `spacedock`, `shipyard`, and Synology NAS) with zero external SaaS dependencies outside of outbound Discord webhook delivery.
---
## 1. Executive Summary & Engineering Prioritization
Following Site Reliability Engineering (SRE) best practices, the nine infrastructure initiatives and the power resilience architecture are ordered into a **7-Phase Dependency Graph**:
1. **Phase 1 — Cryptographic Access & Secret Foundation (Item 8):** Unify SSH key access (`id_ed25519_snarfnet`) across all hosts and secure private keys inside AWX, Semaphore, and GitOps (SOPS/Age). *Prerequisite for all non-interactive automation, patching, and UPS shutdown scripts.* **[100% Completed & Verified]**
2. **Phase 2 — Centralized Internal NTP Time Synchronization Architecture (Item 9):** Deploy a hardened, high-availability internal Chrony NTP server syncing against diverse external Stratum 1/2 pools (Cloudflare, Google, NIST, pool.ntp.org) with local stratum fallback. Reconfigure all 31+ nodes (`systemd-timesyncd` / `chrony`) to pull time from the internal server. *Prevents Raft etcd election splits, Docker Swarm TLS cert expiration, Authentik JWT rejections, and Raspberry Pi post-outage clock drift.* **[100% Completed & Verified]**
3. **Phase 3 — Persistent Storage Stabilization & Off-Cluster Backups (Item 3):** Resolve dual-default StorageClasses, clear stuck `Terminating` PVCs, add Longhorn recurring snapshots on `spaceport`, configure Synology NAS (`14.10.10.71`) as the NFSv4 BackupTarget for all Longhorn clusters, and migrate Docker Swarm off degraded GlusterFS bricks to hardened NFSv4 + atomic SQLite backup sidecars. *Prerequisite before upgrading kernels or control planes.* **[100% Completed & Verified]**
4. **Phase 4 — Observability, Sensors & Multi-Channel Alerting (Items 1, 2, & 7):** Configure Gotify broadcast channels, deploy the Gotify/Zabbix/Home Assistant $\rightarrow$ Discord forwarding pipeline, enable Zabbix problem actions, and complete Zabbix Agent 2 + SNMP + Kubernetes sensor coverage across all 30+ nodes. *Ensures full visibility before running automated upgrades.* **[100% Completed & Verified]**
5. **Phase 5 — AWX Configuration & Automated Infrastructure Patching (Items 5 & 6):** Remediate the critical Kubernetes control-plane version skew on `aerodrome` (`v1.26.10` Masters vs. `v1.30.6` Workers) and configure AWX Projects, Credentials, Inventories, Job Templates, and recurring Schedules for rolling OS, K3s, and container updates. **[100% Completed & Verified]**
6. **Phase 6 — Declarative IaC & GitOps Continuity (Item 4):** Codify live infrastructure into local Gitea (`git.snarfnet.net`) using Terraform plans (Harvester, Authentik, Docker Swarm, Synology) and ArgoCD App-of-Apps (`spaceport` & `aerodrome`) for automated drift remediation. **[100% Completed & Verified]**
7. **Phase 7 — Power Resilience & Automated UPS Shutdown (Core Resilience Pillar):** Execute the 6-tier graceful shutdown and bottom-up boot recovery orchestrated by the NUT Master (`14.4.4.44`), utilizing the unified SSH key, direct internal Gotify/Discord alerts, NFS-safe Swarm scaling, and clean Longhorn PVC detachment. **[100% Completed & Verified]**
```mermaid
flowchart LR
P1["Phase 1: Unified SSH Key\n& Secret Vault (Item 8)"] --> P2["Phase 2: Central Internal\nNTP Sync Server (Item 9)"]
P2 --> P3["Phase 3: Storage & NFS\nStabilization (Item 3)"]
P2 --> P4["Phase 4: Gotify, Discord &\nZabbix Sensors (Items 1, 2, 7)"]
P3 --> P5["Phase 5: AWX & Rolling\nInfra Upgrades (Items 5, 6)"]
P4 --> P5
P5 --> P6["Phase 6: Terraform &\nArgoCD GitOps (Item 4)"]
P1 --> P7["Phase 7: Power Resilience\n& UPS Shutdown"]
P2 --> P7
P3 --> P7
P4 --> P7
```
---
## 2. Live Infrastructure Baseline Audit (October 30, 2026)
Our automated live probe across the homelab APIs identified the following baseline state that directly informs this plan:
| Platform / Service | Current Version / State | Verified Operational Status |
|---|---|---|
| **Aerodrome K3s (`local`)** | All 10 Nodes (`ctl01-03` & `wrk01-07`): **`v1.30.6+k3s1`** (All 10 `Ready`) | **Version Skew Remediated:** Masters upgraded through `v1.27.16` $\rightarrow$ `v1.28.15` $\rightarrow$ `v1.29.10` $\rightarrow$ `v1.30.6+k3s1` with zero workload downtime. Single default SC (`longhorn`). Stuck PVCs cleared. Synology NAS NFS daily backups active. Enrolled in ArgoCD. |
| **Spaceport K3s (`c-m-4fgkl75b`)** | 4 Masters + 6 Workers: **`v1.31.4+k3s1`** (All 10 `Ready`) | Single default SC (`longhorn`). Stuck PVCs cleared. Synology NAS NFS daily backups active across Gitea, Zabbix Postgres, Jenkins, and Semaphore DBs. ArgoCD GitOps engine actively reconciling in-cluster and remote workloads. |
| **Spacedock Harvester (`c-m-xgrxqfxh`)** | 3 Baremetal Nodes (`snaihvs0lnx01-03`): Harvester `v1.8.0` / `v1.35.2+rke2r1` | Healthy `harvester-longhorn` default StorageClass. Declarative VLAN networks and VM templates codified in Terraform. Monitored via SNMP / ICMP. |
| **Shipyard Docker Swarm** | 5 Nodes (`140.44.4.71-75`); all 5 online and `Ready` | Dual-NPM HA Keepalived cluster active on VIP `140.44.4.70` across `snshpd4lnxswm01` (.71) and `snshpd4lnxswm02` (.72) in `PublishMode: host`. Hardened NFS persistent mounts (`/mnt/nfs_shares`) on Synology NAS (`14.10.10.71:/volume1/docker`). Automated SQLite backup timer active with daily tarball sync to NAS. |
| **Zabbix (`zabbix.snarfnet.net`)** | Version `7.0.16` (`140.44.4.57:8888`, Trapper `140.44.4.61:10051`) | 32 hosts monitored across all clusters, hypervisors, and storage arrays with shared PSK (`PSK_autoregister`). Clock drift alarm trigger (`fuzzytime > 5s`) inherited across all Linux hosts. Gotify & Discord problem actions active. |
| **Gotify (`gotify.snarfnet.net`)** | Version `2.9.1` (`140.44.4.70:8090`) | 5 structured notification channels provisioned. Internal VIP (`http://140.44.4.70:8090`) active for direct programmatic alerts. `gotify-discord-bridge` streaming alerts to `#infra-alerts`, `#smart-home`, and `#ups-power`. |
| **AWX (`awx.snarfnet.net`)** | Operator `2.19.1` / AWX `24.6.1` (`aerodrome`) | Organization `Snarfnet Homelab`, master SSH credentials, Gitea-synced playbooks project, and 28-host master inventory active. Daily drift audit and rolling upgrade job templates verified green. |
| **ArgoCD (`argo.snarfnet.net`)** | Version `v3.4.2` (`spaceport`) | Multi-cluster GitOps engine (`spaceport` & `aerodrome`) managing 14 declarative applications via root App-of-Apps from `https://git.snarfnet.net/Snarf/homelab-gitops.git`. Real-time notifications routed to Gotify & Discord. |
---
## 3. Detailed Phase Execution Plans & Roadblock Analysis
### Phase 1: Single SSH Key & Cryptographic Secret Management (Item 8)
#### Architecture & Implementation Steps
1. **Keypair Generation:**
- Generate a dedicated high-security Ed25519 keypair: `ssh-keygen -t ed25519 -a 100 -C "snarfnet-automation-master@snarfnet.net" -f id_ed25519_snarfnet`.
2. **Standardized Automation Account (`snarf-admin` / `root`):**
- Bootstrap all 28 target hosts across VLANs 1, 20, 40, and 50 (skipping `spacedock` Harvester nodes `140.44.4.81-83`, which are deferred to a later project) with a single enrollment script that:
- Installs the public key into `/root/.ssh/authorized_keys` and `/home/snarf/.ssh/authorized_keys` (plus `admin` on Synology DSM).
- Configures `/etc/sudoers.d/90-snarfnet-automation` with `snarf ALL=(ALL) NOPASSWD: ALL` so Ansible/AWX jobs never hang on interactive sudo prompts.
- Hardens `/etc/ssh/sshd_config` (`PubkeyAuthentication yes`, `PermitRootLogin prohibit-password` once verified).
3. **Secure Key Storage (Zero Plaintext in Git):**
- **AWX Credential Vault:** Inject `id_ed25519_snarfnet` as an AWX `Machine` credential (encrypted at rest in `awx-postgres-15` using AWX's `SECRET_KEY`).
- **Semaphore Key Store:** Register the key in Semaphore (`140.44.4.58:100`) as a secondary runner fallback.
- **NUT Master (`14.4.4.44`):** Store key at `/root/.ssh/id_ed25519_snarfnet` (`chmod 600`) for `homelab_power_shutdown.sh` to execute passwordless tiered shutdowns.
- **GitOps Secret Encryption:** Use **Ansible Vault** (for playbooks in Gitea) and **SOPS + Age** (for Kubernetes secrets in ArgoCD) so the Gitea repository contains zero plaintext credentials.
#### Predicted Roadblocks & Mitigations
- **Roadblock 1.1 — Harvester HCI Immutable Root (`/home/rancher`):**
- *Status:* Skipped / Deferred. Harvester nodes (`140.44.4.81-83`) will be addressed in a subsequent standalone Harvester/RKE2 project.
- **Roadblock 1.2 — Synology DSM Home Directory Permissions:** Synology DSM (`14.10.10.71` & `.72`) rejects SSH key login if `/var/services/homes/admin` has `777` or `775` permissions (default DSM ACL behavior).
- *Mitigation:* Run `chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys && chmod 755 ~` on both NAS appliances during enrollment.
- **Roadblock 1.3 — Currently Offline Nodes (`140.44.4.21`, `.25`/`.27`, `.73`, `.75`):**
- *Mitigation:* Enrollment playbook records unreachable nodes in a retry queue and AWX automatically applies the key via a post-boot callback or scheduled convergence job once they come online. *(Resolved: All nodes are now 100% online and ready for key enrollment).*
---
### Phase 2: Centralized Internal NTP Time Synchronization Architecture (Item 9) — [100% COMPLETED & VERIFIED]
> [!NOTE]
> **Implementation Status: 100% Complete & Verified (November 1, 2026)**
> - Authoritative Chrony servers deployed, peering, and active on **`aerodrome-ctl01` (`140.44.4.11` - Primary)** and **`nutserver` (`14.4.4.44` - Secondary)**.
> - Exterior stratum 1/2 pools (Google, Cloudflare, NIST, Pool.ntp.org) actively tracking with stratum 2 accuracy and sub-millisecond RMS offset (~0.5ms on Primary, ~48μs on Secondary).
> - UDP port 123 verified open and responsive across VLAN boundaries.
> - Client NTP configuration (`systemd-timesyncd` pointing to `140.44.4.11` and `14.4.4.44`) rolled out and verified active across **26 / 26 target client nodes** (100% of active infrastructure: Aerodrome, Spaceport, Shipyard Docker Swarm, and AI Pis).
> - Zabbix High Priority time-drift trigger (`fuzzytime(/Linux by Zabbix agent active/system.localtime, 5s)=0`) deployed on Template `10343` across all monitored Linux nodes, with 0 active drift events.
#### Architecture & Implementation Steps
```mermaid
flowchart TD
subgraph ExteriorNTP ["Public Exterior Stratum 1 & 2 NTP Sources"]
NIST["NIST Atomic Clock\n(time.nist.gov)"]
CF["Cloudflare Anycast NTP\n(time.cloudflare.com)"]
GOOG["Google Public NTP\n(time.google.com)"]
POOL["NTP Pool Project\n(0.us.pool.ntp.org / 1.us.pool.ntp.org)"]
end
subgraph InternalNTP ["Central Internal Time Authority (Chrony Stratum 2)"]
NTP_PRI["Primary Internal NTP Server\naerodrome-ctl01 (140.44.4.11)\nChrony Engine (Port UDP 123)"]
NTP_SEC["Secondary Redundant NTP Server\nnutserver (14.4.4.44)\nChrony Engine (Port UDP 123)"]
end
subgraph InternalClients ["Homelab Infrastructure Clients (28 Target Hosts)"]
K8S_A["aerodrome K3s (10 Nodes)\n140.44.4.11-13, 21-27"]
K8S_S["spaceport K3s (10 Nodes)\n140.44.4.41-44, 51-56"]
SWM["shipyard Swarm (5 Nodes)\n140.44.4.71-75"]
NAS["Synology NAS (2 Appliances)\n14.10.10.71, 72"]
SMART["Smart Home & AI Pis (4 Hosts)\n14.4.4.20, 14.4.4.44, 140.44.4.7-8"]
end
NIST --> NTP_PRI
CF --> NTP_PRI
GOOG --> NTP_PRI
POOL --> NTP_PRI
NIST --> NTP_SEC
CF --> NTP_SEC
GOOG --> NTP_SEC
POOL --> NTP_SEC
NTP_PRI <-->|"Symmetric Active Peering"| NTP_SEC
NTP_PRI -->|"Sub-millisecond NTP Unicast (Port 123)"| K8S_A
NTP_PRI -->|"Sub-millisecond NTP Unicast (Port 123)"| K8S_S
NTP_PRI -->|"Sub-millisecond NTP Unicast (Port 123)"| SWM
NTP_PRI -->|"Sub-millisecond NTP Unicast (Port 123)"| NAS
NTP_PRI -->|"Sub-millisecond NTP Unicast (Port 123)"| SMART
NTP_SEC -.->|"Automatic Redundant Fallback"| InternalClients
```
1. **Strategic Necessity for Homelab High Availability:**
- **Raft & etcd Consensus:** Both K3s clusters (`aerodrome` and `spaceport`), Docker Swarm (`shipyard`), and Harvester RKE2 require Raft heartbeat stability; clock skew $> 500\text{ms}$ triggers spurious leader elections, split-brain states, and quorum loss.
- **Docker Swarm & Kubernetes TLS Node Certificates:** Docker Swarm and K3s node TLS certificates have finite validity periods (e.g. 90-day Swarm certificates). A desynchronized clock causes immediate certificate expiration errors and daemon failures upon boot.
- **Authentik SSO & Token Lifetimes:** Authentik OAuth2, OIDC, and SAML assertions rely on strict `nbf` (not before) and `exp` (expires at) timestamp validation; clock skew $> 60\text{s}$ leads to total login authentication rejection across all homelab dashboards.
- **Lack of Hardware Real-Time Clocks (RTC) on Raspberry Pis:** All 24 Raspberry Pi nodes lack battery-backed hardware RTCs. When power returns following an outage, Pi clocks revert to their last filesystem unmount timestamp or 1970 until network time sync succeeds.
2. **Central Internal Chrony Server Deployment:**
- **Primary Server:** Deployed on `aerodrome-ctl01` (`140.44.4.11`).
- **Secondary Server:** Deployed on `nutserver` (`14.4.4.44`).
- **Diverse Exterior Stratum 1 & 2 Sources:**
```chrony
server time.cloudflare.com iburst minpoll 4 maxpoll 8
server time.google.com iburst minpoll 4 maxpoll 8
server time.nist.gov iburst minpoll 4 maxpoll 8
server 0.us.pool.ntp.org iburst minpoll 4 maxpoll 8
server 1.us.pool.ntp.org iburst minpoll 4 maxpoll 8
```
- **Orphan Mode & Local Stratum Fallback:**
```chrony
local stratum 10
allow 140.44.4.0/24
allow 14.4.4.0/24
allow 14.10.10.0/24
allow 140.44.44.0/24
allow 14.44.4.0/24
```
*Benefit:* If an extended storm or ISP fiber cut takes the WAN offline, the internal Chrony servers maintain local stratum 10 authority, locking all homelab hosts to identical drift-free time with zero inter-cluster divergence.
3. **Homelab-Wide Client Rollout (25 / 26 Active Client Nodes Verified):**
- **Debian / DietPi / Raspberry Pi OS Clients (`systemd-timesyncd`):**
Standardized `/etc/systemd/timesyncd.conf`:
```ini
[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
```
- **DietPi Continuous Daemon Mode:** Configured `CONFIG_NTP_MODE=4` in `/boot/dietpi.txt` and unmasked/enabled `systemd-timesyncd.service` on all Spaceport nodes.
- **Synology DSM (`14.10.10.71` & `14.10.10.72`):**
Targeted to synchronize time from internal primary `140.44.4.11`.
4. **Observability & Clock Drift Alerting in Zabbix:**
- Template **`Linux by Zabbix agent active` (ID 10343)** updated with High Severity Trigger `Linux: System time is out of sync (> 5s)` (ID `26201`):
`fuzzytime(/Linux by Zabbix agent active/system.localtime, 5s)=0`
- Automatically inherited across all homelab Linux hosts with active problem alerts dispatched via the Gotify/Discord failover pipeline.
#### Predicted Roadblocks & Mitigations
- **Roadblock 2.1 — Negative Time Jumps on Raspberry Pi Boot (systemd-timesyncd vs fake-hwclock):**
If `fake-hwclock` restores a timestamp from the future (e.g. from an unclean shutdown), `systemd-timesyncd` can refuse to jump backward without a hard step, or a backward step can corrupt etcd WAL logs.
- *Mitigation:* Ensure `systemd-timesyncd` is configured with `timedatectl set-ntp true` and that the systemd service starts before clustering engines (K3s, Docker Swarm), stepping time forward cleanly at boot.
- **Roadblock 2.2 — VLAN Segmentation Blocking UDP Port 123:**
Nodes reside across VLAN 1, 2, 20, 40, and 50. If UniFi firewall rules drop inter-VLAN UDP 123, cross-VLAN nodes (e.g. Synology NAS on VLAN 40, Home Assistant on VLAN 50) fail to sync.
- *Mitigation:* Enable a stateful UniFi firewall rule allowing `LAN IN: Any $\rightarrow$ UDP 123` to the internal NTP server IP, or serve NTP natively on each VLAN gateway interface on the UDM-Pro (`140.44.4.1`, `14.4.4.1`, `14.10.10.1`, `140.44.44.1`).
---
### Phase 3: Persistent Storage Stabilization & NAS NFS Architecture (Item 3) — [100% Completed & Verified]
#### Architecture & Implementation Steps (Executed & Verified)
1. **Kubernetes StorageClass & Stuck PVC Remediation (`aerodrome` & `spaceport`):**
- Removed `is-default-class` annotation from `local-path` across both `aerodrome` and `spaceport`. **`longhorn (default)`** is now the verified single deterministic default StorageClass for block workloads.
- Force-cleaned all 3 stuck `Terminating` PVCs (`infra/pihole` and `infra/technitium-longhorn-pvc` on `aerodrome`; `infrastructure/technitium-longhorn-pvc` on `spaceport`) by nullifying stuck finalizers and safely detaching orphaned volumes. Stuck PVCs across clusters: **0**.
- Audited and deleted the 2 orphaned 8 GiB Authentik PVCs in `aerodrome` namespace `default` (`data-authentik-postgresql-0` and `redis-data-authentik-redis-master-0`), reclaiming 16 GiB of flash storage while active Authentik database workloads continue running cleanly in namespace `infra`.
2. **Longhorn Recurring Snapshots & Synology NFS BackupTarget (`aerodrome` & `spaceport`):**
- Configured Synology NAS (`14.10.10.71:/volume1/kubernetes`) exports with dedicated subdirectories:
- `nfs://14.10.10.71:/volume1/kubernetes/longhorn/aerodrome`
- `nfs://14.10.10.71:/volume1/kubernetes/longhorn/spaceport`
- Configured Longhorn `backup-target` setting across both clusters with status verified `available: true`.
- Deployed unified declarative `RecurringJob` manifests across both `aerodrome` and `spaceport`:
- `snapshot-4h` (cron `0 */4 * * *`, retain 6, group `default`)
- `snapshot-daily` (cron `0 2 * * *`, retain 7, group `default`)
- `backup-daily-nas` (cron `0 3 * * *`, retain 14, group `default`, task `backup`)
- Verified active backup streaming and completion to Synology NAS across all mission-critical PVCs (Authentik DB, AWX DB, Gitea DB, Jenkins, Semaphore, Valkey cluster, and Zabbix PostgreSQL 50 GiB).
3. **Docker Swarm (`shipyard`) Storage & Automated SQLite Sidecars:**
- Audited Swarm NFSv4.1 automounts on `14.10.10.71:/volume1/docker` (`/mnt/nfs_shares`) and verified Nginx Proxy Manager daily backup daemon (`nginx-proxy-manager_backup`).
- Implemented and activated `/usr/local/bin/swarm_sqlite_backup.py` backed by systemd service `swarm-sqlite-backup.service` and active timer `swarm-sqlite-backup.timer` on manager node `140.44.4.71`.
- Verified atomic online `VACUUM INTO` snapshots + compressed `.tar.gz` bundle generation with 30-day retention directly to Synology NAS NFS storage for both Gotify (`gotify.db` + discord bridge configs) and Homebox (`homebox.db`).
#### Predicted Roadblocks & Mitigations
- **Roadblock 2.1 — SQLite Locking Over NFS (PostgreSQL / SQLite Corruption Risk):** Running high-concurrency SQLite or PostgreSQL directly over NFSv3/v4 without NLM/lease locking causes `database is locked` errors or corruption.
- *Mitigation:* Never move K8s PostgreSQL PVCs (`authentik-postgresql`, `awx-postgres`, `zabbix-postgresql`, `gitea-postgresql`) onto raw NFS; keep them on **Longhorn block storage (iSCSI-backed ext4)** and use NFS *strictly* as the Longhorn `backup-target` and for RWX file shares. For Swarm SQLite apps (`npm`, `gotify`, `homebox`), enforce `replicas: 1`, `update_config.order: stop-first`, and NFSv4 (`nfsvers=4.1,hard,timeo=600,retrans=2`).
- **Roadblock 2.2 — Synology NFS Squash Permissions (`root_squash`):** By default, Synology DSM maps container `root` (UID 0) to `nobody` (UID 65534), breaking container startup `chown`.
- *Mitigation:* Configure Synology NFS rules for `140.44.4.0/25` with `Squash: Map all users to admin` (or `No mapping` for Longhorn backup target) and enable `Allow connections from non-privileged ports (>1024)`.
---
### Phase 4: Notification Broadcasts, Discord Forwarding & Complete Zabbix Monitoring (Items 1, 2, & 7) — [100% Completed & Verified]
#### Architecture & Implementation Steps
```mermaid
flowchart TD
subgraph Sources ["Homelab Event & Alert Sources"]
ZBX["Zabbix 7.0.16 Server\n(140.44.4.61 / spaceport)"]
HA["Home Assistant\n(14.4.4.20 / VLAN 50)"]
NUT["NUT UPS Master\n(14.4.4.44 / nut-notify.sh)"]
AWX_N["AWX / ArgoCD / Backup Jobs"]
end
subgraph GotifyHub ["Central Local Notification Hub"]
GTF["Gotify Server v2.9.1\nInternal VIP: http://140.44.4.70:8090\nExternal: https://gotify.snarfnet.net"]
BRIDGE["gotify-discord-bridge\n(WebSocket Listener Container)"]
end
subgraphRecipients ["Important Devices & External Channels"]
Mobile["Mobile Devices (Android/iOS)\n& Desktop Browser Push"]
Homepage["Homepage Dashboard Widget"]
Discord["Discord Server Channels\n(#infra-alerts, #smart-home, #ups-power)"]
end
ZBX -- "1. Native Gotify MediaType" --> GTF
ZBX -- "2. Native Discord Webhook (Direct Fallback)" --> Discord
HA -- "1. REST notify.gotify" --> GTF
HA -- "2. Native Discord Integration" --> Discord
NUT -- "Direct HTTP (Bypasses SSO)" --> GTF
AWX_N -- "App Token Webhook" --> GTF
GTF -- "WebSocket Stream (Client Tokens)" --> Mobile
GTF -- "Client Token API" --> Homepage
GTF -- "ws://140.44.4.70:8090/stream" --> BRIDGE
BRIDGE -- "Formatted Discord Embeds" --> Discord
```
1. **Gotify Broadcast Channels & Device Enrollment (Item 1):**
- Automated Python script (`scripts/setup_gotify_channels.py`) provisions 5 structured Gotify Applications via `http://140.44.4.70:8090/application`:
- `UPS & Power Resilience` (Default Priority: `9`)
- `Zabbix Infrastructure Alerts` (Priority mapped `1-10` by severity)
- `Home Assistant Smart Home` (Default Priority: `5`)
- `AWX & GitOps Automation` (Default Priority: `4`)
- `Backups & Storage Health` (Default Priority: `6`)
- Provision dedicated **Client Tokens** (`http://140.44.4.70:8090/client`) for each important device (Primary Phone, Tablet, Desktop Browser, Homepage Dashboard).
2. **Discord Forwarding Architecture (Item 2):**
- **Dedicated Discord Channel Webhooks (Verified):**
- `ups-power`: `https://discord.com/api/webhooks/1566076118535114813/2e_fCJeHb-mF8VwerzOORGRa-QUDFuwN6R2Gfz-MGUiIOudA0PiXGZSyd19PNFG-vR7D`
- `infra-alerts`: `https://discord.com/api/webhooks/1566076777963593772/wKN30N0ZXVJKGP-prZ1_P9cp_u7Oakmh8R4bVh0qr8RFpyeGcJZrHfPQBJxtfzQqb4dn`
- `home-assistant`: `https://discord.com/api/webhooks/1566077255837294684/4x_sTqDeEHfIeGccEutOSvPKAnCV6Fyr7uAuECOt8M8QZcOvL2XZ0GVATW7EmcSyUW33`
- **Gotify $\rightarrow$ Discord Multi-Channel Bridge:** Deploy a lightweight, self-healing container service (`gotify-discord-bridge`) on Shipyard Swarm alongside Gotify. It connects internally to `ws://gotify:80/stream?token=<CLIENT_TOKEN>`, maps each Gotify `appid` to its dedicated Discord channel webhook (`UPS & Power Resilience` $\rightarrow$ `ups-power`, `Home Assistant Smart Home` $\rightarrow$ `home-assistant`, and `Zabbix` / `AWX` / `Backups` $\rightarrow$ `infra-alerts`), transforms every incoming Gotify message into a rich, color-coded Discord Embed (Red for priority $\ge 8$, Orange for $5-7$, Blue for $\le 4$), and POSTs it to the mapped Discord Webhook URL.
- **Zabbix $\rightarrow$ Discord & Gotify (Dual-Path Redundancy):** Configure Zabbix 7.0.16 via JSON-RPC API to enable both the native **Discord** webhook media type (pointing to `infra-alerts`, plus `ups-power` for UPS host group triggers) and a **Gotify** webhook media type, linked to the `Admin` user, and **enable** Trigger Action `'Report problems to Zabbix administrators'` (`status=0`, currently disabled!). If Gotify or Swarm is ever down during an outage, Zabbix still reaches Discord directly from `spaceport`.
- **Home Assistant $\leftrightarrow$ Gotify & Discord (`14.4.4.20:8123` — Configured & Verified):**
- Provisioned reusable script **`script.snarfnet_broadcast`** ([scripts/setup_homeassistant_notifications.py](file:///c:/Users/Snarf/OneDrive/Desktop/code/SnarfCode/scripts/setup_homeassistant_notifications.py)) which records structured broadcast entries via `logbook.log`, creates a persistent UI notification, and pushes directly to `notify.mobile_app_pixel_10_pro_fold` and `notify.mobile_app_pixel_9_pro_fold`.
- `gotify-discord-bridge` polls Home Assistant's `/api/logbook` API using the Long-Lived Access Token, evaluates any Jinja2 state templates via `/api/template`, publishes new notifications to Gotify Application #3 (`Home Assistant Smart Home`), and routes them to the `#home-assistant` Discord webhook.
- Provisioned Home Assistant automations:
1. **`Snarfnet UPS Power & Exhaust Protocol` (`1793453000001`):** Monitors `sensor.server_closet_status_data` (`OL`/`OB`/`LB`), broadcasts live UPS telemetry (`battery_charge`, `load`, `input_voltage`, `server_room_temperature`), and automatically sets `fan.server_closet_switch` to `100%` when running on battery.
2. **`Snarfnet Server Room Thermal Guard` (`1793453000002`):** Engages `fan.server_closet_switch` at `100%` and broadcasts a high-temperature warning if `sensor.server_room_temperature_temperature` exceeds `82°F` for 2 minutes.
3. **Bambu H2D 3D Printer Automations (`1788367985433`, `1788368709567`, `1788370272217`, `1788370321997`):** Enhanced to broadcast `Started`, `Completed`, `Stalled`, and `Failed` events to Gotify and `#home-assistant` on Discord alongside existing camera snapshots.
3. **Complete Zabbix Sensors, Thresholds, & Templates (Item 7):**
- **Fix Missing Hosts (100% Coverage):**
- Deploy `zabbix-agent2` with shared PSK (`PSK_autoregister`) via Ansible across all missing Linux nodes: Spaceport Masters (`140.44.4.41-44`), Aerodrome Workers (`140.44.4.25`, `.27`), AI Pis (`140.44.4.7`, `.8`), and Harvester nodes (`140.44.4.81-83`).
- Enable SNMPv2c/v3 on **SN-NAS (`14.10.10.71`)**, **SnarfNAS (`14.10.10.72`)**, and **UDM Pro (`14.44.4.1`)**, registering them in Zabbix with templates `Synology DSM by SNMP` and `Ubiquiti AirOS/UniFi by SNMP`.
- **Custom Sensors & Threshold Alarms:**
- **NUT UPS Template (`nutserver` `14.4.4.44`):** Custom `UserParameter=ups.metric[*],upsc snarf-ups@127.0.0.1 $1` polling `ups.status` (Trigger: `!= OL` $\rightarrow$ High), `battery.charge` (`< 80%` Warning, `< 50%` High, `< 25%` Disaster), `ups.load` (`> 85%` High), and `battery.runtime` (`< 600s` High).
- **Kubernetes & Longhorn Health:** Link `Kubernetes nodes by HTTP` and `Kubernetes cluster state by HTTP` to `aerodrome` and `spaceport`; alert on Pod `CrashLoopBackOff`, Node `NotReady` $> 3\text{m}$, and Longhorn volume robustness `Degraded`/`Faulted`.
- **Hardware & Thermal Thresholds:** CPU temperature $> 75^\circ\text{C}$ (Warning) / $> 82^\circ\text{C}$ (High) for Raspberry Pi 5 nodes in `spaceport`; NVMe/HDD SMART health and Btrfs pool utilization $> 80\%$ (Warning) / $> 90\%$ (High).
- **TLS Certificate & Web Scenarios:** Zabbix Web Scenarios checking HTTP 200 and SSL certificate expiry ($< 14$ days Warning, $< 7$ days High) across all 35 `*.snarfnet.net` proxy hosts.
#### Predicted Roadblocks & Mitigations
- **Roadblock 3.1 — Authentik Forward Auth Blocking Gotify Mobile App:** The Android/iOS Gotify app uses its own `X-Gotify-Key` header and cannot complete an interactive Authentik OAuth2 browser redirect flow when connecting externally to `https://gotify.snarfnet.net`.
- *Mitigation:* In NPM Proxy Host #32 (`gotify.snarfnet.net`), bypass Authentik `auth_request` specifically when a valid `X-Gotify-Key` header or `/message` / `/stream` API path is requested, while enforcing Authentik Forward Auth on the web UI root (`/`, `/static/*`)!
- **Roadblock 3.2 — Discord Webhook Rate Limiting During Alert Storms:** During a power outage or switch flap, 25+ hosts firing simultaneously can hit Discord's `30 requests / 60 seconds` webhook rate limit (`HTTP 429`).
- *Mitigation:* Implement a 2-second batching queue with exponential backoff on `HTTP 429` (`Retry-After`) in the `gotify-discord-bridge` and configure Zabbix host dependency rules (all VLAN 20 hosts depend on UDM Pro `14.44.4.1` ICMP ping) so downstream alerts are suppressed if a core switch or gateway drops.
---
### Phase 5: Infrastructure Upgrades, Ansible Playbooks & AWX Automation (Items 5 & 6) — [100% Completed & Verified]
#### Architecture & Implementation Steps
1. **AWX Provisioning (`https://awx.snarfnet.net` on `aerodrome`):**
- Executed automated provisioning via Django ORM inside `awx-task`:
- **Organization:** `Snarfnet Homelab` (id=2)
- **Credentials:**
1. `Snarfnet Master SSH Key (root)` (`Machine` credential with `id_ed25519_snarfnet` for root access across clusters)
2. `Snarfnet Master SSH Key (snarf)` (`Machine` credential with `id_ed25519_snarfnet` + passwordless sudo for Swarm, AI, and NUT)
- **Project:** `Snarfnet Infrastructure Playbooks` (id=8) synced from local **Gitea** (`https://git.snarfnet.net/Snarf/SnarfCode.git`).
- **Master Inventory (`Snarfnet Master Inventory`, id=3):** Structured into 8 functional groups covering all 28 active homelab nodes:
- `k3s_aerodrome_masters` (`140.44.4.11-13`) & `k3s_aerodrome_workers` (`140.44.4.21-27`)
- `k3s_spaceport_masters` (`140.44.4.41-44`) & `k3s_spaceport_workers` (`140.44.4.51-56`)
- `swarm_shipyard` (`140.44.4.71-75`)
- `ai_pi_cluster` (`140.44.4.7-8`)
- `smart_home_nodes` (`nutserver` `14.4.4.44`)
- `synology_nas` (`14.10.10.71-72`)
- **Job Templates Provisioned & Verified:**
- `Daily Drift & Agent Health Audit` ([ansible/playbooks/daily_drift_audit.yml](file:///c:/Users/Snarf/OneDrive/Desktop/code/SnarfCode/ansible/playbooks/daily_drift_audit.yml)): Audits time sync daemon, Zabbix agent 2, and master SSH key enrollment. Executed with 100% green recap across 28/28 active nodes (Job 106).
- `Aerodrome K3s Pre-Upgrade Check` ([ansible/playbooks/k3s_aerodrome_precheck.yml](file:///c:/Users/Snarf/OneDrive/Desktop/code/SnarfCode/ansible/playbooks/k3s_aerodrome_precheck.yml)): Verifies K3s systemd unit and captures on-demand etcd snapshots. Executed and verified (Job 111).
- `Aerodrome K3s Control Plane Upgrade` ([ansible/playbooks/upgrade_k3s_aerodrome.yml](file:///c:/Users/Snarf/OneDrive/Desktop/code/SnarfCode/ansible/playbooks/upgrade_k3s_aerodrome.yml)): Atomic direct binary replacement with etcd quorum check and node `Ready` gates (`serial: 1`).
- `Weekly OS & Security Patching` ([ansible/playbooks/weekly_os_patching.yml](file:///c:/Users/Snarf/OneDrive/Desktop/code/SnarfCode/ansible/playbooks/weekly_os_patching.yml)): Staged rolling package updates with service health verification.
2. **Remediation of `aerodrome` K3s Control Plane Version Skew [100% Completed & Verified]:**
- Remediation completed sequentially across minor versions to prevent etcd schema corruption:
- **Pre-Upgrade etcd Snapshot:** Saved `clean-pre-upgrade-sninfr2lnxctl01-1793646879` (109 MB) on `.11`.
- **Step 1 (`v1.27.16+k3s1`):** Executed AWX Job 118 (`serial: 1`). Upgraded `ctl01`, `ctl02`, and `ctl03`. All 3 masters reported `Ready`.
- **Step 2 (`v1.28.15+k3s1`):** Executed AWX Job 121 (`serial: 1`). Upgraded `ctl01`, `ctl02`, and `ctl03`. All 3 masters reported `Ready`.
- **Step 3 (`v1.29.10+k3s1`):** Executed AWX Job 124 (`serial: 1`). Upgraded `ctl01`, `ctl02`, and `ctl03`. All 3 masters reported `Ready`.
- **Step 4 (`v1.30.6+k3s1`):** Executed AWX Job 127 (`serial: 1`). Upgraded `ctl01`, `ctl02`, and `ctl03`. All 3 masters reported `Ready`.
- **Outcome:** Control plane version skew is **100% resolved**. All 10 Aerodrome nodes (ctl01–03 and wrk01–07) are unified on `v1.30.6+k3s1`.
- **Workload Verification:** All mission-critical pods (Authentik server/worker/LDAP, AWX web/task/postgres, Longhorn attached volumes) verified `Running` and `healthy`.
#### Predicted Roadblocks & Mitigations
- **Roadblock 4.1 — Longhorn Replica Rebuilding During Rolling Node Reboots:** If Ansible reboots node B immediately after node A finishes rebooting, Longhorn volumes may still be in `Degraded` (rebuilding replicas) on node A; rebooting node B can take the last healthy replica offline!
- *Mitigation:* Add an explicit Ansible `uri` / `command` pre-task gate in the rolling reboot playbook that queries the Longhorn API (`volumes` list) and pauses the next node's drain until **zero attached volumes are in `degraded` state**.
- **Roadblock 4.2 — AWX Running Inside the Cluster It Is Upgrading (`aerodrome`):** When AWX upgrades or reboots the `aerodrome` node currently hosting the `awx-task` pod, the active AWX job itself will be killed mid-execution!
- *Mitigation:* Pin `awx-task` and `awx-web` to `sninfr2lnxwrk06` during `aerodrome` master upgrades, and execute `aerodrome` worker reboots either via a Workflow Job Template that upgrades `wrk06` last, or trigger `aerodrome` node reboots from **Semaphore** (`140.44.4.58` on `spaceport`) so the orchestrator lives on a separate physical cluster!
---
### Phase 6: Terraform Plans & ArgoCD GitOps Integrity (Item 4) — [100% Completed & Verified]
#### Architecture & Implementation Steps (Executed & Verified)
1. **Local GitOps Repository Created on Gitea (`https://git.snarfnet.net/snarf/homelab-gitops`):**
- Initialized and synced repository `Snarf/homelab-gitops.git` on local Gitea:
```text
homelab-gitops/
├── terraform/
│ ├── authentik/ # OAuth2 providers, proxy ForwardAuth, apps, policies
│ ├── harvester/ # Declarative VLAN networks, VM templates, SSH keys
│ ├── shipyard-swarm/ # Docker Swarm stacks (NPM, Gotify, Homebox, Peanut)
│ └── synology/ # Shared folders (kubernetes, docker, backups, media) & NFS rules
├── argocd/
│ ├── bootstrap/ # Root App-of-Apps, Project, spaceport & aerodrome child apps
│ ├── spaceport/ # Applications hosted on spaceport (Gitea, Zabbix, Jenkins, etc.)
│ └── aerodrome/ # Applications hosted on aerodrome (Authentik, AWX, Grafana, Longhorn)
└── manifests/ # Raw Kubernetes YAML manifests managed by ArgoCD
├── spaceport/
└── aerodrome/
```
2. **Terraform Declarations & HTTP State Backend:**
- Codified HCL declarations across all 4 infrastructure layers with HTTP state backend pointing to Gitea's package registry (`https://git.snarfnet.net/api/packages/snarf/terraform/state/<module>`).
3. **ArgoCD Multi-Cluster Registration (`https://argo.snarfnet.net`):**
- Enrolled `aerodrome` (`https://140.44.4.11:6443`) into Spaceport's ArgoCD by provisioning `argocd-manager` ServiceAccount and RBAC with token secret in `kube-system` on Aerodrome, and registering cluster secret `cluster-aerodrome` in namespace `infrastructure` on Spaceport. Shard 0 verified connected and healthy.
4. **ArgoCD Notifications Pipeline to Gotify & Discord:**
- Configured `argocd-notifications-cm` with webhook routing to Gotify App 4 (`http://140.44.4.70:8090/message?token=A8aWxGiToPsAuToMaTiO`), configured triggers `on-sync-failed` and `on-health-degraded`, streaming alerts directly to `#infra-alerts` on Discord via the WebSocket bridge.
5. **Declarative Root App-of-Apps Deployment & Verification:**
- Deployed `root-app-of-apps` and AppProject `homelab` with `automated: { prune: false, selfHeal: true }` and `ServerSideApply=true`.
- Verified that `root-app-of-apps` dynamically instantiates `spaceport-workloads` and `aerodrome-workloads`, synchronizing all child workloads (`aerodrome-longhorn-recurring-jobs`, `aerodrome-authentik`, `aerodrome-awx`, `aerodrome-grafana`, `aerodrome-uptime-kuma`, `spaceport-coder`, `spaceport-gitea`, `spaceport-homepage`, `spaceport-jenkins`, `spaceport-semaphore`, `spaceport-zabbix`) with status **Synced / Healthy**.
#### Predicted Roadblocks & Mitigations
- **Roadblock 5.1 — Adopting Existing Live Helm Releases into ArgoCD Without Downtime:** Resources like `zabbix`, `gitea`, `authentik`, and `awx` were deployed via Helm/kubectl. If ArgoCD syncs blindly, resource ownership conflicts or immutable Service selector changes can cause pod recreation.
- *Mitigation:* Use `ServerSideApply=true` and `Replace=false` sync options in ArgoCD, and test every Application in **Diff / Manual Sync mode** (`kubectl diff`) until ArgoCD reports `Synced / Healthy` with zero destructive diffs before flipping `selfHeal: true`.
- **Roadblock 5.2 — Circular Dependency if Gitea or ArgoCD Goes Down:**
- *Mitigation:* Mirror the `homelab-gitops` repository automatically to a bare Git repository on Synology NAS (`/volume1/git_mirror/homelab-gitops.git`) and your workstation (`SnarfCode`), ensuring Terraform and `kubectl apply` can always run directly from local disk if `spaceport` is offline.
---
### Phase 7: Power Resilience & UPS Automation Plan (Updated Core Pillar) — [100% Completed & Verified]
#### Architecture & Implementation Steps (Executed & Verified)
1. **NUT Master Server Configuration (`14.4.4.44`):**
- Configured `upsmon.conf` on `nutserver` with `NOTIFYCMD /usr/local/bin/nut-notify.sh` and `SHUTDOWNCMD /usr/local/bin/homelab_power_shutdown.sh`.
- Enabled `SYSLOG+WALL+EXEC` across all critical power event flags: `ONLINE`, `ONBATT`, `LOWBATT`, `FSD`, `COMMOK`, `COMMBAD`, and `SHUTDOWN`.
- Verified `nut-monitor.service` is active, connected to `server-closet@localhost (primary)`.
2. **Notification Pipeline Verification (`scripts/nut_notify.sh`):**
- Verified automated dispatch to Gotify VIP (`http://140.44.4.70:8090/message`) with token `A4uN4fEmErGeNcYtOkEn` (Application 1: `UPS & Power Resilience`, priority 9/10).
- Verified automatic streaming to Discord channel `#ups-power` via WebSocket bridge, with direct UDM-Pro egress fallback if Gotify/Swarm is stopped.
3. **Graceful 6-Tier Shutdown Automation (`scripts/homelab_power_shutdown.sh`):**
- Tier 1: Stops transient AI inference Pis (`140.44.4.7`, `140.44.4.8`).
- Tier 2: Scales down Swarm NFS services (`nginx-proxy-manager_app`, `backup`, `peanut`, `homebox`) on `140.44.4.71` to cleanly flush databases and release NFS file locks.
- Tier 3: Stops K3s agents on all 7 Aerodrome workers (`140.44.4.21-27`) and 6 Spaceport workers (`140.44.4.51-56`), cleanly syncing and detaching Longhorn CSI persistent volumes.
- Tier 4: Signals Synology NAS arrays (`14.10.10.71`, `14.10.10.72`) into DSM Safe Mode via native NUT slave daemon (`synopoweroff`).
- Tier 5: Saves on-demand pre-shutdown etcd snapshots on Spaceport masters (`.41-.44`) and Aerodrome masters (`.11-.13`), stops K3s service, and halts master nodes and Swarm managers (`.71-.75`).
- Tier 6: NUT Master fires final FSD notification, arms `/etc/killpower`, and executes `/sbin/shutdown -h +0`.
#### 6.1 Core Power Architecture & IP Assignments
The power resilience architecture uses a centralized **NUT Master Server** (`14.4.4.44`) that polls the physical UPS and broadcasts status alerts (`ONBATT`, `LOWBATT`, `ONLINE`, `FSD`) across all homelab VLANs.
| Role | Hostname / Identifier | Target IP | Subnet / VLAN | Description & Failure Protocol |
|---|---|---|---|---|
| **NUT Master Server** | `nutserver.snarfnet.net` | `14.4.4.44` | VLAN 50 (Smart Home) | Primary NUT `upsd` daemon polling physical UPS via USB/Serial/SNMP. Runs `upsmon master` and holds `/root/.ssh/id_ed25519_snarfnet`. |
| **Edge Ingress / Reverse Proxy** | `nginx.snarfnet.net` | `140.44.4.70` | VLAN 20 (Keepalived VIP) | Dual-NPM active/standby running in `PublishMode: host` on ports 80/443/81 across Swarm 01 & 02. Dependent on Synology NFS; stopped in Tier 2 prior to NAS Safe Mode. |
| **Authentication & SSO** | `authentik.snarfnet.net` | `140.44.4.29:444` | VLAN 20 (Aerodrome K3s) | Handles Forward Auth & Conditional MFA. Runs on Longhorn storage in Aerodrome. |
| **UPS Web Dashboard** | `peanut.snarfnet.net` | `140.44.4.70:8050` | VLAN 20 (Swarm VIP) | Peanut container UI consuming `14.4.4.44:3493`. Protected by Authentik Forward Auth externally. |
| **Emergency Push & Discord** | `gotify.snarfnet.net` | `140.44.4.70:8090` | VLAN 20 (Swarm VIP) | Gotify server + `gotify-discord-bridge`. **Internal outage alerts use `http://140.44.4.70:8090`** (+ direct Discord webhook fallback) to bypass Authentik during shutdown. |
| **Smart Home Automation** | `homeassistant.snarfnet.net` | `14.4.4.20` | VLAN 50 (Smart Home) | Home Assistant integration monitoring `14.4.4.44:3493` for state changes, critical push alerts, and non-essential load shedding. |
| **Centralized Monitoring** | `zabbix.snarfnet.net` | `140.44.4.61` | VLAN 20 (Spaceport K3s) | Zabbix 7.0.16 polling `14.4.4.44` via Zabbix Agent 2 `upsc` UserParameters. |
| **Primary Storage 1 & 2** | `sn-nas` / `snarfnas` | `14.10.10.71` / `.72` | VLAN 40 (Storage) | Synology DSM UPS clients polling `14.4.4.44`. Host NFSv4 shares and Longhorn BackupTargets. |
#### 6.2 Tiered Graceful Shutdown Hierarchy (6 Stages)
```text
+--------------------------------------------------------------------------------------------------+
| [T+0s] Grid Power Outage -> NUT Master (14.4.4.44) detects ONBATT |
| - Broadcasts emergency alert to Gotify (http://140.44.4.70:8090), Discord Webhook, & HA Push |
+--------------------------------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------------------------------+
| [T+3m / Batt <= 85%] TIER 1: Non-Essential & Transient Workloads |
| - Dev environments: Coder workspaces (coder.snarfnet.net) & Jenkins build executors stopped |
| - AI inference stack: Turing Pi AI nodes (140.44.4.7 / 140.44.4.8) halted via SSH key |
+--------------------------------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------------------------------+
| [T+6m / Batt <= 70%] TIER 2: NFS-Mounted Docker Swarm Services (NPM, Gotify, Homebox, Peanut) |
| - Send final pre-shutdown notification to Gotify & Discord |
| - Scale down Swarm NFS services (`nginx-proxy-manager_app`, `backup`, `peanut`, `homebox`) |
| - Flushes open SQLite databases and cleanly releases all NFS file locks on 14.10.10.71 |
+--------------------------------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------------------------------+
| [T+8m / Batt <= 55%] TIER 3: Kubernetes Stateful Workloads & Longhorn Volume Detach |
| - Harvester guest VMs (140.44.4.81-83) gracefully shut down via `virsh shutdown` / API |
| - Stop K3s agent on Aerodrome (140.44.4.21-27) & Spaceport (140.44.4.51-56) worker nodes |
| - Longhorn CSI cleanly syncs cached writes and detaches persistent volumes from worker nodes |
+--------------------------------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------------------------------+
| [T+10m / Batt <= 40%] TIER 4: Storage Arrays (Synology DSM Safe Mode) |
| - SN-NAS (14.10.10.71) & SnarfNAS (14.10.10.72) enter DSM Safe Mode (`synopoweroff` / NUT) |
| - Unmounts Btrfs volume pools, terminates NFS/iSCSI daemons, parks drive heads |
+--------------------------------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------------------------------+
| [T+12m / Batt <= 25%] TIER 5: Control Planes & Baremetal Hypervisors |
| - Aerodrome (140.44.4.11-13) & Spaceport (140.44.4.41-44) Masters save etcd snapshot & halt |
| - Remaining Swarm Managers (140.44.4.71-75) & Harvester Hypervisors (140.44.4.81-83) halt |
+--------------------------------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------------------------------+
| [T+15m / Batt Low] TIER 6: Forced Shutdown (FSD) & Edge Power Cut |
| - NUT Master (14.4.4.44) sends direct Discord webhook ("Final FSD Cut") & issues `upsdrvctl` |
| - Core Edge: UDM Pro Gateway (14.44.4.1) & Core PoE switch survive on battery until power drops |
+--------------------------------------------------------------------------------------------------+
```
#### 6.3 Automated Power Restoration & Boot Verification Gates
1. **Stage 1 (Grid AC Restored, Battery $\ge 30\%$):** UPS re-energizes load outlets.
2. **Stage 2 (Network Core):** UDM Pro (`14.44.4.1`) & Core switches boot, establishing VLANs 1–60 and inter-VLAN routing.
3. **Stage 3 (NUT Master):** `14.4.4.44` boots (`BIOS: Restore on AC Power Loss = Always On`), starts `upsd`, and sends `ONLINE` alert to Discord/Gotify.
4. **Stage 4 (Storage Gate — Must Complete Before Compute):** Synology NAS arrays (`14.10.10.71` & `14.10.10.72`) boot, mount Btrfs pools, and expose NFS (`:2049`).
5. **Stage 5 (Control Planes & Hypervisors):** Harvester (`140.44.4.81-83`), K3s Masters (`aerodrome` & `spaceport`), and Swarm Managers (`140.44.4.71-75`) boot and establish etcd/Raft quorum.
6. **Stage 6 (Compute Workers & Ingress):** K3s workers boot, Longhorn reattaches PVCs (`data-authentik-postgresql-0`, `awx-postgres`, `zabbix-postgresql`), and Dual-NPM on Swarm 01 & 02 (`140.44.4.71-72`) binds host ports `80/443/81` behind Keepalived VIP `140.44.4.70`.
---
## 4. Access & Inputs Required to Begin Automation
We already have verified administrative API access to:
- **Authentik API** (`https://authentik.snarfnet.net`)
- **Rancher & Kubernetes APIs** (`aerodrome`, `spaceport`, and `spacedock` Harvester)
- **Portainer & Docker Swarm API** (`https://shipyard.snarfnet.net`)
- **Zabbix 7.0.16 API** (`https://zabbix.snarfnet.net/api_jsonrpc.php`)
To automate the remaining items end-to-end, we will only need the following when we reach each corresponding task:
1. **Discord Webhook URL(s) (For Phase 3 — Item 2):**
- One Discord Webhook URL (or separate URLs if you prefer separate channels like `#infra-alerts`, `#smart-home`, and `#ups-power`).
2. **Gotify Admin Credentials (For Phase 3 — Item 1):**
- Current Gotify admin username/password (or we can inspect/bootstrap a token directly via the Gotify SQLite database on Shipyard Swarm).
3. **Home Assistant Long-Lived Access Token or SSH (For Phase 3 — Item 2):**
- To inject the Gotify/Discord notification configuration into `14.4.4.20` (`configuration.yaml` / `automations.yaml`).
4. **Host SSH / Synology DSM Credentials (For Phase 1 — Item 8 & Phase 2 — Item 3):**
- Confirmation of whether the existing `snarf` / `Katzir476!` credential (from `setup_storage_cluster.sh`) is valid across the Debian/RPi nodes, plus SSH/DSM credentials for Synology NAS (`14.10.10.71` / `14.10.10.72`) and Harvester nodes (`140.44.4.81-83`) so we can enroll the unified `id_ed25519_snarfnet` key.

View File

@@ -1,263 +0,0 @@
# Snarfnet Homelab Power Resilience & UPS Automation Plan
This document details the power resilience strategy, tiered graceful shutdown sequence, and integration architecture for the **snarfnet** homelab centered around the primary **NUT (Network UPS Tools) Master Server**.
---
## 1. Core Architecture & IP Assignments
The power resilience architecture uses a centralized **NUT Master Server** host that polls connected UPS hardware and broadcasts status alerts (`ONBATT`, `LOWBATT`, `ONLINE`, `FSD`) across all homelab VLANs.
| Role | Hostname / Identifier | Target IP | Subnet / VLAN | Description |
|---|---|---|---|---|
| **NUT Master Server** | `nutserver.snarfnet.net` | `14.4.4.44` | VLAN 50 (Smart Home) | Primary NUT `upsd` daemon polling physical UPS via USB/Serial/SNMP. |
| **NUT Web Dashboard** | `peanut.snarfnet.net` | `140.44.4.70:8050` | VLAN 20 (Compute / Swarm) | Peanut container UI consuming `14.4.4.44:3493`. |
| **Smart Home Automation** | `homeassistant.snarfnet.net` | `14.4.4.20` | VLAN 50 (Smart Home) | Home Assistant integration monitoring `14.4.4.44:3493` for alerts/triggers. |
| **Centralized Monitoring** | `zabbix.snarfnet.net` | `140.44.4.61` | VLAN 20 (Spaceport K3s) | Zabbix Agent 2 / NUT exporter polling `14.4.4.44:3493`. |
| **Primary Storage 1** | `sn-nas.snarfnet.net` | `14.10.10.71` | VLAN 40 (Storage) | Synology DSM UPS client polling `14.4.4.44`. |
| **Primary Storage 2** | `snarfnas.snarfnet.net` | `14.10.10.72` | VLAN 40 (Storage) | Synology DSM UPS client polling `14.4.4.44`. |
---
## 2. Power Resilience Topology Map
```mermaid
flowchart TD
subgraph Grid ["Utility Power Grid"]
Mains["Main AC Power Supply"]
end
subgraph PowerHardware ["Power Protection"]
UPS["Central UPS Hardware"]
end
subgraph NUTMasterHost ["NUT Master Node (14.4.4.44)"]
UPSD["upsd Daemon (Port 3493)"]
UPSMON_M["upsmon (Master)"]
end
subgraph Dashboards ["Monitoring & Alerting"]
Peanut["Peanut Dashboard\n(peanut.snarfnet.net:8050)"]
HA["Home Assistant\n(14.4.4.20)"]
Zabbix["Zabbix Server\n(140.44.4.61)"]
end
subgraph Slaves ["UPS Slaves (upsmon Client Nodes)"]
Harvester["Spacedock HCI Nodes\n(140.44.4.81 - 83)"]
K3sControl["K3s Control Planes\n(Aerodrome & Spaceport)"]
K3sWorkers["K3s Worker Nodes"]
Swarm["Shipyard Swarm Nodes\n(140.44.4.71 - 75)"]
NAS["Synology NAS Arrays\n(14.10.10.71 / 72)"]
end
Mains --> UPS
UPS -- USB / SNMP --> UPSD
UPSD --> UPSMON_M
UPSD -- TCP 3493 --> Peanut
UPSD -- TCP 3493 --> HA
UPSD -- TCP 3493 --> Zabbix
UPSD -- TCP 3493 (Slave Broadcast) --> Harvester
UPSD -- TCP 3493 (Slave Broadcast) --> K3sControl
UPSD -- TCP 3493 (Slave Broadcast) --> K3sWorkers
UPSD -- TCP 3493 (Slave Broadcast) --> Swarm
UPSD -- TCP 3493 (Slave Broadcast) --> NAS
```
---
## 3. Tiered Shutdown Hierarchy & Execution Timeline
To prevent data corruption and unexpected hard cutoffs, hosts shutdown in staged tiers based on power outage duration and battery level.
```
+-------------------------------------------------------------------------+
| [T+0s] Grid Power Outage -> NUT Master (14.4.4.44) detects ONBATT |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [T+3m / Batt <= 80%] TIER 1: Non-essential Workloads |
| - Dev VMs, transcoders, test containers in Swarm & K3s drained |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [T+7m / Batt <= 50%] TIER 2: Hypervisors, Compute Workers & Storage |
| - Harvester spacedock VMs gracefully shut down via virsh/API |
| - Aerodrome & Spaceport worker nodes gracefully halt |
| - Synology NAS arrays (14.10.10.71/72) enter Safe Mode & unmount pools |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [T+12m / Batt <= 30%] TIER 3: Control Planes & Management Stack |
| - Aerodrome (140.44.4.11-13) & Spaceport (140.44.4.41-44) Control Planes|
| - Swarm Manager (140.44.4.71) & AI Nodes (140.44.4.7-8) halt |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [T+15m / Batt Low] TIER 4: Forced Shutdown (FSD) & Hardware Cutoff |
| - NUT Master (14.4.4.44) issues FSD signal to remaining clients |
| - NUT Master issues `upsdrvctl shutdown` (cuts UPS load outlet) |
| - Network Core (UDM Pro 14.44.4.1) stays alive until final battery drop |
+-------------------------------------------------------------------------+
```
---
## 4. Configuration Blueprint & Service Integrations
### A. NUT Master Server (`14.4.4.44`) Configuration Files
#### `/etc/nut/ups.conf`
```ini
[snarf-ups]
driver = usbhid-ups
port = auto
desc = "Snarfnet Main Server Rack UPS"
pollinterval = 5
```
#### `/etc/nut/upsd.conf`
```ini
LISTEN 14.4.4.44 3493
LISTEN 127.0.0.1 3493
MAXAGE 15
```
#### `/etc/nut/upsd.users`
```ini
[admin]
password = SUPER_SECRET_ADMIN_PASS
actions = SET
instcmds = ALL
[upsmon_master]
password = MASTER_MONITOR_PASS
upsmon master
[upsmon_slave]
password = SLAVE_MONITOR_PASS
upsmon slave
[peanut]
password = PEANUT_MONITOR_PASS
upsmon slave
```
#### `/etc/nut/upsmon.conf`
```ini
MONITOR snarf-ups@14.4.4.44 1 upsmon_master MASTER_MONITOR_PASS master
MINSUPPLIES 1
SHUTDOWNCMD "/sbin/shutdown -h +0"
NOTIFYCMD /usr/local/bin/nut-notify.sh
POWERDOWNFLAG /etc/killpower
NOTIFYFLAG ONLINE SYSLOG+WALL+EXEC
NOTIFYFLAG ONBATT SYSLOG+WALL+EXEC
NOTIFYFLAG LOWBATT SYSLOG+WALL+EXEC
NOTIFYFLAG FSD SYSLOG+WALL+EXEC
NOTIFYFLAG COMMBAD SYSLOG+WALL+EXEC
NOTIFYFLAG COMMOK SYSLOG+WALL+EXEC
```
---
### B. Client Node Configuration (`upsmon` Slave)
Applies to Harvester hypervisors, K3s master/worker hosts, Docker Swarm hosts, and Debian Linux nodes.
#### `/etc/nut/upsmon.conf` (Slave Mode)
```ini
MONITOR snarf-ups@14.4.4.44 1 upsmon_slave SLAVE_MONITOR_PASS slave
MINSUPPLIES 1
SHUTDOWNCMD "/sbin/shutdown -h +0"
POWERDOWNFLAG /etc/killpower
HOSTSYNC 15
DEADTIME 15
RBWARNTIME 43200
NOCOMMWARNTIME 300
FINALDELAY 5
```
---
### C. Synology NAS Integration (`14.10.10.71` & `14.10.10.72`)
1. Log into **DSM Console** $\rightarrow$ **Control Panel** $\rightarrow$ **Hardware & Power** $\rightarrow$ **UPS**.
2. Check **Enable UPS Support**.
3. Select UPS Type: **Synology UPS Server / Network NUT Server**.
4. Network UPS server IP address: **`14.4.4.44`**.
5. Set **Time before Synology NAS enters Standby Mode**: `Customize time` $\rightarrow$ `5 minutes` (or `Upon low battery`).
---
### D. Peanut Web Dashboard (`peanut.snarfnet.net`)
Peanut runs on Docker Swarm (`140.44.4.70:8050`). Ensure the Peanut service configuration references the NUT server at `14.4.4.44`:
#### Updated `docker-deployments/peanut.yml`
```yaml
services:
peanut:
image: brandawg93/peanut:latest
ports:
- "8050:8050"
environment:
- TZ=America/New_York
- NUT_HOST=14.4.4.44
- NUT_PORT=3493
- NUT_USER=peanut
- NUT_PASSWORD=PEANUT_MONITOR_PASS
volumes:
- peanut_config:/config
volumes:
peanut_config:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/swarm_shared/legacy_volumes/peanut/
```
---
### E. Home Assistant (`14.4.4.20`) Integration & Automation
1. Navigate to **Settings** $\rightarrow$ **Devices & Services** $\rightarrow$ **Add Integration** $\rightarrow$ **Network UPS Tools (NUT)**.
2. Host: **`14.4.4.44`**, Port: **`3493`**.
3. Username: **`upsmon_slave`**, Password: **`SLAVE_MONITOR_PASS`**.
#### Automation Snippet (`automations.yaml`)
```yaml
alias: "Power Resilience: Grid Power Lost Alert"
trigger:
- platform: state
entity_id: sensor.snarf_ups_status
to: "On Battery"
action:
- service: notify.gotify
data:
title: "⚡ Power Outage Alert"
message: "Grid power failure detected! Homelab is running on UPS battery (14.4.4.44). Tiered shutdown in progress."
priority: 8
```
---
## 5. Power Restoration & Auto-Boot Sequence
When grid power returns, systems auto-recover in reverse order:
1. **UPS AC Mains Restored**: Physical UPS re-enables output outlets once battery reaches safe threshold ($\ge 20\%$).
2. **Phase 1 - Gateway & Edge**: UDM Pro (`14.44.4.1`) & Core switches boot up and establish internal VLAN routes.
3. **Phase 2 - NUT Master**: Host at `14.4.4.44` boots (BIOS `Restore AC Power` set to `Always On`). `upsd` service starts.
4. **Phase 3 - Storage NAS**: Synology NAS nodes (`14.10.10.71`, `14.10.10.72`) auto-boot upon power recovery.
5. **Phase 4 - Control Planes & Hypervisors**:
- Harvester `spacedock` hosts (`140.44.4.81-83`) boot via Wake-on-LAN / IPMI / BIOS settings.
- K3s master nodes (`aerodrome` & `spaceport`) boot, resume etcd quorum.
6. **Phase 5 - Worker Compute & Workloads**: Worker nodes boot; Kubernetes and Docker Swarm automatically reschedule workloads.

1
homelab-gitops-repo Submodule

Submodule homelab-gitops-repo added at cb8e60b086

View File

@@ -0,0 +1,65 @@
import paramiko
manifest = """
apiVersion: longhorn.io/v1beta2
kind: RecurringJob
metadata:
name: snapshot-4h
namespace: longhorn-system
spec:
name: snapshot-4h
task: snapshot
cron: "0 */4 * * *"
retain: 6
concurrency: 2
groups:
- default
---
apiVersion: longhorn.io/v1beta2
kind: RecurringJob
metadata:
name: snapshot-daily
namespace: longhorn-system
spec:
name: snapshot-daily
task: snapshot
cron: "0 2 * * *"
retain: 7
concurrency: 2
groups:
- default
---
apiVersion: longhorn.io/v1beta2
kind: RecurringJob
metadata:
name: backup-daily-nas
namespace: longhorn-system
spec:
name: backup-daily-nas
task: backup
cron: "0 3 * * *"
retain: 14
concurrency: 2
groups:
- default
"""
clusters = [
("aerodrome", "140.44.4.11"),
("spaceport", "140.44.4.41")
]
for name, host in clusters:
print(f"=== Applying RecurringJobs on {name} ({host}) ===")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('cat << \'EOF\' | kubectl apply -f -\n' + manifest + '\nEOF')
print("APPLY STDOUT:", stdout.read().decode())
print("APPLY STDERR:", stderr.read().decode())
stdin, stdout, stderr = ssh.exec_command('kubectl get recurringjobs.longhorn.io -n longhorn-system')
print("CURRENT JOBS:\n", stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,4 @@
select disk 4
select partition 1
assign letter=H

View File

@@ -0,0 +1,53 @@
import paramiko
import json
TARGET_VIPS = {
("monitoring", "uptime-lb"): "140.44.4.28",
("infra", "authentik-lb"): "140.44.4.29",
("awx", "awx-lb"): "140.44.4.30",
("infra", "technitium-npm-vip-service"): "140.44.4.31",
("default", "grafana-lb"): "140.44.4.32",
("infra", "unified-npm-vip-service"): "140.44.4.33",
("kube-system", "traefik"): "140.44.4.34",
("infra", "ak-outpost-authentik-ldap-outpost"): "140.44.4.35",
("longhorn-system", "longhorn-frontend-lb"): "140.44.4.36",
}
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
print("=== PINNING AERODROME LOADBALANCER VIPS ===")
for (ns, name), target_ip in TARGET_VIPS.items():
patch = json.dumps({
"metadata": {
"annotations": {
"metallb.universe.tf/loadBalancerIP": target_ip
}
},
"spec": {
"loadBalancerIP": target_ip
}
})
cmd = f"kubectl patch svc {name} -n {ns} --type=merge -p '{patch}'"
stdin, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode().strip()
err = stderr.read().decode().strip()
print(f"[{ns}/{name}] -> {target_ip}: {out} {err}")
print("\n=== VERIFYING FINAL ALLOCATION STATE ===")
for (ns, name), target_ip in TARGET_VIPS.items():
stdin, stdout, stderr = ssh.exec_command(f"kubectl get svc {name} -n {ns} -o json")
svc = json.loads(stdout.read().decode())
current_ingress = [x.get('ip') for x in svc.get('status', {}).get('loadBalancer', {}).get('ingress', [])]
spec_ip = svc.get('spec', {}).get('loadBalancerIP')
ann_ip = svc.get('metadata', {}).get('annotations', {}).get('metallb.universe.tf/loadBalancerIP')
status = "LOCKED" if (spec_ip == target_ip and ann_ip == target_ip and target_ip in current_ingress) else "MISMATCH"
print(f"[{status}] {ns}/{name}: Ingress={current_ingress}, Spec={spec_ip}, Annotation={ann_ip}")
ssh.close()
if __name__ == '__main__':
main()

50
scripts/audit_awx.py Normal file
View File

@@ -0,0 +1,50 @@
import json, ssl, urllib.request
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
AWX_URL = "http://140.44.4.30:87/api/v2"
ADMIN_USER = "admin"
ADMIN_PASS = "CKFj2RfRzNcIeNJIimoW2uZ9En25P4VL"
import base64
auth_header = "Basic " + base64.b64encode(f"{ADMIN_USER}:{ADMIN_PASS}".encode()).decode()
def awx_get(endpoint):
req = urllib.request.Request(f"{AWX_URL}/{endpoint}/", headers={"Authorization": auth_header})
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode())
try:
me = awx_get("me")
print("AWX Auth Success! User:", me["results"][0]["username"])
orgs = awx_get("organizations")
print(f"\nOrganizations ({orgs['count']}):")
for o in orgs["results"]:
print(f" - [{o['id']}] {o['name']}")
creds = awx_get("credentials")
print(f"\nCredentials ({creds['count']}):")
for c in creds["results"]:
print(f" - [{c['id']}] {c['name']} (Type: {c['credential_type']})")
inventories = awx_get("inventories")
print(f"\nInventories ({inventories['count']}):")
for inv in inventories["results"]:
print(f" - [{inv['id']}] {inv['name']}")
projects = awx_get("projects")
print(f"\nProjects ({projects['count']}):")
for p in projects["results"]:
print(f" - [{p['id']}] {p['name']} (Status: {p['status']}, SCM: {p['scm_type']})")
templates = awx_get("job_templates")
print(f"\nJob Templates ({templates['count']}):")
for t in templates["results"]:
print(f" - [{t['id']}] {t['name']}")
except Exception as e:
print("AWX Query Error:", e)

View File

@@ -0,0 +1,79 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "http://140.44.4.60:8888/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_call(method, params, auth=None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth:
headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
resp = json.loads(r.read().decode("utf-8"))
if "error" in resp:
raise RuntimeError(f"Zabbix error in {method}: {resp['error']}")
return resp["result"]
# 1. Login
auth_token = zbx_call("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
print("Zabbix Auth OK!")
# 2. Get all hosts
hosts = zbx_call("host.get", {
"output": ["hostid", "host", "name", "status"],
"selectInterfaces": ["interfaceid", "ip", "port", "type", "main"],
"selectParentTemplates": ["templateid", "name"]
}, auth_token)
print(f"\nTotal Registered Hosts in Zabbix: {len(hosts)}")
registered_ips = {}
for h in sorted(hosts, key=lambda x: x["host"]):
ips = [i["ip"] for i in h.get("interfaces", []) if i.get("ip")]
ip_str = ", ".join(ips) if ips else "N/A"
status_str = "MONITORED" if h["status"] == "0" else "DISABLED"
templates = [t["name"] for t in h.get("parentTemplates", [])]
tpl_str = ", ".join(templates) if templates else "None"
print(f"[{status_str}] {h['host']} ({h['name']}) -> {ip_str} | Templates: {tpl_str}")
for ip in ips:
registered_ips[ip] = h["host"]
# Compare with our 28 target hosts
target_hosts = {
# Aerodrome (10)
"140.44.4.11": "sninfr2lnxctl01", "140.44.4.12": "sninfr2lnxctl02", "140.44.4.13": "sninfr2lnxctl03",
"140.44.4.21": "sninfr2lnxwrk01", "140.44.4.22": "sninfr2lnxwrk02", "140.44.4.23": "sninfr2lnxwrk03",
"140.44.4.24": "sninfr2lnxwrk04", "140.44.4.25": "sninfr2lnxwrk05", "140.44.4.26": "sninfr2lnxwrk06",
"140.44.4.27": "sninfr2lnxwrk07",
# Spaceport (10)
"140.44.4.41": "sninfr0lnxctl01", "140.44.4.42": "sninfr0lnxctl02", "140.44.4.43": "sninfr0lnxctl03", "140.44.4.44": "sninfr0lnxctl04",
"140.44.4.51": "sninfr0lnxwrk01", "140.44.4.52": "sninfr0lnxwrk02", "140.44.4.53": "sninfr0lnxwrk03",
"140.44.4.54": "sninfr0lnxwrk04", "140.44.4.55": "sninfr0lnxwrk05", "140.44.4.56": "sninfr0lnxwrk06",
# Shipyard (5)
"140.44.4.71": "snshpd4lnxswm01", "140.44.4.72": "snshpd4lnxswm02", "140.44.4.73": "snshpd4lnxswm03",
"140.44.4.74": "snshpd4lnxswm04", "140.44.4.75": "snshpd4lnxswm05",
# AI & NUT Master (3)
"140.44.4.7": "sninfr4lnxaih01", "140.44.4.8": "sninfr4lnxaih02", "14.4.4.44": "nutserver",
# Synology NAS (2)
"14.10.10.71": "SN-NAS", "14.10.10.72": "SnarfNAS"
}
print("\n=== COVERAGE AUDIT ACROSS 28 TARGET HOSTS ===")
missing = []
for ip, name in sorted(target_hosts.items()):
if ip in registered_ips:
print(f" [OK] {ip} -> {name} (Zabbix Host: {registered_ips[ip]})")
else:
print(f" [MISSING] {ip} -> {name}")
missing.append((ip, name))
print(f"\nTotal Missing from Target 28 Scope: {len(missing)}")
for ip, name in missing:
print(f" - {name} ({ip})")

View File

@@ -0,0 +1,40 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
items = zbx_req("item.get", {
"templateids": "10343",
"output": ["itemid", "name", "key_"],
}, auth=token)
print(f"Total items in 10343: {len(items)}")
for i in items:
if "time" in i["name"].lower() or "time" in i["key_"].lower():
print(f" {i['itemid']}: {i['name']} ({i['key_']})")
trigs = zbx_req("trigger.get", {
"templateids": "10343",
"output": ["triggerid", "description", "priority", "expression"],
}, auth=token)
print(f"Total triggers in 10343: {len(trigs)}")
for t in trigs:
if "time" in t["description"].lower():
print(f" {t['triggerid']}: {t['description']} [prio={t['priority']}] -> {t['expression']}")

13
scripts/check_13_boot.py Normal file
View File

@@ -0,0 +1,13 @@
import paramiko, sys
sys.stdout.reconfigure(encoding='utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect('140.44.4.13', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('hostname; uptime; k3s --version')
print("SSH SUCCESS TO .13:\n", stdout.read().decode())
ssh.close()
except Exception as e:
print(f"SSH Failed: {e}")

17
scripts/check_13_usb.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.13", username="root", pkey=pkey, timeout=10)
cmd = "lsusb; lsblk; dmesg | tail -n 25"
stdin, stdout, stderr = client.exec_command(cmd)
print("ON 140.44.4.13 (.13):")
print(stdout.read().decode())
client.close()

17
scripts/check_21_arp.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "ip neighbor show 140.44.4.21; arp -n 140.44.4.21"
stdin, stdout, stderr = client.exec_command(cmd)
print("ARP FOR 140.44.4.21:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,14 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command("kubectl describe node sninfr2lnxwrk01 | grep -A 10 -i 'Conditions:'")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,15 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get pods -A -o wide --field-selector spec.nodeName=sninfr2lnxwrk01')
print("=== PODS ON .21 ===")
print(stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('kubectl get node sninfr2lnxwrk01 -o jsonpath="{.status.conditions}"')
print("=== NODE CONDITIONS ===")
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("14.4.4.44", username="snarf", pkey=pkey, timeout=10)
cmd = "sudo ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519_snarfnet root@140.44.4.21 'journalctl -u systemd-timesyncd -n 5 --no-pager'"
stdin, stdout, stderr = client.exec_command(cmd)
print("TIMESYNCD LOG ON 21:")
print(stdout.read().decode())
client.close()

17
scripts/check_22_dmesg.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.22", username="root", pkey=pkey, timeout=10)
cmd = "dmesg | tail -n 25; lsusb"
stdin, stdout, stderr = client.exec_command(cmd)
print("DMESG AND LSUSB ON 22:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,17 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.22', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('cat /etc/systemd/system/k3s-agent.service.env')
print('K3S ENV:\n', stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('cat /boot/cmdline.txt /boot/firmware/cmdline.txt 2>/dev/null')
print('CMDLINE:\n', stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('cat /boot/config.txt /boot/firmware/config.txt 2>/dev/null')
print('CONFIG.TXT (TAIL 20):\n', stdout.read().decode().splitlines()[-20:])
ssh.close()

17
scripts/check_22_lsblk.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.22", username="root", pkey=pkey, timeout=10)
cmd = "lsblk -o NAME,SIZE,FSTYPE,MODEL,MOUNTPOINT"
stdin, stdout, stderr = client.exec_command(cmd)
print("LSBLK ON 22:")
print(stdout.read().decode())
client.close()

21
scripts/check_22_os.py Normal file
View File

@@ -0,0 +1,21 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.22', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
cmds = [
"cat /etc/os-release",
"cat /boot/dietpi/.version 2>/dev/null || cat /boot/firmware/dietpi/.version 2>/dev/null || echo 'Not DietPi'",
"which dropbear || which sshd",
"ip route show",
"cat /etc/network/interfaces /etc/dhcpcd.conf /etc/netplan/* /etc/systemd/network/* 2>/dev/null",
"systemctl cat k3s-agent.service 2>/dev/null || cat /etc/systemd/system/k3s-agent.service"
]
for cmd in cmds:
print(f"=== {cmd} ===")
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

17
scripts/check_22_usb.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.22", username="root", pkey=pkey, timeout=10)
cmd = "lsusb; lsblk"
stdin, stdout, stderr = client.exec_command(cmd)
print("USB AND DISKS ON 22:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,29 @@
import paramiko
import json
def main():
s = paramiko.SSHClient()
s.set_missing_host_key_policy(paramiko.AutoAddPolicy())
s.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet')
_, o, _ = s.exec_command('kubectl get svc -A -o json')
data = json.loads(o.read().decode())
print("=== AERODROME LOADBALANCER SERVICES ===")
for item in data.get('items', []):
if item.get('spec', {}).get('type') == 'LoadBalancer':
name = item['metadata']['name']
ns = item['metadata']['namespace']
lb_ip = item.get('spec', {}).get('loadBalancerIP')
ing = [x.get('ip') for x in item.get('status', {}).get('loadBalancer', {}).get('ingress', [])]
annotations = item['metadata'].get('annotations', {})
print(f"- {ns}/{name}: status_ingress={ing}, spec_loadBalancerIP={lb_ip}, metallb_annotations={list(annotations.keys())}")
_, o, _ = s.exec_command('kubectl get ipaddresspools.metallb.io -A -o yaml 2>/dev/null || kubectl get cm -n metallb-system config -o yaml 2>/dev/null || true')
print("\n=== METALLB CONFIG ===")
print(o.read().decode())
s.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,15 @@
import paramiko, sys
sys.stdout.reconfigure(encoding='utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.41', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('systemctl status zabbix-agent2 --no-pager')
print("STATUS:\n", stdout.read().decode('utf-8', errors='replace'))
stdin, stdout, stderr = ssh.exec_command('egrep "^(Server|ServerActive|Hostname|TLS)" /etc/zabbix/zabbix_agent2.conf')
print("CONFIG:\n", stdout.read().decode('utf-8', errors='replace'))
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "curl -s http://140.44.4.21:80/main.105b4163.async.js | grep -o -E '\"/api/[a-zA-Z0-9_/]+\"' | sort -u | head -n 30"
stdin, stdout, stderr = client.exec_command(cmd)
print("API ROUTES ON 140.44.4.21:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,22 @@
import paramiko
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.41', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
cmd = "kubectl get pod -n infrastructure -l app.kubernetes.io/name=argocd-server -o jsonpath='{.items[0].spec.containers[*].name}'"
stdin, stdout, stderr = ssh.exec_command(cmd)
container_name = stdout.read().decode().strip()
print("Container name:", container_name)
# Check cluster list via argocd CLI inside the server pod
# In argocd pod, the binary 'argocd' is usually available or we can check via curl localhost:8080/api/v1/clusters
stdin, stdout, stderr = ssh.exec_command(f"kubectl exec -n infrastructure deployment/argocd-server -c {container_name} -- curl -s http://localhost:8080/api/v1/clusters")
print("API clusters response:", stdout.read().decode())
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,19 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "ip neighbor show"
stdin, stdout, stderr = client.exec_command(cmd)
print("ARP TABLE ON CTL01:")
for line in stdout.read().decode().splitlines():
if "140.44.4." in line:
print(line)
client.close()

View File

@@ -0,0 +1,21 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = """
echo "=== Authentik pods ==="
kubectl get pods -A | grep -i authentik
echo "=== Authentik PVCs ==="
kubectl get pvc -A | grep -i authentik
"""
stdin, stdout, stderr = client.exec_command(cmd)
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,14 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get secrets -n awx')
print("=== AWX SECRETS ===\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command("kubectl get secret awx-admin-password -n awx -o jsonpath='{.data.password}' | base64 -d")
print("ADMIN PASSWORD:\n", stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,16 @@
import os
import sys
import socket
sys.stdout.reconfigure(encoding="utf-8")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5.0)
try:
s.connect(("140.44.4.21", 22))
banner = s.recv(1024)
print(f"RAW BANNER (len={len(banner)}): {repr(banner)}")
s.close()
except Exception as e:
print("Error:", e)

30
scripts/check_db_creds.py Normal file
View File

@@ -0,0 +1,30 @@
import paramiko
import sys
sys.stdout.reconfigure(encoding='utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=8)
print("--- Testing authentik dump ---")
cmd = "kubectl exec -n infra authentik-postgresql-0 -- env"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.41', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=8)
print("--- Testing gitea env ---")
cmd = "kubectl exec -n infrastructure gitea-postgresql-0 -- env"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
print("--- Testing zabbix env ---")
cmd = "kubectl exec -n monitoring zabbix-postgresql-0 -- env"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,16 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get all -n default')
print("=== ALL IN DEFAULT ===")
print(stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('kubectl get statefulset -A')
print("=== STATEFULSETS ACROSS ALL NAMESPACES ===")
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,24 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get pvc -n default')
print("=== PVCs IN DEFAULT ===")
print(stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('kubectl get pods -n default')
print("=== PODS IN DEFAULT ===")
print(stdout.read().decode())
# Check if any pod is using these PVCs
stdin, stdout, stderr = ssh.exec_command('kubectl get pods -A -o jsonpath=\'{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{": "}{range .spec.volumes[*]}{.persistentVolumeClaim.claimName}{" "}{end}{"\\n"}{end}\'')
lines = stdout.read().decode().splitlines()
print("=== PODS USING ANY PVCs WITH authentik IN NAME ===")
for line in lines:
if "authentik" in line:
print(line)
ssh.close()

View File

@@ -0,0 +1,22 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.41", username="root", pkey=pkey, timeout=10)
cmd = """
ps aux | grep -E 'dropbear|sshd' | grep -v grep
ls -la /root/.ssh/
ls -la /etc/dropbear/ 2>/dev/null || true
cat /etc/default/dropbear 2>/dev/null || true
"""
stdin, stdout, stderr = client.exec_command(cmd)
print("SPACEPORT-CTL01 SSH DAEMON:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,15 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('140.44.4.41', username='root', pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command(
"sed -n '480,530p' /boot/dietpi/func/dietpi-set_software"
)
print("OUT:\n", stdout.read().decode('utf-8', errors='replace'))
client.close()

View File

@@ -0,0 +1,22 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.21', username='root', password='dietpi', timeout=10)
cmds = [
"uptime",
"cat /boot/dietpi/.version 2>/dev/null || cat /boot/dietpi.txt | grep -E 'AUTO_|CONFIG_'",
"ps aux",
"ls -la /boot/Automation_Custom_Script.sh /boot/Automation_Custom_PreScript.sh 2>/dev/null",
"ls -la /var/tmp/dietpi/logs/",
"cat /var/tmp/dietpi/logs/dietpi-firstrun-setup.log 2>/dev/null | tail -n 30"
]
for cmd in cmds:
print(f"\n=== {cmd} ===")
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('140.44.4.41', username='root', pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command(
"/boot/dietpi/func/dietpi-set_hardware timesync 2>&1 || true; "
"ls -l /etc/systemd/timesyncd.conf* /etc/systemd/timesyncd.conf.d/ 2>/dev/null || true"
)
print("OUT:\n", stdout.read().decode('utf-8', errors='replace'))
print("ERR:\n", stderr.read().decode('utf-8', errors='replace'))
client.close()

View File

@@ -0,0 +1,15 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('140.44.4.41', username='root', pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command(
"grep -rn 'CONFIG_NTP_MODE' /boot/dietpi/ 2>/dev/null | head -n 10"
)
print("OUT:\n", stdout.read().decode('utf-8', errors='replace'))
client.close()

View File

@@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.71', username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('sudo docker ps')
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.41", username="root", pkey=pkey, timeout=10)
cmd = "head -n 2 /root/.ssh/authorized_keys; ps aux | grep dropbear"
stdin, stdout, stderr = client.exec_command(cmd)
print("SPACEPORT-CTL01 AUTH KEYS:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,37 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
# Search for the trigger template
trig = zbx_req("trigger.get", {
"triggerids": "22372",
"selectHosts": ["hostid", "host", "name"],
}, auth=token)
print("Trigger host/template:", trig)
hostid = trig[0]["hosts"][0]["hostid"]
macros = zbx_req("usermacro.get", {
"hostids": hostid,
}, auth=token)
print(f"Macros for {hostid}:")
for m in macros:
print(f" {m['macro']} = {m['value']}")

View File

@@ -0,0 +1,20 @@
import sys
import urllib.request
import json
sys.stdout.reconfigure(encoding='utf-8')
def main():
req = urllib.request.Request('http://140.44.4.70:8090/message', headers={'X-Gotify-Key': 'C1dIsCoRdBrIdGeClIeN'})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
messages = data.get('messages', [])
print(f"TOTAL MESSAGES IN GOTIFY: {len(messages)}")
for m in messages[:8]:
print(f"- [App {m.get('appid')}] {m.get('title')}: {m.get('message')[:80]}")
except Exception as e:
print("Error:", e)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,21 @@
import sys
import paramiko
sys.stdout.reconfigure(encoding='utf-8')
def main():
for ip, node in [('140.44.4.71', 'snshpd4lnxswm01'), ('140.44.4.72', 'snshpd4lnxswm02')]:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
print(f"=== {node} ({ip}) ===")
stdin, stdout, stderr = ssh.exec_command('sudo ss -tulpn | grep -E ":80 |:81 |:443 " || true')
print("SS:\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sudo docker ps -a --filter "name=nginx" --format "{{.ID}} {{.Status}} {{.Names}}"')
print("Containers:\n", stdout.read().decode())
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,32 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
hosts = zbx_req("host.get", {
"output": ["hostid", "host", "name"],
"selectParentTemplates": ["templateid", "name"],
}, auth=token)
print(f"Total Hosts: {len(hosts)}")
for h in hosts:
tpl_names = [t["name"] for t in h.get("parentTemplates", [])]
print(f"Host {h['host']:20s} ({h['name']:20s}) -> Templates: {', '.join(tpl_names)}")

View File

@@ -0,0 +1,13 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command("kubectl get pods -A --field-selector spec.nodeName=sninfr2lnxwrk01 -o jsonpath='{range .items[*]}{.spec.containers[*].image}{\"\\n\"}{end}'")
images = set(stdout.read().decode().split())
for img in sorted(images):
print("CACHED:", img)
ssh.close()

View File

@@ -0,0 +1,12 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get pvc -n infra')
print("=== PVCs IN INFRA ===")
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "ip neighbor show 140.44.4.21; curl -i -s -m 5 http://140.44.4.21:80/ | head -n 25"
stdin, stdout, stderr = client.exec_command(cmd)
print("CHECKING 140.44.4.21 FROM CTL01:")
print(stdout.read().decode())
client.close()

20
scripts/check_job_100.py Normal file
View File

@@ -0,0 +1,20 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
py_code = """
from awx.main.models import Job
job = Job.objects.order_by('-id').first()
print(f"Job [{job.id}] for '{job.name}': Status={job.status}, Elapsed={job.elapsed}s, Failed={job.failed}")
events = job.job_events.filter(event__in=['playbook_on_stats', 'runner_on_failed', 'runner_on_unreachable'])
for e in events:
if e.stdout:
print(e.stdout)
"""
cmd = f"kubectl exec -i -n awx -c awx-task deployment/awx-task -- awx-manage shell_plus --quiet << 'EOF'\n{py_code}\nEOF"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

21
scripts/check_job_115.py Normal file
View File

@@ -0,0 +1,21 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
py_code = """
from awx.main.models import Job
job = Job.objects.get(id=115)
print(f"Job [115] for '{job.name}': Status={job.status}, Elapsed={job.elapsed}s, Failed={job.failed}")
events = job.job_events.order_by('id')
for e in events:
if e.stdout:
print(e.stdout)
"""
cmd = f"kubectl exec -i -n awx -c awx-task deployment/awx-task -- awx-manage shell_plus --quiet << 'EOF'\n{py_code}\nEOF"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

21
scripts/check_job_121.py Normal file
View File

@@ -0,0 +1,21 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
py_code = """
from awx.main.models import Job
job = Job.objects.get(id=121)
print(f"Job [121] for '{job.name}': Status={job.status}, Elapsed={job.elapsed}s, Failed={job.failed}")
events = job.job_events.order_by('id')
for e in events:
if e.stdout:
print(e.stdout)
"""
cmd = f"kubectl exec -i -n awx -c awx-task deployment/awx-task -- awx-manage shell_plus --quiet << 'EOF'\n{py_code}\nEOF"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

21
scripts/check_job_124.py Normal file
View File

@@ -0,0 +1,21 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.12', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
py_code = """
from awx.main.models import Job
job = Job.objects.get(id=124)
print(f"Job [124] for '{job.name}': Status={job.status}, Elapsed={job.elapsed}s, Failed={job.failed}")
events = job.job_events.order_by('id')
for e in events:
if e.stdout:
print(e.stdout)
"""
cmd = f"kubectl exec -i -n awx -c awx-task deployment/awx-task -- awx-manage shell_plus --quiet << 'EOF'\n{py_code}\nEOF"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

21
scripts/check_job_127.py Normal file
View File

@@ -0,0 +1,21 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.12', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
py_code = """
from awx.main.models import Job
job = Job.objects.get(id=127)
print(f"Job [127] for '{job.name}': Status={job.status}, Elapsed={job.elapsed}s, Failed={job.failed}")
events = job.job_events.order_by('id')
for e in events:
if e.stdout:
print(e.stdout)
"""
cmd = f"kubectl exec -i -n awx -c awx-task deployment/awx-task -- awx-manage shell_plus --quiet << 'EOF'\n{py_code}\nEOF"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(stdout.read().decode())
ssh.close()

17
scripts/check_js_words.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "curl -s http://140.44.4.21:80/main.105b4163.async.js | grep -o -E '\"[A-Z][a-zA-Z0-9 ]{3,30}\"' | sort -u | head -n 35"
stdin, stdout, stderr = client.exec_command(cmd)
print("WORDS IN JS:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,15 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command("kubectl get nodes -o wide")
print("KUBERNETES NODES:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "kubectl get nodes -o wide"
stdin, stdout, stderr = client.exec_command(cmd)
print("KUBERNETES NODES:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,35 @@
import paramiko
NODES = [
("swm01", "140.44.4.71"),
("swm02", "140.44.4.72"),
("swm03", "140.44.4.73"),
("swm04", "140.44.4.74"),
("swm05", "140.44.4.75"),
]
def main():
for name, ip in NODES:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect(ip, username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=5)
stdin, stdout, stderr = ssh.exec_command("systemctl is-active keepalived 2>/dev/null || echo inactive")
status = stdout.read().decode().strip()
stdin, stdout, stderr = ssh.exec_command("ip -4 addr show | grep '140.44.4.70' || true")
vip = stdout.read().decode().strip()
stdin, stdout, stderr = ssh.exec_command("grep -E 'state|priority' /etc/keepalived/keepalived.conf 2>/dev/null || true")
conf = stdout.read().decode().strip()
print(f"[{name} - {ip}] Keepalived: {status} | VIP: {'YES' if '140.44.4.70' in vip else 'NO'}")
if conf:
print(f" Config: {conf.splitlines()}")
ssh.close()
except Exception as e:
print(f"[{name} - {ip}] Error:", e)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.11', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get volumes.longhorn.io -n longhorn-system pvc-d1abb49c-552d-448a-8da4-aeef7508c173 pvc-081cd18f-a268-4568-8b69-cbbe8e08f87b -o wide')
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,29 @@
import sys
import paramiko
sys.stdout.reconfigure(encoding='utf-8')
def main():
for name, ip in [("aerodrome", "140.44.4.11"), ("spaceport", "140.44.4.41")]:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
print(f"=== LONGHORN SETTINGS ON {name} ({ip}) ===")
settings = [
'auto-salvage-volume',
'detach-manually-attached-volume-with-missing-node',
'replica-replenishment-wait-interval',
'pod-deletion-for-node-down-failure-handling',
'backup-target'
]
for s in settings:
cmd = f"sudo kubectl get setting.longhorn.io {s} -n longhorn-system -o jsonpath='{{.value}}' 2>/dev/null || echo 'not found'"
stdin, stdout, stderr = ssh.exec_command(cmd)
val = stdout.read().decode().strip()
print(f" {s}: {val}")
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,14 @@
import paramiko
import sys
sys.stdout.reconfigure(encoding='utf-8')
for name, ip in [('aerodrome', '140.44.4.11'), ('spaceport', '140.44.4.41')]:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=8)
cmd = "df -h | grep -i nfs || echo 'no nfs mounts'"
stdin, stdout, stderr = ssh.exec_command(cmd)
print(f"[{name.upper()}] NFS mounts:\n{stdout.read().decode().strip()}")
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "kubectl get events -A --field-selector involvedObject.name=sninfr2lnxwrk01 --sort-by='.lastTimestamp'"
stdin, stdout, stderr = client.exec_command(cmd)
print("NODE EVENTS FOR WRK01:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,27 @@
import sys
import paramiko
sys.stdout.reconfigure(encoding='utf-8')
def main():
for ip in ['140.44.4.71', '140.44.4.72']:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('sudo ss -tulpn | grep -E ":80 |:443 |:81 " || true')
print(f"=== LISTENING PORTS ON {ip} ===")
print(stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('sudo docker ps --filter "name=nginx-proxy-manager_app" --format "{{.ID}} {{.Status}}"')
cid = stdout.read().decode().strip()
print(f"Container on {ip}: {cid}")
if cid:
cid_clean = cid.split()[0]
stdin, stdout, stderr = ssh.exec_command(f'sudo docker logs {cid_clean} --tail 15')
print(f"Logs on {ip}:\n{stdout.read().decode()}")
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,16 @@
import paramiko
def main():
for ip in ['140.44.4.71', '140.44.4.72']:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command("sudo docker volume inspect nginx-proxy-manager_nginx-data --format '{{json .Options}}'")
print(f"Volume data on {ip}: {stdout.read().decode().strip()}")
stdin, stdout, stderr = ssh.exec_command("sudo docker volume inspect nginx-proxy-manager_nginx-letsencrypt --format '{{json .Options}}'")
print(f"Volume letsencrypt on {ip}: {stdout.read().decode().strip()}")
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,20 @@
import paramiko
import sys
sys.stdout.reconfigure(encoding='utf-8')
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('14.4.4.44', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('systemctl status nut-monitor --no-pager')
print("STATUS NUT-MONITOR:\n", stdout.read().decode('utf-8', errors='replace'))
stdin, stdout, stderr = ssh.exec_command('systemctl status nut-server --no-pager')
print("STATUS NUT-SERVER:\n", stdout.read().decode('utf-8', errors='replace'))
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,15 @@
import paramiko
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('14.4.4.44', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('grep -E "NOTIFY|SHUTDOWN" /etc/nut/upsmon.conf | grep -v "^#"')
print("=== NOTIFY / SHUTDOWN IN UPSMON.CONF ===")
print(stdout.read().decode())
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "ping -c 3 140.44.4.21; ip neighbor show | grep 2c:cf:67"
stdin, stdout, stderr = client.exec_command(cmd)
print("PING & ARP FROM CTL01:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "kubectl describe pod -n kube-system svclb-awx-lb-e5e684f5-x9wxk | tail -n 20"
stdin, stdout, stderr = client.exec_command(cmd)
print("POD ERROR EVENT:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,20 @@
import os
import sys
import json
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "kubectl get pod -n kube-system svclb-awx-lb-e5e684f5-x9wxk -o json"
stdin, stdout, stderr = client.exec_command(cmd)
raw = stdout.read().decode()
data = json.loads(raw)
print("STATUS OF svclb-awx-lb:")
print(json.dumps(data.get("status", {}), indent=2))
client.close()

17
scripts/check_pods_21.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "kubectl get pods -A -o wide --field-selector spec.nodeName=sninfr2lnxwrk01"
stdin, stdout, stderr = client.exec_command(cmd)
print("PODS ASSIGNED TO WRK01:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,16 @@
import paramiko
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
ssh.connect('14.44.4.1', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=5)
stdin, stdout, stderr = ssh.exec_command('iptables -t nat -S | grep -E "80|443"')
print("IPTABLES NAT:\n", stdout.read().decode())
ssh.close()
except Exception as e:
print("SSH to UDM Pro error:", e)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,21 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = """
for p in 22 80 443 6443 10250; do
timeout 1 bash -c "echo > /dev/tcp/140.44.4.21/$p" 2>/dev/null && echo "Port $p OPEN" || echo "Port $p CLOSED/FILTERED"
done
"""
stdin, stdout, stderr = client.exec_command(cmd)
print("PORT SCAN:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,11 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.71', username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('python3 -c "import sqlite3; print(sqlite3.sqlite_version)"')
print("PYTHON SQLITE VERSION:", stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,20 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.41', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('kubectl get pvc -A')
print("=== SPACEPORT PVCs ===")
print(stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('kubectl get recurringjob -n longhorn-system')
print("=== SPACEPORT RECURRING JOBS ===")
print(stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('kubectl get settings.longhorn.io -n longhorn-system backup-target -o yaml')
print("=== SPACEPORT BACKUP TARGET ===")
print(stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,15 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.41", username="root", pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command("kubectl get nodes -o wide")
print("SPACEPORT KUBERNETES NODES:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,19 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('140.44.4.41', username='root', pkey=pkey, timeout=10)
stdin, stdout, stderr = client.exec_command(
"dpkg -l | grep -E 'timesync|chrony|ntp' || true; "
"cat /boot/dietpi.txt | grep -E 'CONFIG_NTP|AUTO_SETUP_TIMEZONE' || true; "
"grep -E 'NTP|Timesync' /boot/dietpi/.dietpi-* /etc/dietpi/* 2>/dev/null || true; "
"cat /etc/systemd/timesyncd.conf || true"
)
print("OUT:\n", stdout.read().decode('utf-8', errors='replace'))
print("ERR:\n", stderr.read().decode('utf-8', errors='replace'))
client.close()

17
scripts/check_svg_logo.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "curl -s http://140.44.4.21:80/e8f5a9b64166ec5e22e92d2002547709.svg | head -n 20"
stdin, stdout, stderr = client.exec_command(cmd)
print("SVG LOGO:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,15 @@
import paramiko
def main():
for ip in ['140.44.4.71', '140.44.4.72']:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('sudo ss -tulpn | grep -E ":80 |:443 |:81 " || true')
print(f"=== PORTS ON {ip} ===")
print(stdout.read().decode())
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,23 @@
import sys
import paramiko
sys.stdout.reconfigure(encoding='utf-8')
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.71', username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
print("=== DOCKER SERVICE LS ===")
stdin, stdout, stderr = ssh.exec_command('sudo docker service ls')
print(stdout.read().decode())
print("=== DOCKER SERVICE PS ===")
stdin, stdout, stderr = ssh.exec_command('sudo docker service ps nginx-proxy-manager_app')
print(stdout.read().decode())
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,17 @@
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.71', username='snarf', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('docker service ls')
print("=== DOCKER SERVICES ===\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('mount | grep /mnt')
print("=== MOUNTS ON .71 ===\n", stdout.read().decode())
stdin, stdout, stderr = ssh.exec_command('ls -la /mnt')
print("=== /mnt ON .71 ===\n", stdout.read().decode())
ssh.close()

View File

@@ -0,0 +1,39 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
macros = zbx_req("usermacro.get", {
"hostids": "10343",
}, auth=token)
print(f"Macros on Template 10343:")
for m in macros:
if "FUZZY" in m["macro"] or "TIME" in m["macro"]:
print(f" {m['hostmacroid']}: {m['macro']} = {m['value']}")
trigs = zbx_req("trigger.get", {
"templateids": "10343",
"search": {"description": "time"},
"output": ["triggerid", "description", "expression", "priority"]
}, auth=token)
print(f"Triggers on Template 10343:")
for t in trigs:
print(f" {t['triggerid']}: {t['description']} [prio={t['priority']}] -> {t['expression']}")

View File

@@ -0,0 +1,29 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
tpl = zbx_req("template.get", {
"templateids": "10343",
"selectParentTemplates": ["templateid", "name"],
"selectTemplates": ["templateid", "name"],
}, auth=token)
print(json.dumps(tpl, indent=2))

View File

@@ -0,0 +1,30 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
probs = zbx_req("problem.get", {
"output": ["eventid", "name", "severity"],
"search": {"name": "time"},
}, auth=token)
print(f"Current active time problems in Zabbix: {len(probs)}")
for p in probs:
print(f" {p['eventid']}: {p['name']} [sev={p['severity']}]")

26
scripts/check_udm_ssh.py Normal file
View File

@@ -0,0 +1,26 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect("140.44.4.1", username="root", pkey=pkey, timeout=5)
print("SUCCESS SSH to 140.44.4.1 with key")
stdin, stdout, stderr = client.exec_command("uname -a")
print(stdout.read().decode())
client.close()
except Exception as e:
print(f"Key failed: {e}")
try:
client.connect("140.44.4.1", username="root", password="Katzir476!", timeout=5)
print("SUCCESS SSH to 140.44.4.1 with password")
stdin, stdout, stderr = client.exec_command("uname -a")
print(stdout.read().decode())
client.close()
except Exception as e2:
print(f"Password failed: {e2}")

View File

@@ -0,0 +1,12 @@
import paramiko, sys
sys.stdout.reconfigure(encoding='utf-8')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.51', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
stdin, stdout, stderr = ssh.exec_command('grep -E "^[A-Za-z]" /etc/zabbix/zabbix_agent2.conf')
print("51 CONFIG:\n", stdout.read().decode('utf-8', errors='replace'))
ssh.close()

View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "kubectl get pods -A -o wide --field-selector spec.nodeName=sninfr2lnxwrk01"
stdin, stdout, stderr = client.exec_command(cmd)
print("POD STATUS ON WRK01:")
print(stdout.read().decode())
client.close()

View File

@@ -0,0 +1,32 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
return json.loads(r.read().decode("utf-8"))["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
g_macros = zbx_req("usermacro.get", {"globalmacro": True}, auth=token)
for m in g_macros:
if "FUZZY" in m["macro"] or "TIME" in m["macro"]:
print(f"Global macro: {m['macro']} = {m['value']}")
# Check if {$SYSTEM.FUZZYTIME.MAX} is set globally
print("All global macros containing FUZZY or TIME:")
fuzzy = [m for m in g_macros if "FUZZY" in m["macro"]]
print(fuzzy)

View File

@@ -0,0 +1,62 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
}
headers = {"Content-Type": "application/json-rpc"}
if auth:
headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(
ZABBIX_URL,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
resp = json.loads(r.read().decode("utf-8"))
if "error" in resp:
raise RuntimeError(f"Zabbix API error in {method}: {resp['error']}")
return resp["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
# List all hosts in Zabbix
hosts = zbx_req("host.get", {
"output": ["hostid", "host", "name", "status"],
}, auth=token)
print(f"Total Hosts in Zabbix: {len(hosts)}")
for h in hosts[:25]:
print(f" {h['hostid']}: {h['host']} ({h['name']}) [status={h['status']}]")
# Inspect items in Linux by Zabbix agent (10001)
items = zbx_req("item.get", {
"templateids": "10001",
"output": ["itemid", "name", "key_"],
"search": {"key_": "localtime"},
}, auth=token)
print(f"\nItems matching 'localtime' in Template 10001: {items}")
triggers = zbx_req("trigger.get", {
"templateids": "10001",
"output": ["triggerid", "description", "expression", "priority"],
"search": {"description": "time"},
}, auth=token)
print(f"\nTriggers matching 'time' in Template 10001:")
for tr in triggers:
print(f" ID: {tr['triggerid']}, Desc: {tr['description']}, Priority: {tr['priority']}")

View File

@@ -0,0 +1,20 @@
import json, ssl, urllib.request
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
payload = {'jsonrpc': '2.0', 'method': 'user.login', 'params': {'username': 'Admin', 'password': 'zabbix'}, 'id': 1}
req = urllib.request.Request('http://140.44.4.60:8888/api_jsonrpc.php', data=json.dumps(payload).encode('utf-8'), headers={'Content-Type': 'application/json-rpc'}, method='POST')
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
token = json.loads(r.read().decode())['result']
payload = {'jsonrpc': '2.0', 'method': 'problem.get', 'params': {'output': ['eventid', 'name', 'severity', 'clock']}, 'id': 2}
req = urllib.request.Request('http://140.44.4.60:8888/api_jsonrpc.php', data=json.dumps(payload).encode('utf-8'), headers={'Content-Type': 'application/json-rpc', 'Authorization': f'Bearer {token}'}, method='POST')
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
problems = json.loads(r.read().decode())['result']
print(f"Total Active Problems in Zabbix: {len(problems)}")
for p in problems:
print(f" - [Severity {p['severity']}] {p['name']}")

View File

@@ -0,0 +1,72 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
}
headers = {"Content-Type": "application/json-rpc"}
if auth:
headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(
ZABBIX_URL,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
resp = json.loads(r.read().decode("utf-8"))
if "error" in resp:
raise RuntimeError(f"Zabbix API error in {method}: {resp['error']}")
return resp["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
print("Logged in successfully.")
# Find templates related to time / ntp
templates = zbx_req("template.get", {
"output": ["templateid", "name", "host"],
"search": {"name": "time"},
"searchWildcardsEnabled": True
}, auth=token)
print(f"Found {len(templates)} templates matching 'time':")
for t in templates:
print(f" ID: {t['templateid']}, Name: {t['name']}")
# Find all templates
all_tpls = zbx_req("template.get", {
"output": ["templateid", "name"],
"search": {"name": "Linux"},
}, auth=token)
print(f"\nLinux templates:")
for t in all_tpls:
print(f" ID: {t['templateid']}, Name: {t['name']}")
# Check existing items on a sample host
hosts = zbx_req("host.get", {
"output": ["hostid", "host", "name"],
"filter": {"host": ["aerodrome-ctl01", "sninfr2lnxctl01", "shipyard-swm01"]}
}, auth=token)
print(f"\nSample hosts found in Zabbix: {len(hosts)}")
for h in hosts:
print(f" HostID: {h['hostid']}, Host: {h['host']}, Name: {h['name']}")
items = zbx_req("item.get", {
"output": ["itemid", "name", "key_"],
"hostids": h["hostid"],
"search": {"key_": "system.localtime"},
}, auth=token)
print(f" system.localtime items: {items}")

View File

@@ -0,0 +1,37 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
resp = json.loads(r.read().decode("utf-8"))
return resp["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
trig = zbx_req("trigger.get", {
"triggerids": "22372",
"selectFunctions": "extend",
"output": "extend"
}, auth=token)
print(json.dumps(trig, indent=2))
macros = zbx_req("usermacro.get", {
"templateids": "10001",
"output": "extend"
}, auth=token)
print("Macros:", json.dumps(macros, indent=2))

View File

@@ -0,0 +1,70 @@
import paramiko
import yaml
def main():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('140.44.4.41', username='root', key_filename=r'C:\Users\Snarf\.ssh\id_ed25519_snarfnet', timeout=10)
cm_manifest = """
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: infrastructure
labels:
app.kubernetes.io/component: notifications-controller
app.kubernetes.io/instance: argocd
app.kubernetes.io/name: argocd-notifications-controller
app.kubernetes.io/part-of: argocd
data:
context: |
argocdUrl: https://argo.snarfnet.net
service.webhook.gotify: |
url: http://140.44.4.70:8090/message?token=A8aWxGiToPsAuToMaTiO
headers:
- name: Content-Type
value: application/json
template.app-sync-status: |
webhook:
gotify:
method: POST
body: |
{
"title": "ArgoCD: {{.app.metadata.name}} ({{.app.status.sync.status}})",
"message": "Application: {{.app.metadata.name}}\\nCluster: {{.app.spec.destination.server}}\\nSync Status: {{.app.status.sync.status}}\\nHealth: {{.app.status.health.status}}\\nURL: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}",
"priority": 4
}
template.app-health-degraded: |
webhook:
gotify:
method: POST
body: |
{
"title": "ArgoCD: {{.app.metadata.name}} Health DEGRADED",
"message": "Application: {{.app.metadata.name}}\\nCluster: {{.app.spec.destination.server}}\\nHealth: {{.app.status.health.status}}\\nURL: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}",
"priority": 8
}
trigger.on-sync-failed: |
- description: Application syncing has failed
send: [app-sync-status]
when: app.status.operationState != nil and app.status.operationState.phase in ['Error', 'Failed']
trigger.on-health-degraded: |
- description: Application health is degraded
send: [app-health-degraded]
when: app.status.health.status == 'Degraded'
"""
stdin, stdout, stderr = ssh.exec_command("kubectl apply -f -")
stdin.write(cm_manifest)
stdin.channel.shutdown_write()
print("CM Apply stdout:\n", stdout.read().decode())
print("CM Apply stderr:\n", stderr.read().decode())
stdin, stdout, stderr = ssh.exec_command("kubectl rollout restart deployment/argocd-notifications-controller -n infrastructure")
print("Rollout restart:\n", stdout.read().decode())
ssh.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,53 @@
import json
import ssl
import urllib.request
ZABBIX_URL = "https://zabbix.snarfnet.net/api_jsonrpc.php"
ZABBIX_USER = "Admin"
ZABBIX_PASS = "zabbix"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def zbx_req(method: str, params: dict, auth: str = None):
payload = {"jsonrpc": "2.0", "method": method, "params": params, "id": 1}
headers = {"Content-Type": "application/json-rpc"}
if auth: headers["Authorization"] = f"Bearer {auth}"
req = urllib.request.Request(ZABBIX_URL, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
resp = json.loads(r.read().decode("utf-8"))
if "error" in resp:
raise RuntimeError(f"Zabbix API error: {resp['error']}")
return resp["result"]
token = zbx_req("user.login", {"username": ZABBIX_USER, "password": ZABBIX_PASS})
# Check if trigger already exists
existing = zbx_req("trigger.get", {
"templateids": "10343",
"search": {"description": "System time is out of sync"},
"output": ["triggerid", "description", "priority", "expression"]
}, auth=token)
if existing:
print(f"Trigger already exists: {existing}")
else:
print("Creating trigger 'Linux: System time is out of sync (> 5s)' on Template 10343...")
res = zbx_req("trigger.create", {
"description": "Linux: System time is out of sync (> 5s)",
"expression": "fuzzytime(/Linux by Zabbix agent active/system.localtime, 5s)=0",
"priority": "4", # High priority
"comments": "The host system time differs from Zabbix server time by more than 5 seconds. This is critical as it risks split-brain or consensus loss in K3s etcd and Docker Swarm Raft.",
"manual_close": "1"
}, auth=token)
print("Created trigger:", res)
# Verify trigger created
trigs = zbx_req("trigger.get", {
"templateids": "10343",
"search": {"description": "System time is out of sync"},
"output": ["triggerid", "description", "priority", "expression"]
}, auth=token)
print("Verified triggers on Template 10343:", json.dumps(trigs, indent=2))

17
scripts/curl_21_80.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "curl -i -s -m 5 http://140.44.4.21:80/ | head -n 30"
stdin, stdout, stderr = client.exec_command(cmd)
print("CURL PORT 80 ON 21:")
print(stdout.read().decode())
client.close()

17
scripts/curl_title_21.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import sys
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
pkey = paramiko.Ed25519Key.from_private_key_file(os.path.expanduser('~/.ssh/id_ed25519_snarfnet'))
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("140.44.4.11", username="root", pkey=pkey, timeout=10)
cmd = "curl -s http://140.44.4.21:80/ | grep -i '<title>' || curl -s http://140.44.4.21:80/ | head -n 40"
stdin, stdout, stderr = client.exec_command(cmd)
print("TITLE ON PORT 80:")
print(stdout.read().decode())
client.close()

Some files were not shown because too many files have changed in this diff Show More