feat: Add OTA auto-update and guest IPv6 via Yggdrasil

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 08:09:27 +00:00
parent 6d6cffa42c
commit c5b9229ad0
6 changed files with 260 additions and 13 deletions

View File

@@ -0,0 +1,3 @@
/etc/parahub/
/etc/yggdrasil.conf
/etc/dropbear/

View File

@@ -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

View File

@@ -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"

View File

@@ -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=""

View File

@@ -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

View File

@@ -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}"
}
# ============================================================================