Lab 02 — Engagement Platform Build
Duration: 75 minutes
Every package in an OpenWrt image is a deliberate choice. On a 16 MB NOR flash chip, the wrong choice means the firmware doesn’t fit. On an unconstrained container, it means missing a tool at the worst possible moment. This lab makes that tradeoff concrete by producing two artifacts from the same OpenWrt 23.05.3 ImageBuilder baseline:
- Contract A — engagement-platform: a VS Code devcontainer image carrying the full engagement-stack (Tailscale, cloudflared, Python, wrangler, tcpdump, nmap, etc.). Flash is not a constraint here; the image is your primary operator tool for Labs 05–14.
- Contract B — drop-mango: a sysupgrade
.binfor the GL.iNet Mango (GL-MT300N-V2) that must fit in 16 MB NOR flash. Heavy packages are deliberately absent and installed post-flash via opkg onto an ExtRoot USB drive in Lab 03.
Both targets pin OpenWrt 23.05.3 / ramips / mt76x8 so any student building on any host on the same day can compare SHA256 checksums against a known-good reference build.
Learning objectives
- Understand the ImageBuilder model: pre-compiled packages assembled into a firmware image without a full source build.
- Build two firmware contracts from one baseline and explain why their package lists diverge.
- Read and interpret
build-manifest.jsonas a reproducibility artifact. - Verify a squashfs size constraint programmatically — and understand what to cut when the constraint fails.
Pre-state
Before starting this lab confirm:
# Lab 01 is complete — SSH to Mango works
ssh -o ConnectTimeout=5 root@<span data-profile-ip>192.168.8.1</span> 'echo ok'
# Docker is running
docker info | grep -E '^Server Version'
# Pull the ImageBuilder image (course Dockerfile extends it; this warms the cache)
docker pull openwrt/imagebuilder:ramips-mt76x8-23.05.3
# Clone/checkout confirms the canonical bundle files are present
ls courses/engagement-platform-labs/.devcontainer/Dockerfile
ls courses/engagement-platform-labs/labs/Makefile
ls courses/engagement-platform-labs/labs/lab02-imagebuilder-firmware/build-engagement-platform.sh
ls courses/engagement-platform-labs/labs/lab02-imagebuilder-firmware/build-drop-mango.sh
ls courses/engagement-platform-labs/labs/shared/files-mango/etc/banner
ls courses/engagement-platform-labs/labs/shared/build-manifest.schema.json
All of these files are checked into the repo. If any are missing, git status will show
them as untracked or deleted — restore with git checkout HEAD -- <path>.
Walkthrough
1. Inspect the build contracts
Spend five minutes reading the four key files. The rest of the lab will make more sense with this context.
# All commands run from the course root:
# cd courses/engagement-platform-labs
# Contract A: devcontainer Dockerfile
# Adds engagement-stack on top of openwrt/rootfs:x86-64-23.05.3
# No flash constraint; heavy packages all present.
cat .devcontainer/Dockerfile
# Contract B: Mango drop firmware build script
# Must fit in 16MB NOR. Minimal package list.
# Heavy packages (tailscale, python3, cloudflared) are ABSENT — installed post-flash in Lab 03.
cat labs/lab02-imagebuilder-firmware/build-drop-mango.sh
# The PACKAGES variable in build-drop-mango.sh is the engineering document.
# Read it alongside Contract A to understand what was cut and why.
# Shared overlay baked into the Mango image
cat labs/shared/files-mango/etc/banner
cat labs/shared/files-mango/etc/uci-defaults/99-enroll.sh.template
# Note: the template placeholders ({{WORKER_URL}} etc.) are NOT substituted here.
# Lab 12 substitutes real secrets and rebuilds.
# Makefile: the two build targets and their per-lab validator hooks
cat labs/Makefile
Discussion checkpoint (instructor-led, ~5 minutes):
Compare the PACKAGES lists between Contract A (.devcontainer/Dockerfile, the opkg install lines) and Contract B (build-drop-mango.sh, the PACKAGES= variable).
Ask: which packages appear in Contract A but not B? Why? The answer is the engineering
constraint: tailscale, python3, cloudflared, wrangler, nmap, nginx — all
multi-megabyte — simply don’t fit in 16 MB alongside a kernel and bootloader. The Mango
carries only what it needs to survive independently and self-enroll; everything else lives
on the ExtRoot USB (Lab 03) or in the devcontainer.
2. Build Contract A — engagement-platform devcontainer
The build script wraps docker build and writes a build-manifest.json to
labs/output/.
cd courses/engagement-platform-labs/labs
# Via the Makefile (recommended)
make engagement-platform
# Or invoke the script directly from the repo root
bash lab02-imagebuilder-firmware/build-engagement-platform.sh
Expected output (abbreviated):
>>> engagement-platform build
OPENWRT_VERSION=23.05.3
IMAGE_TAG=epl-engagement-platform:23.05.3
DEVCONTAINER=.../engagement-platform-labs/.devcontainer
[+] Building ...
=> FROM openwrt/rootfs:x86-64-23.05.3
=> opkg update && opkg install ca-bundle ca-certificates curl ...
=> opkg install tailscale luci nginx-ssl ...
=> cloudflared binary download
=> npm install -g wrangler@4
...
>>> built: epl-engagement-platform:23.05.3
digest: sha256:<...>
docker image size: <N> bytes
rootfs tar: labs/output/engagement-platform-rootfs.tar (<N> bytes, sha256=<...>)
>>> next: open this folder in VS Code and 'Reopen in Container'
The build writes labs/output/build-manifest.json. Examine it:
cat labs/output/build-manifest.json
Expected structure (values will differ):
{
"role": "engagement-platform",
"openwrt_version": "23.05.3",
"openwrt_target": "x86_64",
"imagebuilder_image": "sha256:...",
"image_sha256": "...",
"image_size_bytes": 123456789,
"created_at": "2026-05-03T...",
"builder_host": "yourlaptop"
}
First-run note: the opkg install tailscale layer can take 3–5 minutes to download
on a slow connection; subsequent rebuilds use Docker’s layer cache and are much faster.
The cloudflared binary download adds another 30–60 seconds on first run.
3. Open the devcontainer in VS Code
Now that the image is built, reopen the project inside the container:
- In VS Code, open the Command Palette (
Ctrl+Shift+P/Cmd+Shift+P). - Select “Dev Containers: Reopen in Container”.
- VS Code builds (or uses the cached image) and attaches. Open a new terminal.
Confirm you are inside OpenWrt:
cat /etc/openwrt_release
# DISTRIB_ID="OpenWrt"
# DISTRIB_RELEASE="23.05.3"
# DISTRIB_TARGET="x86_64"
# Confirm the engagement-stack tools are present
for bin in tailscale cloudflared python3 wrangler git curl jq tcpdump nmap; do
command -v "$bin" && echo "ok: $bin"
done
The post-create hook (post-create.sh) ran automatically and wrote
labs/output/devcontainer-manifest.json with tool versions. Check it:
cat labs/output/devcontainer-manifest.json
4. Build Contract B — drop-mango firmware
The Mango build runs inside the ImageBuilder Docker container — the ImageBuilder image
ships with the cross-compilation toolchain and pre-built packages for ramips/mt76x8.
You do not need a native MIPS toolchain on your laptop.
# From the labs/ directory (course root → labs/)
cd courses/engagement-platform-labs/labs
# Via the Makefile
make drop-mango
The Makefile invokes:
docker compose run --rm imagebuilder \
/labs/lab02-imagebuilder-firmware/build-drop-mango.sh
Expected output (abbreviated):
>>> drop-mango build
PROFILE=glinet_gl-mt300n-v2
FILES_DIR=/labs/shared/files-mango
SOURCE_DATE_EPOCH=1714694400
OUTPUT_DIR=/labs/output
PWD=/home/buildbot/openwrt-imagebuilder-23.05.3-ramips-mt76x8.Linux-x86_64
>>> built: bin/targets/ramips/mt76x8/openwrt-...-glinet_gl-mt300n-v2-drop-v1-squashfs-sysupgrade.bin
size: <N> bytes
sha256: <...>
squashfs rootfs: <M> bytes
>>> artifacts in /labs/output/
The build script enforces a squashfs ceiling of 13 MB. If the package list is too large, the build exits with:
ERROR: squashfs rootfs N bytes exceeds ceiling 13631488
trim the PACKAGES list or move heavy packages to ExtRoot (Lab 03)
This is intentional. The constraint is the lesson.
5. Verify build artifacts and squashfs size
# Confirm both artifacts exist
ls -lh labs/output/
# Expected:
# engagement-platform-rootfs.tar
# openwrt-...-glinet_gl-mt300n-v2-drop-v1-squashfs-sysupgrade.bin
# build-manifest.json
# devcontainer-manifest.json (written by post-create.sh)
# Verify the Mango .bin is present
BIN=$(ls labs/output/*glinet_gl-mt300n-v2*sysupgrade.bin 2>/dev/null | head -1)
echo "$BIN"
# Compute size in MB for human review
wc -c < "$BIN" | awk '{printf "%.2f MB\n", $1/1024/1024}'
# Should be well under 16 MB
# Read the drop-mango build-manifest.json
cat labs/output/build-manifest.json
# Verify "role": "drop-mango" and "openwrt_version": "23.05.3"
Compare SHA256 with a neighbor’s build:
Both builds used SOURCE_DATE_EPOCH=1714694400 (set in build-drop-mango.sh) to strip
squashfs timestamps. Two students building from the same commit on the same day should
produce identical .bin SHA256 values:
sha256sum labs/output/*sysupgrade.bin
If the SHAs differ, check whether the package feeds served different package versions.
The package_list_sha256 field in build-manifest.json records a hash of the package
list itself; if that matches between two students but the .bin doesn’t, a package
metadata difference in the feed is the likely cause.
6. Compare the two contracts (discussion)
With both artifacts in hand, compare them side by side:
# Contract A: what's in the devcontainer rootfs tarball
tar tf labs/output/engagement-platform-rootfs.tar | grep -E 'tailscale|cloudflared|python3|wrangler' | head -20
# Contract B: what's in the Mango squashfs
# (unsquashfs requires squashfs-tools on the host; skip if unavailable)
# Instead, read the package list from the ImageBuilder manifest
ls labs/output/*.manifest 2>/dev/null || \
docker compose run --rm imagebuilder \
cat /home/buildbot/openwrt-imagebuilder-23.05.3-ramips-mt76x8.Linux-x86_64/bin/targets/ramips/mt76x8/*.manifest
Key questions to answer before moving on:
- Which packages in Contract A are absent from Contract B? (Answer: tailscale, cloudflared, python3, nmap, nginx, luci, wrangler — all too large for 16 MB NOR.)
- What does Contract B have that enables it to grow past its NOR constraint? (Answer:
block-mount,kmod-usb-storage,kmod-fs-ext4,e2fsprogs— the ExtRoot toolchain taught in Lab 03.) - Why does Contract B omit
dnsmasq,firewall4, andnftables? (Answer: the Mango in its drop role connects only via Tailscale, not as a router; no DNS/firewall/NAT needed from the NOR image. These can be added to ExtRoot later if the mission profile requires them.)
Post-state
When this lab is complete:
-
labs/output/engagement-platform-rootfs.tarexists and SHA256 is recorded inlabs/output/build-manifest.json. -
labs/output/openwrt-...-glinet_gl-mt300n-v2-drop-v1-squashfs-sysupgrade.binexists and fits in 16 MB (confirmed byvalidate.sh). - The devcontainer is rebuilt and VS Code shows “Dev Container: EPL Engagement Platform (OpenWrt 23.05.3)” in the bottom-left status bar.
- You can articulate the three packages that define the size boundary between what fits in NOR and what must go on ExtRoot.
Validation
# From the course root
chmod +x courses/engagement-platform-labs/labs/lab02-imagebuilder-firmware/validate.sh
bash courses/engagement-platform-labs/labs/lab02-imagebuilder-firmware/validate.sh
# Or via Makefile
cd courses/engagement-platform-labs/labs
make validate-lab02-imagebuilder-firmware
The script checks:
labs/output/build-manifest.jsonexists androlefield is present and valid.- The sysupgrade
.binforglinet_gl-mt300n-v2exists inlabs/output/. - The
.binis smaller than 16,777,216 bytes (16 MB hard ceiling — the NOR chip size). build-manifest.jsonvalidates againstlabs/shared/build-manifest.schema.jsonusingpython3(available in the devcontainer or the host).
Take-home extension
See take-home/lab02-mt3000-build/ (not yet written — Wave 4 content). The scope:
- Same dual-contract exercise on
mediatek/filogic/glinet_gl-mt3000. - The MT3000 carries eMMC, so the “drop firmware” has no 16 MB NOR constraint.
- The contrast becomes package selection for a different reason: what should the MT3000 carry that the Mango cannot, and vice versa for a mission that uses both?
Troubleshooting
make drop-mango: docker compose run fails — imagebuilder service not found
The docker-compose.yml must be present in courses/engagement-platform-labs/labs/.
Verify:
ls courses/engagement-platform-labs/labs/docker-compose.yml
docker compose -f courses/engagement-platform-labs/labs/docker-compose.yml config
If the file is missing, check git status — it may be untracked or deleted.
opkg download errors during engagement-platform build
The devcontainer Dockerfile runs opkg update at build time against the OpenWrt 23.05.3
package feeds. If a feed is temporarily unavailable:
# Retry the build (Docker caches layers, so only the failed layer reruns)
make engagement-platform
# If the feed is consistently down, check the OpenWrt downloads mirror status:
# https://downloads.openwrt.org/
The cloudflared binary download in the Dockerfile is the most likely to fail on a
corporate network (GitHub releases may be blocked). If so, the instructor can pre-stage
the binary in .devcontainer/ and update the Dockerfile to COPY it instead of
curl-ing it.
squashfs ceiling exceeded — build-drop-mango.sh exits with ERROR
The 13 MB ceiling is enforced by the build script. If you modified PACKAGES and exceeded
it:
# Identify the heaviest packages in the image
docker compose run --rm imagebuilder \
find /home/buildbot/openwrt-imagebuilder-23.05.3-ramips-mt76x8.Linux-x86_64/bin/targets \
-name '*.ipk' | xargs ls -lS 2>/dev/null | head -20
Candidate packages to remove: e2fsprogs (small, but check) and any extra kmod modules.
Do not remove block-mount or kmod-usb-storage — they are required for ExtRoot in
Lab 03.
build-manifest.json validation fails (python3 not found on host)
validate.sh uses python3 to validate JSON against the schema. If your host lacks
Python 3, run validation from inside the devcontainer:
# Open devcontainer terminal in VS Code, then:
bash /workspaces/engagement-platform-labs/labs/lab02-imagebuilder-firmware/validate.sh
Or install python3 on the host (apt install python3 / brew install python3).
ValidateOutputPaster lab="lab02")