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:
436
modules/flare/commands.go
Normal file
436
modules/flare/commands.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user