feat: kit-busrouter v2.0 — complete Synology + GL fleet router platform
Includes: - package/ GL-XE3000 kit-busrouter (opkg) - scripts/provision.sh (GL) and provision-synology.sh (Synology) - syno-balance/ — new WAN balancer replacing aiwanbal (SmartWAN adapter) - kit-connect/ — unified connectivity SPK (Tailscale + reverse SSH) - docs/deployment/synology-rt2600ac-checklist.md — 62-point checklist - docs/provisioning/device-identity.md — fleet identity spec - docs/pilot/checklist.md — field pilot validation - x4078_20260721.dss — reference config backup Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
#!/bin/sh
|
||||
# telemetry-synology.sh — Synology SRM telemetry payload builder for fleet hub.
|
||||
#
|
||||
# Reads aiwanbal state files from /tmp/aiwanbal/ and optionally queries the
|
||||
# Eyeride Eyenet (OpenWrt ubus) for GPS position. Posts JSON to the fleet hub
|
||||
# over Tailscale with disk-buffered retry on failure.
|
||||
#
|
||||
# Config (environment variables):
|
||||
# TELEMETRY_HUB POST endpoint (default: http://10.88.0.1:8080/api/telemetry)
|
||||
# TELEMETRY_BUFFER_DIR disk buffer for failed POSTs (default: /tmp/busrouter/telemetry-buf)
|
||||
# AIWANBAL_STATE_DIR aiwanbal runtime state (default: /tmp/aiwanbal)
|
||||
# EYERIDE_IP Eyeride OpenWrt IP for GPS (default: 192.168.10.1)
|
||||
# EYERIDE_PORT Eyeride ubus port (default: 8080)
|
||||
# EYERIDE_USER Eyeride SSH/ubus username (default: root)
|
||||
# EYERIDE_PASSWORD Eyeride password (required for GPS)
|
||||
#
|
||||
# Public API:
|
||||
# eyeride_gps query GPS from Eyeride ubus; echo 'lat lon fix';
|
||||
# exit 1 if unavailable
|
||||
# telemetry_synology_collect emit JSON payload to stdout from aiwanbal state files
|
||||
# telemetry_synology_flush retry all buffered payloads; return 1 if hub still down
|
||||
# telemetry_synology_send collect + flush + post; buffer on failure
|
||||
#
|
||||
# Cron mode: runs telemetry_synology_send when executed directly.
|
||||
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/busrouter}"
|
||||
: "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}"
|
||||
: "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}"
|
||||
: "${AIWANBAL_STATE_DIR:=/tmp/aiwanbal}"
|
||||
: "${EYERIDE_IP:=192.168.10.1}"
|
||||
: "${EYERIDE_PORT:=8080}"
|
||||
: "${EYERIDE_USER:=root}"
|
||||
_TELEM_SEQ=0
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Escape a string value for JSON (backslash and double-quote only).
|
||||
_json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
|
||||
|
||||
# Read an aiwanbal state file, return empty string if missing.
|
||||
_aiwanbal_val() {
|
||||
local key="$1"
|
||||
cat "${AIWANBAL_STATE_DIR}/${key}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Eyeride GPS (ubus JSON-RPC) ─────────────────────────────────────────────────
|
||||
#
|
||||
# The Eyeride Eyenet is an OpenWrt device at 192.168.10.1:8080 with ubus
|
||||
# JSON-RPC. GPS data may be available through:
|
||||
# 1. ubus call gps status (if gps service is running)
|
||||
# 2. ubus call location status (alternative service name)
|
||||
# 3. /tmp/gps/last_fix on the Eyeride (mounted via NFS/SMB or scraped)
|
||||
#
|
||||
# This function tries option 1 first, then falls back gracefully.
|
||||
|
||||
# Authenticate to Eyeride ubus, return session token on stdout.
|
||||
_eyeride_login() {
|
||||
local _pass="${EYERIDE_PASSWORD:-}"
|
||||
[ -n "$_pass" ] || return 1
|
||||
|
||||
local _login_body _login_resp _session
|
||||
_login_body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"call\",\"params\":[\"00000000000000000000000000000000\",\"session\",\"login\",{\"username\":\"${EYERIDE_USER}\",\"password\":\"${_pass}\"}]}"
|
||||
_login_resp=$(curl -s --connect-timeout 5 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$_login_body" \
|
||||
"http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null) || return 1
|
||||
|
||||
# Check for auth failure: ubus returns [6] for permission denied
|
||||
if [ -z "$_login_resp" ] || echo "$_login_resp" | grep -q '"result":\[6\]'; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
_session=$(echo "$_login_resp" | sed 's/.*"ubus_rpc_session":"\([^"]*\)".*/\1/')
|
||||
[ -n "$_session" ] && [ "$_session" != "$_login_resp" ] || return 1
|
||||
printf '%s\n' "$_session"
|
||||
}
|
||||
|
||||
# Call a ubus method on the Eyeride with an active session token.
|
||||
# Usage: _eyeride_ubus_call <session> <service> <method> [params_json]
|
||||
_eyeride_ubus_call() {
|
||||
local _session="$1" _svc="$2" _method="$3" _params="${4:-{}}"
|
||||
local _body
|
||||
_body="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"call\",\"params\":[\"${_session}\",\"${_svc}\",\"${_method}\",${_params}]}"
|
||||
curl -s --connect-timeout 5 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$_body" \
|
||||
"http://${EYERIDE_IP}:${EYERIDE_PORT}/ubus" 2>/dev/null
|
||||
}
|
||||
|
||||
# Query GPS position from Eyeride.
|
||||
# Emits 'lat lon fix_quality' on success; exit 1 on failure or no fix.
|
||||
eyeride_gps() {
|
||||
local _session _resp _lat _lon _fix
|
||||
|
||||
_session=$(_eyeride_login) || return 1
|
||||
|
||||
# Try 'gps' service first (common OpenWrt gpsd ubus binding)
|
||||
_resp=$(_eyeride_ubus_call "$_session" "gps" "status") 2>/dev/null
|
||||
if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then
|
||||
# gps service not found — try 'location' service
|
||||
_resp=$(_eyeride_ubus_call "$_session" "location" "status") 2>/dev/null
|
||||
fi
|
||||
|
||||
# Try to extract lat/lon from ubus response JSON
|
||||
_lat=$(echo "$_resp" | sed 's/.*"latitude":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
_lon=$(echo "$_resp" | sed 's/.*"longitude":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
# Also try snake_case variants
|
||||
[ "$_lat" = "$_resp" ] && _lat=$(echo "$_resp" | sed 's/.*"lat":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
[ "$_lon" = "$_resp" ] && _lon=$(echo "$_resp" | sed 's/.*"lon":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
# Alt spelling
|
||||
[ "$_lat" = "$_resp" ] && _lat=$(echo "$_resp" | sed 's/.*"lat_deg":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
[ "$_lon" = "$_resp" ] && _lon=$(echo "$_resp" | sed 's/.*"lon_deg":\([0-9.-]*\).*/\1/' 2>/dev/null)
|
||||
|
||||
# Sanitize: if sed didn't match, the field equals the full response
|
||||
[ "$_lat" = "$_resp" ] && _lat=""
|
||||
[ "$_lon" = "$_resp" ] && _lon=""
|
||||
|
||||
if [ -n "$_lat" ] && [ -n "$_lon" ]; then
|
||||
_fix=2 # assume 2D fix if we got coordinates
|
||||
[ -n "$_fix" ] || _fix=2
|
||||
logger -t busrouter -p daemon.debug "eyeride_gps: ${_lat} ${_lon} fix=${_fix}"
|
||||
printf '%s %s %s\n' "$_lat" "$_lon" "$_fix"
|
||||
return 0
|
||||
fi
|
||||
|
||||
logger -t busrouter "eyeride_gps: no fix from Eyeride"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Telemetry Collection ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# State files read from AIWANBAL_STATE_DIR (written by aiwanbal daemon each cycle):
|
||||
# wan1_score_avg, wan2_score_avg integer 0–100
|
||||
# wan1_latency, wan2_latency ms
|
||||
# wan1_throughput, wan2_throughput B/s (converted to Mbps below)
|
||||
# wan1_upload, wan2_upload B/s
|
||||
# wan1_jitter, wan2_jitter ms
|
||||
# wan1_packet_loss, wan2_packet_loss %
|
||||
# wan1_rsrp, wan2_rsrp dBm (cellular signal)
|
||||
# wan1_rsrq, wan2_rsrq dB
|
||||
# wan1_sinr, wan2_sinr dB
|
||||
# wan1_technology, wan2_technology e.g. "5G-NR", "LTE"
|
||||
# wan1_modem_type, wan2_modem_type e.g. "eyeride", "peplink", "none"
|
||||
# wan0_mode "active" or "failover"
|
||||
# wan0_starlink_quality quality score (if Starlink connected)
|
||||
# wan0_starlink_latency ms
|
||||
# wan0_starlink_obstruction bool-ish
|
||||
# wan0_starlink_outage bool-ish
|
||||
|
||||
# Read one WAN's metrics and emit a JSON object fragment.
|
||||
# Usage: _synology_wan_json <wan_num>
|
||||
_synology_wan_json() {
|
||||
local n="$1"
|
||||
local score lat dl ul jitter loss rsrp rsrq sinr tech carrier modem
|
||||
|
||||
score=$(_aiwanbal_val "wan${n}_score_avg")
|
||||
lat=$(_aiwanbal_val "wan${n}_latency")
|
||||
local throughput_bytes
|
||||
throughput_bytes=$(_aiwanbal_val "wan${n}_throughput")
|
||||
dl=""
|
||||
[ -n "$throughput_bytes" ] && dl=$(awk "BEGIN { printf \"%.1f\", ${throughput_bytes} / 125000 }" 2>/dev/null)
|
||||
[ -z "$dl" ] && dl="0"
|
||||
local upload_bytes
|
||||
upload_bytes=$(_aiwanbal_val "wan${n}_upload")
|
||||
ul=""
|
||||
[ -n "$upload_bytes" ] && ul=$(awk "BEGIN { printf \"%.1f\", ${upload_bytes} / 125000 }" 2>/dev/null)
|
||||
[ -z "$ul" ] && ul="0"
|
||||
jitter=$(_aiwanbal_val "wan${n}_jitter")
|
||||
loss=$(_aiwanbal_val "wan${n}_packet_loss")
|
||||
rsrp=$(_aiwanbal_val "wan${n}_rsrp")
|
||||
rsrq=$(_aiwanbal_val "wan${n}_rsrq")
|
||||
sinr=$(_aiwanbal_val "wan${n}_sinr")
|
||||
tech=$(_aiwanbal_val "wan${n}_technology")
|
||||
modem=$(_aiwanbal_val "wan${n}_modem_type")
|
||||
|
||||
# Carrier is not tracked separately by aiwanbal — derive from modem type
|
||||
carrier=""
|
||||
case "$modem" in
|
||||
eyeride) carrier="Eyeride" ;;
|
||||
peplink) carrier="Peplink" ;;
|
||||
zte) carrier="ZTE" ;;
|
||||
mofi) carrier="Mofi" ;;
|
||||
none|generic|"") carrier="" ;;
|
||||
*) carrier="$modem" ;;
|
||||
esac
|
||||
|
||||
local esc_tech esc_carrier
|
||||
esc_tech=$(_json_str "${tech:-unknown}")
|
||||
esc_carrier=$(_json_str "${carrier:-unknown}")
|
||||
|
||||
printf '{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s,"rsrp_dbm":%s,"rsrq_db":%s,"sinr_db":%s,"technology":"%s","carrier":"%s"}' \
|
||||
"${score:-0}" \
|
||||
"${lat:-null}" \
|
||||
"${dl:-0}" \
|
||||
"${ul:-0}" \
|
||||
"${jitter:-null}" \
|
||||
"${loss:-null}" \
|
||||
"${rsrp:-null}" \
|
||||
"${rsrq:-null}" \
|
||||
"${sinr:-null}" \
|
||||
"${esc_tech}" \
|
||||
"${esc_carrier}"
|
||||
}
|
||||
|
||||
# Build the full telemetry JSON payload from aiwanbal state files.
|
||||
# Emits JSON on stdout; never fails — uses defaults for all missing fields.
|
||||
telemetry_synology_collect() {
|
||||
local now device_id uptime_s version
|
||||
now=$(date +%s)
|
||||
device_id=$(cat /etc/busrouter/device-id 2>/dev/null || hostname)
|
||||
uptime_s=$(awk '{printf "%d", $1}' /proc/uptime 2>/dev/null)
|
||||
version=$(cat /etc/busrouter/version 2>/dev/null || echo "dev")
|
||||
|
||||
# GPS
|
||||
local gps_lat gps_lon gps_fix
|
||||
if gps_out=$(eyeride_gps 2>/dev/null); then
|
||||
read -r gps_lat gps_lon gps_fix <<-EOF
|
||||
$gps_out
|
||||
EOF
|
||||
fi
|
||||
|
||||
# WAN 1 + WAN 2
|
||||
local wan1_json wan2_json
|
||||
wan1_json=$(_synology_wan_json 1)
|
||||
wan2_json=$(_synology_wan_json 2)
|
||||
|
||||
# Active WAN — which one is currently carrying traffic
|
||||
local mode sel_primary
|
||||
mode=$(_aiwanbal_val "wan0_mode")
|
||||
case "$mode" in
|
||||
failover)
|
||||
# In failover, primary is the active one; check which is up
|
||||
local wan1_state wan2_state
|
||||
wan1_state=$(_aiwanbal_val "wan1_state")
|
||||
wan2_state=$(_aiwanbal_val "wan2_state")
|
||||
if [ "$wan2_state" = "up" ]; then
|
||||
sel_primary="wan2"
|
||||
else
|
||||
sel_primary="wan1"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Load-balance mode: both are active; wan1 is primary
|
||||
sel_primary="wan1"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Starlink (may be attached to one of the WANs)
|
||||
local sl_lat sl_dl sl_obs sl_outage
|
||||
sl_lat=$(_aiwanbal_val "wan0_starlink_latency")
|
||||
sl_dl=$(_aiwanbal_val "wan0_starlink_quality")
|
||||
sl_obs=$(_aiwanbal_val "wan0_starlink_obstruction")
|
||||
sl_outage=$(_aiwanbal_val "wan0_starlink_outage")
|
||||
|
||||
# Convert Starlink quality to bps (quality is a score 0–100, not bps)
|
||||
# Store quality as-is; the hub can interpret it
|
||||
local sl_quality
|
||||
sl_quality="${sl_dl:-0}"
|
||||
|
||||
local esc_id esc_ver esc_sel
|
||||
esc_id=$(_json_str "$device_id")
|
||||
esc_ver=$(_json_str "$version")
|
||||
esc_sel=$(_json_str "$sel_primary")
|
||||
|
||||
printf '{"device_id":"%s","timestamp":%d,"uptime":%d,"version":"%s","platform":"synology","sel_primary":"%s","gps":{"lat":%s,"lon":%s,"fix":%s},"wan":{"wan1":%s,"wan2":%s},"starlink":{"quality":%s,"latency_ms":%s,"obstructed":%s,"outage":%s}}\n' \
|
||||
"${esc_id}" "${now:-0}" "${uptime_s:-0}" "${esc_ver}" "${esc_sel}" \
|
||||
"${gps_lat:-null}" "${gps_lon:-null}" "${gps_fix:-null}" \
|
||||
"${wan1_json}" "${wan2_json}" \
|
||||
"${sl_quality:-0}" "${sl_lat:-null}" "${sl_obs:-0}" "${sl_outage:-0}"
|
||||
}
|
||||
|
||||
# ── Hub POST + Buffering ─────────────────────────────────────────────────────────
|
||||
|
||||
# POST a JSON file to the hub. Returns 0 on HTTP 2xx, 1 on any failure.
|
||||
_telemetry_try_post() {
|
||||
local file="$1"
|
||||
local code
|
||||
code=$(curl -sf -X POST -H "Content-Type: application/json" \
|
||||
--data-binary @"$file" -o /dev/null -w "%{http_code}" \
|
||||
--max-time 10 "$TELEMETRY_HUB" 2>/dev/null)
|
||||
case "$code" in 2*) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# Retry all buffered payloads in timestamp order.
|
||||
# Stops on first failure to avoid hammering a down hub.
|
||||
telemetry_synology_flush() {
|
||||
[ -d "$TELEMETRY_BUFFER_DIR" ] || return 0
|
||||
local f
|
||||
for f in "$TELEMETRY_BUFFER_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
if _telemetry_try_post "$f"; then
|
||||
rm -f "$f"
|
||||
logger -t busrouter -p daemon.debug "telemetry_synology_flush: sent $(basename "$f")"
|
||||
else
|
||||
logger -t busrouter "telemetry_synology_flush: hub unreachable, stopping"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Collect metrics, flush old buffer, POST current payload.
|
||||
# On POST failure: saves payload to TELEMETRY_BUFFER_DIR.
|
||||
# Never blocks the caller on hub downtime.
|
||||
telemetry_synology_send() {
|
||||
local payload
|
||||
payload=$(telemetry_synology_collect)
|
||||
|
||||
telemetry_synology_flush 2>/dev/null || true
|
||||
|
||||
mkdir -p "$TELEMETRY_BUFFER_DIR"
|
||||
_TELEM_SEQ=$(( _TELEM_SEQ + 1 ))
|
||||
local tmpfile="${TELEMETRY_BUFFER_DIR}/$(date +%s)_$$_${_TELEM_SEQ}.json"
|
||||
# Cap buffer to 60 files to protect tmpfs
|
||||
local buf_count
|
||||
buf_count=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | wc -l)
|
||||
if [ "${buf_count:-0}" -ge 60 ]; then
|
||||
local oldest
|
||||
oldest=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | sort | head -n 1)
|
||||
[ -f "$oldest" ] && rm -f "$oldest"
|
||||
fi
|
||||
printf '%s\n' "$payload" > "$tmpfile"
|
||||
|
||||
if _telemetry_try_post "$tmpfile"; then
|
||||
rm -f "$tmpfile"
|
||||
logger -t busrouter -p daemon.debug "telemetry_synology_send: posted ok"
|
||||
else
|
||||
logger -t busrouter "telemetry_synology_send: hub unreachable, buffered $(basename "$tmpfile")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Entry Point ──────────────────────────────────────────────────────────────────
|
||||
# When executed directly (e.g. from cron), run a single telemetry cycle.
|
||||
case "${0##*/}" in
|
||||
telemetry-synology.sh|telemetry-synology)
|
||||
telemetry_synology_send
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user