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>
This commit is contained in:
CyberMind-FR 2026-02-03 09:36:04 +01:00
parent cd4e991761
commit 857622ff56
4 changed files with 225 additions and 53 deletions

View File

@ -376,10 +376,92 @@ ml_join_reject() {
}
# ============================================================================
# IPK Serving
# IPK Generation & Serving
# ============================================================================
# Validate token and serve IPK file
# 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"
@ -390,37 +472,58 @@ ml_ipk_serve() {
if [ "$valid" != "true" ]; then
echo "Status: 403 Forbidden"
echo "Content-Type: application/json"
echo "Access-Control-Allow-Origin: *"
echo ""
echo "$validation"
return 1
fi
# Find IPK file
# 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"
# Resolve glob
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
echo "Status: 404 Not Found"
echo "Content-Type: application/json"
echo ""
echo '{"error":"ipk_not_found"}'
return 1
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=$(basename "$ipk_file")
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
@ -433,23 +536,31 @@ ml_ipk_bundle_info() {
[ -f "$f" ] && ipk_file="$f"
done
if [ -z "$ipk_file" ]; then
echo '{"available":false}'
return 1
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
local filename=$(basename "$ipk_file")
local filesize=$(wc -c < "$ipk_file")
local sha256=$(sha256sum "$ipk_file" | cut -d' ' -f1)
cat <<-EOF
{
"available": true,
"filename": "$filename",
"size": $filesize,
"sha256": "$sha256"
}
EOF
}
# ============================================================================
@ -784,6 +895,9 @@ case "${1:-}" in
ipk-info)
ml_ipk_bundle_info
;;
ipk-generate)
ml_ipk_generate "$2" "$3" "$4" "$5"
;;
init)
ml_init
echo "Master-link initialized"

View File

@ -1,25 +1,20 @@
#!/bin/sh
# Master-Link API - Serve SecuBox IPK bundle
# POST /api/master-link/ipk
# Master-Link API - Serve SecuBox IPK (pre-built or generated on-the-fly)
# GET /api/master-link/ipk?token=TOKEN — direct download / opkg install
# POST /api/master-link/ipk — download with token in JSON body
# Auth: Token-validated
# NOTE: Headers are sent by ml_ipk_serve, not here
# Handle CORS preflight first
# Handle CORS preflight
if [ "$REQUEST_METHOD" = "OPTIONS" ]; then
echo "Content-Type: text/plain"
echo "Access-Control-Allow-Origin: *"
echo "Access-Control-Allow-Methods: POST, OPTIONS"
echo "Access-Control-Allow-Methods: GET, POST, OPTIONS"
echo "Access-Control-Allow-Headers: Content-Type"
echo ""
exit 0
fi
if [ "$REQUEST_METHOD" = "GET" ]; then
# GET with query string token - for direct download
echo "Content-Type: application/json"
echo "Access-Control-Allow-Origin: *"
echo ""
# Load library
. /usr/lib/secubox/master-link.sh >/dev/null 2>&1
@ -30,10 +25,14 @@ if [ "$REQUEST_METHOD" = "GET" ]; then
fi
if [ -z "$token" ]; then
echo '{"error":"missing_token","hint":"POST with {\"token\":\"...\"} or GET with ?token=..."}'
echo "Content-Type: application/json"
echo "Access-Control-Allow-Origin: *"
echo ""
echo '{"error":"missing_token","hint":"GET with ?token=TOKEN"}'
exit 0
fi
# ml_ipk_serve handles all headers (Content-Type, Content-Length, etc.)
ml_ipk_serve "$token"
exit 0
fi
@ -46,12 +45,10 @@ if [ "$REQUEST_METHOD" != "POST" ]; then
exit 0
fi
# Load library
# POST: token in JSON body
. /usr/lib/secubox/master-link.sh >/dev/null 2>&1
# Read POST body
read -r input
token=$(echo "$input" | jsonfilter -e '@.token' 2>/dev/null)
if [ -z "$token" ]; then

View File

@ -56,6 +56,11 @@ else
[ -z "$depth" ] && depth=0
ipk_info=$(ml_ipk_bundle_info 2>/dev/null)
ipk_available=$(echo "$ipk_info" | jsonfilter -e '@.available' 2>/dev/null)
ipk_type=$(echo "$ipk_info" | jsonfilter -e '@.type' 2>/dev/null)
# Detect master IP for IPK URL
master_ip=$(echo "${HTTP_HOST:-}" | cut -d: -f1)
[ -z "$master_ip" ] && master_ip=$(uci -q get network.lan.ipaddr)
cat <<-EOF
{
@ -63,7 +68,9 @@ else
"fingerprint": "$fp",
"hostname": "$hostname",
"depth": $depth,
"ipk_available": ${ipk_available:-false}
"ipk_available": ${ipk_available:-false},
"ipk_type": "${ipk_type:-unknown}",
"ipk_url_base": "http://${master_ip}:7331/api/master-link/ipk"
}
EOF
fi

View File

