secubox-openwrt/package/secubox/secubox-master-link/files/usr/lib/secubox/master-link.sh
CyberMind-FR 857622ff56 feat(master-link): Add dynamic join IPK generation for mesh onboarding
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>
2026-02-03 09:36:04 +01:00

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