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
34 changes: 34 additions & 0 deletions cmd/agentbbs/health_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
17 changes: 17 additions & 0 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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"}`))
}
8 changes: 8 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package store

import (
"context"
"database/sql"
"errors"
"fmt"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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() }
6 changes: 6 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading