From c5b9229ad0c7db005d03f96c7c1562678ee09038 Mon Sep 17 00:00:00 2001 From: Parahub AI Date: Fri, 6 Feb 2026 08:09:27 +0000 Subject: [PATCH] feat: Add OTA auto-update and guest IPv6 via Yggdrasil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTA: build.sh writes version/profile to firmware, generates manifest.json with SHA256 per device. parahub-autoupdate script runs nightly at 3am, fetches manifest (Yggdrasil first), verifies checksum, runs sysupgrade. sysupgrade.conf preserves /etc/parahub/, yggdrasil.conf, dropbear keys. Guest IPv6: Yggdrasil 300::/64 subnet assigned to guest via SLAAC. Separate yggdrasil firewall zone (5 zones total) with guest→yggdrasil forwarding. IPv6 exempt from tc shaping — full speed to Parahub services. IPv6 to WAN blocked. Heartbeat now reads version from file, not hardcoded. Co-Authored-By: Claude Opus 4.6 --- files/etc/sysupgrade.conf | 3 + files/etc/uci-defaults/99-parahub-mesh | 100 ++++++++++++++++++++++--- files/usr/bin/parahub-autoupdate | 97 ++++++++++++++++++++++++ files/usr/bin/parahub-heartbeat | 3 +- files/usr/bin/parahub-speed-control | 7 ++ scripts/build.sh | 63 +++++++++++++++- 6 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 files/etc/sysupgrade.conf create mode 100755 files/usr/bin/parahub-autoupdate diff --git a/files/etc/sysupgrade.conf b/files/etc/sysupgrade.conf new file mode 100644 index 0000000..94aedad --- /dev/null +++ b/files/etc/sysupgrade.conf @@ -0,0 +1,3 @@ +/etc/parahub/ +/etc/yggdrasil.conf +/etc/dropbear/ diff --git a/files/etc/uci-defaults/99-parahub-mesh b/files/etc/uci-defaults/99-parahub-mesh index 8b295d3..c0d2390 100755 --- a/files/etc/uci-defaults/99-parahub-mesh +++ b/files/etc/uci-defaults/99-parahub-mesh @@ -382,13 +382,12 @@ set firewall.@zone[-1].output='ACCEPT' set firewall.@zone[-1].forward='ACCEPT' add_list firewall.@zone[-1].network='private' -# --- Zone: guest (IPv4 only — IPv6 blocked) --- +# --- Zone: guest (dual-stack — IPv6 for Yggdrasil services) --- add firewall zone set firewall.@zone[-1].name='guest' set firewall.@zone[-1].input='REJECT' set firewall.@zone[-1].output='ACCEPT' set firewall.@zone[-1].forward='REJECT' -set firewall.@zone[-1].family='ipv4' add_list firewall.@zone[-1].network='guest' # --- Zone: wan --- @@ -430,14 +429,13 @@ set firewall.@rule[-1].dest_port='67' set firewall.@rule[-1].target='ACCEPT' set firewall.@rule[-1].family='ipv4' -# --- Rule: guest DNS to router only (hijacked to DoH) --- +# --- Rule: guest DNS to router (hijacked to DoH, dual-stack) --- add firewall rule set firewall.@rule[-1].name='Guest DNS' set firewall.@rule[-1].src='guest' set firewall.@rule[-1].proto='tcpudp' set firewall.@rule[-1].dest_port='53' set firewall.@rule[-1].target='ACCEPT' -set firewall.@rule[-1].family='ipv4' # --- Rule: block guest → lan (isolation) --- add firewall rule @@ -447,10 +445,20 @@ set firewall.@rule[-1].dest='lan' set firewall.@rule[-1].proto='all' set firewall.@rule[-1].target='REJECT' -# --- Rule: block ALL IPv6 from guest (leak prevention) --- +# --- Rule: allow guest DHCPv6/RA (IPv6 address assignment) --- add firewall rule -set firewall.@rule[-1].name='Block guest IPv6' +set firewall.@rule[-1].name='Guest RA' set firewall.@rule[-1].src='guest' +set firewall.@rule[-1].proto='icmp' +set firewall.@rule[-1].family='ipv6' +set firewall.@rule[-1].icmp_type='router-solicitation' +set firewall.@rule[-1].target='ACCEPT' + +# --- Rule: block guest IPv6 to WAN (only Yggdrasil allowed via forwarding) --- +add firewall rule +set firewall.@rule[-1].name='Block guest IPv6 WAN' +set firewall.@rule[-1].src='guest' +set firewall.@rule[-1].dest='wan' set firewall.@rule[-1].proto='all' set firewall.@rule[-1].family='ipv6' set firewall.@rule[-1].target='REJECT' @@ -608,8 +616,44 @@ set network.yggdrasil.proto='none' YGG_EOF uci commit network - # Add yggdrasil to LAN zone (mesh nodes trust each other) - uci add_list firewall.@zone[0].network='yggdrasil' + # Yggdrasil firewall zone (separate from LAN — allows guest→yggdrasil forwarding) + uci batch <<-YGG_FW_EOF +add firewall zone +set firewall.@zone[-1].name='yggdrasil' +set firewall.@zone[-1].input='REJECT' +set firewall.@zone[-1].output='ACCEPT' +set firewall.@zone[-1].forward='REJECT' +add_list firewall.@zone[-1].network='yggdrasil' + +# Allow GRE6 protocol input (tunnel endpoint) +add firewall rule +set firewall.@rule[-1].name='Allow GRE6 input' +set firewall.@rule[-1].src='yggdrasil' +set firewall.@rule[-1].proto='47' +set firewall.@rule[-1].target='ACCEPT' + +# Allow ICMPv6 on Yggdrasil +add firewall rule +set firewall.@rule[-1].name='Allow ICMPv6 Ygg' +set firewall.@rule[-1].src='yggdrasil' +set firewall.@rule[-1].proto='icmp' +set firewall.@rule[-1].family='ipv6' +set firewall.@rule[-1].target='ACCEPT' + +# Forwardings: LAN ↔ Yggdrasil (owner full access) +add firewall forwarding +set firewall.@forwarding[-1].src='lan' +set firewall.@forwarding[-1].dest='yggdrasil' + +add firewall forwarding +set firewall.@forwarding[-1].src='yggdrasil' +set firewall.@forwarding[-1].dest='lan' + +# Forwarding: guest → yggdrasil (IPv6 Parahub services at full speed) +add firewall forwarding +set firewall.@forwarding[-1].src='guest' +set firewall.@forwarding[-1].dest='yggdrasil' +YGG_FW_EOF uci commit firewall # Enable yggdrasil service @@ -623,17 +667,50 @@ else fi # ============================================================================ -# 12. HEARTBEAT (phone-home to Parahub cloud) +# 12. GUEST IPv6 VIA YGGDRASIL (Bumblebee only — full-speed Parahub access) +# ============================================================================ + +if [ "$ROLE" != "bee" ]; then + # Extract Yggdrasil 300::/64 subnet for guest SLAAC + YGG_SUBNET=$(yggdrasil -subnet -useconffile /etc/yggdrasil.conf 2>/dev/null) + # YGG_SUBNET format: 300:xxxx:xxxx:xxxx::/64 + YGG_SUBNET_PREFIX="${YGG_SUBNET%%::*}" # 300:xxxx:xxxx:xxxx + + if [ -n "$YGG_SUBNET_PREFIX" ]; then + # Assign IPv6 from Yggdrasil subnet to guest interface + uci set network.guest.ip6addr="${YGG_SUBNET_PREFIX}::1/64" + uci commit network + + # Configure RA/SLAAC for guest (odhcpd) + uci batch <<-GUEST6_EOF +set dhcp.guest.ra='server' +set dhcp.guest.dhcpv6='disabled' +set dhcp.guest.ra_management='0' +set dhcp.guest.ra_default='1' +set dhcp.guest.ra_slaac='1' +GUEST6_EOF + uci commit dhcp + + logger -t parahub-mesh "Guest IPv6: ${YGG_SUBNET_PREFIX}::1/64 (SLAAC)" + else + logger -t parahub-mesh "Warning: Could not extract Yggdrasil subnet, guest IPv6 disabled" + fi +fi + +# ============================================================================ +# 13. HEARTBEAT (phone-home to Parahub cloud) # ============================================================================ chmod +x /usr/bin/parahub-heartbeat +chmod +x /usr/bin/parahub-autoupdate -# Cron: every 5 minutes +# Cron: heartbeat every 5 minutes + OTA update nightly at 3am echo "*/5 * * * * /usr/bin/parahub-heartbeat" >> /etc/crontabs/root +echo "0 3 * * * /usr/bin/parahub-autoupdate" >> /etc/crontabs/root /etc/init.d/cron enable # ============================================================================ -# 13. FINAL +# 14. FINAL # ============================================================================ # Log completion @@ -651,6 +728,7 @@ else logger -t parahub-mesh "Yggdrasil: ${YGG_ADDR}" logger -t parahub-mesh "GRE tunnel: 172.16.0.2 → VPS gateway (Mullvad Portugal)" logger -t parahub-mesh "Kill switch: guest→vpn_tunnel only (no wan)" + logger -t parahub-mesh "Guest IPv6: Yggdrasil SLAAC (full speed, firewall restricted)" logger -t parahub-mesh "bat0 gw_mode: server (gateway for Bee nodes)" fi diff --git a/files/usr/bin/parahub-autoupdate b/files/usr/bin/parahub-autoupdate new file mode 100755 index 0000000..1c8a1b2 --- /dev/null +++ b/files/usr/bin/parahub-autoupdate @@ -0,0 +1,97 @@ +#!/bin/sh +# Parahub Mesh — OTA Auto-Update +# Checks manifest.json for new firmware, downloads and verifies, runs sysupgrade. +# Runs nightly via cron. Lock file prevents concurrent runs. + +LOCK="/tmp/parahub-autoupdate.lock" +MANIFEST_YGG="http://[200:abb9:5810:37d3:8a4c:98a6:b82b:969a]/firmware/manifest.json" +MANIFEST_PUBLIC="https://parahub.io/firmware/manifest.json" +FIRMWARE_YGG="http://[200:abb9:5810:37d3:8a4c:98a6:b82b:969a]/firmware" +FIRMWARE_PUBLIC="https://parahub.io/firmware" + +# Lock — exit if already running +if [ -f "$LOCK" ]; then + LOCK_PID=$(cat "$LOCK" 2>/dev/null) + if kill -0 "$LOCK_PID" 2>/dev/null; then + logger -t parahub-update "Already running (pid $LOCK_PID), exiting" + exit 0 + fi + rm -f "$LOCK" +fi +echo $$ > "$LOCK" +trap 'rm -f "$LOCK"' EXIT + +# Read current version and device profile +CURRENT_VERSION=$(cat /etc/parahub/version 2>/dev/null) +DEVICE_PROFILE=$(cat /etc/parahub/profile 2>/dev/null) +ROLE=$(cat /etc/parahub/role 2>/dev/null || echo "unknown") + +if [ -z "$CURRENT_VERSION" ] || [ -z "$DEVICE_PROFILE" ]; then + logger -t parahub-update "Missing /etc/parahub/version or /etc/parahub/profile, skipping" + exit 1 +fi + +# Fetch manifest (Yggdrasil first for bumblebee, public for bee) +MANIFEST="" +if [ "$ROLE" != "bee" ] && ping6 -c 1 -W 3 200:abb9:5810:37d3:8a4c:98a6:b82b:969a >/dev/null 2>&1; then + MANIFEST=$(curl -s -m 30 "$MANIFEST_YGG" 2>/dev/null) + BASE_URL="$FIRMWARE_YGG" +fi + +if [ -z "$MANIFEST" ]; then + MANIFEST=$(curl -s -m 30 "$MANIFEST_PUBLIC" 2>/dev/null) + BASE_URL="$FIRMWARE_PUBLIC" +fi + +if [ -z "$MANIFEST" ]; then + logger -t parahub-update "Failed to fetch manifest" + exit 1 +fi + +# Parse manifest with jsonfilter +NEW_VERSION=$(echo "$MANIFEST" | jsonfilter -e '$.version' 2>/dev/null) +SYSUPGRADE_FILE=$(echo "$MANIFEST" | jsonfilter -e "$.devices.${DEVICE_PROFILE}.sysupgrade" 2>/dev/null) +EXPECTED_SHA256=$(echo "$MANIFEST" | jsonfilter -e "$.devices.${DEVICE_PROFILE}.sha256" 2>/dev/null) + +if [ -z "$NEW_VERSION" ]; then + logger -t parahub-update "Could not parse version from manifest" + exit 1 +fi + +if [ -z "$SYSUPGRADE_FILE" ] || [ -z "$EXPECTED_SHA256" ]; then + logger -t parahub-update "Device ${DEVICE_PROFILE} not found in manifest" + exit 0 +fi + +# Compare versions +if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then + logger -t parahub-update "Already up to date: ${CURRENT_VERSION}" + exit 0 +fi + +logger -t parahub-update "Update available: ${CURRENT_VERSION} → ${NEW_VERSION}" + +# Download firmware +FIRMWARE_PATH="/tmp/firmware.bin" +rm -f "$FIRMWARE_PATH" + +curl -s -m 600 -o "$FIRMWARE_PATH" "${BASE_URL}/${SYSUPGRADE_FILE}" 2>/dev/null + +if [ ! -f "$FIRMWARE_PATH" ]; then + logger -t parahub-update "Download failed: ${SYSUPGRADE_FILE}" + exit 1 +fi + +# Verify SHA256 +ACTUAL_SHA256=$(sha256sum "$FIRMWARE_PATH" | cut -d' ' -f1) + +if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then + logger -t parahub-update "SHA256 mismatch! Expected: ${EXPECTED_SHA256}, Got: ${ACTUAL_SHA256}" + rm -f "$FIRMWARE_PATH" + exit 1 +fi + +logger -t parahub-update "SHA256 verified, starting sysupgrade to ${NEW_VERSION}..." + +# Run sysupgrade (preserves /etc/config/ by default + /etc/sysupgrade.conf entries) +sysupgrade "$FIRMWARE_PATH" diff --git a/files/usr/bin/parahub-heartbeat b/files/usr/bin/parahub-heartbeat index 94183d0..e6b647f 100755 --- a/files/usr/bin/parahub-heartbeat +++ b/files/usr/bin/parahub-heartbeat @@ -32,7 +32,8 @@ case "$HW" in *) HW="${HW:-unknown}" ;; esac -PAYLOAD="{\"mac\":\"${MAC}\",\"hostname\":\"${HOSTNAME}\",\"yggdrasil_address\":\"${YGG_ADDR}\",\"firmware_version\":\"25.12.0-rc4\",\"hardware_profile\":\"${HW}\",\"uptime\":${UPTIME},\"private_ssid\":\"${SSID}\",\"firmware_role\":\"${ROLE}\",\"mesh_ip\":\"${MESH_IP}\"}" +FW_VERSION=$(cat /etc/parahub/version 2>/dev/null || echo "unknown") +PAYLOAD="{\"mac\":\"${MAC}\",\"hostname\":\"${HOSTNAME}\",\"yggdrasil_address\":\"${YGG_ADDR}\",\"firmware_version\":\"${FW_VERSION}\",\"hardware_profile\":\"${HW}\",\"uptime\":${UPTIME},\"private_ssid\":\"${SSID}\",\"firmware_role\":\"${ROLE}\",\"mesh_ip\":\"${MESH_IP}\"}" RESPONSE="" diff --git a/files/usr/bin/parahub-speed-control b/files/usr/bin/parahub-speed-control index 8dfb286..dc4497b 100755 --- a/files/usr/bin/parahub-speed-control +++ b/files/usr/bin/parahub-speed-control @@ -25,12 +25,16 @@ case "$1" in # tc filter: packets with mark 0x20 → paid class tc filter add dev $IFACE parent 1: protocol ip handle 0x20 fw flowid 1:20 + # Exempt ALL IPv6 from shaping — only Yggdrasil IPv6 reaches guests (firewall enforced) + tc filter add dev $IFACE parent 1: protocol ipv6 prio 1 u32 match u32 0 0 flowid 1:20 + # --- Ingress shaping (client → router, i.e. upload for client) via IFB --- ip link add ifb-guest type ifb 2>/dev/null ip link set ifb-guest up tc qdisc del dev $IFACE ingress 2>/dev/null tc qdisc add dev $IFACE ingress tc filter add dev $IFACE parent ffff: protocol ip u32 match u32 0 0 action mirred egress redirect dev ifb-guest + tc filter add dev $IFACE parent ffff: protocol ipv6 u32 match u32 0 0 action mirred egress redirect dev ifb-guest tc qdisc del dev ifb-guest root 2>/dev/null tc qdisc add dev ifb-guest root handle 1: htb default 10 @@ -41,6 +45,9 @@ case "$1" in tc qdisc add dev ifb-guest parent 1:20 fq_codel tc filter add dev ifb-guest parent 1: protocol ip handle 0x20 fw flowid 1:20 + # Exempt ALL IPv6 from ingress shaping + tc filter add dev ifb-guest parent 1: protocol ipv6 prio 1 u32 match u32 0 0 flowid 1:20 + # --- nftables: paid_clients set + mark rules --- nft add table inet parahub 2>/dev/null nft flush table inet parahub 2>/dev/null diff --git a/scripts/build.sh b/scripts/build.sh index 6f153e3..c9c3c1b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -167,12 +167,14 @@ build_firmware() { packages="${PACKAGES_BUMBLEBEE[*]} ${PACKAGES_EXTRA:-}" fi - # Create temp FILES dir with role marker + # Create temp FILES dir with role marker + version/profile local tmpfiles tmpfiles=$(mktemp -d) cp -a "${PROJECT_DIR}/files/"* "$tmpfiles/" mkdir -p "$tmpfiles/etc/parahub" echo "$FIRMWARE_ROLE" > "$tmpfiles/etc/parahub/role" + echo "$OPENWRT_VERSION" > "$tmpfiles/etc/parahub/version" + echo "$PROFILE" > "$tmpfiles/etc/parahub/profile" echo "Building firmware for profile: ${PROFILE}" echo "Role: ${FIRMWARE_ROLE}" @@ -192,6 +194,65 @@ build_firmware() { ls -lh "${PROJECT_DIR}/output/"*.bin 2>/dev/null || true ls -lh "${PROJECT_DIR}/output/"*.img* 2>/dev/null || true ls -lh "${PROJECT_DIR}/output/"*.itb 2>/dev/null || true + + # Update manifest.json with this device's sysupgrade info + update_manifest +} + +update_manifest() { + local manifest="${PROJECT_DIR}/output/manifest.json" + local sysupgrade_file sha256 + + # Find the sysupgrade.bin for this profile + sysupgrade_file=$(ls "${PROJECT_DIR}/output/"*"${PROFILE}"*-sysupgrade.bin 2>/dev/null | head -1) + + if [ -z "$sysupgrade_file" ]; then + echo "Warning: No sysupgrade.bin found for ${PROFILE}, skipping manifest update" + return + fi + + sha256=$(sha256sum "$sysupgrade_file" | cut -d' ' -f1) + local filename + filename=$(basename "$sysupgrade_file") + + # Create or update manifest.json + if [ -f "$manifest" ]; then + # Update existing manifest — replace version + add/update device entry + local tmp + tmp=$(mktemp) + python3 -c " +import json, sys +with open('$manifest') as f: + m = json.load(f) +m['version'] = '$OPENWRT_VERSION' +m.setdefault('devices', {})['$PROFILE'] = { + 'sysupgrade': '$filename', + 'sha256': '$sha256' +} +json.dump(m, sys.stdout, indent=2) +" > "$tmp" && mv "$tmp" "$manifest" + else + # Create new manifest + python3 -c " +import json, sys +m = { + 'version': '$OPENWRT_VERSION', + 'devices': { + '$PROFILE': { + 'sysupgrade': '$filename', + 'sha256': '$sha256' + } + } +} +json.dump(m, sys.stdout, indent=2) +" > "$manifest" + fi + + echo "" + echo "Manifest updated: ${manifest}" + echo " Device: ${PROFILE}" + echo " File: ${filename}" + echo " SHA256: ${sha256}" } # ============================================================================