No description
  • C 81.5%
  • CMake 13.9%
  • Shell 2.7%
  • HTML 1%
  • Assembly 0.9%
Find a file
Tarsow e31c34cf64
Merge rickroll captive portal
Open AP, DHCP, wildcard DNS and an HTTP server that intercepts OS
connectivity probes to trigger the captive-portal browser.
2026-08-01 18:26:18 +02:00
assets Serve the rickroll GIF streamed from flash 2026-08-01 18:17:03 +02:00
docs/superpowers Correct DHCP pool range and vendored licence attribution 2026-08-01 17:30:26 +02:00
lib Bring up open AP with DHCP and wildcard DNS 2026-08-01 17:19:25 +02:00
src Add watchdog, and fix connection resets under concurrent load 2026-08-01 18:25:56 +02:00
tests Add router with host tests 2026-08-01 17:58:19 +02:00
tools Add GIF pipeline and generate the rickroll asset 2026-08-01 17:42:55 +02:00
.gitignore Add project skeleton with blink 2026-08-01 17:06:21 +02:00
AGENTS.md Update handoff for the out-of-order GIF work 2026-08-01 17:43:51 +02:00
CMakeLists.txt Add watchdog, and fix connection resets under concurrent load 2026-08-01 18:25:56 +02:00
pico_sdk_import.cmake Add project skeleton with blink 2026-08-01 17:06:21 +02:00
README.md Add README 2026-08-01 18:26:13 +02:00

Pico 2 W Rickroll Captive Portal

An open WiFi access point on a Raspberry Pi Pico 2 W. Any device that joins gets its captive-portal sheet opened automatically onto a looping GIF served from the board's flash.

Build

Requires PICO_SDK_PATH pointing at a pico-sdk >= 2.0 checkout with submodules initialised, and an arm-none-eabi toolchain with newlib.

export PICO_SDK_PATH=$HOME/pico/pico-sdk
cmake -S . -B build -G Ninja
cmake --build build
picotool load -f -x build/rickroll_ap.uf2

-f resets a running board into BOOTSEL over USB CDC, so the button is usually unnecessary.

Tests

The request parser and the router are pure C with no embedded dependencies and run on the host:

cmake -S tests -B build-tests -G Ninja
cmake --build build-tests
ctest --test-dir build-tests --output-on-failure

The test project is deliberately separate from the firmware build and never calls pico_sdk_init(). That is what keeps the two pure units honest: an errant lwIP include breaks this build immediately.

Replacing the GIF

tools/make-gif.sh /path/to/video.mp4 <start-seconds> <duration-seconds>
cmake --build build

The script enforces a 2 MB budget and fails if the result exceeds it. Set CROP to an ffmpeg crop spec (w:h:x:y) to trim a watermark or pillarbox bars before scaling:

CROP=616:424:118:0 tools/make-gif.sh clip.mp4 1.2 6

Configuration

Everything tunable lives in src/config.h — SSID, gateway address, the connection limit, buffer sizes and the watchdog timeout.

How it works

DHCP hands every client the board as both its router and its DNS server. The DNS server answers every query with 192.168.4.1, so the connectivity probe each OS fires on joining a network (connectivitycheck.gstatic.com, captive.apple.com, msftconnecttest.com) arrives at our HTTP server. Each of those probes expects a very specific response; we answer all of them with a 302 instead, the OS concludes a captive portal is present, and it opens the sign-in browser.

The routing table is three lines:

/              -> 200 text/html
/rickroll.gif  -> 200 image/gif
*              -> 302 http://192.168.4.1/

The wildcard covers every probe URL without naming any of them, so it stays correct when a vendor changes an endpoint.

Assets are embedded with .incbin and written to the network directly from memory-mapped flash (RP2350 maps it at 0x10000000), so the GIF never occupies RAM. Headers are written with TCP_WRITE_FLAG_COPY because the connection slot holding them is released before lwIP finishes transmitting; the body is written without it, because flash outlives every connection.

Verified behaviour

On hardware, against MAX_CONNS of 8:

  • Byte-exact round trip: curl of the GIF cmps clean against the source file.
  • 8 concurrent fetches all byte-exact.
  • 12 concurrent: exactly 4 refused, the other 8 byte-exact, device still serving afterwards.
  • All three OS probe URLs return 302 with the correct Location.
  • Malformed request returns 400.
  • 40 sequential requests with no connection-slot leak.

Limitations

Accepted design decisions, not bugs.

  • HTTPS is not intercepted. Typing a real domain usually gets upgraded to HTTPS by HSTS and fails with a connection error rather than the portal. Serving TLS would need a certificate the client does not trust, and a browser warning is worse than the failure. The captive-portal path is plain HTTP, which is why it works reliably.
  • Android Private DNS (DNS-over-TLS) bypasses the wildcard resolver and may suppress the portal.
  • The CYW43439 associates roughly 10 stations in AP mode, and the DHCP pool is 192.168.4.16.23 — 8 leases, fixed by the vendored DHCP server.
  • HEAD requests receive a body, which is wrong per RFC 9110 but harmless here: no captive-portal probe uses HEAD.

Licence note

lib/dhcpserver.* and lib/dnsserver.* are vendored unmodified from pico-examples. They carry different licences:

  • dhcpserver.c/.h — MIT, Copyright (c) 2018-2019 Damien P. George (originally from MicroPython)
  • dnsserver.c/.h — BSD-3-Clause, Copyright (c) 2022 Raspberry Pi (Trading) Ltd.

Use this on a network you own, and pick an SSID that does not impersonate a real business — that turns a prank into somebody else's support ticket.