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

@@ -98,6 +98,7 @@ if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err
| `flare.VAPIDHeader(endpoint, subject, keys, now)` | The RFC 8292 `Authorization` header value. |
| `flare.Encrypt(payload, sub)` | The RFC 8291 `aes128gcm` request body. |
| `flare.HostAllowed(host, allowed)`, `flare.DefaultAllowedHosts` | The endpoint host allowlist and its default. |
| `flare.Commands(app)`, `flare.GenerateVAPIDKeysCommandName`, `flare.TestPushCommandName` | The `websockets:generate-vapid-keys` and `websockets:test-push` commands and their names. |
| `flare.ErrPushDisabled`, `flare.ErrEndpointNotAllowed`, `flare.ErrSubscriptionGone`, `flare.ErrUserNotFound`, `flare.ErrPayloadTooLarge`, `flare.ErrInvalidVAPIDKeys`, `flare.ErrInvalidSubject`, `flare.StatusError` | Errors. |
| `flare.ContentEncoding`, `flare.MaxPayloadSize`, `flare.DefaultTTL`, `flare.DefaultTimeout`, `flare.VAPIDTokenLifetime`, `flare.PublicKeyLength`, `flare.PrivateKeyLength` | Constants. |
@@ -112,9 +113,20 @@ if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err
| `push.ttl` | `2419200` | Default `TTL` header, in seconds or as a duration string. |
| `push.allowed_hosts` | FCM, Mozilla autopush, `*.push.apple.com`, `*.notify.windows.com` | Push service hosts an endpoint may point at, as a list or a comma-separated string. |
## CLI commands
`flare.Commands(app)` returns two commands for the application binary. An application adds them to the list its plugin returns from `Commands`.
| Command | Description |
|---------|-------------|
| `websockets:generate-vapid-keys [--update] [--show-current]` | Shows the configured keys, truncated to the first 8 and last 4 characters with their length and a check mark when they decode to a valid pair. `--show-current` stops there. Otherwise it generates a new P-256 pair, validates its length and base64url alphabet and prints both keys. With `--update` the keys are saved through `compass.Config.Set` and `compass.Config.Persist` to `env/<environment>/overrides.yaml` in the config directory (mode 0600; other keys in the file are kept). Without it, the command prints `SUMMER_PUSH__PUBLIC_KEY=…` and `SUMMER_PUSH__PRIVATE_KEY=…` lines to set by hand. |
| `websockets:test-push <user_id> [--show-config]` | Prints the push configuration: enabled, whether each key is set with its length (never the value), the subject and its format. It then reads the user's subscriptions from the published `flare.SubscriptionSource`, lists them (endpoint shortened to 60 characters, user agent, when subscribed and last used) and asks `Send test notification?` (default yes; a non-interactive run takes the default). It sends one encrypted test push to each subscription and reports each result. `--show-config` is accepted for compatibility; the configuration is always shown. |
`websockets:test-push` exits 1 when no subscription source is published (`no subscription source registered`), when the user is unknown or has no subscriptions, when push is disabled (it lists the subscriptions but sends nothing), and when any send fails. The test payload is `{"title":"<app.name> test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":<unix>}}`.
## Dependencies
- `backpack` and `compass` from this repository.
- `backpack`, `bonfire` and `compass` from this repository.
- `github.com/golang-jwt/jwt/v5` (the ES256 VAPID token).
- Everything else is the standard library: `crypto/ecdh`, `crypto/ecdsa`, `crypto/hkdf`, `crypto/aes`, `crypto/cipher` and `net/http`. No Web Push library is used.

436
modules/flare/commands.go Normal file
View File

