Files
silo-server/internal/notifications/relay_credentials.go
QuickandGitHub 3fd0912cb3 fix(notifications): align with Worker push relay (#374)
* fix(notifications): align with Worker push relay

* fix(notifications): disable invalid APNs tokens
2026-07-10 15:48:37 -04:00

219 lines
8.0 KiB
Go

package notifications
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
relayRegisterPath = "/v1/deployments/register"
relayRotatePath = "/v1/deployments/rotate"
relayRenewPath = "/v1/deployments/renew"
relayRenewBefore = 7 * 24 * time.Hour
relayRenewJitter = 24 * time.Hour
)
type RelayHTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
type RelayCredentialResult struct {
Credential PushRelayCredential
RequestID string
APNsTopics []string
}
type relayCredentialResponse struct {
RequestID string `json:"request_id"`
DeploymentID string `json:"deployment_id"`
APIKey string `json:"api_key"`
KeyPrefix string `json:"key_prefix"`
APNsTopics []string `json:"apns_topics"`
ExpiresAt string `json:"expires_at"`
}
type relayNestedError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
} `json:"error"`
}
type RelayCredentialError struct {
Status int
Code string
Message string
RetryAfter time.Duration
}
func (e RelayCredentialError) Error() string { return e.Code }
// NormalizePushRelayURL accepts the official production origin or one exact
// operator-configured development/staging origin. Capabilities and device
// tokens must never be sent to an arbitrary URL supplied through the admin UI.
func NormalizePushRelayURL(raw, developmentOrigin string) (string, error) {
value := strings.TrimRight(strings.TrimSpace(raw), "/")
if value == "" {
value = DefaultPushRelayURL
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" {
return "", errors.New("relay_url must be an HTTPS origin")
}
allowed := map[string]bool{DefaultPushRelayURL: true}
if override := strings.TrimRight(strings.TrimSpace(developmentOrigin), "/"); override != "" {
allowed[override] = true
}
if !allowed[value] {
return "", errors.New("relay_url is not an allowed Silo relay origin")
}
return value, nil
}
func IsLegacyPushRelayKey(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "rk_")
}
func LoadPushRelayCredential(ctx context.Context, settings *Settings) PushRelayCredential {
return PushRelayCredential{
RelayURL: settings.PushRelayURL(ctx),
DeploymentID: settings.PushRelayDeploymentID(ctx),
APIKey: settings.PushRelayAPIKey(ctx),
ExpiresAt: settings.PushRelayExpiresAt(ctx),
KeyPrefix: settings.PushRelayKeyPrefix(ctx),
ReregistrationRequired: settings.PushRelayReregistrationRequired(ctx),
}
}
func RelayCredentialNeedsRenewal(now, expiresAt time.Time, deploymentID string) bool {
if expiresAt.IsZero() {
return false
}
digest := sha256.Sum256([]byte(deploymentID))
jitterSeconds := int64(digest[0])<<8 | int64(digest[1])
jitter := time.Duration(jitterSeconds) * relayRenewJitter / 65535
return !now.Before(expiresAt.Add(-(relayRenewBefore + jitter)))
}
func RelayRotationIdempotencyKey(deploymentID, capability string) string {
digest := sha256.Sum256([]byte("silo-relay-rotation\x00" + deploymentID + "\x00" + capability))
return "silo-rotate-" + hex.EncodeToString(digest[:])
}
func RequestRelayCredential(ctx context.Context, client RelayHTTPDoer, relayURL, path, capability, idempotencyKey string) (RelayCredentialResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, relayURL+path, bytes.NewReader([]byte("{}")))
if err != nil {
return RelayCredentialResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Silo-Server/PushRelayCredential")
if capability != "" {
req.Header.Set("Authorization", "Bearer "+capability)
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(req)
if err != nil {
return RelayCredentialResult{}, RelayCredentialError{Status: http.StatusBadGateway, Code: "relay_unreachable", Message: "Push relay could not be reached"}
}
defer func() { _ = resp.Body.Close() }()
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
if readErr != nil {
return RelayCredentialResult{}, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var parsed relayNestedError
_ = json.Unmarshal(data, &parsed)
code := strings.TrimSpace(parsed.Error.Code)
if code == "" {
code = fmt.Sprintf("relay_http_%d", resp.StatusCode)
}
message := strings.TrimSpace(parsed.Error.Message)
if message == "" {
message = http.StatusText(resp.StatusCode)
}
return RelayCredentialResult{}, RelayCredentialError{
Status: resp.StatusCode,
Code: code,
Message: message,
RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()),
}
}
var parsed relayCredentialResponse
if err := json.Unmarshal(data, &parsed); err != nil {
return RelayCredentialResult{}, RelayCredentialError{Status: http.StatusBadGateway, Code: "relay_bad_response", Message: "Push relay returned invalid JSON"}
}
expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(parsed.ExpiresAt))
if err != nil || strings.TrimSpace(parsed.DeploymentID) == "" || strings.TrimSpace(parsed.APIKey) == "" || strings.TrimSpace(parsed.KeyPrefix) == "" {
return RelayCredentialResult{}, RelayCredentialError{Status: http.StatusBadGateway, Code: "relay_bad_response", Message: "Push relay returned an incomplete credential response"}
}
return RelayCredentialResult{
Credential: PushRelayCredential{
RelayURL: relayURL,
DeploymentID: strings.TrimSpace(parsed.DeploymentID),
APIKey: strings.TrimSpace(parsed.APIKey),
ExpiresAt: expiresAt,
KeyPrefix: strings.TrimSpace(parsed.KeyPrefix),
},
RequestID: parsed.RequestID,
APNsTopics: parsed.APNsTopics,
}, nil
}
func RegisterRelayCredential(ctx context.Context, settings *Settings, client RelayHTTPDoer, relayURL string) (RelayCredentialResult, error) {
result, err := RequestRelayCredential(ctx, client, relayURL, relayRegisterPath, "", "")
if err != nil {
return RelayCredentialResult{}, err
}
if err := settings.UpdatePushRelayCredential(ctx, result.Credential); err != nil {
return RelayCredentialResult{}, err
}
return result, nil
}
func RotateRelayCredential(ctx context.Context, settings *Settings, client RelayHTTPDoer, current PushRelayCredential) (RelayCredentialResult, error) {
key := RelayRotationIdempotencyKey(current.DeploymentID, current.APIKey)
result, err := RequestRelayCredential(ctx, client, current.RelayURL, relayRotatePath, current.APIKey, key)
if err != nil {
return RelayCredentialResult{}, err
}
if result.Credential.DeploymentID != current.DeploymentID {
return RelayCredentialResult{}, RelayCredentialError{Status: http.StatusBadGateway, Code: "relay_bad_response", Message: "Push relay changed the deployment during rotation"}
}
if err := settings.UpdatePushRelayCredential(ctx, result.Credential); err != nil {
return RelayCredentialResult{}, err
}
return result, nil
}
func RenewRelayCredential(ctx context.Context, settings *Settings, client RelayHTTPDoer, current PushRelayCredential) (RelayCredentialResult, error) {
result, err := RequestRelayCredential(ctx, client, current.RelayURL, relayRenewPath, current.APIKey, "")
if err != nil {
return RelayCredentialResult{}, err
}
if result.Credential.DeploymentID != current.DeploymentID {
return RelayCredentialResult{}, RelayCredentialError{Status: http.StatusBadGateway, Code: "relay_bad_response", Message: "Push relay changed the deployment during renewal"}
}
if err := settings.UpdatePushRelayCredential(ctx, result.Credential); err != nil {
return RelayCredentialResult{}, err
}
return result, nil
}
func MarkRelayReregistrationRequired(ctx context.Context, settings *Settings, current PushRelayCredential) error {
current.ReregistrationRequired = true
return settings.UpdatePushRelayCredential(ctx, current)
}