eleven / ocho Public

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>
James Adam 7e8a31ff27cd3c776fbc43df30c45890634cc10d parent 334eded
3 files changed, +151 −0
  • home/README.md +19 −0
  • home/home_test.go +85 −0
  • home/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>".