@ -61,6 +61,20 @@
.success-msg { color: var(--success); font-size: 0.85rem; padding: 0.75rem; background: rgba(34, 197, 94, 0.1); border-radius: 0.375rem; margin-top: 0.5rem; }
.hidden { display: none; }
.ipk-contents { background: var(--bg); padding: 0.6rem 0.8rem; border-radius: 0.375rem; margin-bottom: 0.75rem; font-size: 0.75rem; }
.ipk-contents .label { color: var(--muted); margin-bottom: 0.25rem; }
.ipk-contents .field { font-family: ui-monospace, monospace; padding: 0.1rem 0; }
.ipk-contents .field span { color: var(--accent); }
.cmd-box { background: var(--bg); padding: 0.5rem 0.8rem; border-radius: 0.375rem; font-family: ui-monospace, monospace; font-size: 0.75rem; word-break: break-all; cursor: pointer; position: relative; border: 1px solid var(--border); transition: border-color 0.2s; }
.cmd-box:hover { border-color: var(--accent); }
.cmd-box .copy-hint { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--muted); font-size: 0.65rem; font-family: system-ui, sans-serif; }
.btn-row { display: flex; gap: 0.5rem; }
.btn-row button { flex: 1; }
button.secondary { background: var(--border); color: var(--text); }
button.secondary:hover { background: #475569; }
</style>
</head>
<body>
@ -108,9 +122,24 @@
</div>
<div id="action-download" class="card hidden">
<div class="card-title">Download Package</div>
<div class="card-title">Join Package</div>
<div id="ipk-info"></div>
<button class="primary" id="btn-download" onclick="downloadIPK()">Download SecuBox IPK</button>
<div id="ipk-contents" class="ipk-contents hidden">
<div class="label">This package configures your device as:</div>
<div class="field">role = <span>peer</span></div>
<div class="field">upstream = <span id="ipk-upstream">-</span></div>
<div class="field">depth = <span id="ipk-depth">-</span></div>
</div>
<div id="ipk-cmd" class="hidden" style="margin-bottom:0.75rem;">
<div style="color:var(--muted);font-size:0.75rem;margin-bottom:0.25rem;">Install directly via SSH:</div>
<div class="cmd-box" id="opkg-cmd" onclick="copyCmd(this)">
<span class="copy-hint">click to copy</span>
</div>
</div>
<div class="btn-row">
<button class="primary" id="btn-download" onclick="downloadIPK()">Download IPK</button>
<button class="secondary" id="btn-skip" onclick="showJoinStep()">Skip</button>
</div>
</div>
<div id="action-join" class="card hidden">
@ -162,21 +191,29 @@
'<div class="info-row"><span class="info-label">Fingerprint</span><span class="info-value fingerprint">' + (masterInfo.fingerprint || '-') + '</span></div>' +
'<div class="info-row"><span class="info-label">Role</span><span class="info-value">' + (masterInfo.role || '-') + '</span></div>' +
'<div class="info-row"><span class="info-label">Depth</span><span class="info-value">' + (masterInfo.depth || 0) + '</span></div>' +
(masterInfo.ipk_available ? '<div class="info-row"><span class="info-label">IPK</span><span class="status-badge status-ok">Available</span></div>' : '');
'<div class="info-row"><span class="info-label">Join Package</span><span class="status-badge status-ok">Ready</span></div>';
// Move to step 2
setStep(1);
document.getElementById('action-download').classList.remove('hidden');
if (masterInfo.ipk_available) {
document.getElementById('ipk-info').innerHTML =
'<p style="color:var(--muted);font-size:0.8rem;margin-bottom:0.75rem;">The SecuBox package is ready for download. Install it on your device with: <code style="background:var(--bg);padding:0.15rem 0.4rem;border-radius:0.2rem;">opkg install secubox*.ipk</code></p>';
} else {
document.getElementById('ipk-info').innerHTML =
'<p style="color:var(--muted);font-size:0.8rem;margin-bottom:0.75rem;">No IPK bundle available on this master. You can skip to joining if SecuBox is already installed.</p>';
document.getElementById('btn-download').textContent = 'Skip to Join';
document.getElementById('btn-download').onclick = function() { showJoinStep(); };
}
// Build IPK download URL and opkg command
var ipkUrl = 'http://' + location.host + '/api/master-link/ipk?token=' + encodeURIComponent(token);
var opkgCmd = 'opkg install "' + ipkUrl + '"';
var peerDepth = (masterInfo.depth || 0) + 1;
document.getElementById('ipk-info').innerHTML =
'<p style="color:var(--muted);font-size:0.8rem;margin-bottom:0.75rem;">' +
'Install this package to join <strong>' + (masterInfo.hostname || 'master') + '</strong> as a mesh peer.</p>';
// Show IPK contents preview
document.getElementById('ipk-upstream').textContent = location.hostname || '-';
document.getElementById('ipk-depth').textContent = peerDepth;
document.getElementById('ipk-contents').classList.remove('hidden');
// Show opkg install command
document.getElementById('opkg-cmd').insertAdjacentHTML('afterbegin', opkgCmd);
document.getElementById('ipk-cmd').classList.remove('hidden');
} catch (e) {
document.getElementById('master-info').innerHTML =
'<div class="error">Could not reach master node: ' + e.message + '</div>';
@ -285,6 +322,23 @@
});
}
function copyCmd(el) {
var text = el.textContent.replace('click to copy', '').replace('copied!', '').trim();
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(function() {
var hint = el.querySelector('.copy-hint');
if (hint) { hint.textContent = 'copied!'; setTimeout(function() { hint.textContent = 'click to copy'; }, 2000); }
});
} else {
var ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
async function generateFingerprint() {
// Generate a browser-side fingerprint (temporary; real one comes from installed SecuBox)
var data = navigator.userAgent + Date.now() + Math.random();