diff --git a/modules/flare/README.md b/modules/flare/README.md index fdeb835..f4b77e5 100644 --- a/modules/flare/README.md +++ b/modules/flare/README.md @@ -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//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 [--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":" test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":}}`. + ## 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. diff --git a/modules/flare/commands.go b/modules/flare/commands.go new file mode 100644 index 0000000..d664b62 --- /dev/null +++ b/modules/flare/commands.go @@ -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") + } +} diff --git a/modules/flare/commands_test.go b/modules/flare/commands_test.go new file mode 100644 index 0000000..9d51bb0 --- /dev/null +++ b/modules/flare/commands_test.go @@ -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) + }) +} diff --git a/modules/lighthouse/README.md b/modules/lighthouse/README.md index 119497d..cd0dd44 100644 --- a/modules/lighthouse/README.md +++ b/modules/lighthouse/README.md @@ -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. diff --git a/modules/lighthouse/centrifugo/client.go b/modules/lighthouse/centrifugo/client.go index 6416427..2bbd8c9 100644 --- a/modules/lighthouse/centrifugo/client.go +++ b/modules/lighthouse/centrifugo/client.go @@ -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("[]") diff --git a/modules/lighthouse/centrifugo/commands.go b/modules/lighthouse/centrifugo/commands.go new file mode 100644 index 0000000..bf6ff60 --- /dev/null +++ b/modules/lighthouse/centrifugo/commands.go @@ -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" +} diff --git a/modules/lighthouse/centrifugo/commands_test.go b/modules/lighthouse/centrifugo/commands_test.go new file mode 100644 index 0000000..de8c706 --- /dev/null +++ b/modules/lighthouse/centrifugo/commands_test.go @@ -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) + } + }) +}