* fix(notifications): align with Worker push relay * fix(notifications): disable invalid APNs tokens
219 lines
8.0 KiB
Go
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)
|
|
}
|