- C 81.5%
- CMake 13.9%
- Shell 2.7%
- HTML 1%
- Assembly 0.9%
Open AP, DHCP, wildcard DNS and an HTTP server that intercepts OS connectivity probes to trigger the captive-portal browser. |
||
|---|---|---|
| assets | ||
| docs/superpowers | ||
| lib | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| AGENTS.md | ||
| CMakeLists.txt | ||
| pico_sdk_import.cmake | ||
| README.md | ||
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:
curlof the GIFcmps 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
302with the correctLocation. - 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. HEADrequests receive a body, which is wrong per RFC 9110 but harmless here: no captive-portal probe usesHEAD.
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.