#!/bin/sh # syno-daemon.sh — Synology bus router WAN balancing daemon. # Rides on top of SmartWAN (does NOT own routing or health checks). # Uses the same scoring engine as GL kit-busrouter (lib-score.sh + lib-decide.sh). # # Cycle (every CHECK_INTERVAL seconds): # 1. Read SmartWAN state (interfaces, current weights, gateways) # 2. Measure WAN1 / WAN2 latency, jitter, packet loss # 3. Read Eyeride signal strength (if available) # 4. Read Starlink status (if available) # 5. Score each WAN (lib-score.sh 6-factor composite) # 6. Compute target weights (lib-decide.sh) # 7. Write weights to SmartWAN if change >= 10 (smartwan-adapter.sh) # 8. Send telemetry to fleet hub (if hub is reachable) # # NEVER calls loadbalance. NEVER flushes conntrack. set -e # ── Paths ────────────────────────────────────────────────────────── PKG_DIR="${PKG_DIR:-/var/packages/syno-balance/target}" LIB_DIR="${LIB_DIR:-${PKG_DIR}/bin}" CONF="/etc/syno-balance/syno-balance.conf" STATE_DIR="/tmp/syno-balance" PID_FILE="$PKG_DIR/var/syno-balance.pid" LOG_TAG="syno-balance" # ── Source libs (same scoring engine as GL) ──────────────────────── . "${LIB_DIR}/lib-score.sh" . "${LIB_DIR}/lib-decide.sh" . "${LIB_DIR}/smartwan-adapter.sh" mkdir -p "$STATE_DIR" echo "$$" > "$PID_FILE" log() { logger -t "$LOG_TAG" -p local0.warn "syno-balance[$(cat /etc/busrouter/device-id 2>/dev/null || hostname)]: $*"; } warn() { logger -t "$LOG_TAG" -p local0.warn "syno-balance[$(cat /etc/busrouter/device-id 2>/dev/null || hostname)]: WARNING: $*"; } # ── Config defaults ──────────────────────────────────────────────── load_config() { [ -f "$CONF" ] && . "$CONF" : "${CHECK_INTERVAL:=30}" : "${WAN1_IFACE:=eth0}" : "${WAN2_IFACE:=eth2}" : "${SPEEDTEST_AUTO_ENABLED:=0}" : "${EYERIDE_ENABLE:=0}" : "${EYERIDE_IP:=192.168.10.1}" : "${EYERIDE_PORT:=8080}" : "${EYERIDE_USER:=root}" : "${STARLINK_ENABLE:=0}" : "${STARLINK_IP:=192.168.100.1}" : "${STARLINK_PORT:=9200}" : "${PING_TARGETS:=1.1.1.2 9.9.9.9 8.8.8.8}" : "${PING_COUNT:=5}" : "${WEIGHT_MIN:=20}" : "${WEIGHT_MAX:=80}" : "${ROLLING_SAMPLES:=3}" : "${TELEMETRY_HUB:=http://167.172.237.162:8080/api/telemetry}" : "${TELEMETRY_BUFFER_DIR:=/tmp/syno-balance/telemetry-buf}" DEVICE_ID=$(cat /etc/busrouter/device-id 2>/dev/null || hostname 2>/dev/null || echo "unknown") } # ── Measure WAN metrics ──────────────────────────────────────────── measure_wan() { local iface="$1" local gw="$2" local outfile="$STATE_DIR/metric_${iface}" # Latency + jitter + loss via ping (bound to interface) local ping_out="" ping_out=$(ping -I "$iface" -c "$PING_COUNT" -q "${PING_TARGETS%% *}" 2>/dev/null) || true local lat="" loss="" jitter="" if [ -n "$ping_out" ]; then # rtt min/avg/max/mdev lat=$(echo "$ping_out" | awk -F'[/ ]' '/^rtt/ {printf "%.1f", $8}') jitter=$(echo "$ping_out" | awk -F'[/ ]' '/^rtt/ {printf "%.1f", $11}') loss=$(echo "$ping_out" | grep -o '[0-9.]*%' | head -1 | tr -d '%') fi echo "lat=${lat:-0}" > "$outfile" echo "jitter=${jitter:-0}" >> "$outfile" echo "loss=${loss:-0}" >> "$outfile" echo "gw=${gw:-none}" >> "$outfile" echo "ts=$(date +%s)" >> "$outfile" } # ── Read Eyeride signal via ubus (if available) ──────────────────── read_eyeride_signal() { [ "$EYERIDE_ENABLE" != "1" ] && return 1 [ -z "$EYERIDE_PASSWORD" ] && return 1 # ubus call over SSH to Eyeride (OpenWrt device) local sig="" sig=$(sshpass -p "$EYERIDE_PASSWORD" ssh -o StrictHostKeyChecking=no \ -o ConnectTimeout=5 "${EYERIDE_USER}@${EYERIDE_IP}" -p "${EYERIDE_PORT}" \ "ubus call network.interface.wwan status 2>/dev/null | grep -o '\"signal_strength\":[0-9-]*' | cut -d: -f2" 2>/dev/null) || true if [ -n "$sig" ]; then echo "$sig" > "$STATE_DIR/signal_eyeride" return 0 fi return 1 } # ── Read Starlink status (gRPC or HTTP fallback) ─────────────────── read_starlink_status() { [ "$STARLINK_ENABLE" != "1" ] && return 1 # HTTP probe to Starlink dish local status="" status=$(curl -s --connect-timeout 3 "http://${STARLINK_IP}:${STARLINK_PORT}/status" 2>/dev/null) || true if [ -n "$status" ]; then echo "$status" > "$STATE_DIR/starlink_status" return 0 fi return 1 } # ── Score both WANs ──────────────────────────────────────────────── score_wans() { local w1_lat=0 w1_jit=0 w1_loss=0 w1_sig="" local w2_lat=0 w2_jit=0 w2_loss=0 w2_sig="" # Read WAN1 metrics if [ -f "$STATE_DIR/metric_${WAN1_IFACE}" ]; then . "$STATE_DIR/metric_${WAN1_IFACE}" w1_lat="${lat:-0}"; w1_jit="${jitter:-0}"; w1_loss="${loss:-0}" fi # Read WAN2 metrics if [ -f "$STATE_DIR/metric_${WAN2_IFACE}" ]; then . "$STATE_DIR/metric_${WAN2_IFACE}" w2_lat="${lat:-0}"; w2_jit="${jitter:-0}"; w2_loss="${loss:-0}" fi # Read Eyeride signal for WAN1 if [ -f "$STATE_DIR/signal_eyeride" ]; then w1_sig=$(cat "$STATE_DIR/signal_eyeride" 2>/dev/null) fi # Compute scores (same algorithm as GL) local score1=0 score2=0 # WAN1: latency, fake-dl=10, fake-ul=5, jitter, loss, signal score1=$(score_composite "$w1_lat" "10" "5" "$w1_jit" "$w1_loss" "$w1_sig") # WAN2: latency, fake-dl=10, fake-ul=5, jitter, loss, no signal score2=$(score_composite "$w2_lat" "10" "5" "$w2_jit" "$w2_loss" "") # Rolling average for stability local avg1="$score1" avg2="$score2" if [ "$ROLLING_SAMPLES" -gt 1 ]; then # Store last N scores echo "$score1" >> "$STATE_DIR/score_history_${WAN1_IFACE}" echo "$score2" >> "$STATE_DIR/score_history_${WAN2_IFACE}" tail -n "$ROLLING_SAMPLES" "$STATE_DIR/score_history_${WAN1_IFACE}" > "$STATE_DIR/score_roll_1" tail -n "$ROLLING_SAMPLES" "$STATE_DIR/score_history_${WAN2_IFACE}" > "$STATE_DIR/score_roll_2" # Trim if [ "$(wc -l < "$STATE_DIR/score_history_${WAN1_IFACE}")" -gt 10 ]; then tail -n 10 "$STATE_DIR/score_history_${WAN1_IFACE}" > "${STATE_DIR}/score_history_${WAN1_IFACE}.tmp" mv "${STATE_DIR}/score_history_${WAN1_IFACE}.tmp" "$STATE_DIR/score_history_${WAN1_IFACE}" fi avg1=$(awk '{s+=$1}END{printf "%d",(s/NR+0.5)}' "$STATE_DIR/score_roll_1" 2>/dev/null || echo "$score1") avg2=$(awk '{s+=$1}END{printf "%d",(s/NR+0.5)}' "$STATE_DIR/score_roll_2" 2>/dev/null || echo "$score2") fi echo "$avg1" > "$STATE_DIR/score_${WAN1_IFACE}" echo "$avg2" > "$STATE_DIR/score_${WAN2_IFACE}" log "scores: ${WAN1_IFACE}=${avg1} ${WAN2_IFACE}=${avg2}" } # ── Apply weights to SmartWAN ────────────────────────────────────── apply_weights() { local s1=$(cat "$STATE_DIR/score_${WAN1_IFACE}" 2>/dev/null || echo "50") local s2=$(cat "$STATE_DIR/score_${WAN2_IFACE}" 2>/dev/null || echo "50") # WAN1 weight = 100 - WAN2 weight (SmartWAN dw_weight_ratio is WAN1 share) # Our wan_weight gives WAN1 share integer 20-80 local w1=$(wan_weight "$s1" "$s2") # Clamp to configured range [ "$w1" -lt "$WEIGHT_MIN" ] && w1="$WEIGHT_MIN" [ "$w1" -gt "$WEIGHT_MAX" ] && w1="$WEIGHT_MAX" sw_write_weights "$w1" } # ── Main loop ────────────────────────────────────────────────────── log "EVENT=DAEMON_START device=$DEVICE_ID version=${SYNO_BALANCE_VERSION:-0.0}" load_config # Verify SmartWAN is in load-balance mode if ! sw_is_lb_mode; then warn "SmartWAN is not in load-balance mode. Set Load Balancing in Network Center." # Don't exit — router may transition to LB mode later fi _cycle=0 while true; do _cycle=$((_cycle + 1)) # Read SmartWAN state if ! sw_read_state >/dev/null 2>&1; then warn "cannot read SmartWAN state — waiting for interface config" sleep "$CHECK_INTERVAL" continue fi # Measure both WANs measure_wan "$WAN1_IFACE" "$(ip route show dev "$WAN1_IFACE" 2>/dev/null | grep default | awk '{print $3}' | head -1)" measure_wan "$WAN2_IFACE" "$(ip route show dev "$WAN2_IFACE" 2>/dev/null | grep default | awk '{print $3}' | head -1)" # Optional: Eyeride signal, Starlink status read_eyeride_signal 2>/dev/null || true read_starlink_status 2>/dev/null || true # Score + apply score_wans apply_weights # Save cycle state for telemetry echo "$_cycle" > "$STATE_DIR/cycle" sleep "$CHECK_INTERVAL" done