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

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