Generate a minimal IPK on-the-fly when a client visits the master-link landing page, so the "Download Package" step always works even without a pre-built IPK bundle. The IPK configures the peer via postinst uci commands (avoiding file conflicts with secubox-master-link), and can be installed directly via opkg install URL from SSH. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
910 lines
25 KiB
Bash
910 lines
25 KiB
Bash
#!/bin/sh
|
|
# SecuBox Master-Link - Secure Mesh Propagation
|
|
# Manages join tokens, peer onboarding, and gigogne hierarchy
|
|
# Copyright 2026 CyberMind - Licensed under Apache-2.0
|
|
|
|
# Source dependencies
|
|
. /usr/lib/secubox/p2p-mesh.sh 2>/dev/null
|
|
. /usr/lib/secubox/factory.sh 2>/dev/null
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
ML_DIR="/var/lib/secubox-master-link"
|
|
ML_TOKENS_DIR="$ML_DIR/tokens"
|
|
ML_REQUESTS_DIR="$ML_DIR/requests"
|
|
MESH_PORT="${MESH_PORT:-7331}"
|
|
|
|
ml_init() {
|
|
mkdir -p "$ML_DIR" "$ML_TOKENS_DIR" "$ML_REQUESTS_DIR"
|
|
factory_init_keys >/dev/null 2>&1
|
|
mesh_init >/dev/null 2>&1
|
|
}
|
|
|
|
# ============================================================================
|
|
# Token Management
|
|
# ============================================================================
|
|
|
|
# Generate HMAC-based one-time join token
|
|
ml_token_generate() {
|
|
ml_init
|
|
|
|
local ttl=$(uci -q get master-link.main.token_ttl)
|
|
[ -z "$ttl" ] && ttl=3600
|
|
|
|
local now=$(date +%s)
|
|
local expires=$((now + ttl))
|
|
local rand=$(head -c 32 /dev/urandom 2>/dev/null | sha256sum | cut -d' ' -f1)
|
|
[ -z "$rand" ] && rand=$(date +%s%N | sha256sum | cut -d' ' -f1)
|
|
|
|
# HMAC token using master key
|
|
local key_data=$(cat "$KEYFILE" 2>/dev/null)
|
|
local token=$(echo "${key_data}:${rand}:${now}" | sha256sum | cut -d' ' -f1)
|
|
local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1)
|
|
|
|
# Store token in UCI
|
|
local section_id="token_$(echo "$token_hash" | cut -c1-8)"
|
|
uci -q batch <<-EOF
|
|
set master-link.${section_id}=token
|
|
set master-link.${section_id}.hash='${token_hash}'
|
|
set master-link.${section_id}.created='${now}'
|
|
set master-link.${section_id}.expires='${expires}'
|
|
set master-link.${section_id}.peer_fp=''
|
|
set master-link.${section_id}.status='active'
|
|
EOF
|
|
uci commit master-link
|
|
|
|
# Also store full token locally for validation
|
|
echo "$token" > "$ML_TOKENS_DIR/${token_hash}"
|
|
|
|
# Record in blockchain
|
|
local fp=$(factory_fingerprint 2>/dev/null)
|
|
chain_add_block "token_generated" \
|
|
"{\"token_hash\":\"$token_hash\",\"expires\":$expires,\"created_by\":\"$fp\"}" \
|
|
"$(echo "token_generated:${token_hash}:${now}" | sha256sum | cut -d' ' -f1)" >/dev/null 2>&1
|
|
|
|
# Build join URL
|
|
local my_addr=$(uci -q get network.lan.ipaddr)
|
|
[ -z "$my_addr" ] && my_addr=$(ip -4 addr show br-lan 2>/dev/null | grep -oP 'inet \K[0-9.]+' | head -1)
|
|
[ -z "$my_addr" ] && my_addr="$(hostname -i 2>/dev/null | awk '{print $1}')"
|
|
|
|
logger -t master-link "Token generated: ${token_hash} (expires: $(date -d @$expires -Iseconds 2>/dev/null || echo $expires))"
|
|
|
|
cat <<-EOF
|
|
{
|
|
"token": "$token",
|
|
"token_hash": "$token_hash",
|
|
"expires": $expires,
|
|
"ttl": $ttl,
|
|
"url": "http://${my_addr}:${MESH_PORT}/master-link/?token=${token}"
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# Validate a token
|
|
ml_token_validate() {
|
|
local token="$1"
|
|
[ -z "$token" ] && { echo '{"valid":false,"error":"missing_token"}'; return 1; }
|
|
|
|
local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1)
|
|
local now=$(date +%s)
|
|
|
|
# Check token file exists
|
|
if [ ! -f "$ML_TOKENS_DIR/${token_hash}" ]; then
|
|
echo '{"valid":false,"error":"unknown_token"}'
|
|
return 1
|
|
fi
|
|
|
|
# Find token in UCI
|
|
local status=""
|
|
local expires=""
|
|
local found=0
|
|
|
|
local sections=$(uci -q show master-link 2>/dev/null | grep "\.hash=" | sed "s/master-link\.\(.*\)\.hash=.*/\1/")
|
|
for sec in $sections; do
|
|
local sec_hash=$(uci -q get "master-link.${sec}.hash")
|
|
if [ "$sec_hash" = "$token_hash" ]; then
|
|
status=$(uci -q get "master-link.${sec}.status")
|
|
expires=$(uci -q get "master-link.${sec}.expires")
|
|
found=1
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ "$found" -eq 0 ]; then
|
|
echo '{"valid":false,"error":"token_not_found"}'
|
|
return 1
|
|
fi
|
|
|
|
# Check status
|
|
if [ "$status" = "used" ]; then
|
|
echo '{"valid":false,"error":"token_already_used"}'
|
|
return 1
|
|
fi
|
|
|
|
if [ "$status" = "expired" ]; then
|
|
echo '{"valid":false,"error":"token_expired"}'
|
|
return 1
|
|
fi
|
|
|
|
# Check expiry
|
|
if [ "$now" -gt "$expires" ]; then
|
|
# Mark as expired in UCI
|
|
for sec in $sections; do
|
|
local sec_hash=$(uci -q get "master-link.${sec}.hash")
|
|
if [ "$sec_hash" = "$token_hash" ]; then
|
|
uci -q set "master-link.${sec}.status=expired"
|
|
uci commit master-link
|
|
break
|
|
fi
|
|
done
|
|
echo '{"valid":false,"error":"token_expired"}'
|
|
return 1
|
|
fi
|
|
|
|
echo "{\"valid\":true,\"token_hash\":\"$token_hash\",\"expires\":$expires}"
|
|
return 0
|
|
}
|
|
|
|
# Revoke a token
|
|
ml_token_revoke() {
|
|
local token="$1"
|
|
[ -z "$token" ] && return 1
|
|
|
|
local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1)
|
|
|
|
local sections=$(uci -q show master-link 2>/dev/null | grep "\.hash=" | sed "s/master-link\.\(.*\)\.hash=.*/\1/")
|
|
for sec in $sections; do
|
|
local sec_hash=$(uci -q get "master-link.${sec}.hash")
|
|
if [ "$sec_hash" = "$token_hash" ]; then
|
|
uci -q set "master-link.${sec}.status=expired"
|
|
uci commit master-link
|
|
rm -f "$ML_TOKENS_DIR/${token_hash}"
|
|
logger -t master-link "Token revoked: $token_hash"
|
|
echo '{"success":true}'
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
echo '{"success":false,"error":"token_not_found"}'
|
|
return 1
|
|
}
|
|
|
|
# Cleanup expired tokens
|
|
ml_token_cleanup() {
|
|
local now=$(date +%s)
|
|
local cleaned=0
|
|
|
|
local sections=$(uci -q show master-link 2>/dev/null | grep "=token$" | sed "s/master-link\.\(.*\)=token/\1/")
|
|
for sec in $sections; do
|
|
local expires=$(uci -q get "master-link.${sec}.expires")
|
|
local status=$(uci -q get "master-link.${sec}.status")
|
|
[ -z "$expires" ] && continue
|
|
|
|
if [ "$now" -gt "$expires" ] || [ "$status" = "used" ] || [ "$status" = "expired" ]; then
|
|
local hash=$(uci -q get "master-link.${sec}.hash")
|
|
uci -q delete "master-link.${sec}"
|
|
rm -f "$ML_TOKENS_DIR/${hash}"
|
|
cleaned=$((cleaned + 1))
|
|
fi
|
|
done
|
|
|
|
[ "$cleaned" -gt 0 ] && uci commit master-link
|
|
logger -t master-link "Token cleanup: removed $cleaned expired tokens"
|
|
echo "{\"cleaned\":$cleaned}"
|
|
}
|
|
|
|
# ============================================================================
|
|
# Join Protocol
|
|
# ============================================================================
|
|
|
|
# Handle join request from new node
|
|
ml_join_request() {
|
|
local token="$1"
|
|
local peer_fp="$2"
|
|
local peer_addr="$3"
|
|
local peer_hostname="${4:-unknown}"
|
|
|
|
# Validate token
|
|
local validation=$(ml_token_validate "$token")
|
|
local valid=$(echo "$validation" | jsonfilter -e '@.valid' 2>/dev/null)
|
|
|
|
if [ "$valid" != "true" ]; then
|
|
echo "$validation"
|
|
return 1
|
|
fi
|
|
|
|
local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1)
|
|
|
|
# Store join request
|
|
local now=$(date +%s)
|
|
cat > "$ML_REQUESTS_DIR/${peer_fp}.json" <<-EOF
|
|
{
|
|
"fingerprint": "$peer_fp",
|
|
"address": "$peer_addr",
|
|
"hostname": "$peer_hostname",
|
|
"token_hash": "$token_hash",
|
|
"timestamp": $now,
|
|
"status": "pending"
|
|
}
|
|
EOF
|
|
|
|
# Add join_request block to chain
|
|
chain_add_block "join_request" \
|
|
"{\"fp\":\"$peer_fp\",\"addr\":\"$peer_addr\",\"hostname\":\"$peer_hostname\",\"token_hash\":\"$token_hash\"}" \
|
|
"$(echo "join_request:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" >/dev/null 2>&1
|
|
|
|
logger -t master-link "Join request from $peer_hostname ($peer_fp) at $peer_addr"
|
|
|
|
# Check auto-approve
|
|
local auto_approve=$(uci -q get master-link.main.auto_approve)
|
|
if [ "$auto_approve" = "1" ]; then
|
|
ml_join_approve "$peer_fp"
|
|
return $?
|
|
fi
|
|
|
|
echo "{\"success\":true,\"status\":\"pending\",\"message\":\"Join request queued for approval\"}"
|
|
}
|
|
|
|
# Approve a peer join request
|
|
ml_join_approve() {
|
|
local peer_fp="$1"
|
|
|
|
[ -z "$peer_fp" ] && {
|
|
echo '{"error":"missing_fingerprint"}'
|
|
return 1
|
|
}
|
|
|
|
local request_file="$ML_REQUESTS_DIR/${peer_fp}.json"
|
|
[ -f "$request_file" ] || {
|
|
echo '{"error":"no_pending_request"}'
|
|
return 1
|
|
}
|
|
|
|
local peer_addr=$(jsonfilter -i "$request_file" -e '@.address' 2>/dev/null)
|
|
local peer_hostname=$(jsonfilter -i "$request_file" -e '@.hostname' 2>/dev/null)
|
|
local token_hash=$(jsonfilter -i "$request_file" -e '@.token_hash' 2>/dev/null)
|
|
local orig_ts=$(jsonfilter -i "$request_file" -e '@.timestamp' 2>/dev/null)
|
|
[ -z "$orig_ts" ] && orig_ts=0
|
|
local now=$(date +%s)
|
|
local my_fp=$(factory_fingerprint 2>/dev/null)
|
|
local my_depth=$(uci -q get master-link.main.depth)
|
|
[ -z "$my_depth" ] && my_depth=0
|
|
local peer_depth=$((my_depth + 1))
|
|
|
|
# Trust peer via factory TOFU
|
|
factory_trust_peer "$peer_fp" "$peer_addr" >/dev/null 2>&1
|
|
|
|
# Add peer to mesh
|
|
peer_add "$peer_addr" "$MESH_PORT" "$peer_fp" >/dev/null 2>&1
|
|
|
|
# Update request status
|
|
cat > "$request_file" <<-EOF
|
|
{
|
|
"fingerprint": "$peer_fp",
|
|
"address": "$peer_addr",
|
|
"hostname": "$peer_hostname",
|
|
"token_hash": "$token_hash",
|
|
"timestamp": $orig_ts,
|
|
"approved_at": $now,
|
|
"approved_by": "$my_fp",
|
|
"depth": $peer_depth,
|
|
"status": "approved"
|
|
}
|
|
EOF
|
|
|
|
# Mark token as used
|
|
local sections=$(uci -q show master-link 2>/dev/null | grep "\.hash=" | sed "s/master-link\.\(.*\)\.hash=.*/\1/")
|
|
for sec in $sections; do
|
|
local sec_hash=$(uci -q get "master-link.${sec}.hash")
|
|
if [ "$sec_hash" = "$token_hash" ]; then
|
|
uci -q set "master-link.${sec}.status=used"
|
|
uci -q set "master-link.${sec}.peer_fp=$peer_fp"
|
|
uci commit master-link
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Add peer_approved block to chain
|
|
chain_add_block "peer_approved" \
|
|
"{\"fp\":\"$peer_fp\",\"addr\":\"$peer_addr\",\"depth\":$peer_depth,\"approved_by\":\"$my_fp\"}" \
|
|
"$(echo "peer_approved:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" >/dev/null 2>&1
|
|
|
|
# Sync chain with new peer
|
|
gossip_sync >/dev/null 2>&1 &
|
|
|
|
logger -t master-link "Peer approved: $peer_hostname ($peer_fp) at depth $peer_depth"
|
|
|
|
cat <<-EOF
|
|
{
|
|
"success": true,
|
|
"fingerprint": "$peer_fp",
|
|
"address": "$peer_addr",
|
|
"hostname": "$peer_hostname",
|
|
"depth": $peer_depth,
|
|
"status": "approved"
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# Reject a peer join request
|
|
ml_join_reject() {
|
|
local peer_fp="$1"
|
|
local reason="${2:-rejected by admin}"
|
|
|
|
[ -z "$peer_fp" ] && {
|
|
echo '{"error":"missing_fingerprint"}'
|
|
return 1
|
|
}
|
|
|
|
local request_file="$ML_REQUESTS_DIR/${peer_fp}.json"
|
|
[ -f "$request_file" ] || {
|
|
echo '{"error":"no_pending_request"}'
|
|
return 1
|
|
}
|
|
|
|
local my_fp=$(factory_fingerprint 2>/dev/null)
|
|
local now=$(date +%s)
|
|
|
|
# Read all fields before overwriting
|
|
local peer_addr=$(jsonfilter -i "$request_file" -e '@.address' 2>/dev/null)
|
|
local peer_hostname=$(jsonfilter -i "$request_file" -e '@.hostname' 2>/dev/null)
|
|
local orig_ts=$(jsonfilter -i "$request_file" -e '@.timestamp' 2>/dev/null)
|
|
[ -z "$orig_ts" ] && orig_ts=0
|
|
|
|
cat > "$request_file" <<-EOF
|
|
{
|
|
"fingerprint": "$peer_fp",
|
|
"address": "$peer_addr",
|
|
"hostname": "$peer_hostname",
|
|
"timestamp": $orig_ts,
|
|
"rejected_at": $now,
|
|
"rejected_by": "$my_fp",
|
|
"reason": "$reason",
|
|
"status": "rejected"
|
|
}
|
|
EOF
|
|
|
|
# Add peer_rejected block to chain
|
|
chain_add_block "peer_rejected" \
|
|
"{\"fp\":\"$peer_fp\",\"reason\":\"$reason\",\"rejected_by\":\"$my_fp\"}" \
|
|
"$(echo "peer_rejected:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" >/dev/null 2>&1
|
|
|
|
logger -t master-link "Peer rejected: $peer_fp - $reason"
|
|
|
|
echo "{\"success\":true,\"fingerprint\":\"$peer_fp\",\"status\":\"rejected\"}"
|
|
}
|
|
|
|
# ============================================================================
|
|
# IPK Generation & Serving
|
|
# ============================================================================
|
|
|
|
# Generate a minimal join IPK on-the-fly
|
|
# OpenWrt IPK format: tar.gz containing debian-binary, control.tar.gz, data.tar.gz
|
|
# Args: $1=master_ip $2=master_depth $3=master_hostname $4=token_prefix
|
|
# Output: path to generated IPK file on stdout
|
|
ml_ipk_generate() {
|
|
local master_ip="$1"
|
|
local master_depth="${2:-0}"
|
|
local master_hostname="${3:-secubox}"
|
|
local token_prefix="${4:-0000}"
|
|
local peer_depth=$((master_depth + 1))
|
|
local version="1.0.0-${token_prefix}"
|
|
|
|
local work="/tmp/ml-ipk-$$"
|
|
local ipk_file="/tmp/secubox-mesh-join-$$.ipk"
|
|
|
|
rm -rf "$work"
|
|
mkdir -p "$work/ipk" "$work/control" "$work/data"
|
|
|
|
# --- debian-binary ---
|
|
printf '2.0\n' > "$work/ipk/debian-binary"
|
|
|
|
# --- control file ---
|
|
cat > "$work/control/control" <<-CTRL
|
|
Package: secubox-mesh-join
|
|
Version: ${version}
|
|
Architecture: all
|
|
Installed-Size: 1
|
|
Description: SecuBox mesh join - peer of ${master_hostname} (${master_ip})
|
|
Maintainer: SecuBox
|
|
Section: admin
|
|
CTRL
|
|
|
|
# --- postinst script ---
|
|
# Configure master-link via uci instead of shipping config file
|
|
# (avoids file conflict with secubox-master-link package)
|
|
cat > "$work/control/postinst" <<-POSTINST
|
|
#!/bin/sh
|
|
[ -n "\${IPKG_INSTROOT}" ] || {
|
|
uci -q set master-link.main=master-link
|
|
uci -q set master-link.main.enabled='1'
|
|
uci -q set master-link.main.role='peer'
|
|
uci -q set master-link.main.upstream='${master_ip}'
|
|
uci -q set master-link.main.depth='${peer_depth}'
|
|
uci -q set master-link.main.max_depth='3'
|
|
uci -q set master-link.main.token_ttl='3600'
|
|
uci -q set master-link.main.auto_approve='0'
|
|
uci commit master-link
|
|
/etc/init.d/master-link enable 2>/dev/null
|
|
/etc/init.d/master-link start 2>/dev/null
|
|
}
|
|
exit 0
|
|
POSTINST
|
|
chmod 755 "$work/control/postinst"
|
|
|
|
# --- data: empty (config is applied by postinst via uci) ---
|
|
|
|
# --- Build inner tar.gz archives ---
|
|
tar czf "$work/ipk/control.tar.gz" -C "$work/control" . 2>/dev/null
|
|
tar czf "$work/ipk/data.tar.gz" -C "$work/data" . 2>/dev/null
|
|
|
|
# --- Assemble IPK (outer tar.gz) ---
|
|
tar czf "$ipk_file" -C "$work/ipk" \
|
|
debian-binary control.tar.gz data.tar.gz 2>/dev/null
|
|
|
|
rm -rf "$work"
|
|
echo "$ipk_file"
|
|
}
|
|
|
|
# Detect master IP from CGI environment or UCI
|
|
_ml_detect_master_ip() {
|
|
# Try HTTP_HOST (CGI environment, set by uhttpd)
|
|
local ip=$(echo "${HTTP_HOST:-}" | cut -d: -f1)
|
|
[ -n "$ip" ] && { echo "$ip"; return; }
|
|
|
|
# Try UCI network config
|
|
ip=$(uci -q get network.lan.ipaddr)
|
|
[ -n "$ip" ] && { echo "$ip"; return; }
|
|
|
|
# Fallback: parse ip addr
|
|
ip addr show br-lan 2>/dev/null | grep -o 'inet [0-9.]*' | head -1 | cut -d' ' -f2
|
|
}
|
|
|
|
# Validate token and serve IPK file (pre-built or generated)
|
|
ml_ipk_serve() {
|
|
local token="$1"
|
|
|
|
# Validate token
|
|
local validation=$(ml_token_validate "$token")
|
|
local valid=$(echo "$validation" | jsonfilter -e '@.valid' 2>/dev/null)
|
|
|
|
if [ "$valid" != "true" ]; then
|
|
echo "Status: 403 Forbidden"
|
|
echo "Content-Type: application/json"
|
|
echo "Access-Control-Allow-Origin: *"
|
|
echo ""
|
|
echo "$validation"
|
|
return 1
|
|
fi
|
|
|
|
# Try pre-built IPK first
|
|
local ipk_path=$(uci -q get master-link.main.ipk_path)
|
|
[ -z "$ipk_path" ] && ipk_path="/www/secubox-feed/secubox-master-link_*.ipk"
|
|
|
|
local ipk_file=""
|
|
for f in $ipk_path; do
|
|
[ -f "$f" ] && ipk_file="$f"
|
|
done
|
|
|
|
# Fallback: generate minimal join IPK on-the-fly
|
|
local generated=0
|
|
if [ -z "$ipk_file" ]; then
|
|
local master_ip=$(_ml_detect_master_ip)
|
|
local master_depth=$(uci -q get master-link.main.depth)
|
|
[ -z "$master_depth" ] && master_depth=0
|
|
local master_hostname=$(uci -q get system.@system[0].hostname 2>/dev/null)
|
|
[ -z "$master_hostname" ] && master_hostname="secubox"
|
|
local token_hash=$(echo "$token" | sha256sum | cut -d' ' -f1)
|
|
local token_prefix=$(echo "$token_hash" | cut -c1-8)
|
|
|
|
ipk_file=$(ml_ipk_generate "$master_ip" "$master_depth" "$master_hostname" "$token_prefix")
|
|
generated=1
|
|
|
|
if [ -z "$ipk_file" ] || [ ! -f "$ipk_file" ]; then
|
|
echo "Status: 500 Internal Server Error"
|
|
echo "Content-Type: application/json"
|
|
echo "Access-Control-Allow-Origin: *"
|
|
echo ""
|
|
echo '{"error":"ipk_generation_failed"}'
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
local filename="secubox-mesh-join.ipk"
|
|
[ "$generated" = "0" ] && filename=$(basename "$ipk_file")
|
|
local filesize=$(wc -c < "$ipk_file")
|
|
|
|
echo "Content-Type: application/octet-stream"
|
|
echo "Content-Disposition: attachment; filename=\"$filename\""
|
|
echo "Content-Length: $filesize"
|
|
echo "Access-Control-Allow-Origin: *"
|
|
echo ""
|
|
cat "$ipk_file"
|
|
|
|
# Clean up generated IPK
|
|
[ "$generated" = "1" ] && rm -f "$ipk_file"
|
|
}
|
|
|
|
# Return IPK metadata
|
|
ml_ipk_bundle_info() {
|
|
local ipk_path=$(uci -q get master-link.main.ipk_path)
|
|
[ -z "$ipk_path" ] && ipk_path="/www/secubox-feed/secubox-master-link_*.ipk"
|
|
|
|
local ipk_file=""
|
|
for f in $ipk_path; do
|
|
[ -f "$f" ] && ipk_file="$f"
|
|
done
|
|
|
|
if [ -n "$ipk_file" ]; then
|
|
local filename=$(basename "$ipk_file")
|
|
local filesize=$(wc -c < "$ipk_file")
|
|
local sha256=$(sha256sum "$ipk_file" | cut -d' ' -f1)
|
|
|
|
cat <<-EOF
|
|
{
|
|
"available": true,
|
|
"type": "prebuilt",
|
|
"filename": "$filename",
|
|
"size": $filesize,
|
|
"sha256": "$sha256"
|
|
}
|
|
EOF
|
|
else
|
|
# Dynamic generation always available
|
|
cat <<-EOF
|
|
{
|
|
"available": true,
|
|
"type": "dynamic",
|
|
"filename": "secubox-mesh-join.ipk",
|
|
"description": "Minimal join package (generated on-the-fly)"
|
|
}
|
|
EOF
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# Gigogne (Nested Hierarchy)
|
|
# ============================================================================
|
|
|
|
# Promote an approved peer to sub-master
|
|
ml_promote_to_submaster() {
|
|
local peer_fp="$1"
|
|
|
|
[ -z "$peer_fp" ] && {
|
|
echo '{"error":"missing_fingerprint"}'
|
|
return 1
|
|
}
|
|
|
|
# Check depth limit
|
|
local depth_ok=$(ml_check_depth)
|
|
local can_promote=$(echo "$depth_ok" | jsonfilter -e '@.can_promote' 2>/dev/null)
|
|
|
|
if [ "$can_promote" != "true" ]; then
|
|
echo "$depth_ok"
|
|
return 1
|
|
fi
|
|
|
|
local request_file="$ML_REQUESTS_DIR/${peer_fp}.json"
|
|
[ -f "$request_file" ] || {
|
|
echo '{"error":"peer_not_found"}'
|
|
return 1
|
|
}
|
|
|
|
local peer_status=$(jsonfilter -i "$request_file" -e '@.status' 2>/dev/null)
|
|
if [ "$peer_status" != "approved" ]; then
|
|
echo '{"error":"peer_not_approved"}'
|
|
return 1
|
|
fi
|
|
|
|
local peer_addr=$(jsonfilter -i "$request_file" -e '@.address' 2>/dev/null)
|
|
local my_depth=$(uci -q get master-link.main.depth)
|
|
[ -z "$my_depth" ] && my_depth=0
|
|
local new_depth=$((my_depth + 1))
|
|
local now=$(date +%s)
|
|
|
|
# Read all fields before truncating the file
|
|
local peer_hostname=$(jsonfilter -i "$request_file" -e '@.hostname' 2>/dev/null)
|
|
local token_hash=$(jsonfilter -i "$request_file" -e '@.token_hash' 2>/dev/null)
|
|
local orig_ts=$(jsonfilter -i "$request_file" -e '@.timestamp' 2>/dev/null)
|
|
local orig_approved=$(jsonfilter -i "$request_file" -e '@.approved_at' 2>/dev/null)
|
|
|
|
cat > "$request_file" <<-EOF
|
|
{
|
|
"fingerprint": "$peer_fp",
|
|
"address": "$peer_addr",
|
|
"hostname": "$peer_hostname",
|
|
"token_hash": "$token_hash",
|
|
"timestamp": $orig_ts,
|
|
"approved_at": $orig_approved,
|
|
"promoted_at": $now,
|
|
"depth": $new_depth,
|
|
"role": "sub-master",
|
|
"status": "approved"
|
|
}
|
|
EOF
|
|
|
|
# Add peer_promoted block to chain
|
|
chain_add_block "peer_promoted" \
|
|
"{\"fp\":\"$peer_fp\",\"new_role\":\"sub-master\",\"new_depth\":$new_depth}" \
|
|
"$(echo "peer_promoted:${peer_fp}:${now}" | sha256sum | cut -d' ' -f1)" >/dev/null 2>&1
|
|
|
|
# Notify the peer to update its role (via mesh API)
|
|
curl -s --connect-timeout 5 -X POST \
|
|
"http://$peer_addr:$MESH_PORT/api/master-link/status" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"action\":\"promote\",\"role\":\"sub-master\",\"depth\":$new_depth}" 2>/dev/null &
|
|
|
|
logger -t master-link "Peer promoted to sub-master: $peer_fp at depth $new_depth"
|
|
|
|
cat <<-EOF
|
|
{
|
|
"success": true,
|
|
"fingerprint": "$peer_fp",
|
|
"new_role": "sub-master",
|
|
"new_depth": $new_depth
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# Forward blocks to upstream master
|
|
ml_propagate_block_upstream() {
|
|
local block_data="$1"
|
|
local upstream=$(uci -q get master-link.main.upstream)
|
|
[ -z "$upstream" ] && return 0
|
|
|
|
curl -s --connect-timeout 5 -X POST \
|
|
"http://$upstream:$MESH_PORT/api/chain" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$block_data" 2>/dev/null
|
|
|
|
return $?
|
|
}
|
|
|
|
# Check if depth allows sub-master promotion
|
|
ml_check_depth() {
|
|
local depth=$(uci -q get master-link.main.depth)
|
|
local max_depth=$(uci -q get master-link.main.max_depth)
|
|
[ -z "$depth" ] && depth=0
|
|
[ -z "$max_depth" ] && max_depth=3
|
|
|
|
local next_depth=$((depth + 1))
|
|
|
|
if [ "$next_depth" -ge "$max_depth" ]; then
|
|
echo "{\"can_promote\":false,\"depth\":$depth,\"max_depth\":$max_depth,\"error\":\"max_depth_reached\"}"
|
|
return 1
|
|
fi
|
|
|
|
echo "{\"can_promote\":true,\"depth\":$depth,\"max_depth\":$max_depth,\"next_depth\":$next_depth}"
|
|
return 0
|
|
}
|
|
|
|
# ============================================================================
|
|
# Status & Peer Listing
|
|
# ============================================================================
|
|
|
|
# Return mesh status
|
|
ml_status() {
|
|
ml_init 2>/dev/null
|
|
|
|
local role=$(uci -q get master-link.main.role)
|
|
local depth=$(uci -q get master-link.main.depth)
|
|
local upstream=$(uci -q get master-link.main.upstream)
|
|
local max_depth=$(uci -q get master-link.main.max_depth)
|
|
local enabled=$(uci -q get master-link.main.enabled)
|
|
local auto_approve=$(uci -q get master-link.main.auto_approve)
|
|
local fp=$(factory_fingerprint 2>/dev/null)
|
|
|
|
[ -z "$role" ] && role="master"
|
|
[ -z "$depth" ] && depth=0
|
|
[ -z "$max_depth" ] && max_depth=3
|
|
[ -z "$enabled" ] && enabled=1
|
|
|
|
# Count peers by status
|
|
local pending=0
|
|
local approved=0
|
|
local rejected=0
|
|
for req in "$ML_REQUESTS_DIR"/*.json; do
|
|
[ -f "$req" ] || continue
|
|
local st=$(jsonfilter -i "$req" -e '@.status' 2>/dev/null)
|
|
case "$st" in
|
|
pending) pending=$((pending + 1)) ;;
|
|
approved) approved=$((approved + 1)) ;;
|
|
rejected) rejected=$((rejected + 1)) ;;
|
|
esac
|
|
done
|
|
|
|
# Count active tokens
|
|
local active_tokens=0
|
|
local now=$(date +%s)
|
|
local sections=$(uci -q show master-link 2>/dev/null | grep "=token$" | sed "s/master-link\.\(.*\)=token/\1/")
|
|
for sec in $sections; do
|
|
local status=$(uci -q get "master-link.${sec}.status")
|
|
local expires=$(uci -q get "master-link.${sec}.expires")
|
|
if [ "$status" = "active" ] && [ -n "$expires" ] && [ "$now" -lt "$expires" ]; then
|
|
active_tokens=$((active_tokens + 1))
|
|
fi
|
|
done
|
|
|
|
# Chain height
|
|
local chain_height=0
|
|
[ -f "$CHAIN_FILE" ] && chain_height=$(jsonfilter -i "$CHAIN_FILE" -e '@.blocks[*]' 2>/dev/null | wc -l)
|
|
|
|
local hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname)
|
|
|
|
cat <<-EOF
|
|
{
|
|
"enabled": $enabled,
|
|
"role": "$role",
|
|
"depth": $depth,
|
|
"max_depth": $max_depth,
|
|
"upstream": "$upstream",
|
|
"fingerprint": "$fp",
|
|
"hostname": "$hostname",
|
|
"auto_approve": $auto_approve,
|
|
"peers": {
|
|
"pending": $pending,
|
|
"approved": $approved,
|
|
"rejected": $rejected,
|
|
"total": $((pending + approved + rejected))
|
|
},
|
|
"active_tokens": $active_tokens,
|
|
"chain_height": $chain_height
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# List all peers with details
|
|
ml_peer_list() {
|
|
local first=1
|
|
echo '{"peers":['
|
|
|
|
for req in "$ML_REQUESTS_DIR"/*.json; do
|
|
[ -f "$req" ] || continue
|
|
[ $first -eq 0 ] && echo ","
|
|
first=0
|
|
cat "$req" | tr '\n' ' ' | tr '\t' ' '
|
|
done
|
|
|
|
echo ']}'
|
|
}
|
|
|
|
# Build mesh tree from chain blocks
|
|
ml_tree() {
|
|
local fp=$(factory_fingerprint 2>/dev/null)
|
|
local hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname)
|
|
local role=$(uci -q get master-link.main.role)
|
|
local depth=$(uci -q get master-link.main.depth)
|
|
[ -z "$role" ] && role="master"
|
|
[ -z "$depth" ] && depth=0
|
|
|
|
echo '{"tree":{'
|
|
echo "\"fingerprint\":\"$fp\","
|
|
echo "\"hostname\":\"$hostname\","
|
|
echo "\"role\":\"$role\","
|
|
echo "\"depth\":$depth,"
|
|
echo '"children":['
|
|
|
|
# Build children from approved peers
|
|
local first=1
|
|
for req in "$ML_REQUESTS_DIR"/*.json; do
|
|
[ -f "$req" ] || continue
|
|
local st=$(jsonfilter -i "$req" -e '@.status' 2>/dev/null)
|
|
[ "$st" != "approved" ] && continue
|
|
|
|
local child_fp=$(jsonfilter -i "$req" -e '@.fingerprint' 2>/dev/null)
|
|
local child_hostname=$(jsonfilter -i "$req" -e '@.hostname' 2>/dev/null)
|
|
local child_addr=$(jsonfilter -i "$req" -e '@.address' 2>/dev/null)
|
|
local child_depth=$(jsonfilter -i "$req" -e '@.depth' 2>/dev/null)
|
|
local child_role=$(jsonfilter -i "$req" -e '@.role' 2>/dev/null)
|
|
[ -z "$child_depth" ] && child_depth=$((depth + 1))
|
|
[ -z "$child_role" ] && child_role="peer"
|
|
|
|
# Check if peer is online
|
|
local online="false"
|
|
curl -s --connect-timeout 2 "http://$child_addr:$MESH_PORT/api/status" >/dev/null 2>&1 && online="true"
|
|
|
|
[ $first -eq 0 ] && echo ","
|
|
first=0
|
|
|
|
cat <<-CHILD
|
|
{
|
|
"fingerprint": "$child_fp",
|
|
"hostname": "$child_hostname",
|
|
"address": "$child_addr",
|
|
"role": "$child_role",
|
|
"depth": $child_depth,
|
|
"online": $online
|
|
}
|
|
CHILD
|
|
done
|
|
|
|
echo ']}}'
|
|
}
|
|
|
|
# ============================================================================
|
|
# Auth Helpers
|
|
# ============================================================================
|
|
|
|
# Check if request is from local origin (127.0.0.1 or LAN)
|
|
ml_check_local_auth() {
|
|
local remote_addr="${REMOTE_ADDR:-}"
|
|
|
|
case "$remote_addr" in
|
|
127.0.0.1|::1|"")
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
# Check if from LAN subnet
|
|
local lan_addr=$(uci -q get network.lan.ipaddr)
|
|
local lan_mask=$(uci -q get network.lan.netmask)
|
|
|
|
if [ -n "$lan_addr" ]; then
|
|
local lan_prefix=$(echo "$lan_addr" | cut -d. -f1-3)
|
|
local remote_prefix=$(echo "$remote_addr" | cut -d. -f1-3)
|
|
[ "$lan_prefix" = "$remote_prefix" ] && return 0
|
|
fi
|
|
|
|
# Check for LuCI session cookie
|
|
local cookie="${HTTP_COOKIE:-}"
|
|
if echo "$cookie" | grep -q "sysauth="; then
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# ============================================================================
|
|
# Main CLI
|
|
# ============================================================================
|
|
case "${1:-}" in
|
|
token-generate)
|
|
ml_token_generate
|
|
;;
|
|
token-validate)
|
|
ml_token_validate "$2"
|
|
;;
|
|
token-revoke)
|
|
ml_token_revoke "$2"
|
|
;;
|
|
token-cleanup)
|
|
ml_token_cleanup
|
|
;;
|
|
join-request)
|
|
ml_join_request "$2" "$3" "$4" "$5"
|
|
;;
|
|
join-approve)
|
|
ml_join_approve "$2"
|
|
;;
|
|
join-reject)
|
|
ml_join_reject "$2" "$3"
|
|
;;
|
|
promote)
|
|
ml_promote_to_submaster "$2"
|
|
;;
|
|
status)
|
|
ml_status
|
|
;;
|
|
peers)
|
|
ml_peer_list
|
|
;;
|
|
tree)
|
|
ml_tree
|
|
;;
|
|
ipk-info)
|
|
ml_ipk_bundle_info
|
|
;;
|
|
ipk-generate)
|
|
ml_ipk_generate "$2" "$3" "$4" "$5"
|
|
;;
|
|
init)
|
|
ml_init
|
|
echo "Master-link initialized"
|
|
;;
|
|
*)
|
|
# Sourced as library - do nothing
|
|
:
|
|
;;
|
|
esac
|