diff --git a/cmd/agentbbs/health_test.go b/cmd/agentbbs/health_test.go new file mode 100644 index 0000000..977d3a0 --- /dev/null +++ b/cmd/agentbbs/health_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/profullstack/agentbbs/internal/store" +) + +func TestHandleHealth(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + a := &app{st: st} + + rec := httptest.NewRecorder() + a.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != `{"status":"ok","db":"ok"}` { + t.Fatalf("healthy: %d %s", rec.Code, rec.Body.String()) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Fatalf("Cache-Control = %q", cc) + } + + st.Close() + rec = httptest.NewRecorder() + a.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + if rec.Code != http.StatusServiceUnavailable || rec.Body.String() != `{"status":"error","db":"down"}` { + t.Fatalf("closed store: %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 675fbcf..58538b6 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -272,6 +272,7 @@ func main() { mux.HandleFunc("/verify", a.handleVerify) mux.HandleFunc("/irc-auth", a.handleIRCAuth) // Ergo auth-script: members-only gate mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + mux.HandleFunc("/api/health", a.handleHealth) // Caddy proxies it: status.profullstack.com // OpenAccess descriptor for the BBS host itself (Caddy proxies the // well-known path here); the files host serves the same one. mux.Handle(files.OpenAccessPath, files.OpenAccessHandler()) @@ -2395,3 +2396,19 @@ func remoteIP(s ssh.Session) string { } return s.RemoteAddr().String() } + +// handleHealth is the public health check status.profullstack.com polls via +// Caddy: it proves this process (the one serving SSH :22) is up and its SQLite +// store answers a query within 3s. Never returns error details. +func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if err := a.st.Ping(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"error","db":"down"}`)) + return + } + _, _ = w.Write([]byte(`{"status":"ok","db":"ok"}`)) +} diff --git a/internal/store/store.go b/internal/store/store.go index 12fec43..f403dd7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3,6 +3,7 @@ package store import ( + "context" "database/sql" "errors" "fmt" @@ -235,6 +236,8 @@ type Store interface { // SetFilesSetting writes a Files service setting. Idempotent upsert. SetFilesSetting(key, value string) error + // Ping runs a trivial query, for the public health check. + Ping(ctx context.Context) error Close() error } @@ -1175,4 +1178,9 @@ func (s *sqliteStore) SetFilesSetting(key, value string) error { return err } +func (s *sqliteStore) Ping(ctx context.Context) error { + var one int + return s.db.QueryRowContext(ctx, "select 1").Scan(&one) +} + func (s *sqliteStore) Close() error { return s.db.Close() } diff --git a/setup.sh b/setup.sh index 8b20c3b..987bd12 100755 --- a/setup.sh +++ b/setup.sh @@ -807,6 +807,12 @@ ${DOMAIN} { reverse_proxy http://${HTTP_ADDR} } + # Public health check (status.profullstack.com): answered by the agentbbs + # process itself after a SQLite query, so a 200 proves the BBS is up. + handle /api/health { + reverse_proxy http://${HTTP_ADDR} + } + # IRC over WebSocket: Caddy terminates TLS and proxies to Ergo's loopback # WebSocket listener, so web clients hit wss://${DOMAIN}/irc and agents get a # WebSocket transport without exposing another public port. (No-op if IRC=0;