@@ -0,0 +1,436 @@
package flare
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// Returned after a command has printed why it failed, so the binary exits 1
// without repeating the message.
var (
errGenerateFailed = errors.New(GenerateVAPIDKeysCommandName + ": failed")
errTestPushFailed = errors.New(TestPushCommandName + ": failed")
)
// Names of the push console commands.
const (
GenerateVAPIDKeysCommandName = "websockets:generate-vapid-keys"
TestPushCommandName = "websockets:test-push"
)
var base64URLChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Commands returns the Web Push console commands for an application
// binary: GenerateVAPIDKeysCommandName and TestPushCommandName. An
// application appends them to the commands its plugin returns from
// Commands.
func Commands(app *backpack.App) []bonfire.Command {
return []bonfire.Command{generateVAPIDKeysCommand(app), testPushCommand(app)}
}
func flagSet(in bonfire.Input, name string) bool {
v, ok := in.Flag(name)
return ok && v != "false"
}
// generateVAPIDKeysCommand ports the WinterCMS GenerateVapidKeys command.
// Configured keys are only ever shown truncated; a newly generated pair is
// printed in full, because that is the command's purpose.
func generateVAPIDKeysCommand(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: GenerateVAPIDKeysCommandName,
Description: "Generate VAPID keys for Web Push notifications",
Flags: []bonfire.Flag{
{Name: "update", Bare: true, Description: "Save the new keys to the environment's overrides file"},
{Name: "show-current", Bare: true, Description: "Show the configured keys (truncated) and stop"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
out.Println("Web Push VAPID Key Generator")
out.Println("============================")
out.Println()
showCurrentKeys(out, configOf(app))
if flagSet(in, "show-current") {
return nil
}
out.Println()
out.Println("Generating new VAPID keys...")
keys, err := GenerateVAPIDKeys()
if err != nil {
out.Error("Failed to generate VAPID keys: " + err.Error())
return errGenerateFailed
}
out.Success("New keys generated successfully!")
out.Println()
if !validateNewKeys(out, keys) {
return errGenerateFailed
}
out.Println()
out.Println(fmt.Sprintf("Public Key (%d chars):", len(keys.PublicKey)))
out.Println("──────────────────────")
out.Println(keys.PublicKey)
out.Println()
out.Println(fmt.Sprintf("Private Key (%d chars):", len(keys.PrivateKey)))
out.Println("──────────────────────")
out.Println(keys.PrivateKey)
out.Println()
if flagSet(in, "update") {
if err := saveKeys(app, keys); err != nil {
out.Error("Failed to save the keys: " + err.Error())
return errGenerateFailed
}
out.Success(fmt.Sprintf("Keys saved to env/%s/overrides.yaml in the config directory", app.Config.Environment()))
out.Println()
} else {
out.Println("To set the keys manually, add them to the application's environment:")
out.Println()
out.Println("SUMMER_PUSH__PUBLIC_KEY=" + keys.PublicKey)
out.Println("SUMMER_PUSH__PRIVATE_KEY=" + keys.PrivateKey)
out.Println()
out.Println("Or run this command with --update to save them to the environment's overrides file.")
out.Println()
}
out.Println("Next steps:")
out.Println(" 1. Restart the application so it reads the new keys")
out.Println(" 2. Browsers subscribed with the old public key must subscribe again")
out.Println(" 3. Test a subscription over HTTPS (browsers refuse push on plain HTTP)")
out.Println()
out.Success("VAPID key generation completed")
return nil
},
}
}
func configOf(app *backpack.App) Config {
if app == nil {
return LoadConfig(nil)
}
return LoadConfig(app.Config)
}
// showCurrentKeys prints the configured keys as first 8 + "..." + last 4
// characters with their length, and a check mark when they decode to a
// 65-byte public point and a 32-byte private scalar.
func showCurrentKeys(out bonfire.Output, cfg Config) {
out.Println("Current keys:")
printKey := func(label, key string, wantBytes int) {
if key == "" {
out.Println(" " + label + "Not configured")
return
}
mark := "✓"
if raw, err := decodeBase64URL(key); err != nil || len(raw) != wantBytes {
mark = "✗ INVALID"
}
out.Println(fmt.Sprintf(" %s%s (%d chars) %s", label, truncateKey(key), len(key), mark))
}
printKey("Public: ", cfg.PublicKey, publicKeySize)
printKey("Private: ", cfg.PrivateKey, 32)
}
// truncateKey keeps the first 8 and last 4 characters. A value too short
// for that to hide anything is not shown at all.
func truncateKey(s string) string {
if len(s) < 20 {
return "..."
}
return s[:8] + "..." + s[len(s)-4:]
}
func validateNewKeys(out bonfire.Output, keys VAPIDKeys) bool {
out.Println("Keys validated:")
ok := true
if len(keys.PublicKey) == PublicKeyLength {
out.Success(fmt.Sprintf("Public key: %d characters (correct)", len(keys.PublicKey)))
} else {
out.Error(fmt.Sprintf("Public key: %d characters (expected %d)", len(keys.PublicKey), PublicKeyLength))
ok = false
}
if len(keys.PrivateKey) == PrivateKeyLength {
out.Success(fmt.Sprintf("Private key: %d characters (correct)", len(keys.PrivateKey)))
} else {
out.Error(fmt.Sprintf("Private key: %d characters (expected %d)", len(keys.PrivateKey), PrivateKeyLength))
ok = false
}
if base64URLChars.MatchString(keys.PublicKey) && base64URLChars.MatchString(keys.PrivateKey) {
out.Success("Base64url encoding valid")
} else {
out.Error("Invalid base64url encoding")
ok = false
}
if _, err := ParseVAPIDKeys(keys.PublicKey, keys.PrivateKey); err != nil {
out.Error(err.Error())
ok = false
}
return ok
}
// saveKeys stores the pair as runtime overrides and persists them to the
// environment's overrides file (mode 0600).
func saveKeys(app *backpack.App, keys VAPIDKeys) error {
if app == nil || app.Config == nil {
return errors.New("no configuration loaded")
}
if err := app.Config.Set("push.public_key", keys.PublicKey); err != nil {
return err
}
if err := app.Config.Set("push.private_key", keys.PrivateKey); err != nil {
return err
}
return app.Config.Persist()
}
// testPushPayload is the test notification. Field order is the JSON order.
type testPushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Data testPushData `json:"data"`
}
type testPushData struct {
Test bool `json:"test"`
Timestamp int64 `json:"timestamp"`
}
// testPushCommand ports the WinterCMS TestPushNotifications command. The
// subscriptions come from the flare.SubscriptionSource the application
// publishes; without one the command stops.
func testPushCommand(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: TestPushCommandName,
Description: "Test push notification system and send test notification",
Args: []bonfire.Arg{{Name: "user_id", Description: "User ID to send test notification to", Required: true}},
Flags: []bonfire.Flag{
// Accepted for compatibility: the configuration is always shown.
{Name: "show-config", Bare: true, Description: "Display current push configuration"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
out.Println("Push Notification Tester")
out.Println("========================")
out.Println()
svc, err := From(app)
if err != nil {
out.Error(err.Error())
return errTestPushFailed
}
cfg := svc.Config()
showPushConfig(out, cfg)
rawID, _ := in.Argument("user_id")
userID, err := strconv.ParseUint(strings.TrimSpace(rawID), 10, 64)
if err != nil || userID == 0 || userID > uint64(^uint(0)) {
out.Error(fmt.Sprintf("User %s not found", rawID))
return errTestPushFailed
}
src, ok := app.Lookup[SubscriptionSource]()
if !ok || src == nil {
out.Error("no subscription source registered")
out.Println("The application must publish a flare.SubscriptionSource to read stored push subscriptions.")
return errTestPushFailed
}
subs, err := src.Subscriptions(ctx, uint(userID))
if errors.Is(err, ErrUserNotFound) {
out.Error(fmt.Sprintf("User %d not found", userID))
return errTestPushFailed
}
if err != nil {
out.Error("Failed to read push subscriptions: " + err.Error())
return errTestPushFailed
}
out.Info(fmt.Sprintf("Testing push for user ID %d", userID))
out.Println()
if len(subs) == 0 {
out.Error("No push subscriptions found for this user")
out.Println()
out.Println("The user needs to:")
out.Println(" 1. Visit the app in a browser")
out.Println(" 2. Grant notification permission when prompted")
out.Println(" 3. Wait for subscription to be saved")
out.Println()
out.Println("Debug steps:")
out.Println(" - Check browser console for errors")
out.Println(" - Ensure HTTPS is being used")
out.Println(" - Verify service worker is registered")
return errTestPushFailed
}
out.Success(fmt.Sprintf("Found %d subscription(s)", len(subs)))
out.Println()
now := time.Now()
for _, s := range subs {
out.Println(fmt.Sprintf("Subscription #%d:", s.ID))
out.Println(" Endpoint: " + endpointPreview(s.Endpoint))
out.Println(" User Agent: " + s.UserAgent)
out.Println(" Subscribed: " + ago(s.SubscribedAt, now))
if s.LastUsedAt != nil {
out.Println(" Last Used: " + ago(s.LastUsedAt, now))
}
out.Println()
}
send, err := out.Confirm("Send test notification?", true)
if err != nil {
return err
}
if !send {
return nil
}
if !cfg.Enabled {
out.Error("Push is disabled (push.enabled is false); no notification was sent")
return errTestPushFailed
}
return sendTestPush(ctx, out, svc, appName(app), subs)
},
}
}
func showPushConfig(out bonfire.Output, cfg Config) {
out.Println("Current Configuration:")
if cfg.Enabled {
out.Println(" Enabled: ✓ Yes")
} else {
out.Println(" Enabled: ✗ No")
}
setLine := func(label, v string) {
if v == "" {
out.Println(" " + label + ": ✗ Missing")
return
}
out.Println(fmt.Sprintf(" %s: ✓ Set (%d chars)", label, len(v)))
}
setLine("Public Key", cfg.PublicKey)
setLine("Private Key", cfg.PrivateKey)
if cfg.Subject != "" {
out.Println(" Subject: " + cfg.Subject)
} else {
out.Println(" Subject: Not set")
}
if cfg.Subject != "" && cfg.PublicKey != "" {
out.Println()
if n := len(cfg.PublicKey); n != 87 && n != 88 {
out.Warning(fmt.Sprintf("Public key length unusual: %d chars (expected 87-88)", n))
} else {
out.Success(fmt.Sprintf("Public key length valid: %d chars", n))
}
if n := len(cfg.PrivateKey); n != PrivateKeyLength {
out.Warning(fmt.Sprintf("Private key length unusual: %d chars (expected %d)", n, PrivateKeyLength))
} else {
out.Success(fmt.Sprintf("Private key length valid: %d chars", n))
}
switch {
case strings.HasPrefix(cfg.Subject, "mailto:"):
out.Success("Subject format: mailto (valid for production)")
case strings.HasPrefix(cfg.Subject, "https://"):
out.Success("Subject format: HTTPS URL (valid for testing)")
default:
out.Warning("Subject format unusual: should start with 'mailto:' or 'https://'")
}
}
out.Println()
}
func sendTestPush(ctx context.Context, out bonfire.Output, svc *Service, name string, subs []SubscriptionInfo) error {
now := time.Now()
payload, err := json.Marshal(testPushPayload{
Title: name + " test",
Body: "This is a test push notification sent at " + now.Format("15:04:05"),
Data: testPushData{Test: true, Timestamp: now.Unix()},
})
if err != nil {
return err
}
out.Info("Sending test notification...")
pusher := svc.Pusher()
failed := 0
for _, s := range subs {
if err := pusher.Send(ctx, s.Subscription, payload, SendOptions{}); err != nil {
failed++
msg := err.Error()
if errors.Is(err, ErrSubscriptionGone) {
msg += " (delete this subscription)"
}
out.Error(fmt.Sprintf("Subscription #%d: %s", s.ID, msg))
continue
}
out.Success(fmt.Sprintf("Subscription #%d: sent", s.ID))
}
out.Println()
if failed > 0 {
out.Error(fmt.Sprintf("Failed to send %d of %d notification(s)", failed, len(subs)))
out.Println()
out.Println("Common causes:")
out.Println(" - Invalid VAPID keys")
out.Println(" - Subscription endpoint no longer valid")
out.Println(" - Endpoint host not in push.allowed_hosts")
out.Println(" - Push service unavailable or network connectivity issues")
return errTestPushFailed
}
out.Success("Test notification sent successfully!")
out.Println("Check the browser for the notification.")
return nil
}
func appName(app *backpack.App) string {
if app != nil && app.Config != nil {
if n := strings.TrimSpace(app.Config.String("app.name")); n != "" {
return n
}
}
return "SummerCMS"
}
// endpointPreview is the first 60 characters of the endpoint and "...".
func endpointPreview(endpoint string) string {
if len(endpoint) > 60 {
endpoint = endpoint[:60]
}
return endpoint + "..."
}
// ago renders t relative to now, as "3 days ago".
func ago(t *time.Time, now time.Time) string {
if t == nil || t.IsZero() {
return "unknown"
}
d := now.Sub(*t)
if d < 0 {
return "just now"
}
unit := func(n int64, name string) string {
if n == 1 {
return "1 " + name + " ago"
}
return strconv.FormatInt(n, 10) + " " + name + "s ago"
}
switch {
case d < time.Minute:
return unit(int64(d/time.Second), "second")
case d < time.Hour:
return unit(int64(d/time.Minute), "minute")
case d < 24*time.Hour:
return unit(int64(d/time.Hour), "hour")
case d < 7*24*time.Hour:
return unit(int64(d/(24*time.Hour)), "day")
case d < 30*24*time.Hour:
return unit(int64(d/(7*24*time.Hour)), "week")
case d < 365*24*time.Hour:
return unit(int64(d/(30*24*time.Hour)), "month")
default:
return unit(int64(d/(365*24*time.Hour)), "year")
}
}

