Files
kit-busrouter/syno-balance/bin/syno-daemon.sh
T
allen f15ac69925 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>
2026-07-22 00:07:14 +00:00

227 lines
9.0 KiB
Bash
Executable File

#!/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