Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ domain: demo.example.com # fallback suffix for bare-path apps
tunnel:
name: rserver # your tunnel's name (never generated)
protocol: http2 # optional; force TCP/443 when your ISP throttles UDP/QUIC (default: QUIC)
maintenance_page: true # optional; edge Worker serves a branded page on a full-tunnel-down (1033)
access:
emails: [me@example.com] # Cloudflare Access wall before first exposure
defaults:
Expand Down Expand Up @@ -291,6 +292,15 @@ and personal-hosting** tool for demos, side projects, and sharing work in
progress. It is not a replacement for a server; when the laptop wakes,
`cloudflared` reconnects within ~5–10 seconds and everything is live again.

**Visitors don't see a raw error while you're away.** If a single app is down,
Caddy serves a branded *"temporarily offline"* page instead of a bare 502 β€”
automatic, no config. If the *whole* tunnel is down (lid shut, machine off β†’
Cloudflare's blunt **1033**), set `tunnel.maintenance_page: true` and
`roost tunnel setup` deploys a tiny Cloudflare **Worker** that answers with the
same page from the edge, where your host can't. Both render an identical
self-contained page that auto-retries every 30s. See
[docs/configuration.md](docs/configuration.md#offline--maintenance-ui--two-layers).

---

## ⚑ 60-second quickstart
Expand Down Expand Up @@ -415,6 +425,12 @@ it does:
pill, health, and a colour-coded **memory bar**; a *Needs attention* strip
surfaces anything not running, and metric cards summarise running / memory /
stopped.
- **Dashboard** (sidebar β†’ **Dashboard**, above Resources) β€” a real-time
monitoring page that polls `GET /api/metrics` every 5s and draws hand-rolled
inline SVG charts (no external libs): CPU %, memory %, and network I/O **over
time**, **memory by app**, utilization **gauges** (uptime / memory / disk),
docker **storage & cache**, a **14-day incidents** bar chart, and per-app
uptime β€” plus a stat-tile row. The clickable **logo returns to the home view**.
- **Control** β€” **Start all** / **Stop all**, or per-app **Start** / **Stop**.
Stop leaves Caddy + the tunnel up so the panel stays reachable (only the CLI
`roost down` tears down everything).
Expand Down
37 changes: 37 additions & 0 deletions cmd/roost/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"

"github.com/cdrrazan/roost/internal/config"
"github.com/cdrrazan/roost/internal/generate"
"github.com/cdrrazan/roost/internal/state"
"github.com/cdrrazan/roost/internal/tunnel"
)
Expand Down Expand Up @@ -192,6 +193,26 @@ func accessPatterns(plan []tunnel.PlannedRecord) []string {
return patterns
}

// workerRouteSpecs derives one Worker route per distinct zone in the plan,
// each a "*.<zone>/*" wildcard so the fallback Worker fronts every app in
// that zone. One route per zone keeps the Worker independent of how many
// apps exist β€” the same reason DNS is one wildcard per suffix.
func workerRouteSpecs(plan []tunnel.PlannedRecord) []tunnel.WorkerRouteSpec {
seen := map[string]bool{}
var specs []tunnel.WorkerRouteSpec
for _, rec := range plan {
if seen[rec.Zone.ID] {
continue
}
seen[rec.Zone.ID] = true
specs = append(specs, tunnel.WorkerRouteSpec{
ZoneID: rec.Zone.ID,
Pattern: "*." + rec.Zone.Name + "/*",
})
}
return specs
}

// newTunnelCmd groups `tunnel setup` (create the tunnel, plan and
// create every DNS record, push ingress, apply Access β€” the whole
// remote side, no dashboard visit) and `tunnel access` (policies only).
Expand Down Expand Up @@ -318,6 +339,22 @@ func newTunnelCmd(flags *rootFlags) *cobra.Command {
return err
}

if tc.cfg.Tunnel.MaintenancePage {
page, err := generate.RenderErrorPage()
if err != nil {
return err
}
worker, err := tunnel.EnsureWorker(tc.client, tc.accountID, page, workerRouteSpecs(plan))
if err != nil {
return err
}
tc.st.Worker = worker
if err := tc.st.Save(tc.statePath); err != nil {
return err
}
cmd.Printf("maintenance Worker deployed (%d route(s)) β€” the edge serves roost's offline page when the tunnel is down\n", len(worker.Routes))
}

if tc.cfg.Tunnel.Access != nil {
created, err := tunnel.EnsureAccess(tc.client, tc.accountID, accessPatterns(plan), tc.cfg.Tunnel.Access.Emails)
if err != nil {
Expand Down
35 changes: 35 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ include: # OPTIONAL glob(s) pulling apps from other files
- apps/*.yml
tunnel:
name: roost # explicit; never generated by roost
protocol: http2 # OPTIONAL; force TCP/443 when your ISP drops UDP/QUIC
maintenance_page: true # OPTIONAL; edge Worker serves a branded page on 1033
access: # optional; omit to leave apps public (roost warns)
emails:
- me@example.com
Expand All @@ -44,6 +46,11 @@ apps: [...] # see below
β€” connected / reconnecting-after-wake / down), a per-app detail drawer
(image, restarts, env keys, recent logs), an activity timeline, and a ⌘K
command palette. It auto-refreshes every 5s.
- **Dashboard page** β€” a dedicated real-time monitoring view (sidebar β†’
**Dashboard**) that polls `/api/metrics` every 5s and draws inline-SVG
charts: CPU / memory / network over time, memory by app, utilization gauges
(uptime / memory / disk), docker storage & cache, a 14-day incidents chart,
and per-app uptime.
- **Public status page** β€” the panel also serves a controls-free, secret-free
board at **`/status`** (app name + operational/degraded/down + uptime + open
incident detail), safe to share. It **auto-refreshes every 2 min** so a fresh
Expand Down Expand Up @@ -87,6 +94,34 @@ apps: [...] # see below
and the SSH login command. No effect on how roost runs β€” and the card only shows
behind your Access-gated `control_host`.

## Offline / maintenance UI β€” two layers

When something is down, visitors otherwise see raw error screens. roost replaces
both, matching the failure to who can still answer:

- **An app container is down or unhealthy** (Caddy is up, its upstream isn't β†’
**502/503**). Caddy itself answers with a branded *"temporarily offline"* page.
This is always on β€” roost generates `error.html` and mounts it into Caddy; no
configuration needed.
- **The tunnel is wholly down** (cloudflared stopped, Docker stopped, or the box
is off β†’ Cloudflare **error 1033**). Now *nothing on your host is reachable* β€”
Caddy can't answer, because the tunnel that carries traffic to it is gone. Only
Cloudflare's own edge can respond. Set **`tunnel.maintenance_page: true`** and
`roost tunnel setup` deploys a tiny **Cloudflare Worker** (named
`roost-maintenance`, one route β€” `*.<zone>/*` β€” per routing suffix) that serves
the *same* branded page from the edge. While the stack is healthy the Worker is
invisible: it proxies every request straight through and only substitutes the
page on an origin-connectivity failure (a thrown fetch or a 502/503/504/52x/530).

Both layers render the identical self-contained page (no external assets, dark/
light aware, auto-retries every 30s), so the experience is the same whichever
layer catches the outage. `roost uninstall` removes the Worker and its routes
along with the tunnel; it only ever deletes what roost recorded creating.

> Custom Error Pages in the Cloudflare dashboard would be the "native" fix for
> 1033, but Cloudflare gates the `1000_errors` class to **Enterprise** plans. The
> Worker approach works on the free plan.

## Incident email alerts β€” `notify:`

The `roost web` panel runs a background monitor (every 30s, even with no browser
Expand Down
5 changes: 5 additions & 0 deletions examples/full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ tunnel:
# Always the literal default or your own value β€” roost never invents
# tunnel names, so the Cloudflare dashboard stays recognizable.
name: roost
# When the whole tunnel is down (cloudflared/Docker/box off), Cloudflare
# shows a bare 1033. Set this and `tunnel setup` deploys an edge Worker
# that serves roost's branded "temporarily offline" page instead. (A
# single app being down is handled by Caddy automatically β€” no config.)
maintenance_page: true
# With access set, every routing suffix gets a Cloudflare Access
# wall BEFORE the first `up`. Hostnames leak via Certificate
# Transparency logs within hours; personal apps want this.
Expand Down
10 changes: 10 additions & 0 deletions fleet-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to Fleet are documented here. Format: [Keep a Changelog](https://keepachangelog.com/); versioning: [SemVer](https://semver.org/).

## [1.7.0] β€” 2026-07-31

### Added
- **Offline / maintenance page** (`offline.html`) β€” a standalone branded page to
serve when a service is unreachable (proxy 502/503 or a downed tunnel). An
animated moonlit-harbour scene (a sailboat riding at anchor, drifting waves,
twinkling stars), an *At anchor* status badge, a spinner, and an auto-retry
every 30s (`location.reload`, honours `prefers-reduced-motion`). Fully themed
via the MD3 tokens β€” light + dark. Linked from Components β†’ **Offline page**.

## [1.6.0] β€” 2026-07-28

### Added
Expand Down
1 change: 1 addition & 0 deletions fleet-dashboard/components.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<div class="navlabel">Monitoring</div>
<a href="incidents.html"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span> Incidents</a>
<a href="status.html"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 6v6l4 2"/></svg></span> Status page β†—</a>
<a href="offline.html"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22V8"/><circle cx="12" cy="5" r="3"/><path d="M5 12a7 7 0 0 0 14 0"/><path d="M2 12h3M19 12h3"/></svg></span> Offline page β†—</a>
<a href="components.html" class="active"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg></span> Components</a>
<div class="navlabel">Manage</div>
<a href="settings.html"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4"/></svg></span> Settings</a>
Expand Down
136 changes: 136 additions & 0 deletions fleet-dashboard/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<meta property="og:title" content="Fleet β€” At anchor">
<meta property="og:description" content="This service has pulled into harbor for a moment. It'll set sail again shortly.">
<meta property="og:url" content="https://cdrrazan.github.io/Roost/offline.html">
<meta name="twitter:title" content="Fleet β€” At anchor">
<meta name="twitter:description" content="This service has pulled into harbor for a moment. It'll set sail again shortly.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Fleet">
<meta property="og:image" content="https://cdrrazan.github.io/Roost/assets/img/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://cdrrazan.github.io/Roost/assets/img/og.png">
<title>At anchor Β· Fleet</title>
<link rel="icon" type="image/svg+xml" href="assets/img/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/tokens.css">
<link rel="stylesheet" href="assets/css/fleet.css">
<script src="assets/js/theme.js"></script>
<style>
/* All classes are o-* prefixed so nothing inherits fleet.css component rules. */
body{min-height:100vh;display:grid;place-items:center;padding:24px;margin:0}
.o-card{width:100%;max-width:480px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lg);text-align:center}

/* ---- harbour-at-night scene ---- */
.o-scene{display:block;width:100%;height:194px}
.o-sky-top{stop-color:color-mix(in srgb,var(--brand) 30%,var(--md-surface-container-lowest))}
.o-sky-bot{stop-color:var(--md-surface-container-lowest)}
.o-moon{fill:#fde68a}
.o-crater{fill:color-mix(in srgb,#fde68a 70%,#000 20%);opacity:.5}
.o-star{fill:color-mix(in srgb,var(--ink) 70%,transparent)}
.o-sea{fill:color-mix(in srgb,var(--brand) 20%,var(--md-surface-container-lowest))}
.o-wave{fill:none;stroke:color-mix(in srgb,var(--brand) 45%,transparent);stroke-width:2;stroke-linecap:round}
.o-hull{fill:color-mix(in srgb,var(--ink) 82%,var(--brand))}
.o-sail-main{fill:url(#o-sail)} .o-sail-jib{fill:color-mix(in srgb,var(--brand) 60%,#fff)}
.o-mast{stroke:color-mix(in srgb,var(--ink) 60%,transparent);stroke-width:2;stroke-linecap:round}
.o-flag{fill:var(--danger)}
.o-rope{stroke:color-mix(in srgb,var(--ink) 35%,transparent);stroke-width:1.4}

.o-boat{transform-origin:160px 150px;animation:o-bob 4.2s ease-in-out infinite}
@keyframes o-bob{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(3px) rotate(1.4deg)}}
.o-waves{animation:o-drift 6s ease-in-out infinite}
@keyframes o-drift{0%,100%{transform:translateX(0)}50%{transform:translateX(-8px)}}
.o-flag{transform-origin:160px 92px;animation:o-flap 1.6s ease-in-out infinite}
@keyframes o-flap{0%,100%{transform:scaleX(1)}50%{transform:scaleX(.6)}}
.o-tw{animation:o-tw 3s ease-in-out infinite}
.o-t2{animation-delay:.7s}.o-t3{animation-delay:1.4s}.o-t4{animation-delay:2.1s}
@keyframes o-tw{0%,100%{opacity:.35}50%{opacity:1}}
@media (prefers-reduced-motion:reduce){.o-boat,.o-waves,.o-flag,.o-tw,.o-pulse,.o-spin{animation:none}}

.o-body{padding:26px 32px 30px}
.o-badge{display:inline-flex;align-items:center;gap:8px;white-space:nowrap;padding:6px 14px;margin:0 0 18px;border-radius:var(--radius-full);font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;background:var(--indigo-bg);color:var(--indigo-ink)}
.o-pulse{width:8px;height:8px;border-radius:50%;background:var(--brand);animation:o-pulse 1.6s ease-in-out infinite}
@keyframes o-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
.o-title{font-size:24px;font-weight:700;letter-spacing:-.02em;line-height:1.2;margin:0 0 10px;color:var(--ink)}
.o-sub{color:var(--muted);font-size:15px;line-height:1.6;max-width:360px;margin:0 auto}
.o-retry{margin:22px auto 0;display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--faint)}
.o-spin{width:13px;height:13px;flex:none;border:2px solid color-mix(in srgb,var(--brand) 40%,transparent);border-top-color:var(--brand);border-radius:50%;animation:o-spin 1s linear infinite}
@keyframes o-spin{to{transform:rotate(360deg)}}
.o-retry a{color:var(--brand);font-weight:600;text-decoration:none} .o-retry a:hover{text-decoration:underline}

.o-foot{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;align-items:center;font-size:12.5px;color:var(--faint)}
.o-brand{display:inline-flex;align-items:center;gap:7px;font-weight:700;color:var(--ink)}
.o-brand svg{width:18px;height:18px}
.o-foot a{color:var(--muted);text-decoration:none} .o-foot a:hover{color:var(--brand)}
.o-sep{opacity:.5}
</style>
</head>
<body>
<main class="o-card">
<!-- The fleet is at anchor, riding out the dark. -->
<svg class="o-scene" viewBox="0 0 320 194" role="img" aria-label="A sailboat at anchor in a moonlit harbour at night">
<defs>
<linearGradient id="o-sky" x1="0" y1="0" x2="0" y2="1"><stop class="o-sky-top" offset="0"/><stop class="o-sky-bot" offset="1"/></linearGradient>
<linearGradient id="o-sail" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8b83f7"/><stop offset=".55" stop-color="#5b54e6"/><stop offset="1" stop-color="#4338ca"/></linearGradient>
</defs>

<rect x="0" y="0" width="320" height="194" fill="url(#o-sky)"/>

<!-- moon -->
<circle class="o-moon" cx="258" cy="48" r="24"/>
<circle class="o-crater" cx="251" cy="42" r="4.5"/>
<circle class="o-crater" cx="266" cy="54" r="3"/>
<circle class="o-crater" cx="254" cy="58" r="2.5"/>

<!-- stars -->
<circle class="o-star o-tw" cx="38" cy="38" r="1.7"/>
<circle class="o-star o-tw o-t2" cx="92" cy="24" r="1.3"/>
<circle class="o-star o-tw o-t3" cx="150" cy="44" r="1.5"/>
<circle class="o-star o-tw o-t4" cx="205" cy="30" r="1.2"/>
<circle class="o-star o-tw o-t2" cx="26" cy="92" r="1.3"/>

<!-- the boat, at anchor -->
<g class="o-boat">
<line class="o-rope" x1="150" y1="150" x2="140" y2="176"/>
<line class="o-mast" x1="160" y1="148" x2="160" y2="92"/>
<path class="o-flag" d="M160,90 L176,95 L160,100 Z"/>
<path class="o-sail-jib" d="M157,100 L157,146 L128,146 Z"/>
<path class="o-sail-main" d="M163,98 L163,146 L198,146 Z"/>
<path class="o-hull" d="M120,148 L200,148 L188,166 Q160,173 132,166 Z"/>
</g>

<!-- sea + waves -->
<path class="o-sea" d="M0,168 Q80,160 160,168 T320,168 V194 H0 Z"/>
<g class="o-waves">
<path class="o-wave" d="M-10,178 q20,-6 40,0 t40,0 t40,0 t40,0 t40,0 t40,0 t40,0 t40,0"/>
<path class="o-wave" d="M-10,187 q20,-6 40,0 t40,0 t40,0 t40,0 t40,0 t40,0 t40,0 t40,0" opacity=".6"/>
</g>
</svg>

<div class="o-body">
<div class="o-badge"><span class="o-pulse"></span> At anchor</div>
<h1 class="o-title">This service has dropped anchor</h1>
<p class="o-sub">It's pulled into harbour for a moment β€” restarting or down for a spell
of maintenance. It'll set sail again on its own. No need to refresh.</p>

<div class="o-retry"><span class="o-spin"></span> Auto-checking every 30s &middot; <a href="" onclick="location.reload();return false;">retry now</a></div>

<footer class="o-foot">
<span class="o-brand">
<svg viewBox="0 0 40 40" fill="none" aria-hidden="true"><rect width="40" height="40" rx="11" fill="url(#o-lg)"/><path d="M10.5 19.2 L20 11 L29.5 19.2" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.4 18.4 V28.6 H26.6 V18.4" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.4 28.6 V24 a2.6 2.6 0 0 1 5.2 0 V28.6" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="o-lg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#8b83f7"/><stop offset=".55" stop-color="#5b54e6"/><stop offset="1" stop-color="#4338ca"/></linearGradient></defs></svg>
Fleet
</span>
<span>crafted by <a href="https://github.com/cdrrazan">Rajan Bhattarai</a> <span class="o-sep">&middot;</span> <a href="https://github.com/cdrrazan/roost">github.com/cdrrazan/roost</a></span>
</footer>
</div>
</main>
<script>setTimeout(function(){location.reload();},30000);</script>
</body>
</html>
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ type Tunnel struct {
// Set "http2" to force TCP/443 on networks that throttle or drop UDP
// (many home/office ISPs), where QUIC flaps and every app 502s.
Protocol string `yaml:"protocol"`
// MaintenancePage, when true, makes `tunnel setup` deploy a Cloudflare
// Worker that serves roost's branded "temporarily offline" page from the
// edge whenever the tunnel is wholly down (a 1033 that never reaches
// Caddy). Opt-in: it deploys a Worker + one route per routing suffix.
MaintenancePage bool `yaml:"maintenance_page"`
}

// Access is the optional Cloudflare Access policy configuration.
Expand Down
Loading
Loading