home: serve the planet-scale guide at /infrastructure/, read from disk (#876)
* home: serve the planet-scale guide at /guide/, read from disk James wanted the operator guide (docs/planet-scale-guide/) reachable alongside the home page, with its source staying exactly where it lives in the repo. go:embed can't reach outside home/'s own directory tree without duplicating the guide's source, so this reads from an on-disk directory instead (LCHAT_HOME_GUIDE_DIR, unset by default — the route 404s until an operator opts in and a deploy step copies the guide there). Public, unauthenticated, read-only, and a strict same-origin CSP (no inline scripts/styles, no outside requests) — the guide itself already never names a real hostname, IP, or box identity, so this adds no exposure beyond the guide's own content. Co-Authored-By: Claude <noreply@anthropic.com> * home: rename the guide route from /guide/ to /infrastructure/ Same feature, same design — just a path/naming change requested before merge: LCHAT_HOME_GUIDE_DIR -> LCHAT_HOME_INFRASTRUCTURE_DIR, guideDir -> infrastructureDir, serveGuide -> serveInfrastructure. Co-Authored-By: Claude <noreply@anthropic.com> ---------
Co-authored-by: Claude <noreply@anthropic.com>
3 files changed,
+151
−0
home/README.md+19 −0home/home_test.go+85 −0home/main.go+47 −0
diff --git a/home/README.md b/home/README.md| index f313f3c..93abca4 100644 |
| --- a/home/README.md |
| +++ b/home/README.md |
| @@ -88,3 +88,22 @@ go test ./home |
| hostname once; changing it orphans existing passkeys. Deploy with |
| `./deploy/miren/home.sh <instance> <hostname>` (data-safe redeploys, same rules as |
| `deploy/miren.sh`: never delete the app to update it). |
| + |
| +### Optional: serving the planet-scale guide at /infrastructure/ |
| + |
| +`LCHAT_HOME_INFRASTRUCTURE_DIR=<path>` serves the operator guide |
| +(`docs/planet-scale-guide/` in this repo) at `/infrastructure/`, read |
| +straight from that directory on disk — unset (the default) means the route |
| +404s. The guide's source stays in the repo; only a deploy step copies its |
| +built files onto the box: |
| + |
| +```sh |
| +scp -r docs/planet-scale-guide/* root@<box>:/data/infrastructure/ |
| +# then set LCHAT_HOME_INFRASTRUCTURE_DIR=/data/infrastructure in home's |
| +# environment and restart |
| +``` |
| + |
| +Re-run the `scp` (it overwrites in place) whenever the guide changes; no |
| +restart is needed for guide-only content updates since it's read from disk on |
| +every request, not cached in the process. A restart is only needed the first |
| +time `LCHAT_HOME_INFRASTRUCTURE_DIR` is set. |
diff --git a/home/home_test.go b/home/home_test.go| index adefaaa..f79b0cc 100644 |
| --- a/home/home_test.go |
| +++ b/home/home_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| "strconv" |
| "strings" |
| "testing" |
| @@ -449,3 +450,87 @@ func TestServeRestorePage(t *testing.T) { |
| t.Fatalf("/restore page missing restore.js") |
| } |
| } |
| + |
| +// With no LCHAT_HOME_INFRASTRUCTURE_DIR configured (the default, and every |
| +// existing self-hosted deployment today), /infrastructure/ must 404 — not |
| +// panic, not serve an empty directory listing. |
| +func TestInfrastructureDisabledByDefault(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + for _, path := range []string{"/infrastructure", "/infrastructure/", "/infrastructure/index.html"} { |
| + r := do(t, "GET", ts.URL+path, "", nil, nil) |
| + if r.StatusCode != http.StatusNotFound { |
| + t.Fatalf("GET %s with no infrastructureDir: want 404, got %d", path, r.StatusCode) |
| + } |
| + } |
| +} |
| + |
| +// With LCHAT_HOME_INFRASTRUCTURE_DIR set, /infrastructure/ serves straight |
| +// from that directory — not embedded, not copied — with a strict |
| +// same-origin CSP and no caching. |
| +func TestServeInfrastructureFromDisk(t *testing.T) { |
| + store, err := openSQLiteStore(t.TempDir() + "/home.db") |
| + if err != nil { |
| + t.Fatalf("openSQLiteStore: %v", err) |
| + } |
| + s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret")) |
| + if err != nil { |
| + t.Fatalf("newServer: %v", err) |
| + } |
| + s.infrastructureDir = t.TempDir() |
| + if err := os.WriteFile(s.infrastructureDir+"/index.html", []byte("<h1>planet-scale guide</h1>"), 0o644); err != nil { |
| + t.Fatalf("write fixture guide page: %v", err) |
| + } |
| + ts := httptest.NewServer(s.handler()) |
| + t.Cleanup(ts.Close) |
| + |
| + r := do(t, "GET", ts.URL+"/infrastructure/index.html", "", nil, nil) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("GET /infrastructure/index.html: want 200, got %d", r.StatusCode) |
| + } |
| + if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") { |
| + t.Fatalf("/infrastructure CSP too loose: %q", csp) |
| + } |
| + if cc := r.Header.Get("Cache-Control"); cc != "no-cache" { |
| + t.Fatalf("/infrastructure Cache-Control = %q, want no-cache", cc) |
| + } |
| + body, _ := io.ReadAll(r.Body) |
| + if !strings.Contains(string(body), "planet-scale guide") { |
| + t.Fatalf("/infrastructure/index.html body = %q, missing fixture content", body) |
| + } |
| + |
| + // Bare "/infrastructure" (no trailing slash) redirects rather than |
| + // 404ing or silently listing the directory. |
| + noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := noRedirect.Get(ts.URL + "/infrastructure") |
| + if err != nil { |
| + t.Fatalf("GET /infrastructure: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusMovedPermanently { |
| + t.Fatalf("GET /infrastructure: want 301, got %d", resp.StatusCode) |
| + } |
| + if loc := resp.Header.Get("Location"); loc != "/infrastructure/" { |
| + t.Fatalf("GET /infrastructure Location = %q, want /infrastructure/", loc) |
| + } |
| +} |
| + |
| +// Path traversal must not escape infrastructureDir — http.Dir/http.FileServer |
| +// already guard this, but it is exactly the kind of thing worth pinning with |
| +// a test given this route serves straight off the filesystem. |
| +func TestInfrastructureRejectsPathTraversal(t *testing.T) { |
| + store, err := openSQLiteStore(t.TempDir() + "/home.db") |
| + if err != nil { |
| + t.Fatalf("openSQLiteStore: %v", err) |
| + } |
| + s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret")) |
| + if err != nil { |
| + t.Fatalf("newServer: %v", err) |
| + } |
| + s.infrastructureDir = t.TempDir() |
| + ts := httptest.NewServer(s.handler()) |
| + t.Cleanup(ts.Close) |
| + |
| + r := do(t, "GET", ts.URL+"/infrastructure/../../../../etc/passwd", "", nil, nil) |
| + if r.StatusCode == http.StatusOK { |
| + t.Fatalf("GET /infrastructure/../../../../etc/passwd: want non-200, got 200") |
| + } |
| +} |
diff --git a/home/main.go b/home/main.go| index 312fee7..1157fd8 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -65,6 +65,14 @@ type Server struct { |
| // so the page that launched one can poll for the finish. In-memory: if this |
| // server ever runs replicated, move the marks into the store. |
| enrol *enrolMarks |
| + // infrastructureDir is an optional on-disk directory serving the |
| + // planet-scale operations guide (docs/planet-scale-guide/ in the repo) at |
| + // /infrastructure/ — read from disk, not embedded: go:embed can't reach |
| + // outside this package's own directory tree without duplicating the |
| + // guide's source, so a deploy step copies it here instead (see |
| + // docs/planet-scale-guide's own deploy note). Empty means the route is |
| + // off entirely — most self-hosted deployments have no reason to publish it. |
| + infrastructureDir string |
| } |
| func main() { |
| @@ -103,6 +111,9 @@ func main() { |
| s.pow = newPowGate(bits, s.keys) |
| log.Printf("space creation gated by proof of work (%d bits)", bits) |
| } |
| + if s.infrastructureDir = os.Getenv("LCHAT_HOME_INFRASTRUCTURE_DIR"); s.infrastructureDir != "" { |
| + log.Printf("planet-scale guide served from %s at /infrastructure/", s.infrastructureDir) |
| + } |
| log.Printf("home listening on %s (store: %s, origin: %s)", *addr, storeDesc, *origin) |
| if err := serve(*addr, s.handler()); err != nil { |
| log.Fatal(err) |
| @@ -220,6 +231,24 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /restore", s.serveRestore) |
| mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))) |
| + // The planet-scale operations guide — optional (see the infrastructureDir |
| + // doc comment); s.infrastructureDir == "" answers 404, checked at request |
| + // time like every other optional feature here (s.cloud, s.pow). Read from |
| + // disk so the guide's source stays exactly where it lives in the repo |
| + // (docs/planet-scale-guide/), with nothing duplicated or re-embedded |
| + // here. Public, unauthenticated, and read-only — the guide itself is |
| + // careful to name no real hostname, IP, or box identity (see its own |
| + // PRIVACY convention), so serving it alongside the home page adds no |
| + // exposure beyond the guide's own content. |
| + mux.HandleFunc("GET /infrastructure/", s.serveInfrastructure) |
| + mux.HandleFunc("GET /infrastructure", func(w http.ResponseWriter, r *http.Request) { |
| + if s.infrastructureDir == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + http.Redirect(w, r, "/infrastructure/", http.StatusMovedPermanently) |
| + }) |
| + |
| // Apple app-site-association: lets the native Eleven apps run passkey |
| // ceremonies against this RP (the webcredentials service). Content-blind — |
| // it names only the apps' team+bundle ids, nothing user-specific. |
| @@ -354,6 +383,24 @@ func noCache(next http.Handler) http.Handler { |
| }) |
| } |
| +// serveInfrastructure answers /infrastructure/* from an on-disk directory |
| +// (infrastructureDir), or 404 if unconfigured. A strict, same-origin-only |
| +// CSP: the guide's pages carry no inline scripts/styles and make no outside |
| +// requests (unlike "/", which allows connect-src https: for one client-side |
| +// admin check), so this can be tighter than the rest of the site. Never |
| +// cached — like the embedded pages, the guide must never outlive a deploy. |
| +func (s *Server) serveInfrastructure(w http.ResponseWriter, r *http.Request) { |
| + if s.infrastructureDir == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-cache") |
| + w.Header().Set("Content-Security-Policy", |
| + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; "+ |
| + "object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") |
| + http.StripPrefix("/infrastructure/", http.FileServer(http.Dir(s.infrastructureDir))).ServeHTTP(w, r) |
| +} |
| + |
| // ---- auth helpers --------------------------------------------------------- |
| // bearerToken pulls the token out of "Authorization: Bearer <t>". |