View File

@@ -0,0 +1,368 @@
package flare
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
)
// newCommandApp builds an app on a temporary config directory in the
// development environment, without process environment variables.
func newCommandApp(t *testing.T, files map[string]string) (*backpack.App, string) {
t.Helper()
dir := t.TempDir()
for name, body := range files {
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatalf("config: %v", err)
}
return backpack.New(cfg), dir
}
// runCommand runs one command with empty stdin (prompts take their
// defaults) and returns the combined output.
func runCommand(t *testing.T, app *backpack.App, args ...string) (string, error) {
t.Helper()
var buf bytes.Buffer
root, err := bonfire.NewRootIO("acme", Commands(app), strings.NewReader(""), &buf, &buf)
if err != nil {
t.Fatalf("NewRootIO: %v", err)
}
root.SetArgs(args)
err = root.Execute()
return buf.String(), err
}
func pushYAML(enabled bool, keys VAPIDKeys, hosts ...string) string {
var b strings.Builder
if enabled {
b.WriteString("enabled: true\n")
} else {
b.WriteString("enabled: false\n")
}
b.WriteString("public_key: " + keys.PublicKey + "\n")
b.WriteString("private_key: " + keys.PrivateKey + "\n")
b.WriteString("subject: mailto:ops@example.com\n")
if len(hosts) > 0 {
b.WriteString("allowed_hosts:\n")
for _, h := range hosts {
b.WriteString(" - " + h + "\n")
}
}
return b.String()
}
// keyAfter returns the line that follows a heading and its divider.
func keyAfter(t *testing.T, out, heading string) string {
t.Helper()
lines := strings.Split(out, "\n")
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), heading) && i+2 < len(lines) {
return strings.TrimSpace(lines[i+2])
}
}
t.Fatalf("no %q in output:\n%s", heading, out)
return ""
}
func truncated(s string) string { return s[:8] + "..." + s[len(s)-4:] }
func TestGenerateVAPIDKeysCommand(t *testing.T) {
t.Run("prints a new pair with manual instructions", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": "enabled: false\n"})
out, err := runCommand(t, app, "websockets:generate-vapid-keys")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
for _, want := range []string{"Web Push VAPID Key Generator", "Public: Not configured", "Private: Not configured", "Public key: 87 characters (correct)", "Private key: 43 characters (correct)", "Base64url encoding valid", "VAPID key generation completed"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
pub := keyAfter(t, out, "Public Key (87 chars):")
priv := keyAfter(t, out, "Private Key (43 chars):")
if len(pub) != PublicKeyLength || len(priv) != PrivateKeyLength {
t.Fatalf("key lengths %d/%d", len(pub), len(priv))
}
if _, err := ParseVAPIDKeys(pub, priv); err != nil {
t.Fatalf("printed keys do not parse: %v", err)
}
if !strings.Contains(out, "SUMMER_PUSH__PUBLIC_KEY="+pub) || !strings.Contains(out, "SUMMER_PUSH__PRIVATE_KEY="+priv) {
t.Fatalf("manual instructions missing:\n%s", out)
}
})
t.Run("--show-current truncates the configured keys", func(t *testing.T) {
keys, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
out, err := runCommand(t, app, "websockets:generate-vapid-keys", "--show-current")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
for _, want := range []string{truncated(keys.PublicKey) + " (87 chars) ✓", truncated(keys.PrivateKey) + " (43 chars) ✓"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, keys.PrivateKey) || strings.Contains(out, keys.PublicKey) {
t.Fatalf("--show-current printed a full configured key:\n%s", out)
}
if strings.Contains(out, "Generating") {
t.Fatalf("--show-current generated keys:\n%s", out)
}
})
t.Run("--update persists the pair to the overrides file", func(t *testing.T) {
app, dir := newCommandApp(t, map[string]string{
"push.yaml": "enabled: false\n",
"env/development/overrides.yaml": "app:\n name: acme\n",
})
out, err := runCommand(t, app, "websockets:generate-vapid-keys", "--update")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
pub := keyAfter(t, out, "Public Key (87 chars):")
priv := keyAfter(t, out, "Private Key (43 chars):")
if strings.Contains(out, "SUMMER_PUSH__PRIVATE_KEY=") {
t.Fatalf("--update printed manual instructions:\n%s", out)
}
if !strings.Contains(out, "env/development/overrides.yaml") {
t.Fatalf("output does not name the overrides file:\n%s", out)
}
reloaded, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if reloaded.String("push.public_key") != pub || reloaded.String("push.private_key") != priv {
t.Fatal("the reloaded config does not return the new keys")
}
if reloaded.String("app.name") != "acme" {
t.Fatalf("an earlier override was lost: app.name = %q", reloaded.String("app.name"))
}
info, err := os.Stat(filepath.Join(dir, "env", "development", "overrides.yaml"))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("overrides mode %o, want 0600", info.Mode().Perm())
}
})
}
type fakeSource struct {
subs []SubscriptionInfo
err error
calls atomic.Int32
}
func (f *fakeSource) Subscriptions(_ context.Context, userID uint) ([]SubscriptionInfo, error) {
f.calls.Add(1)
if userID != 5 {
return nil, ErrUserNotFound
}
return f.subs, f.err
}
func publishSource(t *testing.T, app *backpack.App, src SubscriptionSource) {
t.Helper()
if err := app.Publish[SubscriptionSource](src); err != nil {
t.Fatal(err)
}
}
func TestTestPushCommand(t *testing.T) {
keys, err := GenerateVAPIDKeys()
if err != nil {
t.Fatal(err)
}
noSecret := func(t *testing.T, out string) {
t.Helper()
if strings.Contains(out, keys.PrivateKey) {
t.Fatalf("output contains the configured private key:\n%s", out)
}
}
t.Run("no subscription source", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
out, err := runCommand(t, app, "websockets:test-push", "5", "--show-config")
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
for _, want := range []string{"Current Configuration:", "Enabled: ✓ Yes", "Public Key: ✓ Set (87 chars)", "Private Key: ✓ Set (43 chars)", "Subject: mailto:ops@example.com", "Public key length valid: 87 chars", "Private key length valid: 43 chars", "Subject format: mailto", "no subscription source registered"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
noSecret(t, out)
})
t.Run("unknown user", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
publishSource(t, app, &fakeSource{err: ErrUserNotFound})
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil || !strings.Contains(out, "User 5 not found") {
t.Fatalf("err = %v, output:\n%s", err, out)
}
noSecret(t, out)
})
t.Run("no subscriptions", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys)})
publishSource(t, app, &fakeSource{})
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil || !strings.Contains(out, "No push subscriptions found for this user") {
t.Fatalf("err = %v, output:\n%s", err, out)
}
})
var hits atomic.Int32
received := make(chan receivedPush, 8)
var subs []testSubscriber
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path == "/gone" {
w.WriteHeader(http.StatusGone)
return
}
for _, s := range subs {
if s.sub.Endpoint == "https://"+r.Host+r.URL.Path {
received <- verifyVAPIDRequest(r, s)
w.WriteHeader(http.StatusCreated)
return
}
}
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
longPath := "/push/" + strings.Repeat("a", 80)
subs = []testSubscriber{newTestSubscriber(t, srv.URL+longPath+"1"), newTestSubscriber(t, srv.URL+longPath+"2")}
subscribed := time.Now().Add(-3 * 24 * time.Hour)
infos := func(extra ...SubscriptionInfo) []SubscriptionInfo {
out := []SubscriptionInfo{
{Subscription: subs[0].sub, ID: 7, UserAgent: "Firefox 140", SubscribedAt: &subscribed},
{Subscription: subs[1].sub, ID: 8, UserAgent: "Chrome 150", SubscribedAt: &subscribed, LastUsedAt: &subscribed},
}
return append(out, extra...)
}
t.Run("push disabled lists but does not send", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(false, keys, "127.0.0.1")})
publishSource(t, app, &fakeSource{subs: infos()})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
svc.SetHTTPClient(srv.Client())
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
for _, want := range []string{"Enabled: ✗ No", "Found 2 subscription(s)", "Subscription #7:", "Endpoint: " + subs[0].sub.Endpoint[:60] + "...", "User Agent: Firefox 140", "Subscribed: 3 days ago", "Last Used: 3 days ago", "Push is disabled"} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if hits.Load() != 0 {
t.Fatal("a push was sent while push.enabled is false")
}
noSecret(t, out)
})
t.Run("sends one encrypted push per subscription", func(t *testing.T) {
app, _ := newCommandApp(t, map[string]string{
"app.yaml": "name: acme\n",
"push.yaml": pushYAML(true, keys, "127.0.0.1"),
})
publishSource(t, app, &fakeSource{subs: infos()})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
svc.SetHTTPClient(srv.Client())
out, err := runCommand(t, app, "websockets:test-push", "5")
if err != nil {
t.Fatalf("err = %v\n%s", err, out)
}
if !strings.Contains(out, "Test notification sent successfully") {
t.Fatalf("output:\n%s", out)
}
for range 2 {
got := <-received
if got.err != nil {
t.Fatalf("push service rejected: %v", got.err)
}
var body struct {
Title string `json:"title"`
Body string `json:"body"`
Data struct {
Test bool `json:"test"`
Timestamp int64 `json:"timestamp"`
} `json:"data"`
}
if err := json.Unmarshal(got.payload, &body); err != nil {
t.Fatalf("payload %q: %v", got.payload, err)
}
if body.Title != "acme test" || !strings.HasPrefix(body.Body, "This is a test push notification sent at ") || !body.Data.Test || body.Data.Timestamp < time.Now().Add(-time.Minute).Unix() {
t.Fatalf("payload %s", got.payload)
}
if !strings.HasPrefix(string(got.payload), `{"title":"acme test","body":"This is a test push notification sent at `) {
t.Fatalf("payload key order %s", got.payload)
}
}
if n := hits.Load(); n != 2 {
t.Fatalf("%d requests, want 2", n)
}
noSecret(t, out)
})
t.Run("a failed send exits with an error", func(t *testing.T) {
hits.Store(0)
app, _ := newCommandApp(t, map[string]string{"push.yaml": pushYAML(true, keys, "127.0.0.1")})
gone := SubscriptionInfo{Subscription: Subscription{Endpoint: srv.URL + "/gone", P256dh: subs[0].sub.P256dh, Auth: subs[0].sub.Auth}, ID: 9}
publishSource(t, app, &fakeSource{subs: infos(gone)})
svc, err := From(app)
if err != nil {
t.Fatal(err)
}
svc.SetHTTPClient(srv.Client())
out, err := runCommand(t, app, "websockets:test-push", "5")
if err == nil {
t.Fatalf("want an error exit:\n%s", out)
}
for range 2 {
if got := <-received; got.err != nil {
t.Fatalf("push service rejected: %v", got.err)
}
}
if n := hits.Load(); n != 3 {
t.Fatalf("%d requests, want 3 (every subscription attempted)", n)
}
if !strings.Contains(out, "Subscription #9") || !strings.Contains(out, "gone") {
t.Fatalf("output does not report the gone subscription:\n%s", out)
}
noSecret(t, out)
})
}

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)
}
})
}