A captive-portal appliance in a box — DNS funnel, HTTPS, a live console, and a real SSH mini-shell. Point it at any static site.
Roost is a single Go binary that flashes a device image and boots it: it
broadcasts a captive-portal welcome, funnels every DNS name to itself, serves the
image's site over HTTP + HTTPS, streams a live console, and lets you ssh into a
little unix-like shell — all on localhost, no hardware, no sudo.
It began as a way to test a personal Seeed XIAO ESP32-S3 captive-portal
appliance on a laptop, and generalized into a reusable tool: anything with a
captive portal, a wildcard-DNS trick, and an admin shell. The bundled
FriendlyPortal OS example (examples/friendlyportal/) is a full demo and
self-documenting tour.
$ roost run examples/friendlyportal
🕸️ flashing image: FriendlyPortal OS
HTTP (portal) → http://localhost:8080
HTTPS (secure) → https://localhost:8443 (or https://friendlyportal.local after --setup-hosts)
DNS (funnel) → udp:5354 (every name → 127.0.0.1)
SSH (shell) → ssh friendly@localhost -p 2222 (pass: friendly)
DASH (console) → http://localhost:9090
The landing page and the live console (roost run, then browse the site and open :9090):
New here? docs/MAKE-YOUR-OWN.md is a zero-to-hardware, copy-paste walkthrough: install → make an image → customize it → test locally → add a real domain + free HTTPS cert → flash it onto an ESP32. To just tweak the demo, see examples/friendlyportal/README.md.
brew install stevenssparks/tap/roost # Homebrew (recommended)
# …or: go install github.com/stevenssparks/roost@latest
roost # boot the built-in demo image
roost run ./my-image # boot your own image
ssh roost@localhost -p 2222 # log into the shell (demo pass: roost)
open http://localhost:9090 # watch the live consoleOr from a clone: go build -o roost . && ./roost.
setup.sh builds, clears any stale SSH host key for the image's port, and runs
with --setup-hosts (so the domain resolves locally):
./setup.sh examples/friendlyportal # build + host-map + run (uses sudo)
SUDO="" ./setup.sh examples/friendlyportal # no sudo (image uses unprivileged ports)
NO_HOSTS=1 ./setup.sh # don't touch /etc/hosts; run the demo image| Command | What it does |
|---|---|
roost run [image] |
Flash the image (or the embedded demo) and boot every enabled service. |
roost flash [image] |
Load + validate an image and print the config it would boot (dry run). |
roost init [dir] |
Scaffold a new image (roost.json + data/index.html). |
roost cert status <image> |
Show the image's installed TLS cert and days-to-expiry. |
roost cert refresh <image> |
Renew via acme.sh (DNS-01) and install the cert into the image. |
roost doctor [--install] |
Check / install the hardware toolchain (arduino-cli, esptool, mklittlefs, esp32 core). |
roost build-image <image> |
Compile firmware + build the filesystem → one flashable .bin. |
roost flash-board <image> |
Flash the filesystem, or a full --image <bin>, to an ESP32 (plans first; --confirm to write). |
Run-flag overrides (win over the manifest): --http :8080 --https :8443
--dns :5354 --ssh :2222 --dash :9090 --ssh-user --ssh-pass
--answer-ip --extended --setup-hosts --lan.
An image is just a folder — so a GitHub repo can be an image:
my-image/
roost.json manifest: identity, ports, which services run
data/ the site served over HTTP/HTTPS (index.html, …)
certs/ optional: fullchain.pem + privkey.pem
roost.json (every field optional — omitted fields inherit built-in defaults):
{
"name": "FriendlyPortal OS",
"hostname": "friendlyportal",
"prompt": "friendlyportal# ",
"ssid": "FriendlyPortal",
"domain": "friendlyportal.local",
"dnsAnswer": "127.0.0.1",
"extendedShell": false,
"services": {
"http": { "enabled": true, "addr": ":8080" },
"https": { "enabled": true, "addr": ":8443", "certDir": "certs" },
"dns": { "enabled": true, "addr": ":5354" },
"ssh": { "enabled": true, "addr": ":2222", "user": "friendly", "pass": "friendly" },
"dashboard": { "enabled": true, "addr": ":9090" }
}
}ssh <user>@localhost -p 2222 drops you at the image's prompt. Type help for
the list and man <cmd> for any command's manual.
Faithful set (matches the ESP32 firmware — the honest emulation of a microcontroller) plus session niceties:
help man status clients stats log [n] tail top ls [path] cat <path>
rm <path> free uptime wifi dns dhcp who motd clear cls reboot exit
- Login shows a unix-style MOTD with live system stats + the image's custom
message (set
"motd"inroost.json). - Tab-completion for commands and file paths;
?aliaseshelp. taillive-streams web hits (Enter to stop);topis a live text dashboard (any key to quit);dns/dhcpshow network info + stats.exit/logout/quit(or Ctrl-D) disconnect.
Extended set (extendedShell: true in the manifest, or --extended) adds a
unix feel, banner-marked emulator-only, beyond the ESP32 (a real ESP32-S3 has
no MMU and can't run a unix kernel — that layer is for Pi-class targets):
pwd cd whoami id echo date uname hostname version (ver) history about
Roost serves HTTPS with the image's real cert if <image>/<certDir>/fullchain.pem
privkey.pemare present; otherwise it generates a self-signedCN=<domain>so it always runs. To browsehttps://<domain>with a clean padlock locally, run once with--setup-hosts(needs sudo) to map the domain todnsAnswerin/etc/hosts.
Manage the real cert with acme.sh from the CLI:
roost cert status my-portal # CN, issuer, days to expiry
roost cert refresh my-portal # renew (if due) + install into the imagecert refresh renews the image domain's Let's Encrypt cert via DNS-01 and copies
fullchain.pem + privkey.pem into the image's cert dir (and registers an
acme.sh reinstall hook so future auto-renewals land there too). Add --force to
renew before the renewal window.
A device joins the Wi-Fi and probes a URL like /generate_204 to check for
internet. The DNS service answers every lookup with the board's address, so
the probe hits Roost and gets a 302 redirect — the phone pops the portal.
Watch every hit and DNS lookup stream in the console at :9090.
The image is a portable bundle, so it's also the artifact you flash to real
hardware. roost flash-board already builds a LittleFS image from data/ and
writes it to an ESP32 (it prints the plan first; --confirm to write). Still on
the roadmap:
--lan— bind real LAN ports so another device can join over Wi-Fi.
go test ./... # unit + SSH integration tests
go vet ./...Packages: image (manifest/flash), device (shared live state: log ring, stats,
virtual FS, DNS log), services (http, cert, dns, shell, ssh, dashboard),
assets (embedded console page); the default demo image is embedded from
examples/friendlyportal/.
MIT © 2026 Steve Sparks — Roost and the FriendlyPortal OS example are MIT licensed.

