#!/bin/sh # telemetry-synology.sh — Self-contained telemetry for Synology SRM routers. # # Synology RT2600ac routers have NO built-in 5G modem or GPS. Cellular comes # from an external Eyeride device (192.168.10.1:8080) which provides GPS and # signal metrics via OpenWrt ubus JSON-RPC. # # GPS fallback chain: # 1. Eyeride ubus JSON-RPC (gps / location service) → fix=2 or 3 # 2. ip-api.com geolocation by external IP → fix=1 (approximate) # # WAN mapping (canonical GL format): # WAN1 (Ethernet/primary uplink) → modem_0001 # WAN2 (Eyeride cellular) → wan (with rsrp_dbm/rsrq_db/sinr_db) # # Self-collected metrics (no aiwanbal dependency): # - Ping latency/loss per interface (using -I ) # - Throughput from /sys/class/net//statistics/ (bytes delta between cycles) # - Score computed from latency + loss (simple heuristic) # - Eyeride cellular signal via ubus (if available) # # Config (/etc/busrouter/telemetry.conf, sourced as shell): # 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) # EYERIDE_IP Eyeride OpenWrt IP (default: 192.168.10.1) # EYERIDE_PORT Eyeride ubus port (default: 8080) # EYERIDE_USER Eyeride ubus username (default: benmashborn) # EYERIDE_PASSWORD Eyeride password (required for GPS + signal) # WAN1_IFACE Primary WAN interface (default: eth0) # WAN2_IFACE Secondary/cellular WAN interface (default: eth2) # PING_TARGET Host to ping for latency/loss (default: 8.8.8.8) # STATE_DIR Runtime state directory (default: /tmp/busrouter) # # Public API: # eyeride_gps echo 'lat lon fix'; exit 1 on failure # ipgeo_gps echo 'lat lon fix'; exit 1 on failure # gps_fix try eyeride_gps, fall back to ipgeo_gps # telemetry_collect emit JSON payload to stdout (canonical GL format) # telemetry_flush retry all buffered payloads; return 1 if hub still down # telemetry_send collect + flush + post; buffer on failure # # Cron mode: runs telemetry_send when executed directly. # ── Configuration ────────────────────────────────────────────────────────── : "${TELEMETRY_HUB:=http://10.88.0.1:8080/api/telemetry}" : "${TELEMETRY_BUFFER_DIR:=/tmp/busrouter/telemetry-buf}" : "${EYERIDE_IP:=192.168.10.1}" : "${EYERIDE_PORT:=8080}" : "${EYERIDE_USER:=benmashborn}" : "${WAN1_IFACE:=eth0}" : "${WAN2_IFACE:=eth2}" : "${PING_TARGET:=8.8.8.8}" : "${STATE_DIR:=/tmp/busrouter}" # Source per-device overrides if present [ -f /etc/busrouter/telemetry.conf ] && . /etc/busrouter/telemetry.conf _TELEM_SEQ=0 # ── Helpers ──────────────────────────────────────────────────────────────── # Escape a string for JSON (backslash + double-quote only). _json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; } # BusyBox-compatible float arithmetic via awk. _awk_float() { awk "BEGIN { printf \"%.1f\", $1 }" 2>/dev/null || echo "null"; } # ── GPS: Eyeride ubus JSON-RPC ───────────────────────────────────────────── # Authenticate to Eyeride ubus, return session token on stdout. _eyeride_login() { _pass="${EYERIDE_PASSWORD:-}" [ -n "$_pass" ] || return 1 _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. _eyeride_ubus_call() { _session="$1" _svc="$2" _method="$3" _params="${4:-{}}" _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 } # Extract a numeric field from JSON (handles both integer and float). _json_get_num() { echo "$1" | sed -n "s/.*\"$2\": *\(-\?[0-9.]*\).*/\1/p" | head -1 } # Extract a string field from JSON. _json_get_str() { echo "$1" | sed -n "s/.*\"$2\": *\"\([^\"]*\)\".*/\1/p" | head -1 } # Query GPS from Eyeride ubus. Emits 'lat lon fix'; exit 1 on failure. eyeride_gps() { _session=$(_eyeride_login) || return 1 # Try 'gps' service first, then 'location' service _resp=$(_eyeride_ubus_call "$_session" "gps" "status") 2>/dev/null if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then _resp=$(_eyeride_ubus_call "$_session" "location" "status") 2>/dev/null fi # Extract lat/lon from various possible field names _lat=$(_json_get_num "$_resp" "latitude") _lon=$(_json_get_num "$_resp" "longitude") [ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat") [ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon") [ -z "$_lat" ] && _lat=$(_json_get_num "$_resp" "lat_deg") [ -z "$_lon" ] && _lon=$(_json_get_num "$_resp" "lon_deg") if [ -n "$_lat" ] && [ -n "$_lon" ]; then _fix=2 # assume 2D fix from Eyeride 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 } # ── GPS: IP Geolocation Fallback ─────────────────────────────────────────── # Query ip-api.com for approximate location. Emits 'lat lon fix'; exit 1 on failure. ipgeo_gps() { _resp=$(curl -s --connect-timeout 5 "http://ip-api.com/json/" 2>/dev/null | tr -d '\n\r') || return 1 [ -n "$_resp" ] || return 1 _lat=$(_json_get_num "$_resp" "lat") _lon=$(_json_get_num "$_resp" "lon") if [ -n "$_lat" ] && [ -n "$_lon" ]; then _fix=1 # approximate (IP-based) logger -t busrouter -p daemon.debug "ipgeo_gps: ${_lat} ${_lon} fix=${_fix} (IP geolocation)" printf '%s %s %s\n' "$_lat" "$_lon" "$_fix" return 0 fi logger -t busrouter "ipgeo_gps: geolocation failed" return 1 } # GPS with fallback: try Eyeride first, then IP geolocation. gps_fix() { if eyeride_gps 2>/dev/null; then return 0 fi ipgeo_gps } # ── Eyeride Cellular Signal ──────────────────────────────────────────────── # Query cellular signal from Eyeride ubus (network/wwan services). # Returns: rsrp_dbm rsrq_db sinr_db technology carrier_str _eyeride_signal() { _session=$(_eyeride_login) 2>/dev/null || return 1 # Try network.wan status for modem info _resp=$(_eyeride_ubus_call "$_session" "network" "status") 2>/dev/null if [ -z "$_resp" ] || echo "$_resp" | grep -q '"result":\[2\]'; then # Try alternative: wwan or modem service _resp=$(_eyeride_ubus_call "$_session" "wwan" "status") 2>/dev/null fi _rsrp=$(_json_get_num "$_resp" "rsrp") _rsrq=$(_json_get_num "$_resp" "rsrq") _sinr=$(_json_get_num "$_resp" "sinr") _tech=$(_json_get_str "$_resp" "technology") _carrier=$(_json_get_str "$_resp" "operator") # Also try signal-specific fields [ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "signal") [ -z "$_rsrp" ] && _rsrp=$(_json_get_num "$_resp" "rssi") printf '%s %s %s %s %s\n' "${_rsrp:-null}" "${_rsrq:-null}" "${_sinr:-null}" "${_tech:-unknown}" "${_carrier:-Eyeride}" return 0 } # ── WAN Metric Collection ────────────────────────────────────────────────── # Ping an interface to get latency and loss. # On Synology SRM: ping requires root. When running from cron (as root), it works. # When run manually as non-root, gracefully returns null. # NOTE: ping returns exit 0 on success, 1 on any packet loss, 2 on error. # We use sudo first; if sudo ping exits 2 (real error), fall back to direct ping. # Exit 1 (packet loss) is a valid result — do NOT fall back on it. # Usage: _ping_iface → "lat_ms loss_pct" _ping_iface() { _iface="$1" # Try sudo ping first. Only fall back to direct ping if sudo fails (exit 2+). _out=$(sudo ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null) _rc=$? if [ $_rc -ge 2 ]; then _out=$(ping -I "$_iface" -c 3 -W 3 "$PING_TARGET" 2>/dev/null) fi if [ -z "$_out" ]; then echo "null null" return fi # Check for permission denied / other errors if echo "$_out" | grep -qE "permission denied|not permitted|unknown host|Network is unreachable"; then echo "null null" return fi # Extract avg rtt from last line. # Standard ping: "rtt min/avg/max/mdev = 10.5/15.2/20.1/3.5 ms" # BusyBox ping: "round-trip min/avg/max = 10.5/15.2/20.1 ms" _avg=$(echo "$_out" | tail -1 | sed -n 's/.*=\ [0-9.]*\/\([0-9.]*\)\/.*/\1/p') [ -z "$_avg" ] && _avg=$(echo "$_out" | tail -1 | sed -n 's/.*avg\/max.*=\ \([0-9.]*\)\/.*/\1/p') [ -z "$_avg" ] && _avg="null" # Extract loss percentage: "3 packets transmitted, 3 received, 0% packet loss" _loss=$(echo "$_out" | sed -n 's/.* \([0-9]*\)% packet loss.*/\1/p') [ -z "$_loss" ] && _loss="null" printf '%s %s\n' "${_avg}" "${_loss}" | tr -d '\n\r' } # Compute simple WAN score from latency and loss (0-100, higher = better). # Score = max(0, 100 - latency_ms/2 - loss_pct*5) _compute_score() { _lat="$1" _loss="$2" if [ "$_lat" = "null" ] || [ "$_loss" = "null" ]; then echo "0" return fi _score=$(awk "BEGIN { s = 100 - ${_lat}/2 - ${_loss}*5; if (s < 0) s = 0; if (s > 100) s = 100; printf \"%d\", int(s) }" 2>/dev/null) echo "${_score:-0}" } # Read bytes transferred from /sys/class/net//statistics/. # Returns delta from last reading stored in STATE_DIR. _read_bytes_delta() { _iface="$1" _direction="$2" # tx_bytes or rx_bytes _stat_file="/sys/class/net/${_iface}/statistics/${_direction}" _state_file="${STATE_DIR}/bytes_${_iface}_${_direction}" [ -f "$_stat_file" ] || { echo "null"; return; } mkdir -p "$STATE_DIR" 2>/dev/null _current=$(cat "$_stat_file" 2>/dev/null) _prev=$(cat "$_state_file" 2>/dev/null) printf '%s\n' "$_current" > "$_state_file" 2>/dev/null || true if [ -z "$_prev" ] || [ -z "$_current" ]; then echo "null"; return fi _delta=$(( _current - _prev )) if [ "$_delta" -lt 0 ]; then _delta=0 # counter reset fi # Convert bytes to Mbps (bytes → Mb/s, assume ~60s interval) _mbps=$(awk "BEGIN { printf \"%.1f\", ${_delta} * 8 / 60 / 1000000 }" 2>/dev/null) echo "${_mbps:-0.0}" } # Collect metrics for one WAN interface. # Usage: _collect_wan → JSON object fragment _collect_wan() { _iface="$1" # Ping metrics _ping_result=$(_ping_iface "$_iface") _lat=$(echo "$_ping_result" | awk '{print $1}') _loss=$(echo "$_ping_result" | awk '{print $2}') # Throughput (delta since last cycle) _dl=$(_read_bytes_delta "$_iface" "rx_bytes") _ul=$(_read_bytes_delta "$_iface" "tx_bytes") # Score _score=$(_compute_score "$_lat" "$_loss") # Jitter — rough estimate from ping variation (not true jitter, but indicative) _jitter="null" printf '{"score":%s,"latency_ms":%s,"dl_mbps":%s,"ul_mbps":%s,"jitter_ms":%s,"loss_pct":%s}' \ "${_score}" "${_lat}" "${_dl}" "${_ul}" "${_jitter}" "${_loss}" } # ── Telemetry Collection ─────────────────────────────────────────────────── # Detect which interface is the Eyeride cellular link. # The Eyeride runs on 192.168.10.x subnet; the interface with an IP in that # range is the cellular path. The other WAN interface is the primary/fiber uplink. # Returns: cellular_iface ethernet_iface _detect_wan_roles() { # Check if WAN2 is on the Eyeride subnet _wan2_ip=$(ip addr show "$WAN2_IFACE" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) if echo "$_wan2_ip" | grep -q '^192\.168\.10\.'; then echo "$WAN2_IFACE $WAN1_IFACE" # WAN2 = cellular, WAN1 = ethernet else # Check if WAN1 is on the Eyeride subnet _wan1_ip=$(ip addr show "$WAN1_IFACE" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) if echo "$_wan1_ip" | grep -q '^192\.168\.10\.'; then echo "$WAN1_IFACE $WAN2_IFACE" # WAN1 = cellular, WAN2 = ethernet else # Default: WAN2 is cellular (Eyeride typically on secondary port) echo "$WAN2_IFACE $WAN1_IFACE" fi fi } # Derive carrier name for an interface. Uses reverse DNS on gateway or ISP detection. # For the Eyeride (192.168.10.x), the carrier comes from the Eyeride's cellular operator. _detect_carrier() { _iface="$1" _gw=$(ip route show default 2>/dev/null | grep "$_iface" | awk '{print $3}' | head -1) # Check if this is the Eyeride network _ip=$(ip addr show "$_iface" 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1) if echo "$_ip" | grep -q '^192\.168\.10\.'; then # This is the Eyeride — carrier comes from the Eyeride's cellular operator _sig=$(_eyeride_signal 2>/dev/null) _sig_carrier=$(echo "$_sig" | awk '{print $5}') if [ -n "$_sig_carrier" ] && [ "$_sig_carrier" != "null" ] && [ "$_sig_carrier" != "Eyeride" ]; then echo "$_sig_carrier"; return fi echo "Eyeride"; return fi # For the primary/ethernet interface, try to detect from gateway if [ -n "$_gw" ]; then # Common AT&T gateways if echo "$_gw" | grep -qE '^192\.168\.[01]\.'; then echo "AT&T"; return fi # Common Spectrum/Charter if echo "$_gw" | grep -qE '^192\.168\.[0-9]+\.'; then _rev=$(nslookup "$_gw" 2>/dev/null | grep -i name | head -1 | sed 's/.*= //') if echo "$_rev" | grep -qi 'att\|sbcglobal\|bellsouth'; then echo "AT&T"; return fi fi fi # Fallback echo "" } # Add carrier and identity fields to a WAN JSON object. # Usage: _add_wan_identity _add_wan_identity() { _json="$1" _iface="$2" _carrier=$(_detect_carrier "$_iface") _esc_carrier=$(_json_str "${_carrier}") # Strip closing brace, add carrier, re-close printf '%s' "$_json" | sed 's/}$//' if [ -n "$_carrier" ]; then printf ',"carrier":"%s"}' "${_esc_carrier}" else printf '}' fi } # Build the full telemetry JSON payload (canonical GL format). # Emits JSON to stdout; never fails — uses defaults for missing fields. telemetry_collect() { 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 (Eyeride → IP geolocation fallback) gps_lat="null" gps_lon="null" gps_fix="null" if gps_out=$(gps_fix 2>/dev/null); then gps_lat=$(echo "$gps_out" | awk '{print $1}') gps_lon=$(echo "$gps_out" | awk '{print $2}') gps_fix=$(echo "$gps_out" | awk '{print $3}') fi # Auto-detect which interface is cellular vs ethernet/fiber _cell_iface="" _eth_iface="" if _roles=$(_detect_wan_roles 2>/dev/null); then _cell_iface=$(echo "$_roles" | awk '{print $1}') _eth_iface=$(echo "$_roles" | awk '{print $2}') else _cell_iface="$WAN2_IFACE" _eth_iface="$WAN1_IFACE" fi # Collect metrics for both WANs _eth_json=$(_collect_wan "$_eth_iface") _cell_json=$(_collect_wan "$_cell_iface") # Add carrier identity to each WAN _eth_json=$(_add_wan_identity "$_eth_json" "$_eth_iface") _cell_json=$(_add_wan_identity "$_cell_json" "$_cell_iface") # Eyeride cellular signal for the cellular interface eyeride_sig=$(_eyeride_signal 2>/dev/null) _rsrp=$(echo "$eyeride_sig" | awk '{print $1}') _rsrq=$(echo "$eyeride_sig" | awk '{print $2}') _sinr=$(echo "$eyeride_sig" | awk '{print $3}') _tech=$(echo "$eyeride_sig" | awk '{print $4}') # Add cellular signal fields to the cellular WAN JSON if echo "$_cell_json" | grep -q '"loss_pct"'; then _cell_json=$(printf '%s' "$_cell_json" | sed 's/}$//') if [ "$_rsrp" != "null" ] && [ -n "$_rsrp" ]; then _esc_tech=$(_json_str "${_tech:-unknown}") _cell_json="${_cell_json},\"rsrp_dbm\":${_rsrp},\"rsrq_db\":${_rsrq},\"sinr_db\":${_sinr},\"technology\":\"${_esc_tech}\"}" else _cell_json="${_cell_json}}" fi fi # WAN mapping in canonical GL format: # modem_0001 = cellular (Eyeride 5G) # wan = ethernet/fiber (AT&T Uverse) _wan_modem="$WAN2_IFACE" # which physical iface maps to modem_0001 _wan_ether="$WAN1_IFACE" # which physical iface maps to wan # Check if roles were swapped by auto-detection if [ "$_cell_iface" != "$WAN2_IFACE" ]; then # WAN1 is cellular, WAN2 is ethernet _modem_json="$_cell_json" _ether_json="$_eth_json" else # WAN2 is cellular, WAN1 is ethernet (normal) _modem_json="$_cell_json" _ether_json="$_eth_json" fi # Active WAN: determine from routing table (default route interface) sel_primary="wan" # default: fiber/ethernet is primary default_iface=$(ip route show default 2>/dev/null | awk '{print $5}' | head -1) if [ "$default_iface" = "$_cell_iface" ]; then sel_primary="modem_0001" # cellular is the active path fi # Starlink — detect if present sl_lat="null" sl_dl="null" sl_obs="0" sl_outage="0" if ip link show 2>/dev/null | grep -qi starlink; then sl_iface=$(ip link show 2>/dev/null | grep -i starlink | head -1 | awk -F": " '{print $2}' | awk '{print $1}') if [ -n "$sl_iface" ]; then sl_ping=$(_ping_iface "$sl_iface") sl_lat=$(echo "$sl_ping" | awk '{print $1}') sl_dl=$(_read_bytes_delta "$sl_iface" "rx_bytes") fi fi # State — autonomous indicator (always true for fleet routers) state='{"autonomous":true,"qos":{},"wanhealth":{}}' 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","active_wan":"%s","gps":{"lat":%s,"lon":%s,"fix":%s},"wan":{"modem_0001":%s,"wan":%s},"sim_slot":null,"starlink":{"latency_ms":%s,"dl_bps":%s,"obstructed":%s,"outage":%s},"state":%s}\n' \ "${esc_id}" "${now:-0}" "${uptime_s:-0}" "${esc_ver}" "${esc_sel}" "${esc_sel}" \ "${gps_lat}" "${gps_lon}" "${gps_fix}" \ "${_modem_json}" "${_ether_json}" \ "${sl_lat}" "${sl_dl}" "${sl_obs}" "${sl_outage}" \ "${state}" } # ── Hub POST + Buffering ─────────────────────────────────────────────────── # POST a JSON file to the hub. Returns 0 on HTTP 2xx, 1 on any failure. _telemetry_try_post() { file="$1" 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_flush() { [ -d "$TELEMETRY_BUFFER_DIR" ] || return 0 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_flush: sent $(basename "$f")" else logger -t busrouter "telemetry_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_send() { mkdir -p "$STATE_DIR" 2>/dev/null payload=$(telemetry_collect) telemetry_flush 2>/dev/null || true mkdir -p "$TELEMETRY_BUFFER_DIR" 2>/dev/null _TELEM_SEQ=$(( _TELEM_SEQ + 1 )) tmpfile="${TELEMETRY_BUFFER_DIR}/$(date +%s)_$$_${_TELEM_SEQ}.json" # Cap buffer to 60 files to protect tmpfs buf_count=$(ls "$TELEMETRY_BUFFER_DIR"/*.json 2>/dev/null | wc -l) if [ "${buf_count:-0}" -ge 60 ]; then 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_send: posted ok" else logger -t busrouter "telemetry_send: hub unreachable, buffered $(basename "$tmpfile")" fi } # ── Entry Point ──────────────────────────────────────────────────────────── case "${0##*/}" in telemetry-synology.sh|telemetry-synology) telemetry_send ;; esac