2026-07-10 12:10:48 -04:00
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 {
2026-07-10 15:48:37 -04:00
Status int
Code string
Message string
RetryAfter time . Duration
2026-07-10 12:10:48 -04:00
}
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 )
}
2026-07-10 15:48:37 -04:00
return RelayCredentialResult {}, RelayCredentialError {
Status : resp . StatusCode ,
Code : code ,
Message : message ,
RetryAfter : parseRetryAfter ( resp . Header . Get ( "Retry-After" ), time . Now ()),
}
2026-07-10 12:10:48 -04:00
}
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 )
}