Node Activation Script Pack — full code + filenames, commented, and ready to drop in a folder and run.
Here’s your Node Activation Script Pack — full code + filenames, commented, and ready to drop in a folder and run.

Folder Layout
activation-pack/
├─ README.md
├─ config/
│ ├─ nodes.env
│ └─ mqtt.env
├─ scripts/
│ ├─ 00_bootstrap.sh
│ ├─ 01_set_hostname.sh
│ ├─ 02_setup_user_ssh.sh
│ ├─ 03_push_authorized_keys.sh
│ ├─ 04_install_mosquitto.sh
│ └─ 05_enable_heartbeat_service.sh
├─ services/
│ ├─ heartbeat-client.service
│ └─ dashboard.service
└─ python/
├─ requirements.txt
├─ heartbeat_client.py
├─ dashboard_app.py
├─ templates/
│ └─ index.html
└─ static/
└─ style.css

How to Use (Quick Start)
Edit config/nodes.env with your IPs and hostnames (sample included below).
On each node (Jetsons + Pis):
scripts/00_bootstrap.sh (packages, Python venv, paho-mqtt, etc.)
scripts/01_set_hostname.sh <NewHostName> (sets hostname + /etc/hosts)
scripts/02_setup_user_ssh.sh (generates SSH key if missing)
On your admin laptop:
scripts/03_push_authorized_keys.sh (copies your public key to all nodes for passwordless SSH)
Choose a broker node (recommend PI01):
SSH into PI01 → run scripts/04_install_mosquitto.sh
Start heartbeat clients on every node:
scripts/05_enable_heartbeat_service.sh client
Start the dashboard on one node (recommend JETSON01):
scripts/05_enable_heartbeat_service.sh dashboard
Open dashboard in your browser: http://<JETSON01-IP>:8080
README.md
# Empire Node – Activation Pack
This pack brings your Jetsons + Raspberry Pis online with consistent hostnames, SSH keys, an MQTT broker, and a live heartbeat dashboard.
## Steps
1. Edit `config/nodes.env` and `config/mqtt.env`.
2. On each node:
```bash
sudo ./scripts/00_bootstrap.sh
sudo ./scripts/01_set_hostname.sh <HOSTNAME>
./scripts/02_setup_user_ssh.sh
From your admin machine:
./scripts/03_push_authorized_keys.sh
On broker node (e.g., PI01):
sudo ./scripts/04_install_mosquitto.sh
On all nodes:
sudo ./scripts/05_enable_heartbeat_service.sh client
On dashboard node (e.g., JETSON01):
sudo ./scripts/05_enable_heartbeat_service.sh dashboard
Visit: http://<dashboard-node-ip>:8080
Notes
Works on Ubuntu-based JetPack (Jetsons) and Ubuntu/Raspberry Pi OS (Pis).
Default user assumed ubuntu (Ubuntu) or pi (Raspberry Pi OS); adjust in nodes.env as needed.
---
## `config/nodes.env` (EDIT ME)
```ini
# Comma-separated list of nodes in the form HOSTNAME|IP|USER
NODES="JETSON01|192.168.50.21|ubuntu,\
JETSON02|192.168.50.22|ubuntu,\
JETSON03|192.168.50.23|ubuntu,\
PI01|192.168.50.31|ubuntu,\
PI02|192.168.50.32|ubuntu,\
PI03|192.168.50.33|ubuntu,\
PI04|192.168.50.34|ubuntu,\
PI05|192.168.50.35|ubuntu"
# For /etc/hosts population (optional extra entries)
MGMT_GATEWAY_IP="192.168.50.1"
MGMT_DOMAIN="empire.lan"
config/mqtt.env (EDIT ME)
# Broker details (used by both heartbeat client and dashboard)
MQTT_BROKER_HOST="192.168.50.31" # e.g., PI01
MQTT_BROKER_PORT="1883"
MQTT_USERNAME=""
MQTT_PASSWORD=""
MQTT_TOPIC_PREFIX="empire/heartbeat"
DASHBOARD_PORT="8080"
scripts/00_bootstrap.sh
#!/usr/bin/env bash
set -euo pipefail
# Purpose: Install base packages, Python venv, and prerequisites on a node.
# Works on Ubuntu / Debian variants (Jetson + Raspberry Pi OS based on Debian).
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo ./scripts/00_bootstrap.sh"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl git wget vim htop net-tools dnsutils \
python3 python3-venv python3-pip python3-dev \
build-essential pkg-config \
jq unzip
# Prepare app directory
APP_DIR="/opt/empire"
mkdir -p "$APP_DIR"
cp -r ./python "$APP_DIR/"
cp -r ./config "$APP_DIR/"
chown -R ${SUDO_USER:-$USER}:${SUDO_USER:-$USER} "$APP_DIR"
# Create venv and install Python deps
python3 -m venv "$APP_DIR/venv"
source "$APP_DIR/venv/bin/activate"
pip install --upgrade pip
pip install -r "$APP_DIR/python/requirements.txt"
deactivate
echo "[OK] Bootstrap complete on $(hostname)"
scripts/01_set_hostname.sh
#!/usr/bin/env bash
set -euo pipefail
# Purpose: Set hostname and populate /etc/hosts with the cluster map.
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo ./scripts/01_set_hostname.sh <HOSTNAME>"
exit 1
fi
NEW_HOSTNAME="${1:-}"
if [[ -z "$NEW_HOSTNAME" ]]; then
echo "Usage: sudo ./scripts/01_set_hostname.sh <HOSTNAME>"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$ROOT_DIR/config/nodes.env"
echo "$NEW_HOSTNAME" > /etc/hostname
hostnamectl set-hostname "$NEW_HOSTNAME"
# Build hosts file entries
{
echo "127.0.0.1 localhost"
echo "::1 localhost ip6-localhost ip6-loopback"
echo "ff02::1 ip6-allnodes"
echo "ff02::2 ip6-allrouters"
# Management gateway hint
[[ -n "${MGMT_GATEWAY_IP:-}" ]] && echo "$MGMT_GATEWAY_IP gateway.${MGMT_DOMAIN:-local}"
# Cluster nodes
IFS=',' read -ra ARR <<< "$NODES"
for rec in "${ARR[@]}"; do
IFS='|' read -r H IP U <<< "$rec"
echo "$IP ${H}.${MGMT_DOMAIN:-local} $H"
done
} > /etc/hosts
echo "[OK] Hostname set to $NEW_HOSTNAME and /etc/hosts updated."
scripts/02_setup_user_ssh.sh
#!/usr/bin/env bash
set -euo pipefail
# Purpose: Ensure the current (non-root) user has an SSH key and correct perms.
if [[ $EUID -eq 0 ]]; then
USERNAME="${SUDO_USER:-ubuntu}"
else
USERNAME="$USER"
fi
HOME_DIR="$(eval echo "~$USERNAME")"
SSH_DIR="$HOME_DIR/.ssh"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
if [[ ! -f "$SSH_DIR/id_rsa" && ! -f "$SSH_DIR/id_ed25519" ]]; then
# Prefer Ed25519 keys
ssh-keygen -t ed25519 -N "" -f "$SSH_DIR/id_ed25519" -C "$USERNAME@$(hostname)"
KEYFILE="$SSH_DIR/id_ed25519.pub"
else
KEYFILE="$(ls "$SSH_DIR"/*.pub | head -n1)"
fi
touch "$SSH_DIR/authorized_keys"
chmod 600 "$SSH_DIR/authorized_keys"
chown -R "$USERNAME":"$USERNAME" "$SSH_DIR"
echo "[OK] SSH ready for $USERNAME."
echo "Public key:"
cat "$KEYFILE"
scripts/03_push_authorized_keys.sh
#!/usr/bin/env bash
set -euo pipefail
# Purpose: From your admin laptop, push your local public key to all nodes' authorized_keys.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/config/nodes.env"
# Determine which public key to push
DEFAULT_KEY="${HOME}/.ssh/id_ed25519.pub"
[[ ! -f "$DEFAULT_KEY" ]] && DEFAULT_KEY="${HOME}/.ssh/id_rsa.pub"
if [[ ! -f "$DEFAULT_KEY" ]]; then
echo "No public key found in ~/.ssh. Run ssh-keygen first."
exit 1
fi
PUBKEY_CONTENT="$(cat "$DEFAULT_KEY")"
IFS=',' read -ra ARR <<< "$NODES"
for rec in "${ARR[@]}"; do
IFS='|' read -r HOST IP USER <<< "$rec"
echo "[*] Pushing key to $HOST ($IP) as $USER..."
ssh -o StrictHostKeyChecking=no ${USER}@${IP} "mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && grep -qxF \"$PUBKEY_CONTENT\" ~/.ssh/authorized_keys || echo \"$PUBKEY_CONTENT\" >> ~/.ssh/authorized_keys"
echo "[OK] $HOST"
done
scripts/04_install_mosquitto.sh
#!/usr/bin/env bash
set -euo pipefail
# Purpose: Install Mosquitto MQTT broker with a minimal config (no-auth by default; edit for credentials).
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo ./scripts/04_install_mosquitto.sh"
exit 1
fi
apt-get update
apt-get install -y mosquitto mosquitto-clients
# Minimal listener on 1883, anonymous enabled (LAN only; lock down later)
cat >/etc/mosquitto/conf.d/empire.conf <<'EOF'
listener 1883 0.0.0.0
allow_anonymous true
persistence true
persistence_location /var/lib/mosquitto/
EOF
systemctl enable mosquitto
systemctl restart mosquitto
echo "[OK] Mosquitto installed and running on port 1883."
scripts/05_enable_heartbeat_service.sh
#!/usr/bin/env bash
set -euo pipefail
# Purpose: Enable either the heartbeat client or the dashboard as a systemd service.
ROLE="${1:-client}" # client | dashboard
APP_DIR="/opt/empire"
SERV_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../services" && pwd)"
if [[ $EUID -ne 0 ]]; then
echo "Run as root: sudo ./scripts/05_enable_heartbeat_service.sh [client|dashboard]"
exit 1
fi
# Copy services
cp "$SERV_DIR/heartbeat-client.service" /etc/systemd/system/
cp "$SERV_DIR/dashboard.service" /etc/systemd/system/
systemctl daemon-reload
if [[ "$ROLE" == "client" ]]; then
systemctl enable heartbeat-client
systemctl restart heartbeat-client
echo "[OK] Heartbeat client enabled."
elif [[ "$ROLE" == "dashboard" ]]; then
systemctl enable empire-dashboard
systemctl restart empire-dashboard
echo "[OK] Dashboard enabled."
else
echo "Unknown role: $ROLE (use: client | dashboard)"
exit 2
fi
services/heartbeat-client.service
[Unit]
Description=Empire Heartbeat Client
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/empire
EnvironmentFile=/opt/empire/config/mqtt.env
ExecStart=/opt/empire/venv/bin/python /opt/empire/python/heartbeat_client.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Change User=ubuntu to pi on Raspberry Pi OS if needed.
services/dashboard.service
[Unit]
Description=Empire Heartbeat Dashboard (Flask)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/empire
EnvironmentFile=/opt/empire/config/mqtt.env
ExecStart=/opt/empire/venv/bin/python /opt/empire/python/dashboard_app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Change User=ubuntu to pi on Raspberry Pi OS if needed.
python/requirements.txt
Flask==3.0.3
paho-mqtt==2.1.0
gunicorn==22.0.0
python/heartbeat_client.py
#!/usr/bin/env python3
"""
Heartbeat client:
- Publishes a JSON heartbeat every 5 seconds to MQTT.
- Topic: <prefix>/<hostname>
- Payload includes: hostname, ip, ts, cpu load, mem, uptime, and model hint.
"""
import os, json, time, socket, subprocess, datetime
import paho.mqtt.client as mqtt
BROKER = os.getenv("MQTT_BROKER_HOST", "127.0.0.1")
PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
USER = os.getenv("MQTT_USERNAME", "")
PWD = os.getenv("MQTT_PASSWORD", "")
TOPIC_PREFIX = os.getenv("MQTT_TOPIC_PREFIX", "empire/heartbeat")
HOST = socket.gethostname()
def get_ip():
try:
# Gets primary IP without external calls
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "0.0.0.0"
def sh(cmd):
return subprocess.check_output(cmd, shell=True, text=True).strip()
def model_hint():
# Quick hardware hint (Jetson vs Pi vs generic)
try:
cpuinfo = sh("cat /proc/cpuinfo | head -n 20")
if "NVIDIA" in cpuinfo or "Jetson" in cpuinfo:
return "Jetson"
if "Raspberry Pi" in sh("cat /proc/device-tree/model 2>/dev/null || true"):
return "Raspberry Pi"
except Exception:
pass
return "Linux"
def sys_metrics():
# CPU load average (1 min), mem usage %, uptime minutes
try:
load1 = float(os.getloadavg()[0])
except Exception:
load1 = -1.0
mem_total = 0
mem_free = 0
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal"):
mem_total = int(line.split()[1]) # kB
elif line.startswith("MemAvailable"):
mem_free = int(line.split()[1]) # kB
mem_used_pct = round(100.0 * (1 - (mem_free / max(1, mem_total))), 2)
except Exception:
mem_used_pct = -1.0
try:
uptime_s = float(open("/proc/uptime").read().split()[0])
except Exception:
uptime_s = 0.0
return load1, mem_used_pct, int(uptime_s // 60)
def main():
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"hb-{HOST}")
if USER:
client.username_pw_set(USER, PWD)
client.connect(BROKER, PORT, keepalive=30)
client.loop_start()
topic = f"{TOPIC_PREFIX}/{HOST}"
while True:
load1, mem_used_pct, uptime_m = sys_metrics()
payload = {
"host": HOST,
"ip": get_ip(),
"ts": datetime.datetime.utcnow().isoformat() + "Z",
"load1": load1,
"mem_used_pct": mem_used_pct,
"uptime_m": uptime_m,
"model": model_hint(),
}
client.publish(topic, json.dumps(payload), qos=0, retain=True)
time.sleep(5)
if __name__ == "__main__":
main()
python/dashboard_app.py
#!/usr/bin/env python3
"""
Flask dashboard:
- Subscribes to MQTT <prefix>/#
- Maintains in-memory state per host (last payload + last seen)
- Serves a dark UI table at /
"""
import os, json, time, threading
from datetime import datetime, timezone
from flask import Flask, render_template
import paho.mqtt.client as mqtt
BROKER = os.getenv("MQTT_BROKER_HOST", "127.0.0.1")
PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
USER = os.getenv("MQTT_USERNAME", "")
PWD = os.getenv("MQTT_PASSWORD", "")
TOPIC_PREFIX = os.getenv("MQTT_TOPIC_PREFIX", "empire/heartbeat")
WEB_PORT = int(os.getenv("DASHBOARD_PORT", "8080"))
state = {} # host -> {payload:dict, last: epoch_secs}
def on_connect(client, userdata, flags, reason_code, properties=None):
client.subscribe(f"{TOPIC_PREFIX}/#")
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode("utf-8"))
host = payload.get("host", "unknown")
state[host] = {
"payload": payload,
"last": time.time()
}
except Exception:
pass
def mqtt_thread():
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="empire-dashboard")
if USER:
client.username_pw_set(USER, PWD)
client.on_connect = on_connect
client.on_message = on_message
client.connect(BROKER, PORT, keepalive=30)
client.loop_forever()
app = Flask(__name__, template_folder="templates", static_folder="static")
@app.route("/")
def index():
rows = []
now = time.time()
for host, rec in sorted(state.items()):
p = rec["payload"]
age = now - rec["last"]
rows.append({
"host": p.get("host"),
"ip": p.get("ip"),
"model": p.get("model"),
"load1": p.get("load1"),
"mem_used_pct": p.get("mem_used_pct"),
"uptime_m": p.get("uptime_m"),
"ts": p.get("ts"),
"age_s": int(age),
"status": "OK" if age < 20 else ("LATE" if age < 60 else "DOWN")
})
return render_template("index.html", rows=rows, updated=datetime.now(timezone.utc).isoformat())
if __name__ == "__main__":
t = threading.Thread(target=mqtt_thread, daemon=True)
t.start()
app.run(host="0.0.0.0", port=WEB_PORT)
python/templates/index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Empire Mesh – Heartbeat</title>
<link rel="stylesheet" href="/static/style.css">
<meta http-equiv="refresh" content="5">
</head>
<body>
<header>
<h1>Empire Mesh – Heartbeat</h1>
<p class="subtitle">Live node status | Auto-refresh every 5s</p>
</header>
<main>
<table>
<thead>
<tr>
<th>Host</th>
<th>IP</th>
<th>Model</th>
<th>Load(1m)</th>
<th>Mem Used %</th>
<th>Uptime (min)</th>
<th>Last Beat (UTC)</th>
<th>Age (s)</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr class="{{ r.status }}">
<td>{{ r.host }}</td>
<td>{{ r.ip }}</td>
<td>{{ r.model }}</td>
<td>{{ r.load1 }}</td>
<td>{{ r.mem_used_pct }}</td>
<td>{{ r.uptime_m }}</td>
<td>{{ r.ts }}</td>
<td>{{ r.age_s }}</td>
<td><span class="badge {{ r.status }}">{{ r.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</main>
<footer>
<small>Updated: {{ updated }}</small>
</footer>
</body>
</html>
python/static/style.css
/* Dark, neon-adjacent vibe without being loud */
:root {
--bg: #000d1a;
--panel: #061b2b;
--text: #e6f7ff;
--muted: #8fbcd4;
--ok: #19c37d;
--late: #f6c343;
--down: #ef4146;
--accent: #00ccff;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
background: radial-gradient(1200px 800px at 20% 10%, #001223, var(--bg));
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
header { margin-bottom: 16px; }
h1 { margin: 0; color: var(--accent); text-shadow: 0 0 8px rgba(0,204,255,0.3); }
.subtitle { color: var(--muted); margin-top: 4px; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid #07344c;
box-shadow: 0 0 24px rgba(0,204,255,0.08);
border-radius: 12px;
overflow: hidden;
}
thead th {
text-align: left;
padding: 12px;
color: var(--accent);
border-bottom: 1px solid #07344c;
}
tbody td {
padding: 10px 12px;
border-top: 1px solid #072638;
}
tr.OK { background: linear-gradient(0deg, rgba(25,195,125,0.06), transparent); }
tr.LATE { background: linear-gradient(0deg, rgba(246,195,67,0.06), transparent); }
tr.DOWN { background: linear-gradient(0deg, rgba(239,65,70,0.06), transparent); }
.badge {
padding: 2px 8px; border-radius: 10px;
border: 1px solid currentColor; font-weight: 600;
}
.badge.OK { color: var(--ok); }
.badge.LATE { color: var(--late); }
.badge.DOWN { color: var(--down); }
footer { margin-top: 12px; color: var(--muted); }

Extra Ideas (Optional, plug-and-play later)
Retention: Heartbeat uses retain=true, so the dashboard shows last-known state even after restarts.
Auth: Flip Mosquitto to username/password and TLS once you’re stable (we can generate a secure config next).
Metrics: Extend client to include disk usage, GPU temperature (Jetson), and throttling flags (Pi).
Alerts: Add a tiny notifier that sends SMS via Twilio if a node is DOWN > 60s.