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:
@@ -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.VAPIDHeader(endpoint, subject, keys, now)` | The RFC 8292 `Authorization` header value. |
|
||||||
| `flare.Encrypt(payload, sub)` | The RFC 8291 `aes128gcm` request body. |
|
| `flare.Encrypt(payload, sub)` | The RFC 8291 `aes128gcm` request body. |
|
||||||
| `flare.HostAllowed(host, allowed)`, `flare.DefaultAllowedHosts` | The endpoint host allowlist and its default. |
|
| `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.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. |
|
| `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.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. |
|
| `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
|
## Dependencies
|
||||||
|
|
||||||
- `backpack` and `compass` from this repository.
|
- `backpack`, `bonfire` and `compass` from this repository.
|
||||||
- `github.com/golang-jwt/jwt/v5` (the ES256 VAPID token).
|
- `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.
|
- 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
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
368
modules/flare/commands_test.go
Normal file
368
modules/flare/commands_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -193,12 +193,13 @@ for _, pub := range mem.Publications() {
|
|||||||
| Identifier | Description |
|
| Identifier | Description |
|
||||||
|------------|-------------|
|
|------------|-------------|
|
||||||
| `centrifugo.Config`, `centrifugo.LoadConfig` | The `realtime.centrifugo.*` settings with their defaults, plus `TrustedProxies` from `http.trusted_proxies` for logging client IPs. |
|
| `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.DebugInfo` | `api_url`, `enabled`, `api_key_set`. |
|
||||||
| `centrifugo.TokenIssuer`, `centrifugo.NewTokenIssuer` | HS256 token generators: `ForUser`, `Subscription`, `Anonymous`, `ForIdentifier`, `SubscriptionForIdentifier`, `Configured`. |
|
| `centrifugo.TokenIssuer`, `centrifugo.NewTokenIssuer` | HS256 token generators: `ForUser`, `Subscription`, `Anonymous`, `ForIdentifier`, `SubscriptionForIdentifier`, `Configured`. |
|
||||||
| `centrifugo.TokenHandler(svc, issuer)` | The token route handler. |
|
| `centrifugo.TokenHandler(svc, issuer)` | The token route handler. |
|
||||||
| `centrifugo.ProxyHandler(svc, cfg)` | The subscribe proxy 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.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. |
|
| `centrifugo.ErrNotConfigured` | Returned when the API key or token secret an operation needs is empty. |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -218,9 +219,17 @@ for _, pub := range mem.Publications() {
|
|||||||
| `realtime.centrifugo.token_path` | `/api/realtime/token` | Path of the token route. |
|
| `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. |
|
| `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
|
## 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).
|
- `gorm.io/gorm` (broadcast callbacks).
|
||||||
- `github.com/golang-jwt/jwt/v5` (centrifugo token signing).
|
- `github.com/golang-jwt/jwt/v5` (centrifugo token signing).
|
||||||
- The Centrifugo client is plain `net/http`; no Centrifugo SDK is used.
|
- The Centrifugo client is plain `net/http`; no Centrifugo SDK is used.
|
||||||
|
|||||||
@@ -158,6 +158,37 @@ func (c *Client) Unsubscribe(ctx context.Context, userID uint, channel string) e
|
|||||||
return err
|
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 {
|
func (c *Client) data(event string, payload json.RawMessage) eventData {
|
||||||
if len(bytes.TrimSpace(payload)) == 0 {
|
if len(bytes.TrimSpace(payload)) == 0 {
|
||||||
payload = json.RawMessage("[]")
|
payload = json.RawMessage("[]")
|
||||||
|
|||||||
69
modules/lighthouse/centrifugo/commands.go
Normal file
69
modules/lighthouse/centrifugo/commands.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package centrifugo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthCommandName is the name of the Centrifugo health check command.
|
||||||
|
const HealthCommandName = "websockets:health"
|
||||||
|
|
||||||
|
// errHealthFailed is returned by the health command after it has printed
|
||||||
|
// why the check failed, so the binary exits 1.
|
||||||
|
var errHealthFailed = errors.New(HealthCommandName + ": check failed")
|
||||||
|
|
||||||
|
// Commands returns the Centrifugo console commands for an application
|
||||||
|
// binary: the HealthCommandName check. An application appends them to the
|
||||||
|
// commands its plugin returns from Commands.
|
||||||
|
func Commands(app *backpack.App) []bonfire.Command {
|
||||||
|
return []bonfire.Command{healthCommand(app)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthCommand ports the WinterCMS CentrifugoHealthCheck command. The
|
||||||
|
// WinterCMS command only read the local configuration; this one also calls
|
||||||
|
// the Centrifugo info API, so a wrong URL or key fails the check. The API
|
||||||
|
// key is never printed.
|
||||||
|
func healthCommand(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: HealthCommandName,
|
||||||
|
Description: "Check Centrifugo connection health",
|
||||||
|
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||||
|
var cfg Config
|
||||||
|
if app != nil {
|
||||||
|
cfg = LoadConfig(app.Config)
|
||||||
|
} else {
|
||||||
|
cfg = LoadConfig(nil)
|
||||||
|
}
|
||||||
|
client := NewClient(cfg, nil)
|
||||||
|
if !client.Enabled() {
|
||||||
|
out.Error("Centrifugo not configured (API key missing)")
|
||||||
|
out.Println("Set SUMMER_REALTIME__CENTRIFUGO__API_KEY in the environment")
|
||||||
|
return errHealthFailed
|
||||||
|
}
|
||||||
|
out.Info("Checking Centrifugo connection...")
|
||||||
|
out.Println("API URL: " + cfg.APIURL)
|
||||||
|
if _, err := client.Info(ctx); err != nil {
|
||||||
|
out.Error("Connection check failed: " + err.Error())
|
||||||
|
return errHealthFailed
|
||||||
|
}
|
||||||
|
info := client.DebugInfo()
|
||||||
|
out.Success("Configuration OK")
|
||||||
|
out.Table([]string{"Setting", "Value"}, [][]string{
|
||||||
|
{"API URL", info.APIURL},
|
||||||
|
{"Enabled", yesNo(info.Enabled)},
|
||||||
|
{"API Key Set", yesNo(info.APIKeySet)},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func yesNo(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "Yes"
|
||||||
|
}
|
||||||
|
return "No"
|
||||||
|
}
|
||||||
129
modules/lighthouse/centrifugo/commands_test.go
Normal file
129
modules/lighthouse/centrifugo/commands_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package centrifugo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testAPIKey = "test-api-key-7f3c9e"
|
||||||
|
|
||||||
|
func healthApp(t *testing.T, realtimeYAML string) *backpack.App {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "realtime.yaml"), []byte(realtimeYAML), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return backpack.New(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHealth(t *testing.T, app *backpack.App) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
root, err := bonfire.NewRootIO("acme", Commands(app), strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root.SetArgs([]string{"websockets:health"})
|
||||||
|
err = root.Execute()
|
||||||
|
return buf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthCommand(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
var gotAuth, gotBody atomic.Value
|
||||||
|
status := atomic.Int32{}
|
||||||
|
answer := atomic.Value{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
if r.URL.Path != "/api/info" || r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
gotAuth.Store(r.Header.Get("Authorization"))
|
||||||
|
gotBody.Store(string(body))
|
||||||
|
w.WriteHeader(int(status.Load()))
|
||||||
|
_, _ = io.WriteString(w, answer.Load().(string))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
configured := "centrifugo:\n api_url: " + srv.URL + "/api\n api_key: " + testAPIKey + "\n"
|
||||||
|
|
||||||
|
t.Run("missing API key", func(t *testing.T) {
|
||||||
|
out, err := runHealth(t, healthApp(t, "centrifugo:\n api_url: "+srv.URL+"/api\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want an error exit:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Centrifugo not configured (API key missing)") || !strings.Contains(out, "SUMMER_REALTIME__CENTRIFUGO__API_KEY") {
|
||||||
|
t.Fatalf("output:\n%s", out)
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatal("a request was sent without an API key")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Centrifugo answers info", func(t *testing.T) {
|
||||||
|
status.Store(http.StatusOK)
|
||||||
|
answer.Store(`{"result":{"nodes":[{"name":"node-1"}]}}`)
|
||||||
|
out, err := runHealth(t, healthApp(t, configured))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Checking Centrifugo connection...", "API URL: " + srv.URL + "/api", "Configuration OK", "Setting\tValue", "API URL\t" + srv.URL + "/api", "Enabled\tYes", "API Key Set\tYes"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("output lacks %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotAuth.Load() != "apikey "+testAPIKey || gotBody.Load() != "{}" {
|
||||||
|
t.Fatalf("info request: auth %v body %v", gotAuth.Load(), gotBody.Load())
|
||||||
|
}
|
||||||
|
if strings.Contains(out, testAPIKey) {
|
||||||
|
t.Fatalf("output contains the API key:\n%s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Centrifugo answers 500", func(t *testing.T) {
|
||||||
|
status.Store(http.StatusInternalServerError)
|
||||||
|
answer.Store(`oops`)
|
||||||
|
out, err := runHealth(t, healthApp(t, configured))
|
||||||
|
if err == nil || !strings.Contains(out, "Connection check failed:") || strings.Contains(out, "Configuration OK") {
|
||||||
|
t.Fatalf("err = %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, testAPIKey) {
|
||||||
|
t.Fatalf("output contains the API key:\n%s", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Centrifugo answers an error body", func(t *testing.T) {
|
||||||
|
status.Store(http.StatusOK)
|
||||||
|
answer.Store(`{"error":{"code":101,"message":"unauthorized"}}`)
|
||||||
|
out, err := runHealth(t, healthApp(t, configured))
|
||||||
|
if err == nil || !strings.Contains(out, "Connection check failed:") || !strings.Contains(out, "unauthorized") {
|
||||||
|
t.Fatalf("err = %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Centrifugo unreachable", func(t *testing.T) {
|
||||||
|
dead := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
url := dead.URL
|
||||||
|
dead.Close()
|
||||||
|
out, err := runHealth(t, healthApp(t, "centrifugo:\n api_url: "+url+"/api\n api_key: "+testAPIKey+"\n"))
|
||||||
|
if err == nil || !strings.Contains(out, "Connection check failed:") || strings.Contains(out, testAPIKey) {
|
||||||
|
t.Fatalf("err = %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user