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:
parent
cd4e991761
commit
857622ff56
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user