From 01c8b36f13ae649c7eb29854f67990a2d647ff87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kry=C5=A1tof=20Pe=C5=A1ek?= Date: Thu, 5 Nov 2026 21:04:35 +0100 Subject: [PATCH] feat(beacon): native C beacon dispatcher with Bash fallback (zero Python dependency) --- .gitignore | 3 + fleet-beacon | 266 +++--------------------- fleet-beacon.py | 242 ++++++++++++++++++++++ fleet-beacon.sh | 205 ++++++++++++++++++ src/fleet-beacon.c | 502 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 983 insertions(+), 235 deletions(-) create mode 100644 .gitignore create mode 100755 fleet-beacon.py create mode 100755 fleet-beacon.sh create mode 100644 src/fleet-beacon.c diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70c737e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +fleet-beacon-c +*.o +*.a diff --git a/fleet-beacon b/fleet-beacon index 5514c78..a237fe4 100755 --- a/fleet-beacon +++ b/fleet-beacon @@ -1,242 +1,38 @@ -#!/usr/bin/env python3 -""" -fleet-beacon.py — Multi-node Realtime Telemetry Beacon -Part of Observatoř Top (otop) +#!/usr/bin/env bash +# ============================================================================= +# ~/bin/fleet-beacon — Fleet Realtime Telemetry Beacon Dispatcher +# Zero-Bloat Architecture: Native C first, Bash fallback, Python avoided. +# ============================================================================= -Broadcasts lightweight UDP telemetry packets (port 9850) every 1.5s. -Zero external dependencies, minimal CPU impact (< 0.05%). -""" +BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -import os -import sys -import time -import json -import socket -import shutil -import glob -import urllib.request +# Ensure fleet environment is active +if [ -z "$FLEET_NODE" ] || [ -z "$FLEET_ARCH" ]; then + [ -f "$BIN_DIR/bin-env" ] && source "$BIN_DIR/bin-env" 2>/dev/null || true +fi -PORT = 9850 -TARGETS = [ - "", - "192.168.1.255", - "192.168.1.100", # t520 (Ethernet) - "192.168.1.102", # t520 (WiFi) - "192.168.1.104", # bazina (Debian 13) - "192.168.1.101", # boxC (RPi storage) - "192.168.1.105", # penguin (Chromebook) - "192.168.1.103", # phone (Android) - "10.39.9.255", # WireGuard VPN Broadcast - "10.39.9.1", # boxC VPN - "10.0.0.255", - "10.0.0.18", - "10.0.0.14", - "91.139.118.43", - "192.168.43.255", - "192.168.43.1", - "127.0.0.1" -] +# 1. Prefer native C compiled binary (< 1MB RAM, 0% CPU) +if [ -x "$BIN_DIR/fleet-beacon-c" ]; then + exec "$BIN_DIR/fleet-beacon-c" "$@" +fi -def get_hostname(): - if os.path.exists("/data/data/com.termux") or "TERMUX_VERSION" in os.environ: - return "phone" - h = socket.gethostname().split('.')[0] - if h in ("localhost", "android"): - return "phone" - return h +# 2. Compile native C binary on the fly if compiler exists +if command -v gcc >/dev/null 2>&1 && [ -f "$BIN_DIR/src/fleet-beacon.c" ]; then + gcc -O2 -Wall -Wextra -pedantic -o "$BIN_DIR/fleet-beacon-c" "$BIN_DIR/src/fleet-beacon.c" 2>/dev/null || true + if [ -x "$BIN_DIR/fleet-beacon-c" ]; then + exec "$BIN_DIR/fleet-beacon-c" "$@" + fi +fi -def read_cpu_percent(prev_stat): - try: - with open("/proc/stat", "r") as f: - fields = [float(x) for x in f.readline().strip().split()[1:8]] - # fields: user, nice, system, idle, iowait, irq, softirq - idle = fields[3] + fields[4] - total = sum(fields) - if prev_stat is not None: - prev_idle, prev_total = prev_stat - d_idle = idle - prev_idle - d_total = total - prev_total - pct = 100.0 * (1.0 - (d_idle / max(1.0, d_total))) - return round(max(0.0, min(100.0, pct)), 1), (idle, total) - return 0.0, (idle, total) - except Exception: - return 0.0, prev_stat +# 3. Fallback to pure Bash implementation +if [ -x "$BIN_DIR/fleet-beacon.sh" ]; then + exec "$BIN_DIR/fleet-beacon.sh" "$@" +fi -def read_memory(): - total_gb, used_gb = 0.0, 0.0 - try: - with open("/proc/meminfo", "r") as f: - mem = {} - for line in f: - parts = line.split(":") - if len(parts) == 2: - mem[parts[0].strip()] = float(parts[1].strip().split()[0]) - total_kb = mem.get("MemTotal", 0.0) - avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0.0)) - total_gb = round(total_kb / (1024.0 * 1024.0), 1) - used_gb = round((total_kb - avail_kb) / (1024.0 * 1024.0), 1) - except Exception: - pass - return used_gb, total_gb +# 4. Last-ditch Python fallback if neither is available +if [ -x "$BIN_DIR/fleet-beacon.py" ] && command -v python3 >/dev/null 2>&1; then + exec python3 "$BIN_DIR/fleet-beacon.py" "$@" +fi -def read_disk(): - hostname = get_hostname() - check_paths = ["/"] - if hostname == "boxC": - check_paths = ["/mnt/kof17", "/"] - elif hostname == "bazina": - check_paths = ["/mnt/ssd", "/mnt/seagate", "/"] - - for p in check_paths: - if os.path.exists(p): - try: - du = shutil.disk_usage(p) - free_gb = round(du.free / (1024.0 ** 3), 1) - total_gb = round(du.total / (1024.0 ** 3), 1) - return p, free_gb, total_gb - except Exception: - continue - return "/", 0.0, 0.0 - -def read_gpu(): - # AMD GPU busy percent (sysfs) - for path in glob.glob("/sys/class/drm/card*/device/gpu_busy_percent"): - try: - with open(path, "r") as f: - val = f.read().strip() - return f"Navi10 {val}%" - except Exception: - pass - return "" - -def read_temp(): - for path in ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/hwmon/hwmon0/temp1_input"]: - if os.path.exists(path): - try: - with open(path, "r") as f: - return int(int(f.read().strip()) / 1000) - except Exception: - pass - return 0 - -def read_battery(): - # Probe ACPI / sysfs battery capacity - for p in glob.glob("/sys/class/power_supply/BAT*/capacity") + glob.glob("/sys/class/power_supply/*battery*/capacity") + glob.glob("/sys/class/power_supply/sbs-*/capacity"): - try: - with open(p, "r") as f: - return int(f.read().strip()) - except Exception: - pass - return -1 - -def main(): - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - - hostname = get_hostname() - prev_stat = None - print(f"[fleet-beacon] Starting telemetry beacon on {hostname} (port {PORT})...") - - while True: - cpu_pct, prev_stat = read_cpu_percent(prev_stat) - mem_used, mem_total = read_memory() - mount, disk_free, disk_total = read_disk() - gpu_str = read_gpu() - temp_c = read_temp() - bat_pct = read_battery() - - alert = "" - if hostname == "boxC" and mount == "/mnt/kof17" and disk_free < 3.0: - alert = f"kof17 CRITICAL: {disk_free}G free" - elif hostname in ("penguin", "crostini") and disk_free < 1.0: - alert = f"crostini LOW DISK: {disk_free}G free" - alert = f"penguin LOW DISK: {disk_free}G free" - elif bat_pct >= 0 and bat_pct <= 15: - alert = f"{hostname} BATTERY CRITICAL: {bat_pct}%" - - payload = { - "name": hostname, - "hostname": hostname, - "node": hostname, - "arch": os.uname().machine, - "sign": f"[{hostname}:{os.uname().machine}]", - "timestamp": time.time(), - "cpu_percent": cpu_pct, - "mem_used_gb": mem_used, - "mem_total_gb": mem_total, - "disk_mount": mount, - "disk_free_gb": disk_free, - "disk_total_gb": disk_total, - "gpu_str": gpu_str, - "temp_c": temp_c, - "battery_percent": bat_pct, - "alert": alert - } - - msg = json.dumps(payload).encode("utf-8") - - for target in TARGETS: - try: - sock.sendto(msg, (target, PORT)) - except Exception: - pass - - # Update local SHM cache - try: - shm_fleet = "/dev/shm/fleet_telemetry.json" - data = {} - if os.path.exists(shm_fleet): - try: - with open(shm_fleet, "r") as f: - data = json.load(f) - except Exception: - data = {} - data[hostname] = payload - - # Merge any WAN telemetry files (< 30s old) - try: - import glob - for wpath in glob.glob("/dev/shm/fleet_wan_*.json"): - if time.time() - os.path.getmtime(wpath) < 30: - with open(wpath, "r") as wf: - wpayload = json.load(wf) - if isinstance(wpayload, dict) and "name" in wpayload: - data[wpayload["name"]] = wpayload - except Exception: - pass - - tmp_path = f"{shm_fleet}.tmp.{os.getpid()}" - with open(tmp_path, "w") as f: - json.dump(data, f) - try: - os.chmod(tmp_path, 0o666) - except Exception: - pass - os.replace(tmp_path, shm_fleet) - except Exception: - pass - - # WAN HTTP bridge fallback for mobile/remote nodes (e.g. Staroměstská) - if hostname != "boxC": - try: - req = urllib.request.Request( - "http://91.139.118.43/telemetry.php", - data=msg, - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req, timeout=1.5) as resp: - if resp.status == 200: - remote_data = json.loads(resp.read().decode("utf-8")) - if isinstance(remote_data, dict) and "boxC" in remote_data: - shm_fleet = "/dev/shm/fleet_telemetry.json" - tmp_path = f"{shm_fleet}.tmp.{os.getpid()}" - with open(tmp_path, "w") as f: - json.dump(remote_data, f) - os.replace(tmp_path, shm_fleet) - except Exception: - pass - - time.sleep(1.5) - -if __name__ == "__main__": - main() +echo "Error: No viable fleet-beacon runtime found." >&2 +exit 1 diff --git a/fleet-beacon.py b/fleet-beacon.py new file mode 100755 index 0000000..5514c78 --- /dev/null +++ b/fleet-beacon.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +fleet-beacon.py — Multi-node Realtime Telemetry Beacon +Part of Observatoř Top (otop) + +Broadcasts lightweight UDP telemetry packets (port 9850) every 1.5s. +Zero external dependencies, minimal CPU impact (< 0.05%). +""" + +import os +import sys +import time +import json +import socket +import shutil +import glob +import urllib.request + +PORT = 9850 +TARGETS = [ + "", + "192.168.1.255", + "192.168.1.100", # t520 (Ethernet) + "192.168.1.102", # t520 (WiFi) + "192.168.1.104", # bazina (Debian 13) + "192.168.1.101", # boxC (RPi storage) + "192.168.1.105", # penguin (Chromebook) + "192.168.1.103", # phone (Android) + "10.39.9.255", # WireGuard VPN Broadcast + "10.39.9.1", # boxC VPN + "10.0.0.255", + "10.0.0.18", + "10.0.0.14", + "91.139.118.43", + "192.168.43.255", + "192.168.43.1", + "127.0.0.1" +] + +def get_hostname(): + if os.path.exists("/data/data/com.termux") or "TERMUX_VERSION" in os.environ: + return "phone" + h = socket.gethostname().split('.')[0] + if h in ("localhost", "android"): + return "phone" + return h + +def read_cpu_percent(prev_stat): + try: + with open("/proc/stat", "r") as f: + fields = [float(x) for x in f.readline().strip().split()[1:8]] + # fields: user, nice, system, idle, iowait, irq, softirq + idle = fields[3] + fields[4] + total = sum(fields) + if prev_stat is not None: + prev_idle, prev_total = prev_stat + d_idle = idle - prev_idle + d_total = total - prev_total + pct = 100.0 * (1.0 - (d_idle / max(1.0, d_total))) + return round(max(0.0, min(100.0, pct)), 1), (idle, total) + return 0.0, (idle, total) + except Exception: + return 0.0, prev_stat + +def read_memory(): + total_gb, used_gb = 0.0, 0.0 + try: + with open("/proc/meminfo", "r") as f: + mem = {} + for line in f: + parts = line.split(":") + if len(parts) == 2: + mem[parts[0].strip()] = float(parts[1].strip().split()[0]) + total_kb = mem.get("MemTotal", 0.0) + avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0.0)) + total_gb = round(total_kb / (1024.0 * 1024.0), 1) + used_gb = round((total_kb - avail_kb) / (1024.0 * 1024.0), 1) + except Exception: + pass + return used_gb, total_gb + +def read_disk(): + hostname = get_hostname() + check_paths = ["/"] + if hostname == "boxC": + check_paths = ["/mnt/kof17", "/"] + elif hostname == "bazina": + check_paths = ["/mnt/ssd", "/mnt/seagate", "/"] + + for p in check_paths: + if os.path.exists(p): + try: + du = shutil.disk_usage(p) + free_gb = round(du.free / (1024.0 ** 3), 1) + total_gb = round(du.total / (1024.0 ** 3), 1) + return p, free_gb, total_gb + except Exception: + continue + return "/", 0.0, 0.0 + +def read_gpu(): + # AMD GPU busy percent (sysfs) + for path in glob.glob("/sys/class/drm/card*/device/gpu_busy_percent"): + try: + with open(path, "r") as f: + val = f.read().strip() + return f"Navi10 {val}%" + except Exception: + pass + return "" + +def read_temp(): + for path in ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/hwmon/hwmon0/temp1_input"]: + if os.path.exists(path): + try: + with open(path, "r") as f: + return int(int(f.read().strip()) / 1000) + except Exception: + pass + return 0 + +def read_battery(): + # Probe ACPI / sysfs battery capacity + for p in glob.glob("/sys/class/power_supply/BAT*/capacity") + glob.glob("/sys/class/power_supply/*battery*/capacity") + glob.glob("/sys/class/power_supply/sbs-*/capacity"): + try: + with open(p, "r") as f: + return int(f.read().strip()) + except Exception: + pass + return -1 + +def main(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + + hostname = get_hostname() + prev_stat = None + print(f"[fleet-beacon] Starting telemetry beacon on {hostname} (port {PORT})...") + + while True: + cpu_pct, prev_stat = read_cpu_percent(prev_stat) + mem_used, mem_total = read_memory() + mount, disk_free, disk_total = read_disk() + gpu_str = read_gpu() + temp_c = read_temp() + bat_pct = read_battery() + + alert = "" + if hostname == "boxC" and mount == "/mnt/kof17" and disk_free < 3.0: + alert = f"kof17 CRITICAL: {disk_free}G free" + elif hostname in ("penguin", "crostini") and disk_free < 1.0: + alert = f"crostini LOW DISK: {disk_free}G free" + alert = f"penguin LOW DISK: {disk_free}G free" + elif bat_pct >= 0 and bat_pct <= 15: + alert = f"{hostname} BATTERY CRITICAL: {bat_pct}%" + + payload = { + "name": hostname, + "hostname": hostname, + "node": hostname, + "arch": os.uname().machine, + "sign": f"[{hostname}:{os.uname().machine}]", + "timestamp": time.time(), + "cpu_percent": cpu_pct, + "mem_used_gb": mem_used, + "mem_total_gb": mem_total, + "disk_mount": mount, + "disk_free_gb": disk_free, + "disk_total_gb": disk_total, + "gpu_str": gpu_str, + "temp_c": temp_c, + "battery_percent": bat_pct, + "alert": alert + } + + msg = json.dumps(payload).encode("utf-8") + + for target in TARGETS: + try: + sock.sendto(msg, (target, PORT)) + except Exception: + pass + + # Update local SHM cache + try: + shm_fleet = "/dev/shm/fleet_telemetry.json" + data = {} + if os.path.exists(shm_fleet): + try: + with open(shm_fleet, "r") as f: + data = json.load(f) + except Exception: + data = {} + data[hostname] = payload + + # Merge any WAN telemetry files (< 30s old) + try: + import glob + for wpath in glob.glob("/dev/shm/fleet_wan_*.json"): + if time.time() - os.path.getmtime(wpath) < 30: + with open(wpath, "r") as wf: + wpayload = json.load(wf) + if isinstance(wpayload, dict) and "name" in wpayload: + data[wpayload["name"]] = wpayload + except Exception: + pass + + tmp_path = f"{shm_fleet}.tmp.{os.getpid()}" + with open(tmp_path, "w") as f: + json.dump(data, f) + try: + os.chmod(tmp_path, 0o666) + except Exception: + pass + os.replace(tmp_path, shm_fleet) + except Exception: + pass + + # WAN HTTP bridge fallback for mobile/remote nodes (e.g. Staroměstská) + if hostname != "boxC": + try: + req = urllib.request.Request( + "http://91.139.118.43/telemetry.php", + data=msg, + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=1.5) as resp: + if resp.status == 200: + remote_data = json.loads(resp.read().decode("utf-8")) + if isinstance(remote_data, dict) and "boxC" in remote_data: + shm_fleet = "/dev/shm/fleet_telemetry.json" + tmp_path = f"{shm_fleet}.tmp.{os.getpid()}" + with open(tmp_path, "w") as f: + json.dump(remote_data, f) + os.replace(tmp_path, shm_fleet) + except Exception: + pass + + time.sleep(1.5) + +if __name__ == "__main__": + main() diff --git a/fleet-beacon.sh b/fleet-beacon.sh new file mode 100755 index 0000000..d39cf98 --- /dev/null +++ b/fleet-beacon.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# ============================================================================= +# fleet-beacon.sh — Multi-node Realtime Telemetry Beacon (Pure Bash Fallback) +# Observatoř Telemetry Platform // Fleet Operations +# +# Broadcasts lightweight UDP telemetry packets (port 9850) every 1.5s. +# Zero external dependencies, pure coreutils + bash. +# ============================================================================= + +set -e + +PORT=9850 +INTERVAL=1.5 +TARGETS=( + "255.255.255.255" + "192.168.1.255" + "192.168.1.100" + "192.168.1.102" + "192.168.1.104" + "192.168.1.101" + "192.168.1.105" + "192.168.1.103" + "10.39.9.255" + "10.39.9.1" + "10.0.0.255" + "10.0.0.18" + "10.0.0.14" + "91.139.118.43" + "192.168.43.255" + "192.168.43.1" + "127.0.0.1" +) + +# Identity resolution +if [ -z "$FLEET_NODE" ] || [ -z "$FLEET_ARCH" ]; then + [ -f "/etc/fleet_id" ] && source "/etc/fleet_id" 2>/dev/null || true + [ -f "$HOME/.fleet_id" ] && source "$HOME/.fleet_id" 2>/dev/null || true +fi + +RAW_HOST="$(hostname -s 2>/dev/null || uname -n)" +if [ -z "$FLEET_NODE" ]; then + if [ -d "/data/data/com.termux" ] || [ -n "$TERMUX_VERSION" ] || [ "$RAW_HOST" = "localhost" ] || [ "$RAW_HOST" = "android" ]; then + FLEET_NODE="android" + elif [ "$RAW_HOST" = "penguin" ]; then + FLEET_NODE="crostini" + else + FLEET_NODE="$RAW_HOST" + fi +fi +FLEET_ARCH="${FLEET_ARCH:-$(uname -m)}" +FLEET_SIGN="${FLEET_SIGN:-[${FLEET_NODE}:${FLEET_ARCH}]}" + +RUN_ONCE=0 +PRINT_JSON=0 +for arg in "$@"; do + case "$arg" in + --once|-1) RUN_ONCE=1 ;; + --json|-j) RUN_ONCE=1; PRINT_JSON=1 ;; + esac +done + +if [ "$PRINT_JSON" -eq 0 ]; then + echo "[fleet-beacon-sh] Starting Bash telemetry beacon on $FLEET_NODE $FLEET_SIGN (port $PORT)..." +fi + +prev_work=0 +prev_total=0 + +while true; do + # 1. CPU + cpu_pct=0.0 + if [ -r /proc/stat ]; then + read -r _ u n s i io irq sirq stl _ < /proc/stat 2>/dev/null || true + work=$(( u + n + s + irq + sirq + stl )) + total=$(( work + i + io )) + if [ "$prev_total" -gt 0 ] && [ "$total" -gt "$prev_total" ]; then + d_work=$(( work - prev_work )) + d_total=$(( total - prev_total )) + if [ "$d_total" -gt 0 ]; then + cpu_pct=$(awk -v w="$d_work" -v t="$d_total" 'BEGIN { printf "%.1f", (w/t)*100 }') + fi + fi + prev_work=$work + prev_total=$total + fi + + # 2. RAM + mem_used=0.0 + mem_total=0.0 + if [ -r /proc/meminfo ]; then + tot_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo) + avail_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo) + if [ -z "$avail_kb" ]; then + free_kb=$(awk '/^MemFree:/ {print $2}' /proc/meminfo) + buf_kb=$(awk '/^Buffers:/ {print $2}' /proc/meminfo) + cache_kb=$(awk '/^Cached:/ {print $2}' /proc/meminfo) + avail_kb=$(( free_kb + buf_kb + cache_kb )) + fi + if [ -n "$tot_kb" ] && [ "$tot_kb" -gt 0 ]; then + mem_total=$(awk -v t="$tot_kb" 'BEGIN { printf "%.1f", t/1048576 }') + used_kb=$(( tot_kb - avail_kb )) + mem_used=$(awk -v u="$used_kb" 'BEGIN { printf "%.1f", u/1048576 }') + fi + fi + + # 3. Disk + disk_mount="/" + if [ "$FLEET_NODE" = "boxC" ] && [ -d "/mnt/kof17" ]; then + disk_mount="/mnt/kof17" + elif [ "$FLEET_NODE" = "bazina" ] && [ -d "/mnt/ssd" ]; then + disk_mount="/mnt/ssd" + fi + df_out=$(df -B1G "$disk_mount" 2>/dev/null | tail -n 1) || df_out="" + disk_total=$(echo "$df_out" | awk '{print $2}') + disk_free=$(echo "$df_out" | awk '{print $4}') + disk_total="${disk_total:-0.0}" + disk_free="${disk_free:-0.0}" + + # 4. GPU (sysfs) + gpu_str="" + for gpath in /sys/class/drm/card*/device/gpu_busy_percent; do + if [ -r "$gpath" ]; then + gpu_val=$(cat "$gpath" 2>/dev/null || echo "") + [ -n "$gpu_val" ] && gpu_str="Navi10 ${gpu_val}%" && break + fi + done + + # 5. Temp + temp_c=0 + for tpath in /sys/class/thermal/thermal_zone0/temp /sys/class/hwmon/hwmon0/temp1_input; do + if [ -r "$tpath" ]; then + traw=$(cat "$tpath" 2>/dev/null || echo 0) + [ "$traw" -gt 0 ] && temp_c=$(( traw / 1000 )) && break + fi + done + + # 6. Battery + bat_pct=-1 + for bpath in /sys/class/power_supply/BAT*/capacity /sys/class/power_supply/*battery*/capacity; do + if [ -r "$bpath" ]; then + bval=$(cat "$bpath" 2>/dev/null || echo -1) + [ "$bval" -ge 0 ] && bat_pct=$bval && break + fi + done + + # 7. Alert + alert="" + if [ "$FLEET_NODE" = "boxC" ] && [ "$disk_mount" = "/mnt/kof17" ]; then + awk_crit=$(awk -v df="$disk_free" 'BEGIN { print (df < 3.0) ? 1 : 0 }') + [ "$awk_crit" -eq 1 ] && alert="kof17 CRITICAL: ${disk_free}G free" + elif [ "$FLEET_NODE" = "crostini" ]; then + awk_crit=$(awk -v df="$disk_free" 'BEGIN { print (df < 1.0) ? 1 : 0 }') + [ "$awk_crit" -eq 1 ] && alert="crostini LOW DISK: ${disk_free}G free" + fi + if [ "$bat_pct" -ge 0 ] && [ "$bat_pct" -le 15 ]; then + alert="$FLEET_NODE BATTERY CRITICAL: ${bat_pct}%" + fi + + ts=$(date +%s) + payload=$(printf '{"name":"%s","hostname":"%s","node":"%s","arch":"%s","sign":"%s","timestamp":%s,"cpu_percent":%s,"mem_used_gb":%s,"mem_total_gb":%s,"disk_mount":"%s","disk_free_gb":%s,"disk_total_gb":%s,"gpu_str":"%s","temp_c":%d,"battery_percent":%d,"alert":"%s"}' \ + "$FLEET_NODE" "$RAW_HOST" "$FLEET_NODE" "$FLEET_ARCH" "$FLEET_SIGN" \ + "$ts" "$cpu_pct" "$mem_used" "$mem_total" \ + "$disk_mount" "$disk_free" "$disk_total" \ + "$gpu_str" "$temp_c" "$bat_pct" "$alert") + + # Broadcast UDP + for target in "${TARGETS[@]}"; do + if [ -w /dev/udp ]; then + echo -n "$payload" > "/dev/udp/$target/$PORT" 2>/dev/null || true + elif command -v socat >/dev/null 2>&1; then + echo -n "$payload" | socat - "UDP-DATAGRAM:$target:$PORT,broadcast" 2>/dev/null || true + elif command -v nc >/dev/null 2>&1; then + echo -n "$payload" | nc -u -w1 "$target" "$PORT" 2>/dev/null || true + fi + done + + # Update SHM cache + shm_fleet="/dev/shm/fleet_telemetry.json" + tmp_path="${shm_fleet}.tmp.$$" + if [ -f "$shm_fleet" ]; then + # Merge using awk/sed + grep -v "\"${FLEET_NODE}\":" "$shm_fleet" 2>/dev/null | sed -e 's/^}$//' > "$tmp_path" || true + echo " ,\"${FLEET_NODE}\": $payload" >> "$tmp_path" + echo "}" >> "$tmp_path" + else + printf '{\n "%s": %s\n}\n' "$FLEET_NODE" "$payload" > "$tmp_path" + fi + chmod 666 "$tmp_path" 2>/dev/null || true + mv -f "$tmp_path" "$shm_fleet" 2>/dev/null || true + + # WAN HTTP fallback + if [ "$FLEET_NODE" != "boxC" ]; then + if command -v curl >/dev/null 2>&1; then + curl -s -m 1.5 -X POST "http://91.139.118.43/telemetry.php" \ + -H "Content-Type: application/json" -d "$payload" >/dev/null 2>&1 || true + fi + fi + + if [ "$PRINT_JSON" -eq 1 ]; then + echo "$payload" + fi + + [ "$RUN_ONCE" -eq 1 ] && break + sleep "$INTERVAL" +done diff --git a/src/fleet-beacon.c b/src/fleet-beacon.c new file mode 100644 index 0000000..e9e7704 --- /dev/null +++ b/src/fleet-beacon.c @@ -0,0 +1,502 @@ +/* ============================================================================= + * fleet-beacon.c — Multi-node Realtime Telemetry Beacon (Pure C POSIX) + * Observatoř Telemetry Platform // Fleet Operations + * + * Broadcasts UDP telemetry packets (port 9850) every 1.5s. + * Zero runtime dependencies, minimal RAM (< 1MB), 0.00% idle CPU. + * ============================================================================= + */ + +#define _POSIX_C_SOURCE 200809L +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BEACON_PORT 9850 +#define INTERVAL_US 1500000 + +static const char *TARGETS[] = { + "255.255.255.255", + "192.168.1.255", + "192.168.1.100", /* t520 ETH */ + "192.168.1.102", /* t520 WiFi */ + "192.168.1.104", /* bazina */ + "192.168.1.101", /* boxC */ + "192.168.1.105", /* crostini */ + "192.168.1.103", /* android */ + "10.39.9.255", /* WireGuard Broadcast */ + "10.39.9.1", /* boxC VPN */ + "10.0.0.255", + "10.0.0.18", + "10.0.0.14", + "91.139.118.43", + "192.168.43.255", + "192.168.43.1", + "127.0.0.1", + NULL +}; + +static void resolve_identity(char *node, size_t n_len, char *arch, size_t a_len, char *sign, size_t s_len, char *raw_host, size_t h_len) { + /* 1. Hostname */ + char hbuf[128] = {0}; + if (gethostname(hbuf, sizeof(hbuf) - 1) != 0) { + snprintf(hbuf, sizeof(hbuf), "unknown"); + } + char *dot = strchr(hbuf, '.'); + if (dot) *dot = '\0'; + snprintf(raw_host, h_len, "%s", hbuf); + + /* 2. FLEET_NODE override / file probe */ + const char *env_node = getenv("FLEET_NODE"); + if (env_node && *env_node) { + snprintf(node, n_len, "%s", env_node); + } else { + char line[256]; + FILE *fp = fopen("/etc/fleet_id", "r"); + if (!fp) { + char home_f[256]; + const char *home = getenv("HOME"); + if (home) { + snprintf(home_f, sizeof(home_f), "%s/.fleet_id", home); + fp = fopen(home_f, "r"); + } + } + if (fp) { + while (fgets(line, sizeof(line), fp)) { + char *p = line; + while (*p == ' ' || *p == '\t') p++; + if (*p == '#' || *p == '\n' || *p == '\r' || *p == '\0') continue; + + if (strncmp(p, "FLEET_NODE=", 11) == 0) { + char *val = p + 11; + if (*val == '"' || *val == '\'') val++; + size_t l = strlen(val); + while (l > 0 && (val[l-1] == '\n' || val[l-1] == '\r' || val[l-1] == '"' || val[l-1] == '\'' || val[l-1] == ' ')) val[--l] = '\0'; + snprintf(node, n_len, "%s", val); + } else if (strncmp(p, "FLEET_ARCH=", 11) == 0) { + char *val = p + 11; + if (*val == '"' || *val == '\'') val++; + size_t l = strlen(val); + while (l > 0 && (val[l-1] == '\n' || val[l-1] == '\r' || val[l-1] == '"' || val[l-1] == '\'' || val[l-1] == ' ')) val[--l] = '\0'; + snprintf(arch, a_len, "%s", val); + } else if (strncmp(p, "FLEET_SIGN=", 11) == 0) { + char *val = p + 11; + if (*val == '"' || *val == '\'') val++; + size_t l = strlen(val); + while (l > 0 && (val[l-1] == '\n' || val[l-1] == '\r' || val[l-1] == '"' || val[l-1] == '\'' || val[l-1] == ' ')) val[--l] = '\0'; + snprintf(sign, s_len, "%s", val); + } else if (node[0] == '\0') { + size_t l = strlen(p); + while (l > 0 && (p[l-1] == '\n' || p[l-1] == '\r' || p[l-1] == ' ')) p[--l] = '\0'; + snprintf(node, n_len, "%s", p); + } + } + fclose(fp); + } + + if (node[0] == '\0') { + if (access("/data/data/com.termux", F_OK) == 0 || getenv("TERMUX_VERSION") != NULL) { + snprintf(node, n_len, "android"); + } else if (strcmp(hbuf, "localhost") == 0 || strcmp(hbuf, "android") == 0) { + snprintf(node, n_len, "android"); + } else if (strcmp(hbuf, "penguin") == 0) { + snprintf(node, n_len, "crostini"); + } else { + snprintf(node, n_len, "%s", hbuf); + } + } + } + + /* 3. Arch */ + const char *env_arch = getenv("FLEET_ARCH"); + if (env_arch && *env_arch) { + snprintf(arch, a_len, "%s", env_arch); + } else { + struct utsname uts; + if (uname(&uts) == 0) { + snprintf(arch, a_len, "%s", uts.machine); + } else { + snprintf(arch, a_len, "unknown"); + } + } + + /* 4. Sign */ + const char *env_sign = getenv("FLEET_SIGN"); + if (env_sign && *env_sign) { + snprintf(sign, s_len, "%s", env_sign); + } else { + snprintf(sign, s_len, "[%s:%s]", node, arch); + } +} + +static double read_cpu(unsigned long long *prev_work, unsigned long long *prev_total) { + FILE *fp = fopen("/proc/stat", "r"); + if (!fp) return 0.0; + + char line[256]; + if (!fgets(line, sizeof(line), fp)) { + fclose(fp); + return 0.0; + } + fclose(fp); + + unsigned long long user = 0, nice = 0, system = 0, idle = 0, iowait = 0, irq = 0, softirq = 0, steal = 0; + if (sscanf(line, "cpu %llu %llu %llu %llu %llu %llu %llu %llu", + &user, &nice, &system, &idle, &iowait, &irq, &softirq, &steal) < 4) { + return 0.0; + } + + unsigned long long work = user + nice + system + irq + softirq + steal; + unsigned long long total = work + idle + iowait; + + double pct = 0.0; + if (*prev_total > 0 && total > *prev_total) { + unsigned long long d_work = work - *prev_work; + unsigned long long d_total = total - *prev_total; + pct = ((double)d_work / (double)d_total) * 100.0; + if (pct < 0.0) pct = 0.0; + if (pct > 100.0) pct = 100.0; + } + + *prev_work = work; + *prev_total = total; + return pct; +} + +static void read_memory(double *used_gb, double *total_gb) { + *used_gb = 0.0; + *total_gb = 0.0; + + FILE *fp = fopen("/proc/meminfo", "r"); + if (!fp) return; + + char line[128]; + double total_kb = 0.0, avail_kb = 0.0, free_kb = 0.0, buffers_kb = 0.0, cached_kb = 0.0; + int have_avail = 0; + + while (fgets(line, sizeof(line), fp)) { + if (strncmp(line, "MemTotal:", 9) == 0) { + sscanf(line + 9, "%lf", &total_kb); + } else if (strncmp(line, "MemAvailable:", 13) == 0) { + sscanf(line + 13, "%lf", &avail_kb); + have_avail = 1; + } else if (strncmp(line, "MemFree:", 8) == 0) { + sscanf(line + 8, "%lf", &free_kb); + } else if (strncmp(line, "Buffers:", 8) == 0) { + sscanf(line + 8, "%lf", &buffers_kb); + } else if (strncmp(line, "Cached:", 7) == 0) { + sscanf(line + 7, "%lf", &cached_kb); + } + } + fclose(fp); + + if (!have_avail) { + avail_kb = free_kb + buffers_kb + cached_kb; + } + + *total_gb = total_kb / (1024.0 * 1024.0); + *used_gb = (total_kb - avail_kb) / (1024.0 * 1024.0); + if (*used_gb < 0.0) *used_gb = 0.0; +} + +static void read_disk(const char *node, char *mount_out, size_t m_len, double *free_gb, double *total_gb) { + const char *candidates[4] = { "/", NULL, NULL, NULL }; + + if (strcmp(node, "boxC") == 0) { + candidates[0] = "/mnt/kof17"; + candidates[1] = "/"; + } else if (strcmp(node, "bazina") == 0) { + candidates[0] = "/mnt/ssd"; + candidates[1] = "/mnt/seagate"; + candidates[2] = "/"; + } + + for (int i = 0; candidates[i] != NULL; ++i) { + struct statvfs st; + if (statvfs(candidates[i], &st) == 0 && st.f_blocks > 0) { + snprintf(mount_out, m_len, "%s", candidates[i]); + *free_gb = ((double)st.f_bavail * (double)st.f_frsize) / (1024.0 * 1024.0 * 1024.0); + *total_gb = ((double)st.f_blocks * (double)st.f_frsize) / (1024.0 * 1024.0 * 1024.0); + return; + } + } + + snprintf(mount_out, m_len, "/"); + *free_gb = 0.0; + *total_gb = 0.0; +} + +static void read_gpu(char *gpu_out, size_t g_len) { + gpu_out[0] = '\0'; + glob_t gl; + if (glob("/sys/class/drm/card*/device/gpu_busy_percent", 0, NULL, &gl) == 0) { + for (size_t i = 0; i < gl.gl_pathc; ++i) { + FILE *fp = fopen(gl.gl_pathv[i], "r"); + if (fp) { + int busy = 0; + if (fscanf(fp, "%d", &busy) == 1) { + snprintf(gpu_out, g_len, "Navi10 %d%%", busy); + fclose(fp); + break; + } + fclose(fp); + } + } + globfree(&gl); + } +} + +static int read_temp(void) { + const char *paths[] = { + "/sys/class/thermal/thermal_zone0/temp", + "/sys/class/hwmon/hwmon0/temp1_input", + "/sys/class/hwmon/hwmon1/temp1_input", + NULL + }; + for (int i = 0; paths[i] != NULL; ++i) { + FILE *fp = fopen(paths[i], "r"); + if (fp) { + long temp_raw = 0; + if (fscanf(fp, "%ld", &temp_raw) == 1) { + fclose(fp); + return (int)(temp_raw / 1000); + } + fclose(fp); + } + } + return 0; +} + +static int read_battery(void) { + glob_t gl; + const char *patterns[] = { + "/sys/class/power_supply/BAT*/capacity", + "/sys/class/power_supply/*battery*/capacity", + "/sys/class/power_supply/sbs-*/capacity", + NULL + }; + for (int p = 0; patterns[p] != NULL; ++p) { + if (glob(patterns[p], 0, NULL, &gl) == 0) { + for (size_t i = 0; i < gl.gl_pathc; ++i) { + FILE *fp = fopen(gl.gl_pathv[i], "r"); + if (fp) { + int cap = -1; + if (fscanf(fp, "%d", &cap) == 1) { + fclose(fp); + globfree(&gl); + return cap; + } + fclose(fp); + } + } + globfree(&gl); + } + } + return -1; +} + +static void update_shm(const char *node, const char *payload) { + const char *shm_path = "/dev/shm/fleet_telemetry.json"; + char tmp_path[256]; + snprintf(tmp_path, sizeof(tmp_path), "%s.tmp.%d", shm_path, (int)getpid()); + + /* Read existing file up to 64KB */ + char in_buf[65536] = {0}; + FILE *fp = fopen(shm_path, "r"); + if (fp) { + size_t n = fread(in_buf, 1, sizeof(in_buf) - 1, fp); + in_buf[n] = '\0'; + fclose(fp); + } + + FILE *out = fopen(tmp_path, "w"); + if (!out) return; + + /* Write simple JSON envelope */ + if (in_buf[0] == '{') { + /* Filter out existing node key and append new */ + fprintf(out, "{\n"); + int wrote_any = 0; + + /* Scan and copy other nodes */ + const char *known[] = { "t520", "bazina", "boxC", "crostini", "penguin", "android", "phone", NULL }; + for (int k = 0; known[k] != NULL; ++k) { + if (strcmp(known[k], node) == 0) continue; + char needle[64]; + snprintf(needle, sizeof(needle), "\"%s\":", known[k]); + char *pos = strstr(in_buf, needle); + if (pos) { + /* Find opening brace */ + char *brace_start = strchr(pos, '{'); + if (brace_start) { + int depth = 1; + char *ptr = brace_start + 1; + while (*ptr && depth > 0) { + if (*ptr == '{') depth++; + else if (*ptr == '}') depth--; + ptr++; + } + if (depth == 0) { + if (wrote_any) fprintf(out, ",\n"); + fprintf(out, " \"%s\": ", known[k]); + fwrite(brace_start, 1, (size_t)(ptr - brace_start), out); + wrote_any = 1; + } + } + } + } + + if (wrote_any) fprintf(out, ",\n"); + fprintf(out, " \"%s\": %s\n}\n", node, payload); + } else { + fprintf(out, "{\n \"%s\": %s\n}\n", node, payload); + } + + fclose(out); + chmod(tmp_path, 0666); + rename(tmp_path, shm_path); +} + +static void send_wan_http(const char *payload) { + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) return; + + struct timeval tv; + tv.tv_sec = 1; + tv.tv_usec = 500000; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + struct sockaddr_in srv; + memset(&srv, 0, sizeof(srv)); + srv.sin_family = AF_INET; + srv.sin_port = htons(80); + inet_pton(AF_INET, "91.139.118.43", &srv.sin_addr); + + if (connect(sock, (struct sockaddr *)&srv, sizeof(srv)) == 0) { + char req[4096]; + int req_len = snprintf(req, sizeof(req), + "POST /telemetry.php HTTP/1.1\r\n" + "Host: 91.139.118.43\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n\r\n" + "%s", strlen(payload), payload); + if (req_len > 0) { + write(sock, req, (size_t)req_len); + } + } + close(sock); +} + +int main(int argc, char **argv) { + int run_once = 0; + int print_json = 0; + for (int i = 1; i < argc; ++i) { + if (strcmp(argv[i], "--once") == 0 || strcmp(argv[i], "-1") == 0) run_once = 1; + else if (strcmp(argv[i], "--json") == 0 || strcmp(argv[i], "-j") == 0) { run_once = 1; print_json = 1; } + } + + char node[64], arch[65], sign[160], raw_host[64]; + resolve_identity(node, sizeof(node), arch, sizeof(arch), sign, sizeof(sign), raw_host, sizeof(raw_host)); + + int udp_sock = socket(AF_INET, SOCK_DGRAM, 0); + if (udp_sock >= 0) { + int broadcast = 1; + setsockopt(udp_sock, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast)); + } + + if (!print_json) { + printf("[fleet-beacon-c] Starting native telemetry beacon on %s %s (port %d)...\n", + node, sign, BEACON_PORT); + fflush(stdout); + } + + unsigned long long prev_work = 0, prev_total = 0; + + while (1) { + double cpu_pct = read_cpu(&prev_work, &prev_total); + double mem_used = 0.0, mem_total = 0.0; + read_memory(&mem_used, &mem_total); + + char mount[64] = "/"; + double disk_free = 0.0, disk_total = 0.0; + read_disk(node, mount, sizeof(mount), &disk_free, &disk_total); + + char gpu_str[64]; + read_gpu(gpu_str, sizeof(gpu_str)); + + int temp_c = read_temp(); + int bat_pct = read_battery(); + + char alert[256] = ""; + if (strcmp(node, "boxC") == 0 && strcmp(mount, "/mnt/kof17") == 0 && disk_free < 3.0) { + snprintf(alert, sizeof(alert), "kof17 CRITICAL: %.1fG free", disk_free); + } else if ((strcmp(node, "crostini") == 0 || strcmp(node, "penguin") == 0) && disk_free < 1.0) { + snprintf(alert, sizeof(alert), "crostini LOW DISK: %.1fG free", disk_free); + } else if (bat_pct >= 0 && bat_pct <= 15) { + snprintf(alert, sizeof(alert), "%s BATTERY CRITICAL: %d%%", node, bat_pct); + } + + char payload[2048]; + snprintf(payload, sizeof(payload), + "{\"name\":\"%s\",\"hostname\":\"%s\",\"node\":\"%s\",\"arch\":\"%s\",\"sign\":\"%s\"," + "\"timestamp\":%.3f,\"cpu_percent\":%.1f,\"mem_used_gb\":%.1f,\"mem_total_gb\":%.1f," + "\"disk_mount\":\"%s\",\"disk_free_gb\":%.1f,\"disk_total_gb\":%.1f," + "\"gpu_str\":\"%s\",\"temp_c\":%d,\"battery_percent\":%d,\"alert\":\"%s\"}", + node, raw_host, node, arch, sign, + (double)time(NULL), cpu_pct, mem_used, mem_total, + mount, disk_free, disk_total, + gpu_str, temp_c, bat_pct, alert); + + /* 1. Broadcast UDP */ + if (udp_sock >= 0) { + size_t payload_len = strlen(payload); + for (int i = 0; TARGETS[i] != NULL; ++i) { + struct sockaddr_in sin; + memset(&sin, 0, sizeof(sin)); + sin.sin_family = AF_INET; + sin.sin_port = htons(BEACON_PORT); + if (inet_pton(AF_INET, TARGETS[i], &sin.sin_addr) > 0) { + sendto(udp_sock, payload, payload_len, 0, (struct sockaddr *)&sin, sizeof(sin)); + } + } + } + + /* 2. Update local SHM cache */ + update_shm(node, payload); + + /* 3. HTTP WAN Fallback */ + if (strcmp(node, "boxC") != 0) { + send_wan_http(payload); + } + + if (print_json) { + printf("%s\n", payload); + fflush(stdout); + } + + if (run_once) { + break; + } + + usleep(INTERVAL_US); + } + + if (udp_sock >= 0) close(udp_sock); + return 0; +}