feat(11-04): add the websockets health, VAPID key and test-push commands

- centrifugo.Client.Info probes the info API method; an error body fails
- websockets:health ports CentrifugoHealthCheck: exits 1 without an API
  key or when the probe fails, prints the Setting/Value table otherwise
- websockets:generate-vapid-keys prints a new P-256 pair, shows configured
  keys only truncated, and --update persists them to overrides.yaml
- websockets:test-push reads subscriptions from an app-published
  SubscriptionSource, refuses to send while push is disabled and sends
  one encrypted push per subscription
- no command prints a configured private key or the Centrifugo API key
- flare and lighthouse READMEs document the CLI commands
This commit is contained in:
Jakub Zych
2026-09-30 13:52:44 +02:00
parent 5fb22c28d0
commit f55cb444ab
7 changed files with 1057 additions and 3 deletions

View File

@@ -193,12 +193,13 @@ for _, pub := range mem.Publications() {
| Identifier | Description |
|------------|-------------|
| `centrifugo.Config`, `centrifugo.LoadConfig` | The `realtime.centrifugo.*` settings with their defaults, plus `TrustedProxies` from `http.trusted_proxies` for logging client IPs. |
| `centrifugo.Client`, `centrifugo.NewClient` | HTTP API client: `Publish`, `Broadcast`, `Presence`, `Unsubscribe`, `Enabled`, `DebugInfo`. |
| `centrifugo.Client`, `centrifugo.NewClient` | HTTP API client: `Publish`, `Broadcast`, `Presence`, `Unsubscribe`, `Info` (the connectivity probe; an error body is an error), `Enabled`, `DebugInfo`. |
| `centrifugo.DebugInfo` | `api_url`, `enabled`, `api_key_set`. |
| `centrifugo.TokenIssuer`, `centrifugo.NewTokenIssuer` | HS256 token generators: `ForUser`, `Subscription`, `Anonymous`, `ForIdentifier`, `SubscriptionForIdentifier`, `Configured`. |
| `centrifugo.TokenHandler(svc, issuer)` | The token route handler. |
| `centrifugo.ProxyHandler(svc, cfg)` | The subscribe proxy handler. |
| `centrifugo.Driver`, `centrifugo.NewDriver`, `centrifugo.DriverName` | The `lighthouse.Driver`, with `Client`, `Issuer`, `Config` and `Enabled` (an API key is set). |
| `centrifugo.Commands(app)`, `centrifugo.HealthCommandName` | The `websockets:health` command and its name. |
| `centrifugo.ErrNotConfigured` | Returned when the API key or token secret an operation needs is empty. |
## Configuration
@@ -218,9 +219,17 @@ for _, pub := range mem.Publications() {
| `realtime.centrifugo.token_path` | `/api/realtime/token` | Path of the token route. |
| `realtime.centrifugo.subscribe_path` | `/api/realtime/subscribe` | Path of the subscribe proxy route. |
## CLI commands
`centrifugo.Commands(app)` returns `websockets:health` for the application binary. An application adds it to the list its plugin returns from `Commands`.
| Command | Description |
|---------|-------------|
| `websockets:health` | With an empty `realtime.centrifugo.api_key` it prints `Centrifugo not configured (API key missing)` and exits 1 without sending a request. Otherwise it prints the API URL and calls the Centrifugo `info` API method. On success it prints `Configuration OK` and a Setting/Value table (API URL, Enabled, API Key Set); on any failure it prints `Connection check failed: …` and exits 1. The API key is never printed, only whether it is set. |
## Dependencies
- `backpack`, `bouncer`, `compass`, `conga` (the broadcast job), `lagoon` (callback installation), `pact` and `wire` from this repository; the centrifugo driver also uses `surf` for the client IP.
- `backpack`, `bouncer`, `compass`, `conga` (the broadcast job), `lagoon` (callback installation), `pact` and `wire` from this repository; the centrifugo driver also uses `surf` for the client IP and `bonfire` for its command.
- `gorm.io/gorm` (broadcast callbacks).
- `github.com/golang-jwt/jwt/v5` (centrifugo token signing).
- The Centrifugo client is plain `net/http`; no Centrifugo SDK is used.

View File

@@ -158,6 +158,37 @@ func (c *Client) Unsubscribe(ctx context.Context, userID uint, channel string) e
return err
}
// Info POSTs {} to {api_url}/info and returns its result: Centrifugo's node
// list and statistics. It is the connectivity probe of websockets:health.
// Unlike the publishing calls, an answer with an error body is an error.
func (c *Client) Info(ctx context.Context) (map[string]any, error) {
out := map[string]any{}
if !c.Enabled() {
return out, ErrNotConfigured
}
raw, err := c.post(ctx, "/info", struct{}{})
if err != nil {
return out, err
}
var resp struct {
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
Result map[string]any `json:"result"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return out, fmt.Errorf("centrifugo: /info: response is not JSON")
}
if resp.Error != nil {
return out, fmt.Errorf("centrifugo: /info: error %d: %s", resp.Error.Code, resp.Error.Message)
}
if resp.Result != nil {
out = resp.Result
}
return out, nil
}
func (c *Client) data(event string, payload json.RawMessage) eventData {
if len(bytes.TrimSpace(payload)) == 0 {
payload = json.RawMessage("[]")

View File

@@ -0,0 +1,69 @@
package centrifugo
import (
"context"
"errors"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// HealthCommandName is the name of the Centrifugo health check command.
const HealthCommandName = "websockets:health"
// errHealthFailed is returned by the health command after it has printed
// why the check failed, so the binary exits 1.
var errHealthFailed = errors.New(HealthCommandName + ": check failed")
// Commands returns the Centrifugo console commands for an application
// binary: the HealthCommandName check. An application appends them to the
// commands its plugin returns from Commands.
func Commands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{healthCommand(app)}
}
// healthCommand ports the WinterCMS CentrifugoHealthCheck command. The
// WinterCMS command only read the local configuration; this one also calls
// the Centrifugo info API, so a wrong URL or key fails the check. The API
// key is never printed.
func healthCommand(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: HealthCommandName,
Description: "Check Centrifugo connection health",
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
var cfg Config
if app != nil {
cfg = LoadConfig(app.Config)
} else {
cfg = LoadConfig(nil)
}
client := NewClient(cfg, nil)
if !client.Enabled() {
out.Error("Centrifugo not configured (API key missing)")
out.Println("Set SUMMER_REALTIME__CENTRIFUGO__API_KEY in the environment")
return errHealthFailed
}
out.Info("Checking Centrifugo connection...")
out.Println("API URL: " + cfg.APIURL)
if _, err := client.Info(ctx); err != nil {
out.Error("Connection check failed: " + err.Error())
return errHealthFailed
}
info := client.DebugInfo()
out.Success("Configuration OK")
out.Table([]string{"Setting", "Value"}, [][]string{
{"API URL", info.APIURL},
{"Enabled", yesNo(info.Enabled)},
{"API Key Set", yesNo(info.APIKeySet)},
})
return nil
},
}
}
func yesNo(b bool) string {
if b {
return "Yes"
}
return "No"
}

View File

@@ -0,0 +1,129 @@
package centrifugo
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
)
const testAPIKey = "test-api-key-7f3c9e"
func healthApp(t *testing.T, realtimeYAML string) *backpack.App {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "realtime.yaml"), []byte(realtimeYAML), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
func runHealth(t *testing.T, app *backpack.App) (string, error) {
t.Helper()
var buf bytes.Buffer
root, err := bonfire.NewRootIO("acme", Commands(app), strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"websockets:health"})
err = root.Execute()
return buf.String(), err
}
func TestHealthCommand(t *testing.T) {
var hits atomic.Int32
var gotAuth, gotBody atomic.Value
status := atomic.Int32{}
answer := atomic.Value{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path != "/api/info" || r.Method != http.MethodPost {
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
gotAuth.Store(r.Header.Get("Authorization"))
gotBody.Store(string(body))
w.WriteHeader(int(status.Load()))
_, _ = io.WriteString(w, answer.Load().(string))
}))
defer srv.Close()
configured := "centrifugo:\n api_url: " + srv.URL + "/api\n api_key: " + testAPIKey + "\n"
t.Run("missing API key", func(t *testing.T) {
out, err := runHealth(t, healthApp(t, "centrifugo:\n api_url: "+srv.URL+"/api\n"))
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
if !strings.Contains(out, "Centrifugo not configured (API key missing)") || !strings.Contains(out, "SUMMER_REALTIME__CENTRIFUGO__API_KEY") {
t.Fatalf("output:\n%s", out)
}
if hits.Load() != 0 {
t.Fatal("a request was sent without an API key")
}
})
t.Run("Centrifugo answers info", func(t *testing.T) {
status.Store(http.StatusOK)
answer.Store(`{"result":{"nodes":[{"name":"node-1"}]}}`)
out, err := runHealth(t, healthApp(t, configured))
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
for _, want := range []string{"Checking Centrifugo connection...", "API URL: " + srv.URL + "/api", "Configuration OK", "Setting\tValue", "API URL\t" + srv.URL + "/api", "Enabled\tYes", "API Key Set\tYes"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if gotAuth.Load() != "apikey "+testAPIKey || gotBody.Load() != "{}" {
t.Fatalf("info request: auth %v body %v", gotAuth.Load(), gotBody.Load())
}
if strings.Contains(out, testAPIKey) {
t.Fatalf("output contains the API key:\n%s", out)
}
})
t.Run("Centrifugo answers 500", func(t *testing.T) {
status.Store(http.StatusInternalServerError)
answer.Store(`oops`)
out, err := runHealth(t, healthApp(t, configured))
if err == nil || !strings.Contains(out, "Connection check failed:") || strings.Contains(out, "Configuration OK") {
t.Fatalf("err = %v\n%s", err, out)
}
if strings.Contains(out, testAPIKey) {
t.Fatalf("output contains the API key:\n%s", out)
}
})
t.Run("Centrifugo answers an error body", func(t *testing.T) {
status.Store(http.StatusOK)
answer.Store(`{"error":{"code":101,"message":"unauthorized"}}`)
out, err := runHealth(t, healthApp(t, configured))
if err == nil || !strings.Contains(out, "Connection check failed:") || !strings.Contains(out, "unauthorized") {
t.Fatalf("err = %v\n%s", err, out)
}
})
t.Run("Centrifugo unreachable", func(t *testing.T) {
dead := httptest.NewServer(http.NotFoundHandler())
url := dead.URL
dead.Close()
out, err := runHealth(t, healthApp(t, "centrifugo:\n api_url: "+url+"/api\n api_key: "+testAPIKey+"\n"))
if err == nil || !strings.Contains(out, "Connection check failed:") || strings.Contains(out, testAPIKey) {
t.Fatalf("err = %v\n%s", err, out)
}
})
}