Skip to content

StevenSSparks/roost

Repository files navigation

🕸️ Roost

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 bundled FriendlyPortal OS demo

The landing page and the live console (roost run, then browse the site and open :9090):

FriendlyPortal OS landing

Roost live console

📖 Make your own

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.

Install & run

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 console

Or from a clone: go build -o roost . && ./roost.

One-shot setup script

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

Commands

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.

What an image is

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" }
  }
}

The shell

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" in roost.json).
  • Tab-completion for commands and file paths; ? aliases help.
  • tail live-streams web hits (Enter to stop); top is a live text dashboard (any key to quit); dns/dhcp show 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

HTTPS & real certs

Roost serves HTTPS with the image's real cert if <image>/<certDir>/fullchain.pem

  • privkey.pem are present; otherwise it generates a self-signed CN=<domain> so it always runs. To browse https://<domain> with a clean padlock locally, run once with --setup-hosts (needs sudo) to map the domain to dnsAnswer in /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 image

cert 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.

How the captive portal works

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.

Roadmap — from emulator to board

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.

Development

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/.

License

MIT © 2026 Steve Sparks — Roost and the FriendlyPortal OS example are MIT licensed.

About

A captive-portal appliance in a box — DNS funnel, HTTPS, live console, and a real SSH mini-shell. Point it at any static site.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages