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:
@@ -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.
|
||||
|
||||
@@ -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("[]")
|
||||
|
||||
69
modules/lighthouse/centrifugo/commands.go
Normal file
69
modules/lighthouse/centrifugo/commands.go
Normal 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"
|
||||
}
|
||||
129
modules/lighthouse/centrifugo/commands_test.go
Normal file
129
modules/lighthouse/centrifugo/commands_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user