Relay hub
A server can be the relay that apps use for Internet sharing, as iroof.top is.
visitor ──▶ https://alex.your.domain/s/… ──▶ hub ══ WebSocket ══▶ Alex's app
│
app ── POST /relay/v1/register {name} ──────▶│ names: first come, first served
app ── GET /relay/v1/connect ══════════════▶│ one outgoing connection per app| Side | Setting |
|---|---|
| Hub | ROOFTOP_RELAY_DOMAIN=your.domain (optional shared bot: ROOFTOP_RELAY_BOT_TOKEN) |
| Apps | ROOFTOP_RELAY_URL=https://your.domain: links at https://<name>.your.domain |
Requirements
- A wildcard DNS record
*.your.domainpointing at the server. - A wildcard certificate. With Caddy that needs the DNS-01 challenge and a DNS provider module (
xcaddy build --with github.com/caddy-dns/<provider>). - A reverse proxy that passes
Hostthrough (Caddy does by default):
*.your.domain {
tls {
dns <provider> …
}
reverse_proxy iroof:8080
}A splash page on the main domain
On the main domain the hub serves the relay API, its own share links and the owner's app (behind ROOFTOP_PASSWORD). iroof.top shows a small splash page at / instead, with links to the docs and GitHub, in English and 20 other languages (/es/, /zh-hans/, /ja/, …; the list is on every page, and sitemap.xml lists them with their hreflang alternates). landing/index.html is the English page; python3 scripts/landing-i18n.py writes the others from it, from the translations in scripts/landing-i18n/. Each page is a single self-contained file (no scripts, fonts or third-party requests); the questions are native <details> elements. Unknown <name>.your.domain links land there too. The app demo inside it is demo.svg, inlined as a data: image by python3 scripts/landing-demo.py (run it after changing demo.svg, then scripts/landing-i18n.py). Mount the folder into Caddy (./landing:/srv/landing:ro) and route /, the language folders and sitemap.xml:
your.domain {
# Visitors: the splash page. A browser that already sends the owner's password gets the app.
@landing {
method GET HEAD
path / /index.html
not header Authorization *
}
handle @landing {
root * /srv/landing
rewrite * /index.html
header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
file_server
}
# The other languages: /es/, /zh-hans/, … (the folders written by scripts/landing-i18n.py).
@languages {
method GET HEAD
path_regexp lang ^/(es|zh-hans|hi|ar|fr|bn|pt-br|ru|ur|id|de|ja|tr|ko|vi|it|pl|uk|nl|fa)(/|/index\.html)?$
not header Authorization *
}
handle @languages {
root * /srv/landing
header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
file_server
}
handle /sitemap.xml {
root * /srv/landing
file_server
}
# The owner signs in at /owner: the browser asks for the password, keeps sending it, and / opens the app.
handle /owner {
@anon not header Authorization *
handle @anon {
header WWW-Authenticate "Basic realm=\"iRoof.top\""
respond "Sign in to open the app" 401
}
handle {
redir * / 302
}
}
handle {
reverse_proxy iroof:8080
}
}The app still checks the password on every owner request; the matcher only decides who sees the splash page.
Rules
| Hub API | /relay/v1/… on the main domain |
<name>.your.domain | always goes to the app that owns the name, never to the hub's owner UI |
| Names | 3–32 lowercase letters, digits and hyphens; a few (www, api, admin…) reserved |
| Release | after 180 days without a connection |
| New names | rate-limited per IP |
| Ownership | a random secret in the app's settings.json; the hub stores only its SHA-256 |
| State | relay.json and relay-telegram.json in the data directory |