From f96a455dc83f36e215c8c3b1c51f594d87be9473 Mon Sep 17 00:00:00 2001 From: Parahub AI Date: Tue, 10 Feb 2026 20:28:26 +0000 Subject: [PATCH] feat: Dynamic tunnel IP from cloud heartbeat for multi-bumblebee support vpn-tunnel reads IP from /etc/parahub/tunnel_ip instead of hardcoded 172.16.0.2. On first boot, calls heartbeat synchronously to get assignment. Heartbeat parses tunnel_ip from response and restarts vpn-tunnel on change. Co-Authored-By: Claude Opus 4.6 --- files/etc/init.d/parahub-vpn-tunnel | 24 +++++++++++++++++++++--- files/usr/bin/parahub-heartbeat | 25 ++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/files/etc/init.d/parahub-vpn-tunnel b/files/etc/init.d/parahub-vpn-tunnel index 7c075e0..2aab66e 100755 --- a/files/etc/init.d/parahub-vpn-tunnel +++ b/files/etc/init.d/parahub-vpn-tunnel @@ -3,6 +3,9 @@ # Runs after yggdrasil (START=95). OpenWrt 25.x lacks the netifd grev6 protocol # handler, so we create the tunnel manually with ip6gre. # +# Tunnel IP is assigned by Django and stored in /etc/parahub/tunnel_ip. +# If the file doesn't exist (first boot), heartbeat is called synchronously to get it. +# # IMPORTANT: encaplimit must be "none" — Yggdrasil drops IPv6 packets with # Destination Options extension headers (added by default encaplimit 4). @@ -10,8 +13,8 @@ START=96 STOP=10 VPS_YGG="200:39f1:6a26:328a:d901:fbd2:d30d:faef" -GRE_LOCAL_IP="172.16.0.2" GRE_GATEWAY="172.16.0.1" +TUNNEL_IP_FILE="/etc/parahub/tunnel_ip" start() { # Only for Bumblebee role @@ -31,12 +34,27 @@ start() { return 1 fi + # Get tunnel IP — from file, or request via heartbeat on first boot + local gre_local_ip + gre_local_ip=$(cat "$TUNNEL_IP_FILE" 2>/dev/null) + + if [ -z "$gre_local_ip" ]; then + logger -t parahub-vpn "No tunnel IP file, calling heartbeat to get assignment..." + HEARTBEAT_CURL_TIMEOUT=30 /usr/bin/parahub-heartbeat + gre_local_ip=$(cat "$TUNNEL_IP_FILE" 2>/dev/null) + fi + + if [ -z "$gre_local_ip" ]; then + logger -t parahub-vpn "Failed to get tunnel IP from heartbeat, falling back to 172.16.0.2" + gre_local_ip="172.16.0.2" + fi + # Create GRE6 tunnel (encaplimit none — critical for Yggdrasil compatibility) ip -6 tunnel add gre6-vpn mode ip6gre \ remote "$VPS_YGG" \ local "$ygg_addr" \ encaplimit none - ip addr add ${GRE_LOCAL_IP}/24 dev gre6-vpn + ip addr add ${gre_local_ip}/24 dev gre6-vpn ip link set gre6-vpn mtu 1400 up # Default route through GRE (table 100 — used by guest policy routing) @@ -69,7 +87,7 @@ start() { # Reload firewall so vpn_tunnel zone picks up gre6-vpn device /etc/init.d/firewall reload 2>/dev/null & - logger -t parahub-vpn "GRE6 tunnel up: ${GRE_LOCAL_IP} → ${VPS_YGG} via $ygg_addr (encaplimit none)" + logger -t parahub-vpn "GRE6 tunnel up: ${gre_local_ip} → ${VPS_YGG} via $ygg_addr (encaplimit none)" } stop() { diff --git a/files/usr/bin/parahub-heartbeat b/files/usr/bin/parahub-heartbeat index e6b647f..a0611da 100755 --- a/files/usr/bin/parahub-heartbeat +++ b/files/usr/bin/parahub-heartbeat @@ -37,9 +37,12 @@ PAYLOAD="{\"mac\":\"${MAC}\",\"hostname\":\"${HOSTNAME}\",\"yggdrasil_address\": RESPONSE="" +# Use longer timeout if called from vpn-tunnel init (first boot) +CURL_TIMEOUT="${HEARTBEAT_CURL_TIMEOUT:-10}" + if [ "$ROLE" = "bee" ]; then # Bee: no yggdrasil, use public URL only - RESPONSE=$(curl -s -m 10 -X POST \ + RESPONSE=$(curl -s -m "$CURL_TIMEOUT" -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${HEARTBEAT_KEY}" \ -d "$PAYLOAD" \ @@ -47,13 +50,13 @@ if [ "$ROLE" = "bee" ]; then else # Bumblebee: try yggdrasil first, fallback to public if ping6 -c 1 -W 3 200:abb9:5810:37d3:8a4c:98a6:b82b:969a >/dev/null 2>&1; then - RESPONSE=$(curl -s -m 10 -X POST \ + RESPONSE=$(curl -s -m "$CURL_TIMEOUT" -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${HEARTBEAT_KEY}" \ -d "$PAYLOAD" \ "${PARAHUB_API_YGG}" 2>/dev/null) else - RESPONSE=$(curl -s -m 10 -X POST \ + RESPONSE=$(curl -s -m "$CURL_TIMEOUT" -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${HEARTBEAT_KEY}" \ -d "$PAYLOAD" \ @@ -61,6 +64,22 @@ else fi fi +# Parse tunnel_ip from response and update local config (Bumblebee only) +if [ "$ROLE" != "bee" ] && [ -n "$RESPONSE" ]; then + NEW_TUNNEL_IP=$(echo "$RESPONSE" | jsonfilter -e '$.tunnel_ip' 2>/dev/null) + if [ -n "$NEW_TUNNEL_IP" ]; then + OLD_TUNNEL_IP=$(cat /etc/parahub/tunnel_ip 2>/dev/null) + if [ "$OLD_TUNNEL_IP" != "$NEW_TUNNEL_IP" ]; then + echo "$NEW_TUNNEL_IP" > /etc/parahub/tunnel_ip + logger -t parahub-heartbeat "Tunnel IP updated: ${OLD_TUNNEL_IP:-none} → $NEW_TUNNEL_IP" + # Restart vpn-tunnel if it's running (IP changed) + if [ -n "$OLD_TUNNEL_IP" ]; then + /etc/init.d/parahub-vpn-tunnel restart 2>/dev/null & + fi + fi + fi +fi + # Sync paid_clients to speed control (Bumblebee only) if [ "$ROLE" != "bee" ] && [ -x /usr/bin/parahub-speed-control ] && [ -n "$RESPONSE" ]; then PAID_IPS=$(echo "$RESPONSE" | jsonfilter -e '$.paid_clients[*]' 2>/dev/null)