Dev mode — no Cloudflare Access header present. Authenticated as dev@local.
EPL
dev@local

Technical Specifications for Script Development

Note (Mango-only / devcontainer pivot): Canonical firmware build contracts now live in labs/lab02-imagebuilder-firmware/build-engagement-platform.sh and labs/lab02-imagebuilder-firmware/build-drop-mango.sh (invoked via make engagement-platform and make drop-mango from labs/). 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), only HSC🍗🍊🍒🍈 has been verified by hand (bit derivation shown in labs/lab11-chatops-emojichef/test-vectors.txt). The status and reboot vectors 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 in test-vectors.txt to 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.conf block below targets the GL.iNet Flint 2 (GL-MT6000), which shares the mediatek/filogic target with the MT3000. MT3000 (Beryl AX) content has moved to labs/take-home/lab02-mt3000-build/ (TBD — post-workshop expansion for students who acquire MT3000 hardware). For the in-class workshop, use make engagement-platform and make 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

  1. Create repository structure as outlined above
  2. Implement Mango enrollment script with proper error handling
  3. Build Worker template with all endpoints and D1 integration
  4. Test EmojiChef decoder with Discord webhook integration
  5. Validate hardware dependencies (Tailscale on Mango, cloudflared on Flint 2)
  6. Create lab validation scripts for each major milestone
  7. 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