apnsrelay

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultClientFactory

func DefaultClientFactory(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client

DefaultClientFactory is apns.NewClient.

Types

type ClientCache

type ClientCache struct {
	// contains filtered or unexported fields
}

ClientCache owns the shared APNs provider clients. The relay holds ONE client per publisher identity tuple (in practice one, since the relay has a single .p8); all queue workers send through the cached instance so APNs sees a stable provider JWT instead of per-send JWT rotations that trip 429 TooManyProviderTokenUpdates.

func NewClientCache

func NewClientCache(f ClientFactory) *ClientCache

NewClientCache builds a cache with the given factory (DefaultClientFactory for production, a counting stub for the regression test).

func (*ClientCache) Client

func (c *ClientCache) Client(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client

Client returns the cached *apns.Client for the tuple, building it on the first call. Concurrent callers for the same tuple all receive the same instance — the factory runs exactly once per tuple under contention (see the regression test). A tuple change (e.g. a new keyID after .p8 rotation) yields a fresh client; call Invalidate to drop the old one.

keyID/teamID are the PUBLISHER .p8 identity (the relay's own key), NOT the per-installation E2E key_id (Installation.KeyID / Envelope.KeyID, which is an HPKE key label the relay can't sign with). main.go pre-seeds the publisher tuple; Phase-3 send wiring must pass s.KeyID, never inst.KeyID.

func (*ClientCache) Invalidate

func (c *ClientCache) Invalidate(keyID, teamID, bundleID string, env apns.Env)

Invalidate drops the cached client for a tuple. Call after a .p8 rotation so the next send mints a new Signer under the new key rather than reusing the stale JWT from the old key.

type ClientFactory

type ClientFactory func(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client

ClientFactory builds an *apns.Client for a publisher identity. It is injected so the cache can be tested without minting real Signers/JWTs: the 429 invariant (2026-08-07) is "exactly one client — and thus one Signer + one ~50-min provider JWT — per (keyID,teamID,bundleID,env), no matter how many workers race". A counting factory in the regression test proves the cache collapses a burst to a single factory call.

type Credential

type Credential struct {
	DaemonID       string
	InstallationID string // optional: credentials may be installation-scoped
	ExpiresAt      time.Time
}

Credential is the authenticated daemon identity a request resolves to. The relay stamps cred.DaemonID as the APNs payload origin (org) so the iPhone can route a tapped notification to the originating host (1:N model). Access to a given installation is gated by Store.Authorized (the enforced authz check); a credential alone does not authorize pushing to any installation.

type Installation

type Installation struct {
	ID                string
	E2EPubKey         []byte // X25519 HPKE public key; daemons seal alerts to it
	KeyID             string
	DeviceToken       string // APNs push token for ordinary alerts
	LAToken           string // APNs push token for the Live Activity (optional)
	Env               apns.Env
	AuthorizedDaemons map[string]struct{}
	// TokenStale is set when APNs reports the device token unregistered. The
	// queue skips pushes to a stale install until the iPhone re-registers a
	// fresh token (Register clears it), so a dead token stops generating a
	// failed send per queued alert (Phase 3 stale-token removal).
	TokenStale bool
}

Installation is the relay's per-iPhone-install record. The 1:N model: one iPhone install (one E2E keypair + one device token) may be paired with N daemons (Mac, Linux, …). AuthorizedDaemons is the set of daemon_ids that may push to this install; a daemon may submit only if its credential's daemon_id is in the set. Unlinking one daemon removes only its entry, keeping the others.

This is the net-new multi-tenant registry (contract §5); it is NOT the single-tenant daemon apns.Store. It is in-memory for the skeleton — the durable store lands in Phase 3.

type PushAlertsRequest

type PushAlertsRequest struct {
	InstallationID string          `json:"installation_id"`
	KeyID          string          `json:"key_id"`
	CollapseHint   string          `json:"collapse_hint"` // relay uses verbatim as apns-collapse-id
	ExpiresAt      int64           `json:"expires_at"`
	Envelopes      []hpke.Envelope `json:"envelopes"`
}

PushAlertsRequest is POST /v1/push/alerts.

type PushResult

type PushResult struct {
	RequestID    string `json:"request_id"`
	Accepted     int    `json:"accepted"`
	Deduplicated int    `json:"deduplicated"`
	RetryAfter   int    `json:"retry_after,omitempty"` // seconds, set under admission control (Phase 3)
}

PushResult is the 202 body for both push routes.

type RegisterRequest

type RegisterRequest struct {
	InstallationID string   `json:"installation_id"`
	DaemonID       string   `json:"daemon_id"`
	Env            apns.Env `json:"env"`
	DeviceToken    string   `json:"device_token,omitempty"`
	LAToken        string   `json:"la_token,omitempty"`
	E2EPubKey      []byte   `json:"e2e_pubkey,omitempty"`
	KeyID          string   `json:"key_id,omitempty"`
	ProtoVersion   int      `json:"proto_version,omitempty"`
}

RegisterRequest is the body of POST /v1/installations/register. The JSON tags MUST mirror relayclient.registerRequest (snake_case) — without them the decoder matched on PascalCase field names and device_token/e2e_pubkey silently stayed empty, so the relay stored installs it could never actually push to.

type Server

type Server struct {
	Store         *Store
	Clients       *ClientCache
	ConfiguredEnv apns.Env
	KeyID         string
	TeamID        string
	BundleID      string

	// Resolve maps a request to its daemon credential. nil result ⇒ 401. Tests
	// inject a fixed resolver; the production default is bearerResolve.
	Resolve func(r *http.Request) (*Credential, error)
	// contains filtered or unexported fields
}

Server is the relay HTTPS endpoint. It owns the installation registry, the cached APNs client, and the publisher identity (.p8). Daemon credentials are resolved per-request via Resolve; the default impl verifies a publisher-minted Ed25519-signed bearer (relayauth.Verify), with an optional static bootstrap bearer for local dev.

func NewServer

func NewServer(store *Store, clients *ClientCache, env apns.Env, keyID, teamID, bundleID string) *Server

NewServer builds a relay server. clients may be nil (created with the default factory). Resolve defaults to bearerResolve (signed-credential verification).

func (*Server) Close

func (s *Server) Close()

Close stops the async alert lane's worker (abandoning pending items — the 2s TTL would expire them on a fresh relay anyway). Call on shutdown so the worker goroutine exits cleanly instead of leaking past the drain window.

func (*Server) FlushAlerts

func (s *Server) FlushAlerts()

FlushAlerts blocks until the alert lane has drained and no send is in flight. Tests use it for deterministic async assertions.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the mux with all /v1 routes registered.

func (*Server) SetAlertSender

func (s *Server) SetAlertSender(fn func(ctx context.Context, payload map[string]any, deviceToken string, opts apns.SendOpts) error)

SetAlertSender overrides the alert sender (the default uses the cached client). Tests inject a recorder so the relay can accept envelopes without a real .p8.

func (*Server) SetBootstrapCredential

func (s *Server) SetBootstrapCredential(token, daemonID string)

SetBootstrapCredential arms the static pre-shared bearer (local dev). daemonID is stamped as the alert org + used for authorization; it must match the credential the daemon presents so register and submit resolve to the same identity. No-op when token is empty (production: use -verify-key).

func (*Server) SetLiveActivitySender

func (s *Server) SetLiveActivitySender(fn func(ctx context.Context, payload map[string]any, laToken string, opts apns.SendOpts) error)

SetLiveActivitySender overrides the LA sender (default = cached client). Tests inject a recorder so the relay can accept activities without a real .p8.

func (*Server) SetPublisherKey

func (s *Server) SetPublisherKey(key *ecdsa.PrivateKey)

SetPublisherKey sets the relay's APNs provider key (.p8), enabling the default alert sender (apns.Client.SendRaw via the cached client). main loads the .p8 once at startup and calls this; the cached-client invariant (one Signer per identity) lives in ClientCache.

func (*Server) SetVerifyKey

func (s *Server) SetVerifyKey(key ed25519.PublicKey)

SetVerifyKey arms the publisher's Ed25519 public key for signed-credential verification. Production relays MUST call this (or accept only the local-dev bootstrap bearer).

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the in-memory installation registry.

func NewStore

func NewStore() *Store

func (*Store) Authorized

func (s *Store) Authorized(installationID, daemonID string) bool

Authorized reports whether daemonID may push to installationID.

func (*Store) Get

func (s *Store) Get(installationID string) (Installation, bool)

Get returns a snapshot copy of the installation, if present. Callers receive a detached value so they cannot race the store by mutating the live record.

func (*Store) IsStale

func (s *Store) IsStale(installationID string) bool

IsStale reports whether an installation's device token is flagged unregistered.

func (*Store) MarkStale

func (s *Store) MarkStale(installationID string) bool

MarkStale flags an installation's device token as unregistered so the queue stops pushing until the iPhone re-registers. Idempotent. Returns false if the installation is not found.

func (*Store) Register

func (s *Store) Register(req RegisterRequest) (Installation, bool)

Register upserts an installation: it creates the install (first daemon for this iPhone) or adds daemonID to an existing install's authorized set. It does NOT replace existing device/E2E-key material — a second daemon pairing adds itself to the authorized set, it does not overwrite the iPhone's keys/tokens. Returns a snapshot copy and whether the installation was newly created.

func (*Store) UnregisterDaemon

func (s *Store) UnregisterDaemon(installationID, daemonID string) bool

UnregisterDaemon removes one daemon from an installation's authorized set. It keeps the installation for the remaining daemons; the install record is dropped only when the last daemon is removed (the iPhone re-pairs fresh). Returns false if the installation was not found.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL