diff --git a/.claude/prompts/secubox-deb-masterlink.md b/.claude/prompts/secubox-deb-masterlink.md new file mode 100644 index 00000000..7b4ad92a --- /dev/null +++ b/.claude/prompts/secubox-deb-masterlink.md @@ -0,0 +1,240 @@ +# SecuBox-Deb Master-Link API Implementation + +## Overview + +Implement the Master-Link mesh enrollment API for SecuBox-Deb (Debian/Ubuntu VM version). This allows the VM to act as a **master node** that can onboard OpenWrt peer nodes into the mesh network. + +The API should be added to the existing P2P FastAPI service running at `/run/secubox/p2p.sock` and exposed via nginx at `https:///api/v1/p2p/master-link/*`. + +## Current State + +- P2P service: `/usr/bin/uvicorn api.main:app --uds /run/secubox/p2p.sock` +- Token storage: `/var/lib/secubox/p2p/master-link/tokens.json` +- Peer storage: `/var/lib/secubox/p2p/master-link/peers.json` +- The existing VM already has partial master-link support + +## Required API Endpoints + +### 1. Status Endpoint +``` +GET /master-link/status +``` +Returns mesh status: +```json +{ + "enabled": true, + "role": "master", + "depth": 0, + "max_depth": 3, + "upstream": null, + "fingerprint": "sb-", + "hostname": "secubox-vm-x64", + "auto_approve": false, + "peers": { + "pending": 0, + "approved": 3, + "rejected": 0, + "total": 3 + }, + "active_tokens": 1 +} +``` + +### 2. Generate Invite Token +``` +POST /master-link/invite +Content-Type: application/json + +{ + "auto_approve": true, + "ttl": 3600 +} +``` +Returns: +```json +{ + "token": "abc123def456...", + "hash": "sha256-hash-of-token", + "expires": "2026-03-26T16:00:00Z", + "expires_ts": 1774540800, + "ttl": 3600, + "auto_approve": true, + "url": "https://192.168.255.200/master-link/?token=abc123def456..." +} +``` + +**Token Generation Logic:** +```python +import secrets +import hashlib +from datetime import datetime, timedelta + +token = secrets.token_hex(16) # 32 char hex string +token_hash = hashlib.sha256(token.encode()).hexdigest() +expires = datetime.now() + timedelta(seconds=ttl) +``` + +### 3. Join Endpoint (for peers) +``` +POST /master-link/join +Content-Type: application/json + +{ + "token": "abc123def456...", + "fingerprint": "owrt-0050430d1918", + "hostname": "C3BOX", + "address": "192.168.255.1", + "model": "Globalscale MOCHAbin" +} +``` + +**Validation Flow:** +1. Hash incoming token: `sha256(token)` +2. Find matching token in `tokens.json` by hash +3. Check token status is "active" and not expired +4. If `auto_approve` is true, immediately approve +5. Otherwise, queue for manual approval + +**Success Response (auto-approved):** +```json +{ + "status": "approved", + "fingerprint": "owrt-0050430d1918", + "message": "Welcome to the mesh", + "master_fingerprint": "sb-test123456", + "depth": 1 +} +``` + +**Success Response (pending):** +```json +{ + "status": "pending", + "fingerprint": "owrt-0050430d1918", + "message": "Awaiting master approval" +} +``` + +**Error Responses:** +```json +{"status": "error", "message": "Invalid or expired token"} +{"status": "error", "message": "Token already used"} +``` + +### 4. List Peers +``` +GET /master-link/peers +``` +Returns: +```json +{ + "peers": [ + { + "fingerprint": "owrt-0050430d1918", + "hostname": "C3BOX", + "address": "192.168.255.1", + "model": "Globalscale MOCHAbin", + "status": "approved", + "joined_at": "2026-03-26T14:00:32.721532", + "depth": 1 + } + ] +} +``` + +### 5. Approve/Reject Peer +``` +POST /master-link/approve +Content-Type: application/json + +{ + "fingerprint": "owrt-0050430d1918", + "action": "approve" // or "reject" +} +``` + +### 6. Cleanup Tokens +``` +POST /master-link/cleanup +``` +Removes expired and used tokens. + +## Data Structures + +### tokens.json +```json +[ + { + "hash": "sha256-of-token", + "type": "join", + "created": "2026-03-26T11:44:54.033842", + "expires": "2026-03-26T12:44:54.033842", + "expires_ts": 1774529094, + "ttl": 3600, + "status": "active", // active, used, expired + "auto_approve": true, + "peer_fp": null, // filled when used + "used_by": null, + "used_at": null + } +] +``` + +### peers.json +```json +[ + { + "fingerprint": "owrt-0050430d1918", + "hostname": "C3BOX", + "address": "192.168.255.1", + "model": "Globalscale MOCHAbin", + "status": "approved", + "token_hash": "abc123...", + "joined_at": "2026-03-26T14:00:32.721532", + "depth": 1, + "last_seen": "2026-03-26T15:30:00.000000" + } +] +``` + +## CLI Tools (for reference) + +The OpenWrt side has these CLI tools that interact with this API: + +### sbx-mesh-invite (on master) +Generates invite token and outputs join URL/command. + +### sbx-mesh-join (on peer) +Joins a mesh by sending join request with token. + +```bash +# On master (VM) +sbx-mesh-invite --ip 192.168.255.200 +# Output: Token and join URL + +# On peer (OpenWrt) +sbx-mesh-join 192.168.255.200 +``` + +## Implementation Notes + +1. **HTTPS Required**: The join endpoint uses HTTPS (port 443), not HTTP port 7331 +2. **Self-signed Certs**: Peers use `--no-check-certificate` (wget) or `-k` (curl) +3. **Token Security**: Tokens are one-time use; mark as "used" immediately upon successful join +4. **Auto-approve**: When `auto_approve=true`, skip manual approval step +5. **Fingerprint**: Use unique device identifier (MAC-based for OpenWrt, random for VMs) + +## Integration with Existing UI + +The existing LuCI UI at `/admin/services/secubox-mesh` shows: +- Node status (Role, Fingerprint, Peers, Chain) +- ZKP Authentication section +- Generate Token / Cleanup Tokens buttons + +These buttons should call the API endpoints above. + +## File Locations (secubox-deb) + +- API source: `/srv/secubox/api/routers/master_link.py` (to create) +- Data dir: `/var/lib/secubox/p2p/master-link/` +- Config: `/etc/secubox/master-link.yaml` diff --git a/package/secubox/secubox-master-link/files/usr/bin/sbx-mesh-invite b/package/secubox/secubox-master-link/files/usr/bin/sbx-mesh-invite new file mode 100755 index 00000000..88e16f29 --- /dev/null +++ b/package/secubox/secubox-master-link/files/usr/bin/sbx-mesh-invite @@ -0,0 +1,158 @@ +#!/bin/sh +# SecuBox Mesh Invite Generator +# Generates a join URL that can be shared with peer nodes +# Usage: sbx-mesh-invite [--auto-approve] [--ttl SECONDS] + +set -e + +TOKENS_FILE="/var/lib/secubox/p2p/master-link/tokens.json" +AUTO_APPROVE="true" +TTL=3600 +MASTER_IP="" + +# Parse arguments +while [ $# -gt 0 ]; do + case "$1" in + --auto-approve|-a) + AUTO_APPROVE="true" + shift + ;; + --manual-approve|-m) + AUTO_APPROVE="false" + shift + ;; + --ttl|-t) + TTL="$2" + shift 2 + ;; + --ip|-i) + MASTER_IP="$2" + shift 2 + ;; + --help|-h) + cat << 'EOF' +SecuBox Mesh Invite Generator + +Usage: + sbx-mesh-invite [options] + +Options: + -a, --auto-approve Auto-approve joining nodes (default) + -m, --manual-approve Require manual approval + -t, --ttl SECONDS Token validity in seconds (default: 3600) + -i, --ip ADDRESS Master IP address for the invite URL + -h, --help Show this help + +Output: + Prints a join URL that can be copy-pasted to peer nodes. + On the peer, run: sbx-mesh-join +EOF + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# Ensure tokens directory exists +mkdir -p "$(dirname "$TOKENS_FILE")" + +# Generate random token (32 hex chars) +if command -v openssl >/dev/null; then + TOKEN=$(openssl rand -hex 16) +elif [ -r /dev/urandom ]; then + TOKEN=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') +else + # Fallback: use date + random + TOKEN=$(date +%s%N | sha256sum | head -c 32) +fi + +# Calculate hash +if command -v sha256sum >/dev/null; then + HASH=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) +elif command -v openssl >/dev/null; then + HASH=$(printf '%s' "$TOKEN" | openssl dgst -sha256 | awk '{print $2}') +else + echo "Error: No sha256 tool available" >&2 + exit 1 +fi + +# Get timestamps +NOW=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) +NOW_TS=$(date +%s) +EXPIRES_TS=$((NOW_TS + TTL)) +EXPIRES=$(date -Iseconds -d "@$EXPIRES_TS" 2>/dev/null || date -r "$EXPIRES_TS" -Iseconds 2>/dev/null || echo "$EXPIRES_TS") + +# Get local IP for URL +LOCAL_IP="$MASTER_IP" +if [ -z "$LOCAL_IP" ]; then + # Try common LAN interfaces first, prefer 192.168.x.x addresses + for iface in br-lan eth0 enp0s8 enp0s3 ens3; do + candidate=$(ip -4 addr show "$iface" 2>/dev/null | grep -oE 'inet 192\.168\.[0-9.]+' | awk '{print $2}' | head -1) + if [ -n "$candidate" ]; then + LOCAL_IP="$candidate" + break + fi + done + # Fallback to any non-loopback, non-10.x address + if [ -z "$LOCAL_IP" ]; then + LOCAL_IP=$(ip -4 addr show 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0' | grep -v 'inet 10\.' | awk '{print $2}' | head -1) + fi + [ -z "$LOCAL_IP" ] && LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}') + [ -z "$LOCAL_IP" ] && LOCAL_IP="" +fi + +# Create new token entry +NEW_TOKEN="{ + \"hash\": \"$HASH\", + \"type\": \"join\", + \"created\": \"$NOW\", + \"expires\": \"$EXPIRES\", + \"expires_ts\": $EXPIRES_TS, + \"ttl\": $TTL, + \"status\": \"active\", + \"auto_approve\": $AUTO_APPROVE +}" + +# Add to tokens file +if [ -f "$TOKENS_FILE" ]; then + # Append to existing array using Python or jq + if command -v python3 >/dev/null; then + python3 << PYEOF +import json +with open("$TOKENS_FILE", "r") as f: + tokens = json.load(f) +tokens.append(json.loads('''$NEW_TOKEN''')) +with open("$TOKENS_FILE", "w") as f: + json.dump(tokens, f, indent=2) +PYEOF + elif command -v jq >/dev/null; then + tmp=$(mktemp) + jq ". + [$NEW_TOKEN]" "$TOKENS_FILE" > "$tmp" && mv "$tmp" "$TOKENS_FILE" + else + # Manual append (risky but works for simple cases) + sed -i 's/]$/,'"$(echo "$NEW_TOKEN" | tr -d '\n')"']/' "$TOKENS_FILE" + fi +else + echo "[$NEW_TOKEN]" > "$TOKENS_FILE" +fi + +# Output the invite URL +echo "" +echo "Mesh Invite Generated" +echo "=====================" +echo "" +echo "Token: $TOKEN" +echo "Expires: $EXPIRES" +echo "Auto-approve: $AUTO_APPROVE" +echo "" +echo "Join URL (copy this to the peer node):" +echo "" +echo " https://${LOCAL_IP}/master-link/?token=${TOKEN}" +echo "" +echo "Or run on the peer:" +echo "" +echo " sbx-mesh-join ${LOCAL_IP} ${TOKEN}" +echo "" diff --git a/package/secubox/secubox-master-link/files/usr/bin/sbx-mesh-join b/package/secubox/secubox-master-link/files/usr/bin/sbx-mesh-join new file mode 100755 index 00000000..2cf7fe54 --- /dev/null +++ b/package/secubox/secubox-master-link/files/usr/bin/sbx-mesh-join @@ -0,0 +1,236 @@ +#!/bin/sh +# SecuBox Mesh Join CLI Tool +# Usage: sbx-mesh-join +# or: sbx-mesh-join + +set -e + +NODE_ID_FILE="/etc/secubox/node.id" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +log_info() { printf "${GREEN}[+]${NC} %s\n" "$1"; } +log_warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } +log_error() { printf "${RED}[-]${NC} %s\n" "$1"; } +log_step() { printf "${CYAN}[*]${NC} %s\n" "$1"; } + +# Get or generate node fingerprint +get_fingerprint() { + if [ -f "$NODE_ID_FILE" ]; then + cat "$NODE_ID_FILE" + else + mkdir -p /etc/secubox + local mac="" + if [ -f /sys/class/net/br-lan/address ]; then + mac=$(cat /sys/class/net/br-lan/address | tr -d ':') + elif [ -f /sys/class/net/eth0/address ]; then + mac=$(cat /sys/class/net/eth0/address | tr -d ':') + else + mac=$(cat /sys/class/net/*/address 2>/dev/null | head -1 | tr -d ':') + fi + local fp="owrt-${mac}" + echo "$fp" > "$NODE_ID_FILE" + echo "$fp" + fi +} + +# Get local IP +get_local_ip() { + ip -4 addr show br-lan 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1 || \ + ip -4 addr show eth0 2>/dev/null | grep -oE 'inet [0-9.]+' | awk '{print $2}' | head -1 || \ + echo "unknown" +} + +# Parse URL to extract IP and token +parse_url() { + local url="$1" + # Match: http(s)://IP(:PORT)/...?token=XXX + echo "$url" | sed -n 's|.*://\([^:/]*\).*[?&]token=\([^&]*\).*|\1 \2|p' +} + +# Usage +usage() { + cat << 'EOF' +SecuBox Mesh Join Tool + +Usage: + sbx-mesh-join + sbx-mesh-join + +Examples: + sbx-mesh-join 192.168.1.1 abc123def456 + sbx-mesh-join 'https://192.168.1.1/master-link/?token=abc123' + sbx-mesh-join 'https://master.local/master-link/?token=abc123' + +The tool will: + 1. Generate a unique node fingerprint (if not exists) + 2. Collect local device info (hostname, IP, model) + 3. Send join request to the master + 4. Save mesh configuration on success +EOF + exit 1 +} + +# Main +main() { + local master_ip="" + local token="" + + # Parse arguments + case "$#" in + 1) + # Single argument - could be URL + local parsed=$(parse_url "$1") + if [ -n "$parsed" ]; then + master_ip=$(echo "$parsed" | awk '{print $1}') + token=$(echo "$parsed" | awk '{print $2}') + else + log_error "Invalid URL format" + usage + fi + ;; + 2) + # Two arguments: IP and token + master_ip="$1" + token="$2" + ;; + *) + usage + ;; + esac + + if [ -z "$master_ip" ] || [ -z "$token" ]; then + log_error "Missing master IP or token" + usage + fi + + log_info "SecuBox Mesh Join" + echo "" + + # Gather local info + log_step "Collecting node information..." + local fingerprint=$(get_fingerprint) + local hostname=$(uci -q get system.@system[0].hostname 2>/dev/null || hostname || echo "openwrt") + local address=$(get_local_ip) + local model="" + if [ -f /tmp/sysinfo/model ]; then + model=$(cat /tmp/sysinfo/model) + elif command -v jsonfilter >/dev/null && [ -f /etc/board.json ]; then + model=$(jsonfilter -i /etc/board.json -e '@.model.name' 2>/dev/null || echo "Unknown") + else + model="Unknown" + fi + + log_info "Fingerprint: $fingerprint" + log_info "Hostname: $hostname" + log_info "Address: $address" + log_info "Model: $model" + echo "" + + # Prepare JSON payload + local payload="{\"token\":\"${token}\",\"fingerprint\":\"${fingerprint}\",\"hostname\":\"${hostname}\",\"address\":\"${address}\",\"model\":\"${model}\"}" + + log_step "Connecting to master at ${master_ip}..." + + # Send join request (HTTPS with self-signed cert support) + local response="" + local api_url="https://${master_ip}/api/v1/p2p/master-link/join" + + if command -v curl >/dev/null; then + response=$(curl -sf -k --connect-timeout 30 -X POST \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "$api_url" 2>/dev/null) + else + response=$(wget -qO- --no-check-certificate --timeout=30 \ + --header="Content-Type: application/json" \ + --post-data="$payload" \ + "$api_url" 2>/dev/null) + fi + + if [ -z "$response" ]; then + log_error "Failed to connect to master" + exit 1 + fi + + # Parse response + local status="" + local message="" + local master_fp="" + local depth="" + + if command -v jsonfilter >/dev/null; then + status=$(echo "$response" | jsonfilter -e '@.status' 2>/dev/null || true) + message=$(echo "$response" | jsonfilter -e '@.message' 2>/dev/null || true) + master_fp=$(echo "$response" | jsonfilter -e '@.master_fingerprint' 2>/dev/null || true) + depth=$(echo "$response" | jsonfilter -e '@.depth' 2>/dev/null || true) + elif command -v jq >/dev/null; then + status=$(echo "$response" | jq -r '.status // empty') + message=$(echo "$response" | jq -r '.message // empty') + master_fp=$(echo "$response" | jq -r '.master_fingerprint // empty') + depth=$(echo "$response" | jq -r '.depth // empty') + else + # Fallback: grep for status + status=$(echo "$response" | grep -oE '"status"\s*:\s*"[^"]*"' | cut -d'"' -f4) + message=$(echo "$response" | grep -oE '"message"\s*:\s*"[^"]*"' | cut -d'"' -f4) + fi + + echo "" + case "$status" in + approved) + log_info "Successfully joined mesh network!" + [ -n "$master_fp" ] && log_info "Master fingerprint: $master_fp" + [ -n "$depth" ] && log_info "Network depth: $depth" + + # Save to UCI if available + if command -v uci >/dev/null; then + log_step "Saving configuration..." + # Ensure config file and section exist + [ -f /etc/config/masterlink ] || touch /etc/config/masterlink + uci -q get masterlink.settings >/dev/null || uci set masterlink.settings='mesh' + uci set masterlink.settings.enabled='1' + uci set masterlink.settings.role='peer' + uci set masterlink.settings.status='approved' + uci set masterlink.settings.master_ip="$master_ip" + [ -n "$master_fp" ] && uci set masterlink.settings.master_fingerprint="$master_fp" + [ -n "$depth" ] && uci set masterlink.settings.depth="$depth" + uci set masterlink.settings.joined_at="$(date -Iseconds 2>/dev/null || date)" + uci commit masterlink + log_info "Configuration saved" + fi + ;; + pending) + log_warn "Join request submitted - waiting for master approval" + log_info "Check back later or ask the master admin to approve your node" + + # Save pending state + if command -v uci >/dev/null; then + [ -f /etc/config/masterlink ] || touch /etc/config/masterlink + uci -q get masterlink.settings >/dev/null || uci set masterlink.settings='mesh' + uci set masterlink.settings.enabled='1' + uci set masterlink.settings.role='peer' + uci set masterlink.settings.status='pending' + uci set masterlink.settings.master_ip="$master_ip" + uci commit masterlink + fi + ;; + error|rejected) + log_error "Join failed: ${message:-Unknown error}" + exit 1 + ;; + *) + log_error "Unexpected response: $response" + exit 1 + ;; + esac + + echo "" + log_info "Done" +} + +main "$@"