Technical Specifications for Script Development
Note (Mango-only / devcontainer pivot): Canonical firmware build contracts now live in
labs/lab02-imagebuilder-firmware/build-engagement-platform.shandlabs/lab02-imagebuilder-firmware/build-drop-mango.sh(invoked viamake engagement-platformandmake drop-mangofromlabs/). The text below is the original workshop spec, retained for reference context. Prefer the Makefile targets over manual invocation of the scripts described here.
1. Mango Enrollment Script (scripts/enrollment/99-enroll.sh.template)
Requirements
- Must work on GL-MT300N-V2 with 128MB RAM, 16MB flash
- ExtRoot to USB required before Tailscale installation
- Retry logic for network connectivity and Tailscale startup
- Self-deletion after successful enrollment to avoid re-runs
- Proper error logging to /tmp/enrollment.log
Promoted version: The hardened, production-ready template used in the workshop is at
labs/shared/files-mango/etc/uci-defaults/99-enroll.sh.template. Prefer that file over the draft below.
Script Template
#!/bin/sh
# /etc/uci-defaults/99-enroll.sh
# Auto-enrollment for drop device
# Variables to be replaced during build:
# {{WORKER_URL}} - Student's Worker URL
# {{SERVICE_TOKEN_ID}} - CF Access service token ID
# {{SERVICE_TOKEN_SECRET}} - CF Access service token secret
# {{TAILSCALE_KEY}} - Ephemeral auth key with device tag
WORKER_URL="{{WORKER_URL}}"
SERVICE_TOKEN_ID="{{SERVICE_TOKEN_ID}}"
SERVICE_TOKEN_SECRET="{{SERVICE_TOKEN_SECRET}}"
TAILSCALE_KEY="{{TAILSCALE_KEY}}"
LOG_FILE="/tmp/enrollment.log"
MAX_RETRIES=5
log() {
echo "$(date): $1" >> $LOG_FILE
}
wait_for_network() {
local retries=0
while [ $retries -lt 30 ]; do
if ping -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then
log "Network connectivity confirmed"
return 0
fi
sleep 2
retries=$((retries + 1))
done
log "ERROR: Network timeout after 60 seconds"
return 1
}
setup_tailscale() {
log "Starting Tailscale enrollment"
# Generate random hostname to avoid conflicts
local hostname="mango-$(head -c 8 /proc/sys/kernel/random/uuid | tr -d '-')"
tailscale up \
--auth-key="$TAILSCALE_KEY" \
--hostname="$hostname" \
--accept-routes \
--ssh \
--timeout=60s
if [ $? -eq 0 ]; then
log "Tailscale enrollment successful"
# Wait for daemon to fully initialize
sleep 15
return 0
else
log "ERROR: Tailscale enrollment failed"
return 1
fi
}
enroll_with_worker() {
local retries=0
# Get Tailscale status
local hostname=$(tailscale status --peers=false --json 2>/dev/null | \
jsonfilter -e '@.Self.DNSName' 2>/dev/null)
local device_id=$(cat /proc/cpuinfo | grep Serial | cut -d: -f2 | tr -d ' ')
if [ -z "$hostname" ] || [ -z "$device_id" ]; then
log "ERROR: Failed to get hostname or device_id"
return 1
fi
log "Enrolling device: $device_id with hostname: $hostname"
while [ $retries -lt $MAX_RETRIES ]; do
local response=$(curl -s -w "%{http_code}" \
-X POST "$WORKER_URL/v1/devices/enroll" \
-H "CF-Access-Client-Id: $SERVICE_TOKEN_ID" \
-H "CF-Access-Client-Secret: $SERVICE_TOKEN_SECRET" \
-H "Content-Type: application/json" \
--connect-timeout 10 \
--max-time 30 \
-d "{
\"device_id\": \"$device_id\",
\"device_type\": \"mango\",
\"tailscale_hostname\": \"$hostname\",
\"metadata\": {
\"uptime\": \"$(cat /proc/uptime | cut -d' ' -f1)\",
\"load\": \"$(cat /proc/loadavg | cut -d' ' -f1-3)\",
\"free_mem\": \"$(free | grep Mem | awk '{print $4}')\",
\"flash_free\": \"$(df / | tail -1 | awk '{print $4}')\"
}
}")
local http_code="${response: -3}"
local body="${response%???}"
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
log "Worker enrollment successful: $body"
return 0
else
log "Worker enrollment failed (attempt $((retries + 1))): HTTP $http_code - $body"
retries=$((retries + 1))
[ $retries -lt $MAX_RETRIES ] && sleep $((retries * 5))
fi
done
log "ERROR: Worker enrollment failed after $MAX_RETRIES attempts"
return 1
}
# Main execution
main() {
log "Starting enrollment process"
if ! wait_for_network; then
exit 1
fi
if ! setup_tailscale; then
exit 1
fi
if ! enroll_with_worker; then
exit 1
fi
log "Enrollment completed successfully"
# Clean up - remove this script
rm -f /etc/uci-defaults/99-enroll.sh
# Optional: reboot to ensure clean state
# reboot
}
main "$@"
2. Worker Template (scripts/workers/fleet-gateway.js)
Required Environment Variables
// wrangler.toml
[env.production]
vars = { ENVIRONMENT = "production" }
[env.production.d1_databases]
FLEET_DB = { database_name = "fleet-database", database_id = "your-d1-id" }
[env.production.r2_buckets]
ARTIFACTS = { bucket_name = "artifacts-bucket" }
[env.production.kv_namespaces]
RATE_LIMITS = { binding = "RATE_LIMITS", namespace_id = "your-kv-id" }
Core Worker Structure
// Required exports and dependencies
export { default } from './fleet-gateway-worker.js';
// fleet-gateway-worker.js
export default {
async fetch(request, env, ctx) {
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, CF-Access-Client-Id, CF-Access-Client-Secret'
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const url = new URL(request.url);
const response = await handleRequest(url, request, env);
// Add CORS headers to all responses
Object.entries(corsHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
};
async function handleRequest(url, request, env) {
const pathname = url.pathname;
// Route dispatch
switch (pathname) {
case '/v1/health':
return handleHealth();
case '/v1/devices/enroll':
return handleEnroll(request, env);
case '/v1/devices':
return handleDeviceList(request, env);
default:
if (pathname.startsWith('/v1/commands/')) {
return handleCommand(pathname, request, env);
} else if (pathname.startsWith('/v1/jobs/')) {
return handleJobStatus(pathname, request, env);
}
return new Response('Not Found', { status: 404 });
}
}
// Endpoint implementations
async function handleHealth() {
return Response.json({
ok: true,
version: "1.0.0",
timestamp: new Date().toISOString()
});
}
async function handleEnroll(request, env) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
// Verify CF Access service token
const clientId = request.headers.get('CF-Access-Client-Id');
const clientSecret = request.headers.get('CF-Access-Client-Secret');
if (!clientId || !clientSecret) {
return Response.json({ error: 'Missing access credentials' }, { status: 401 });
}
try {
const body = await request.json();
const { device_id, device_type, tailscale_hostname, metadata = {} } = body;
if (!device_id || !device_type || !tailscale_hostname) {
return Response.json({ error: 'Missing required fields' }, { status: 400 });
}
// Generate device tag for ACL scoping
const tag = `device-${device_type}-${Date.now()}`;
// Insert into D1
const stmt = env.FLEET_DB.prepare(`
INSERT OR REPLACE INTO devices
(device_id, device_type, tag, tailscale_hostname, engagement_id, metadata, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, CURRENT_TIMESTAMP)
`);
await stmt.bind(
device_id,
device_type,
tag,
tailscale_hostname,
'workshop-demo',
JSON.stringify(metadata)
).run();
// Audit log
await logAudit(env, {
operator_id: clientId,
device_id,
action: 'enroll',
details: { device_type, hostname: tailscale_hostname },
source_ip: request.headers.get('CF-Connecting-IP')
});
return Response.json({
enrolled: true,
tag,
device_id,
tailscale_hostname
});
} catch (error) {
console.error('Enrollment error:', error);
return Response.json({ error: 'Enrollment failed' }, { status: 500 });
}
}
async function handleDeviceList(request, env) {
// Verify CF Access for operator
const accessHeader = request.headers.get('CF-Access-Jwt-Assertion');
if (!accessHeader) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const stmt = env.FLEET_DB.prepare(`
SELECT device_id, device_type, tag, tailscale_hostname,
enrolled_at, last_seen, metadata
FROM devices
ORDER BY last_seen DESC
`);
const result = await stmt.all();
const devices = result.results.map(row => ({
...row,
metadata: JSON.parse(row.metadata || '{}'),
status: isDeviceOnline(row.last_seen) ? 'online' : 'offline'
}));
return Response.json(devices);
} catch (error) {
console.error('Device list error:', error);
return Response.json({ error: 'Failed to fetch devices' }, { status: 500 });
}
}
async function handleCommand(pathname, request, env) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const deviceId = pathname.split('/')[3];
if (!deviceId) {
return Response.json({ error: 'Invalid device ID' }, { status: 400 });
}
try {
const body = await request.json();
const { command, params = {}, timeout = 30 } = body;
const jobId = generateJobId();
// Store job in KV with expiration
await env.RATE_LIMITS.put(`job:${jobId}`, JSON.stringify({
device_id: deviceId,
command,
params,
status: 'queued',
created_at: new Date().toISOString(),
timeout
}), { expirationTtl: timeout + 60 });
// Here would be the actual command dispatch logic
// For now, simulate with setTimeout equivalent
return Response.json({
job_id: jobId,
status: 'queued',
device_id: deviceId
});
} catch (error) {
console.error('Command error:', error);
return Response.json({ error: 'Command failed' }, { status: 500 });
}
}
// Utility functions
async function logAudit(env, entry) {
const stmt = env.FLEET_DB.prepare(`
INSERT INTO audit_log
(operator_id, device_id, action, details, source_ip, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
`);
await stmt.bind(
entry.operator_id,
entry.device_id || null,
entry.action,
JSON.stringify(entry.details || {}),
entry.source_ip || null,
entry.user_agent || null
).run();
}
function isDeviceOnline(lastSeen) {
const now = new Date();
const lastSeenDate = new Date(lastSeen);
const diffMinutes = (now - lastSeenDate) / (1000 * 60);
return diffMinutes < 5; // Consider online if seen in last 5 minutes
}
function generateJobId() {
return crypto.randomUUID();
}
3. EmojiChef Integration (scripts/workers/emojichef-decoder.js)
Test vector verification needed. Of the three test vectors referenced in this section (
HSC,status,reboot), onlyHSC→🍗🍊🍒🍈has been verified by hand (bit derivation shown inlabs/lab11-chatops-emojichef/test-vectors.txt). Thestatusandrebootvectors appear inconsistent with the algorithm’s bit math (a 6-character string should encode to 8 emoji, not 6 — 6 × 8 bits = 48 bits, 48 / 6 bits-per-emoji = 8 emoji). Trust the implementation; run the Node snippet intest-vectors.txtto regenerate correct vectors before using them in automated tests.
// EmojiChef Quick Recipe (Base-64) decoder
class EmojiChefQuick {
constructor() {
this.base = 0x1F345; // 🍅 (tomato)
this.maxEmoji = 0x1F37F; // 🍿 (popcorn)
this.bitsPerEmoji = 6;
}
decode(emojiString) {
if (!emojiString || typeof emojiString !== 'string') {
throw new Error('Invalid emoji string');
}
const codePoints = [...emojiString].map(emoji => {
const codePoint = emoji.codePointAt(0);
if (codePoint < this.base || codePoint > this.maxEmoji) {
throw new Error(`Invalid emoji for Quick recipe: ${emoji}`);
}
return codePoint - this.base;
});
// Convert 6-bit values to binary string
const binaryString = codePoints.map(value =>
value.toString(2).padStart(this.bitsPerEmoji, '0')
).join('');
// Convert binary to ASCII bytes
const result = [];
for (let i = 0; i < binaryString.length; i += 8) {
const byte = binaryString.substr(i, 8);
if (byte.length === 8) {
result.push(String.fromCharCode(parseInt(byte, 2)));
}
}
return result.join('');
}
encode(text) {
if (!text || typeof text !== 'string') {
throw new Error('Invalid text string');
}
// Convert text to binary
const binaryString = text.split('').map(char =>
char.charCodeAt(0).toString(2).padStart(8, '0')
).join('');
// Convert binary to 6-bit chunks
const result = [];
for (let i = 0; i < binaryString.length; i += this.bitsPerEmoji) {
const chunk = binaryString.substr(i, this.bitsPerEmoji);
if (chunk.length === this.bitsPerEmoji) {
const value = parseInt(chunk, 2);
result.push(String.fromCodePoint(this.base + value));
}
}
return result.join('');
}
// Test examples
static test() {
const chef = new EmojiChefQuick();
const tests = [
{ text: 'HSC', expected: '🍗🍊🍒🍈' },
{ text: 'status', expected: '🥘🥫🥩🌯🥙🥘' },
{ text: 'reboot', expected: '🍱🥤🥩🥓🥨🥯🌯' }
];
tests.forEach(test => {
const encoded = chef.encode(test.text);
const decoded = chef.decode(encoded);
console.log(`"${test.text}" → ${encoded} → "${decoded}"`);
console.log(`Expected: ${test.expected}, Got: ${encoded}`);
console.log(`Match: ${encoded === test.expected ? '✓' : '✗'}\n`);
});
}
}
export { EmojiChefQuick };
4. ImageBuilder Configs (configs/imagebuilder/)
MT3000/Beryl AX note: The
flint2.confblock below targets the GL.iNet Flint 2 (GL-MT6000), which shares the mediatek/filogic target with the MT3000. MT3000 (Beryl AX) content has moved tolabs/take-home/lab02-mt3000-build/(TBD — post-workshop expansion for students who acquire MT3000 hardware). For the in-class workshop, usemake engagement-platformandmake drop-mango.
Flint 2 Config (flint2.conf)
TARGET="mediatek"
SUBTARGET="filogic"
PROFILE="glinet_gl-mt6000"
PACKAGES="tailscale \
block-mount kmod-usb-storage \
lighttpd lighttpd-mod-cgi lighttpd-mod-redirect \
nodogsplash \
curl ca-certificates \
htop tcpdump-mini iperf3 \
dropbear-ed25519 \
luci-ssl luci-app-uhttpd \
kmod-mt7996e \
cloudflared"
DISABLED_PACKAGES="-wpad-basic-wolfssl \
-odhcpd \
-odhcpd-ipv6only"
CUSTOM_FILES="files/"
Mango Config (mango.conf)
TARGET="ramips"
SUBTARGET="mt76x8"
PROFILE="glinet_gl-mt300n-v2"
PACKAGES="tailscale \
block-mount kmod-usb-storage \
tcpdump-mini curl ca-certificates \
dropbear-ed25519 \
kmod-mt76x8"
DISABLED_PACKAGES="-luci* \
-wpad-basic-wolfssl \
-odhcpd \
-odhcpd-ipv6only \
-firewall4 \
-nftables"
# Minimal config for drop device
CUSTOM_FILES="files-mango/"
5. Validation Scripts (scripts/validation/lab-check.sh)
#!/bin/bash
# Lab validation script
check_lab() {
local lab_num=$1
local description=$2
echo "=== Lab $lab_num: $description ==="
case $lab_num in
"1")
check_device_access
;;
"2")
check_custom_firmware
;;
"7")
check_worker_tunnel
;;
"9")
check_d1_schema
;;
"13")
check_mango_enrollment
;;
"14")
check_full_integration
;;
*)
echo "Lab $lab_num validation not implemented"
return 1
;;
esac
}
check_device_access() {
echo "Checking device SSH access..."
if ssh -o ConnectTimeout=5 -o BatchMode=yes root@192.168.8.1 'echo "SSH OK"' 2>/dev/null; then
echo "✓ SSH access working"
return 0
else
echo "✗ SSH access failed"
return 1
fi
}
check_worker_tunnel() {
echo "Checking Worker and Tunnel..."
if [ -z "$WORKER_URL" ]; then
echo "✗ WORKER_URL not set"
return 1
fi
# Check health endpoint
local response=$(curl -s -o /dev/null -w "%{http_code}" "$WORKER_URL/v1/health")
if [ "$response" = "200" ]; then
echo "✓ Worker health check passed"
else
echo "✗ Worker health check failed (HTTP $response)"
return 1
fi
# Check tunnel connectivity
local tunnel_check=$(curl -s -H "User-Agent: curl/lab-check" "$WORKER_URL/v1/health" | jq -r .ok 2>/dev/null)
if [ "$tunnel_check" = "true" ]; then
echo "✓ Tunnel connectivity working"
return 0
else
echo "✗ Tunnel connectivity failed"
return 1
fi
}
check_d1_schema() {
echo "Checking D1 database schema..."
if [ -z "$CLOUDFLARE_ACCOUNT_ID" ] || [ -z "$CLOUDFLARE_API_TOKEN" ]; then
echo "✗ Cloudflare credentials not set"
return 1
fi
# This would need wrangler CLI or direct API calls
echo "⚠ D1 schema check requires manual verification"
echo " Expected tables: devices, audit_log, sessions"
return 0
}
check_mango_enrollment() {
echo "Checking Mango enrollment..."
if [ -z "$WORKER_URL" ]; then
echo "✗ WORKER_URL not set"
return 1
fi
# Check for devices in registry
local device_count=$(curl -s "$WORKER_URL/v1/devices" | jq '. | length' 2>/dev/null)
if [ "$device_count" -gt 0 ]; then
echo "✓ Found $device_count enrolled device(s)"
return 0
else
echo "✗ No enrolled devices found"
return 1
fi
}
# Main script
if [ $# -eq 0 ]; then
echo "Usage: $0 <lab_number> [description]"
echo "Example: $0 7 'First Worker'"
exit 1
fi
check_lab "$1" "${2:-Unknown Lab}"
Next Steps for Implementation
- Create repository structure as outlined above
- Implement Mango enrollment script with proper error handling
- Build Worker template with all endpoints and D1 integration
- Test EmojiChef decoder with Discord webhook integration
- Validate hardware dependencies (Tailscale on Mango, cloudflared on Flint 2)
- Create lab validation scripts for each major milestone
- Document troubleshooting procedures for common failure modes
Required Testing Environment
- Cloudflare account with D1, R2, Workers, Access configured
- Tailscale account with test tailnet and ACL policies
- GL.iNet Flint 2 and Mango units for hardware validation
- Discord server or hack.chat room for ChatOps testing
- Domain pointed to Cloudflare for Tunnel testing