Merge branch 'feat/knock-push' into main
22 files changed,
+1312
−10
README.md+13 −2callscore/meetings.go+49 −0callscore/meetings_test.go+48 −0internal/calls/apns/apns.go+230 −0internal/calls/apns/apns_test.go+276 −0internal/calls/app.go+23 −1internal/calls/config.go+74 −0internal/calls/hub/admission.go+13 −0internal/calls/hub/hub.go+11 −0internal/calls/knockpush.go+132 −0internal/calls/knockpush_test.go+206 −0internal/calls/migrations/0005_create_device_tokens.sql+6 −0internal/calls/migrations/schema.sql+6 −0internal/calls/models.go+30 −0internal/calls/store.go+51 −0native/README.md+14 −7native/apple/Sources/CallsKit/Call.swift+4 −0native/apple/Sources/CallsKit/CallsModel.swift+21 −0native/apple/Sources/CallsKit/RemoteKnocks.swift+51 −0native/apple/Sources/CallsMac/CallsMac.swift+20 −0native/bridge/bridge.go+12 −0native/ios/Sources/CallsPhone.swift+22 −0
CI failed — run details
diff --git a/README.md b/README.md| index 5bd3d35..8982b36 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -135,8 +135,19 @@ default rather than a refusal: `CALLS_ZONE` (an IANA zone, defaulting |
| to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 — |
| only a *malformed* value refuses startup), delivered by `carlos relay |
| enable --app calls` rather than set by hand. Secrets — |
| -`CALLS_INSTANCE_KEY`, `CALLS_DEK` and `CALLS_SMTP_PASS` — go through |
| -`carlos secrets`; the rest go through `carlos env`. Mail reads |
| +`CALLS_INSTANCE_KEY`, `CALLS_DEK`, `CALLS_SMTP_PASS` and |
| +`CALLS_APNS_KEY` — go through `carlos secrets`; the rest go through |
| +`carlos env`. |
| + |
| +Apple push is a sixth group, and all of it or none of it: |
| +`CALLS_APNS_KEY` (the `.p8` as Apple issues it, PEM and multi-line), |
| +`CALLS_APNS_KEY_ID`, `CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS` and |
| +`CALLS_APNS_TOPIC_MACOS`, with `CALLS_APNS_SANDBOX` for a development |
| +build's tokens. Unset, the server sends no push and says so nowhere — |
| +a knock still reaches every member in the room over their own stream, |
| +which is where this app was before push existed. Partly set, startup |
| +refuses: half a push key is a mistake to catch at boot rather than in a |
| +goroutine on the first knock. Mail reads |
| `CALLS_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the |
| platform supplies a sender too |
| (`CARLOS_SMTP_FROM`), which `CALLS_SMTP_FROM` can optionally override, |
diff --git a/callscore/meetings.go b/callscore/meetings.go| index bac90e6..bfc537c 100644 |
| --- a/callscore/meetings.go |
| +++ b/callscore/meetings.go |
| @@ -73,3 +73,52 @@ func StartMeeting(ctx context.Context, origin string, client *http.Client) (stri |
| } |
| return out.Sqid, nil |
| } |
| + |
| +// RegisterDevice tells the server where this device can be reached when |
| +// the app is not running. |
| +// |
| +// Best effort by design, and the caller treats a failure as nothing |
| +// happening: a device that could not register is a device that will not |
| +// be told about a knock, which is exactly where every device was before |
| +// push existed. It is never a reason to fail a join. |
| +func RegisterDevice(ctx context.Context, origin string, client *http.Client, token, platform string) error { |
| + trimmed, err := checkOrigin(origin) |
| + if err != nil { |
| + return err |
| + } |
| + safe, err := safeClient(client) |
| + if err != nil { |
| + return err |
| + } |
| + if platform != "ios" && platform != "macos" { |
| + return errors.New("unknown device platform") |
| + } |
| + body, err := json.Marshal(map[string]string{"token": token, "platform": platform}) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed+"/native/devices", bytes.NewReader(body)) |
| + if err != nil { |
| + return errors.New("cannot build Calls request") |
| + } |
| + req.Header.Set("Accept", "application/json") |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", trimmed) |
| + res, err := safe.Do(req) |
| + if err != nil { |
| + return errors.New("cannot reach Calls") |
| + } |
| + defer res.Body.Close() |
| + switch { |
| + case res.StatusCode == http.StatusNoContent: |
| + return nil |
| + case res.StatusCode == http.StatusNotFound: |
| + // An older server without the endpoint. Not an error worth |
| + // showing anyone: it simply has no push. |
| + return errors.New("this Calls server does not take device registrations") |
| + default: |
| + return errors.New("Calls refused this device registration") |
| + } |
| +} |
diff --git a/callscore/meetings_test.go b/callscore/meetings_test.go| index d96b42c..da67001 100644 |
| --- a/callscore/meetings_test.go |
| +++ b/callscore/meetings_test.go |
| @@ -111,3 +111,51 @@ func TestStartMeetingNamesTheRefusalItCanDoSomethingAbout(t *testing.T) { |
| server.Close() |
| } |
| } |
| + |
| +func TestRegisterDeviceSendsTheTokenAndBothLocks(t *testing.T) { |
| + var body, origin, contentType atomic.Value |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + origin.Store(r.Header.Get("Origin")) |
| + contentType.Store(r.Header.Get("Content-Type")) |
| + var in map[string]string |
| + json.NewDecoder(r.Body).Decode(&in) |
| + body.Store(in) |
| + w.WriteHeader(http.StatusNoContent) |
| + })) |
| + defer server.Close() |
| + if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabbcc", "macos"); err != nil { |
| + t.Fatal(err) |
| + } |
| + in := body.Load().(map[string]string) |
| + if in["token"] != "aabbcc" || in["platform"] != "macos" { |
| + t.Fatal("wrong body", in) |
| + } |
| + if origin.Load() != server.URL || contentType.Load() != "application/json" { |
| + t.Fatal("missing a lock", origin.Load(), contentType.Load()) |
| + } |
| +} |
| + |
| +func TestRegisterDeviceRefusesAnUnknownPlatformWithoutCallingTheServer(t *testing.T) { |
| + var calls atomic.Int32 |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) |
| + defer server.Close() |
| + if err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "android"); err == nil { |
| + t.Fatal("accepted an unknown platform") |
| + } |
| + if calls.Load() != 0 { |
| + t.Fatal("called the server anyway") |
| + } |
| +} |
| + |
| +func TestRegisterDeviceOnAnOlderServerIsAPlainRefusal(t *testing.T) { |
| + // A server without the endpoint has no push. That is a fact about |
| + // the server, not a failure worth showing anyone. |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer server.Close() |
| + err := RegisterDevice(t.Context(), server.URL, server.Client(), "aabb", "ios") |
| + if err == nil || !strings.Contains(err.Error(), "does not take device registrations") { |
| + t.Fatal("unhelpful error", err) |
| + } |
| +} |
diff --git a/internal/calls/apns/apns.go b/internal/calls/apns/apns.go| new file mode 100644 |
| index 0000000..7e54ca8 |
| --- /dev/null |
| +++ b/internal/calls/apns/apns.go |
| @@ -0,0 +1,230 @@ |
| +// Package apns sends one kind of notification to Apple: someone is |
| +// waiting to be let into a meeting. It is deliberately small — no |
| +// badges, no collapse identifiers, no background pushes — because the |
| +// only thing this app has ever needed to say to a closed app is that |
| +// somebody is at the door. |
| +// |
| +// It is cgo-free, like the rest of the server. The provider token is |
| +// ES256 over the stdlib's crypto/ecdsa, and HTTP/2 comes from net/http |
| +// over TLS without help, which is the whole of what APNs requires. |
| +package apns |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/pem" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// Hosts Apple serves. Production tokens are refused by the sandbox and |
| +// the other way round, with an error that names neither, so the choice |
| +// is configuration rather than something to guess at. |
| +const ( |
| + HostProduction = "https://api.push.apple.com" |
| + HostSandbox = "https://api.sandbox.push.apple.com" |
| +) |
| + |
| +// providerTokenLife is how long one JWT is used for. Apple refuses a |
| +// token older than an hour and refuses a client that mints them more |
| +// often than every twenty minutes, so anything in between is correct and |
| +// fifty minutes leaves room for a slow clock on either side. |
| +const providerTokenLife = 50 * time.Minute |
| + |
| +// maxBody caps what is read from an error response. Apple's are a few |
| +// dozen bytes of JSON; anything larger is not Apple. |
| +const maxBody = 8 * 1024 |
| + |
| +type Config struct { |
| + KeyID string |
| + TeamID string |
| + Key *ecdsa.PrivateKey |
| + // Topic per platform — the bundle identifier, which differs between |
| + // the Mac and iPhone apps, so a token is useless without knowing |
| + // which app issued it. |
| + Topics map[string]string |
| + Host string |
| + // Now and HTTP exist for the tests, which run a fake Apple. |
| + Now func() time.Time |
| + HTTP *http.Client |
| +} |
| + |
| +type Client struct { |
| + cfg Config |
| + mu sync.Mutex |
| + jwt string |
| + made time.Time |
| +} |
| + |
| +// Verdict is what the caller must do about a token, which is the only |
| +// part of an APNs response worth carrying upwards. |
| +type Verdict int |
| + |
| +const ( |
| + // Delivered: Apple accepted it. It says nothing about the device. |
| + Delivered Verdict = iota |
| + // Gone: this token is dead and must be deleted. The app was removed, |
| + // or the token was reissued to someone else. Keeping it means |
| + // pushing at nobody for ever. |
| + Gone |
| + // Retry: transient, or ours to fix. The token stays. |
| + Retry |
| +) |
| + |
| +func ParseKey(data []byte) (*ecdsa.PrivateKey, error) { |
| + block, _ := pem.Decode(data) |
| + if block == nil { |
| + return nil, errors.New("apns: key is not PEM; expected the .p8 Apple issued") |
| + } |
| + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) |
| + if err != nil { |
| + return nil, errors.New("apns: key is not a PKCS#8 private key") |
| + } |
| + key, ok := parsed.(*ecdsa.PrivateKey) |
| + if !ok { |
| + return nil, errors.New("apns: key is not an ECDSA key; APNs keys are P-256") |
| + } |
| + return key, nil |
| +} |
| + |
| +func New(cfg Config) (*Client, error) { |
| + if cfg.KeyID == "" || cfg.TeamID == "" || cfg.Key == nil { |
| + return nil, errors.New("apns: key id, team id and key are all required") |
| + } |
| + if len(cfg.Topics) == 0 { |
| + return nil, errors.New("apns: at least one platform topic is required") |
| + } |
| + if cfg.Host == "" { |
| + cfg.Host = HostProduction |
| + } |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + if cfg.HTTP == nil { |
| + // No Timeout here: the deadline rides on the request context, so |
| + // one slow push cannot outlive the caller that asked for it. |
| + cfg.HTTP = &http.Client{} |
| + } |
| + return &Client{cfg: cfg}, nil |
| +} |
| + |
| +// providerToken mints or reuses the ES256 JWT. Held under the mutex for |
| +// the whole mint: two goroutines racing here would each make one, and |
| +// Apple counts that against a rate limit measured in tokens per |
| +// connection rather than tokens per need. |
| +func (c *Client) providerToken() (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + now := c.cfg.Now() |
| + if c.jwt != "" && now.Sub(c.made) < providerTokenLife { |
| + return c.jwt, nil |
| + } |
| + header := encodeSegment(map[string]any{"alg": "ES256", "kid": c.cfg.KeyID, "typ": "JWT"}) |
| + claims := encodeSegment(map[string]any{"iss": c.cfg.TeamID, "iat": now.Unix()}) |
| + signing := header + "." + claims |
| + digest := sha256.Sum256([]byte(signing)) |
| + r, s, err := ecdsa.Sign(rand.Reader, c.cfg.Key, digest[:]) |
| + if err != nil { |
| + return "", errors.New("apns: cannot sign the provider token") |
| + } |
| + // JWS wants the raw r||s pair, each left-padded to the curve size — |
| + // not the ASN.1 form ecdsa.SignASN1 would give, which Apple rejects |
| + // with a message about the token being malformed and no more. |
| + size := (c.cfg.Key.Curve.Params().BitSize + 7) / 8 |
| + signature := make([]byte, 2*size) |
| + r.FillBytes(signature[:size]) |
| + s.FillBytes(signature[size:]) |
| + c.jwt = signing + "." + base64.RawURLEncoding.EncodeToString(signature) |
| + c.made = now |
| + return c.jwt, nil |
| +} |
| + |
| +func encodeSegment(value map[string]any) string { |
| + b, _ := json.Marshal(value) |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| + |
| +// Push tells one device that someone is waiting. `name` is the waiting |
| +// person's own text, from the server's roster, so it is carried as a |
| +// JSON value and never built into one. |
| +func (c *Client) Push(ctx context.Context, token, platform, name string) (Verdict, error) { |
| + topic, ok := c.cfg.Topics[platform] |
| + if !ok { |
| + return Retry, fmt.Errorf("apns: no topic configured for platform %q", platform) |
| + } |
| + jwt, err := c.providerToken() |
| + if err != nil { |
| + return Retry, err |
| + } |
| + body, err := json.Marshal(map[string]any{ |
| + "aps": map[string]any{ |
| + "alert": map[string]any{ |
| + "title": "Someone is waiting to join", |
| + "body": name, |
| + }, |
| + "sound": "default", |
| + }, |
| + }) |
| + if err != nil { |
| + return Retry, errors.New("apns: cannot build the payload") |
| + } |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/3/device/"+token, bytes.NewReader(body)) |
| + if err != nil { |
| + return Retry, errors.New("apns: cannot build the request") |
| + } |
| + req.Header.Set("authorization", "bearer "+jwt) |
| + req.Header.Set("apns-topic", topic) |
| + req.Header.Set("apns-push-type", "alert") |
| + // 10 is "deliver now", which is the only priority that makes sense |
| + // for a person standing at a door. |
| + req.Header.Set("apns-priority", "10") |
| + req.Header.Set("content-type", "application/json") |
| + res, err := c.cfg.HTTP.Do(req) |
| + if err != nil { |
| + return Retry, errors.New("apns: cannot reach Apple") |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode == http.StatusOK { |
| + return Delivered, nil |
| + } |
| + var out struct { |
| + Reason string `json:"reason"` |
| + } |
| + raw, _ := io.ReadAll(io.LimitReader(res.Body, maxBody)) |
| + _ = json.Unmarshal(raw, &out) |
| + return classify(res.StatusCode, out.Reason) |
| +} |
| + |
| +// classify turns a refusal into the one decision the caller has to make: |
| +// keep this token or delete it. Pure, so the table of Apple's reasons is |
| +// a thing a test can read rather than a thing a fake server has to act |
| +// out one status at a time. |
| +func classify(status int, reason string) (Verdict, error) { |
| + switch reason { |
| + case "BadDeviceToken", "Unregistered", "DeviceTokenNotForTopic": |
| + // The token is wrong for this app or belongs to an app that is |
| + // gone. No amount of retrying changes that. |
| + return Gone, fmt.Errorf("apns: token rejected (%s)", reason) |
| + case "ExpiredProviderToken", "InvalidProviderToken", "MissingProviderToken": |
| + // Ours, not the device's. The token stays; the next push mints a |
| + // new JWT. |
| + return Retry, fmt.Errorf("apns: provider token refused (%s)", reason) |
| + } |
| + if status == http.StatusGone { |
| + return Gone, errors.New("apns: token is no longer registered") |
| + } |
| + if reason == "" { |
| + return Retry, fmt.Errorf("apns: refused with status %d", status) |
| + } |
| + return Retry, fmt.Errorf("apns: refused (%s)", reason) |
| +} |
diff --git a/internal/calls/apns/apns_test.go b/internal/calls/apns/apns_test.go| new file mode 100644 |
| index 0000000..9094156 |
| --- /dev/null |
| +++ b/internal/calls/apns/apns_test.go |
| @@ -0,0 +1,276 @@ |
| +package apns |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/pem" |
| + "math/big" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func testKey(t *testing.T) (*ecdsa.PrivateKey, []byte) { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + der, err := x509.MarshalPKCS8PrivateKey(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) |
| +} |
| + |
| +func client(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { |
| + t.Helper() |
| + key, _ := testKey(t) |
| + server := httptest.NewServer(handler) |
| + t.Cleanup(server.Close) |
| + c, err := New(Config{ |
| + KeyID: "ABC123DEFG", TeamID: "TEAM123456", Key: key, |
| + Topics: map[string]string{"ios": "works.oficina.calls", "macos": "works.oficina.calls.mac"}, |
| + Host: server.URL, HTTP: server.Client(), |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return c, server |
| +} |
| + |
| +func TestParseKeyAcceptsAP8AndRefusesAnythingElse(t *testing.T) { |
| + _, pemBytes := testKey(t) |
| + if _, err := ParseKey(pemBytes); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, input := range map[string]string{ |
| + "not pem": "hello", |
| + "empty": "", |
| + "pem but not a key": "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n", |
| + } { |
| + if _, err := ParseKey([]byte(input)); err == nil { |
| + t.Errorf("accepted %s", name) |
| + } |
| + } |
| +} |
| + |
| +// The provider token is the part Apple rejects most opaquely — a wrong |
| +// signature encoding comes back as "InvalidProviderToken" and nothing |
| +// else — so it is verified here against the public key rather than |
| +// merely produced. |
| +func TestTheProviderTokenIsAValidES256JWTApplesWay(t *testing.T) { |
| + var seen atomic.Value |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + seen.Store(r.Header.Get("authorization")) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { |
| + t.Fatal(err) |
| + } |
| + header, ok := seen.Load().(string) |
| + if !ok || !strings.HasPrefix(header, "bearer ") { |
| + t.Fatal("no bearer token", header) |
| + } |
| + parts := strings.Split(strings.TrimPrefix(header, "bearer "), ".") |
| + if len(parts) != 3 { |
| + t.Fatal("not three JWT segments") |
| + } |
| + var head map[string]any |
| + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| + if err != nil || json.Unmarshal(raw, &head) != nil { |
| + t.Fatal("header is not base64url JSON") |
| + } |
| + if head["alg"] != "ES256" || head["kid"] != "ABC123DEFG" { |
| + t.Fatal("wrong header", head) |
| + } |
| + var claims map[string]any |
| + raw, err = base64.RawURLEncoding.DecodeString(parts[1]) |
| + if err != nil || json.Unmarshal(raw, &claims) != nil { |
| + t.Fatal("claims are not base64url JSON") |
| + } |
| + if claims["iss"] != "TEAM123456" { |
| + t.Fatal("wrong issuer", claims) |
| + } |
| + // The signature must be the raw r||s pair, each padded to 32 bytes. |
| + // ASN.1 — what SignASN1 produces — is the mistake this asserts against. |
| + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| + if err != nil || len(signature) != 64 { |
| + t.Fatalf("signature is %d bytes, want 64 raw r||s", len(signature)) |
| + } |
| + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| + r := new(big.Int).SetBytes(signature[:32]) |
| + s := new(big.Int).SetBytes(signature[32:]) |
| + if !ecdsa.Verify(&c.cfg.Key.PublicKey, digest[:], r, s) { |
| + t.Fatal("signature does not verify against the key that made it") |
| + } |
| +} |
| + |
| +func TestOneTokenIsReusedAndThenReminted(t *testing.T) { |
| + var tokens sync.Map |
| + var count atomic.Int32 |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + tokens.Store(r.Header.Get("authorization"), true) |
| + count.Add(1) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + now := time.Now() |
| + c.cfg.Now = func() time.Time { return now } |
| + for range 3 { |
| + if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + distinct := 0 |
| + tokens.Range(func(_, _ any) bool { distinct++; return true }) |
| + if distinct != 1 { |
| + t.Fatalf("minted %d tokens for three pushes; Apple rate-limits that", distinct) |
| + } |
| + // Past the life, a new one. Apple refuses a token older than an hour. |
| + now = now.Add(providerTokenLife + time.Minute) |
| + if _, err := c.Push(t.Context(), "abc", "ios", "Bea"); err != nil { |
| + t.Fatal(err) |
| + } |
| + distinct = 0 |
| + tokens.Range(func(_, _ any) bool { distinct++; return true }) |
| + if distinct != 2 { |
| + t.Fatal("an expired provider token was reused") |
| + } |
| +} |
| + |
| +func TestTheRequestCarriesWhatApplesRequires(t *testing.T) { |
| + var path, topic, pushType, priority atomic.Value |
| + var payload atomic.Value |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + path.Store(r.URL.Path) |
| + topic.Store(r.Header.Get("apns-topic")) |
| + pushType.Store(r.Header.Get("apns-push-type")) |
| + priority.Store(r.Header.Get("apns-priority")) |
| + var body map[string]any |
| + json.NewDecoder(r.Body).Decode(&body) |
| + payload.Store(body) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + if _, err := c.Push(t.Context(), "devicetoken123", "macos", "Bea O'Neill"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if path.Load() != "/3/device/devicetoken123" { |
| + t.Fatal("wrong path", path.Load()) |
| + } |
| + // The topic follows the platform: the two apps have different bundle |
| + // identifiers, and a token sent under the wrong one is refused with |
| + // DeviceTokenNotForTopic. |
| + if topic.Load() != "works.oficina.calls.mac" { |
| + t.Fatal("wrong topic for macos", topic.Load()) |
| + } |
| + if pushType.Load() != "alert" || priority.Load() != "10" { |
| + t.Fatal("wrong push type or priority", pushType.Load(), priority.Load()) |
| + } |
| + body := payload.Load().(map[string]any) |
| + aps := body["aps"].(map[string]any) |
| + alert := aps["alert"].(map[string]any) |
| + // The name is someone else's text and rides as a JSON value, never |
| + // built into one. |
| + if alert["body"] != "Bea O'Neill" { |
| + t.Fatal("name not carried verbatim", alert["body"]) |
| + } |
| +} |
| + |
| +func TestAnUnknownPlatformIsRefusedWithoutCallingApple(t *testing.T) { |
| + var called atomic.Int32 |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { called.Add(1) }) |
| + verdict, err := c.Push(t.Context(), "abc", "android", "Bea") |
| + if err == nil || verdict != Retry { |
| + t.Fatal("accepted an unknown platform") |
| + } |
| + if called.Load() != 0 { |
| + t.Fatal("called Apple for a platform with no topic") |
| + } |
| +} |
| + |
| +// The table that decides whether a token lives or dies. Pure, so every |
| +// reason Apple documents can be covered without a fake acting each out. |
| +func TestApplesRefusalsAreSortedIntoKeepAndDelete(t *testing.T) { |
| + for _, c := range []struct { |
| + status int |
| + reason string |
| + want Verdict |
| + }{ |
| + {410, "Unregistered", Gone}, |
| + {400, "BadDeviceToken", Gone}, |
| + {400, "DeviceTokenNotForTopic", Gone}, |
| + {410, "", Gone}, |
| + {403, "ExpiredProviderToken", Retry}, |
| + {403, "InvalidProviderToken", Retry}, |
| + {429, "TooManyRequests", Retry}, |
| + {503, "", Retry}, |
| + {500, "InternalServerError", Retry}, |
| + } { |
| + got, err := classify(c.status, c.reason) |
| + if got != c.want { |
| + t.Errorf("status %d reason %q: got %v, want %v", c.status, c.reason, got, c.want) |
| + } |
| + if err == nil { |
| + t.Errorf("status %d reason %q: refusal with no error", c.status, c.reason) |
| + } |
| + } |
| +} |
| + |
| +func TestApplesGoneVerdictReachesTheCaller(t *testing.T) { |
| + c, _ := client(t, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusGone) |
| + w.Write([]byte(`{"reason":"Unregistered"}`)) |
| + }) |
| + verdict, err := c.Push(t.Context(), "dead", "ios", "Bea") |
| + if verdict != Gone || err == nil { |
| + t.Fatal("a dead token must be reported as Gone", verdict, err) |
| + } |
| +} |
| + |
| +func TestAConfigMissingItsCredentialsIsRefusedAtConstruction(t *testing.T) { |
| + key, _ := testKey(t) |
| + full := Config{KeyID: "K", TeamID: "T", Key: key, Topics: map[string]string{"ios": "a"}} |
| + if _, err := New(full); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, broken := range map[string]Config{ |
| + "no key id": {TeamID: "T", Key: key, Topics: full.Topics}, |
| + "no team": {KeyID: "K", Key: key, Topics: full.Topics}, |
| + "no key": {KeyID: "K", TeamID: "T", Topics: full.Topics}, |
| + "no topics": {KeyID: "K", TeamID: "T", Key: key}, |
| + } { |
| + if _, err := New(broken); err == nil { |
| + t.Errorf("accepted a config with %s", name) |
| + } |
| + } |
| +} |
| + |
| +func TestUnreachableAppleIsRetryAndNotGone(t *testing.T) { |
| + // The distinction that matters: a network that is down must never be |
| + // read as "this device is gone", or an outage deletes every token. |
| + key, _ := testKey(t) |
| + c, err := New(Config{ |
| + KeyID: "K", TeamID: "T", Key: key, |
| + Topics: map[string]string{"ios": "a"}, |
| + Host: "http://127.0.0.1:1", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + verdict, err := c.Push(ctx, "abc", "ios", "Bea") |
| + if verdict != Retry || err == nil { |
| + t.Fatal("an unreachable Apple must be Retry", verdict, err) |
| + } |
| +} |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index cf30892..c2f4718 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -18,6 +18,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| "amadan.net/oficina/calls/internal/calls/hub" |
| "amadan.net/oficina/calls/internal/calls/relay" |
| "amadan.net/oficina/calls/internal/calls/seal" |
| @@ -36,6 +37,12 @@ type app struct { |
| linkBudget *budget |
| outsiderBudget *budget |
| hub *hub.Hub |
| + // push is the APNs sender, or nil where no key is configured — which |
| + // is every test and every deployment that has not been given one. |
| + // Nil is not a degraded mode: knocks still reach every member who is |
| + // in the room, exactly as before. It is only the member who is not |
| + // there who goes untold. |
| + push knockPusher |
| // hubSalt keys the session cookie into the hub's session id, so the |
| // hub holds no session material it did not have to. |
| hubSalt string |
| @@ -369,8 +376,22 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| } |
| // The hub reads standing back through the app, and never under its |
| // own lock: hubStanding is a database read. |
| + // The APNs sender, where a key was configured. Built before the hub |
| + // so that a.knocked has something to send with the first time it is |
| + // called, which on a busy instance can be immediately. |
| + if rc.apns != nil { |
| + client, err := apns.New(*rc.apns) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("calls: Apple push: %w", err) |
| + } |
| + a.push = client |
| + rc.Logger.Info("calls: Apple push configured", "sandbox", rc.APNsSandbox) |
| + } |
| + // The hub reads standing back through the app, and never under its |
| + // own lock: hubStanding is a database read. Knocked is the same |
| + // arrangement for the other direction. |
| a.hub = hub.New(hub.Config{ |
| - Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, |
| + Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, Knocked: a.knocked, |
| WaitingRoom: func(ctx context.Context, sqid string) (bool, error) { |
| m, _, err := st.meetingBySqid(ctx, sqid) |
| return m.WaitingRoom, err |
| @@ -473,6 +494,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| } |
| r.Post("/meetings/start", a.startCall) |
| r.Post("/native/meetings/start", a.nativeStartMeeting) |
| + r.Post("/native/devices", a.registerDevice) |
| r.Post("/meetings", a.createMeeting) |
| r.Post("/m/{sqid}/share", a.shareMeeting) |
| r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) |
diff --git a/internal/calls/config.go b/internal/calls/config.go| index 428fd63..5903f98 100644 |
| --- a/internal/calls/config.go |
| +++ b/internal/calls/config.go |
| @@ -1,6 +1,7 @@ |
| package calls |
| import ( |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| "encoding/base64" |
| "fmt" |
| "log/slog" |
| @@ -38,6 +39,29 @@ type Config struct { |
| // AllowedAddresses is the team: the addresses that are members. |
| // CALLS_ALLOWED_ADDRESSES, comma-separated. |
| AllowedAddresses []string |
| + |
| + // The Apple push key, for telling a member who is not in the room |
| + // that somebody is waiting. All four are required together or none |
| + // of them: a half-configured push is a boot-time mistake, not a |
| + // runtime surprise on the first knock. |
| + // |
| + // APNsKey is the .p8 Apple issued, PEM as downloaded, set with |
| + // `carlos secrets set` and never committed. CALLS_APNS_KEY. |
| + APNsKey string |
| + // CALLS_APNS_KEY_ID, the ten characters Apple names the key by. |
| + APNsKeyID string |
| + // CALLS_APNS_TEAM_ID, the Apple developer team. |
| + APNsTeamID string |
| + // CALLS_APNS_TOPIC_IOS and CALLS_APNS_TOPIC_MACOS: the two bundle |
| + // identifiers. Separate because a token is issued per app, and one |
| + // sent under the other app's topic is refused. |
| + APNsTopicIOS string |
| + APNsTopicMacOS string |
| + // CALLS_APNS_SANDBOX sends to Apple's sandbox host, which is where a |
| + // development build's tokens live. A production token refused by the |
| + // sandbox says nothing useful about why, so this is configuration |
| + // rather than something to detect. |
| + APNsSandbox bool |
| // Mailer delivers magic links. Nil outside development is refused; |
| // nil in development logs the link instead of sending it. |
| Mailer mail.Sender |
| @@ -67,6 +91,7 @@ type resolved struct { |
| instanceKey string |
| dek []byte |
| mailer mail.Sender |
| + apns *apns.Config |
| allow allowlist |
| relay []relay.Region |
| relayConfigured bool |
| @@ -134,6 +159,12 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| out.allow = list |
| + push, err := resolveAPNs(cfg) |
| + if err != nil { |
| + return out, err |
| + } |
| + out.apns = push |
| + |
| regions, err := relay.Parse(cfg.Relay) |
| switch { |
| case err == nil: |
| @@ -163,3 +194,46 @@ func resolve(cfg Config) (resolved, error) { |
| } |
| return out, nil |
| } |
| + |
| +// resolveAPNs reads the push key, or reads that there isn't one. |
| +// |
| +// All five settings are required together. A key with no topic, or a |
| +// topic with no key, is somebody halfway through configuring push, and |
| +// the moment to say so is boot — not the first knock, in a goroutine, |
| +// into a log nobody is reading. |
| +// |
| +// No key at all is not an error and not a degraded mode. Knocks still |
| +// reach every member who is in the room, over the streams they always |
| +// used. It is only the member who is elsewhere who goes untold, which |
| +// is exactly where this app was before any of this existed. |
| +func resolveAPNs(cfg Config) (*apns.Config, error) { |
| + present := 0 |
| + for _, v := range []string{cfg.APNsKey, cfg.APNsKeyID, cfg.APNsTeamID, cfg.APNsTopicIOS, cfg.APNsTopicMacOS} { |
| + if strings.TrimSpace(v) != "" { |
| + present++ |
| + } |
| + } |
| + if present == 0 { |
| + return nil, nil |
| + } |
| + if present < 5 { |
| + return nil, fmt.Errorf("calls: Apple push is half-configured (%d of 5 settings present); set all of $CALLS_APNS_KEY, $CALLS_APNS_KEY_ID, $CALLS_APNS_TEAM_ID, $CALLS_APNS_TOPIC_IOS and $CALLS_APNS_TOPIC_MACOS, or none", present) |
| + } |
| + key, err := apns.ParseKey([]byte(cfg.APNsKey)) |
| + if err != nil { |
| + // The error names the shape and never the bytes. |
| + return nil, fmt.Errorf("calls: $CALLS_APNS_KEY is not usable: %w", err) |
| + } |
| + host := apns.HostProduction |
| + if cfg.APNsSandbox { |
| + host = apns.HostSandbox |
| + } |
| + return &apns.Config{ |
| + KeyID: strings.TrimSpace(cfg.APNsKeyID), TeamID: strings.TrimSpace(cfg.APNsTeamID), Key: key, |
| + Topics: map[string]string{ |
| + "ios": strings.TrimSpace(cfg.APNsTopicIOS), |
| + "macos": strings.TrimSpace(cfg.APNsTopicMacOS), |
| + }, |
| + Host: host, |
| + }, nil |
| +} |
diff --git a/internal/calls/hub/admission.go b/internal/calls/hub/admission.go| index 3a97e62..f736614 100644 |
| --- a/internal/calls/hub/admission.go |
| +++ b/internal/calls/hub/admission.go |
| @@ -260,6 +260,19 @@ func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error |
| p.Joined = true |
| p.JoinedAt = h.cfg.Now() |
| h.broadcastLocked(r, Message{Type: TypeKnock, From: p.Peer, Name: p.Name}, admittedMembers) |
| + // Who is already looking, gathered here because it needs the lock and |
| + // must not be re-read later: by the time the notification is sent the |
| + // room may have changed, and telling someone who *was* present is a |
| + // smaller fault than holding the mutex across a network call. |
| + if h.cfg.Knocked != nil { |
| + watching := make([]int64, 0, len(r.peers)) |
| + for _, other := range r.peers { |
| + if other.Admitted && other.Kind == KindMember { |
| + watching = append(watching, other.PersonID) |
| + } |
| + } |
| + go h.cfg.Knocked(r.sqid, watching, p.Name) |
| + } |
| return nil |
| } |
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go| index 22c579c..0ef0aed 100644 |
| --- a/internal/calls/hub/hub.go |
| +++ b/internal/calls/hub/hub.go |
| @@ -72,6 +72,17 @@ type Config struct { |
| // StandingFor re-derives a person's kind and whether they still hold |
| // entry authority for the room. ok=false means standing none. |
| StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error) |
| + // Knocked says that someone has begun waiting. It exists because the |
| + // admission queue lives only here, in memory, and reaches members |
| + // over their own streams — so a member who is not in the room has |
| + // never had any way to learn that somebody is at the door. |
| + // |
| + // Called on a goroutine of its own, never under the lock: the app's |
| + // implementation reads the database and talks to Apple, and the hub's |
| + // mutex is held across every peer in every room. Nil is no |
| + // notification, which is every test and every deployment without a |
| + // push key. |
| + Knocked func(room string, personIDs []int64, name string) |
| } |
| // Participation is the hub's record that a peer is in a room. It is the |
diff --git a/internal/calls/knockpush.go b/internal/calls/knockpush.go| new file mode 100644 |
| index 0000000..84c4277 |
| --- /dev/null |
| +++ b/internal/calls/knockpush.go |
| @@ -0,0 +1,132 @@ |
| +package calls |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| +) |
| + |
| +// maxDeviceToken is Apple's token length with room to spare: 32 bytes |
| +// today, 100 allowed since the format is not promised to stay put. |
| +const maxDeviceToken = 200 |
| + |
| +// knockPushDeadline bounds one round of notifications. A knock is only |
| +// interesting for as long as somebody is still standing at the door. |
| +const knockPushDeadline = 20 * time.Second |
| + |
| +// registerDevice is POST /native/devices: the app saying where it can be |
| +// reached when it is not running. |
| +// |
| +// Behind the same two locks as the other native write — the JSON content |
| +// type an HTML form cannot send, and the Origin csrf.Protect reads — |
| +// because like that one it runs on the session the app is holding rather |
| +// than on no authority at all. |
| +func (a *app) registerDevice(w http.ResponseWriter, r *http.Request) { |
| + nativeHeaders(w) |
| + if r.Header.Get("Content-Type") != "application/json" { |
| + http.Error(w, "expected JSON", http.StatusUnsupportedMediaType) |
| + return |
| + } |
| + var input struct { |
| + Token string `json:"token"` |
| + Platform string `json:"platform"` |
| + } |
| + if json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&input) != nil { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + // Apple's tokens are hex. Insisting on that here means nothing |
| + // arbitrary is ever stored, and a token is never logged: it is an |
| + // address for a person's device and does not belong in a log line. |
| + token := strings.ToLower(strings.TrimSpace(input.Token)) |
| + if token == "" || len(token) > maxDeviceToken { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + if _, err := hex.DecodeString(token); err != nil { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + if input.Platform != "ios" && input.Platform != "macos" { |
| + http.Error(w, "invalid request", http.StatusBadRequest) |
| + return |
| + } |
| + p, ok := currentPerson(r) |
| + if !ok { |
| + http.Error(w, "sign in again", http.StatusUnauthorized) |
| + return |
| + } |
| + if err := a.store.saveDeviceToken(r.Context(), p.ID, token, input.Platform); err != nil { |
| + a.logger.Error("calls: register device", "err", err) |
| + http.Error(w, "cannot register this device", http.StatusInternalServerError) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// knocked is the hub's Knocked callback: somebody has begun waiting, and |
| +// the members who are not in the room have no other way to find out. |
| +// |
| +// `watching` is who was admitted at the moment of the knock. They are |
| +// skipped, because the queue is already on their screen and a push would |
| +// be a second telling of something they can see. That check is the whole |
| +// reason the hub gathers the list under its own lock and passes it in — |
| +// asking again here would race the room. |
| +// |
| +// Everything is best effort and nothing is retried. A knock is worth |
| +// saying once, quickly; a person who has stopped waiting is not worth |
| +// telling anyone about a minute later. |
| +func (a *app) knocked(room string, watching []int64, name string) { |
| + if a.push == nil { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), knockPushDeadline) |
| + defer cancel() |
| + members, err := a.store.memberPersonIDs(ctx, a.allowNow()) |
| + if err != nil { |
| + a.logger.Error("calls: knock push members", "err", err, "room", room) |
| + return |
| + } |
| + looking := make(map[int64]bool, len(watching)) |
| + for _, id := range watching { |
| + looking[id] = true |
| + } |
| + absent := members[:0:0] |
| + for _, id := range members { |
| + if !looking[id] { |
| + absent = append(absent, id) |
| + } |
| + } |
| + tokens, err := a.store.deviceTokensFor(ctx, absent) |
| + if err != nil { |
| + a.logger.Error("calls: knock push tokens", "err", err, "room", room) |
| + return |
| + } |
| + for _, device := range tokens { |
| + verdict, err := a.push.Push(ctx, device.Token, device.Platform, name) |
| + switch verdict { |
| + case apns.Gone: |
| + // Apple says this device will never be reachable again. The |
| + // row is wrong and deleting it is the only hygiene this |
| + // table gets. The token is not logged. |
| + if err := a.store.forgetDeviceToken(ctx, device.Token); err != nil { |
| + a.logger.Error("calls: forget device token", "err", err) |
| + } |
| + case apns.Retry: |
| + a.logger.Warn("calls: knock push refused", "err", err, "room", room, "platform", device.Platform) |
| + } |
| + } |
| +} |
| + |
| +// knockPusher is the narrow part of apns.Client this app uses, named as |
| +// an interface so a test can watch what would have been sent to Apple |
| +// without an Apple to send it to. |
| +type knockPusher interface { |
| + Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) |
| +} |
diff --git a/internal/calls/knockpush_test.go b/internal/calls/knockpush_test.go| new file mode 100644 |
| index 0000000..06bcf42 |
| --- /dev/null |
| +++ b/internal/calls/knockpush_test.go |
| @@ -0,0 +1,206 @@ |
| +package calls |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "amadan.net/oficina/calls/internal/calls/apns" |
| +) |
| + |
| +// recordingPusher stands in for Apple. The verdict is per token so one |
| +// test can have a live device and a dead one at the same time. |
| +type recordingPusher struct { |
| + mu sync.Mutex |
| + sent []string |
| + verdicts map[string]apns.Verdict |
| +} |
| + |
| +func (p *recordingPusher) Push(ctx context.Context, token, platform, name string) (apns.Verdict, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + p.sent = append(p.sent, token+"|"+platform+"|"+name) |
| + if v, ok := p.verdicts[token]; ok { |
| + if v == apns.Gone { |
| + return v, context.Canceled |
| + } |
| + return v, nil |
| + } |
| + return apns.Delivered, nil |
| +} |
| + |
| +func (p *recordingPusher) all() []string { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return append([]string(nil), p.sent...) |
| +} |
| + |
| +func deviceJSON(f *homeRouterFixture, body string) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + r.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +func TestRegisteringADeviceStoresItAndIsIdempotent(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + if w := deviceJSON(f, `{"token":"AABBCC0011","platform":"macos"}`); w.Code != http.StatusNoContent { |
| + t.Fatal("refused", w.Code, w.Body.String()) |
| + } |
| + var count int |
| + if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 1 { |
| + t.Fatal("expected one row", count) |
| + } |
| + // The app re-registers on every launch. That must move last_seen, |
| + // not accumulate rows. |
| + if w := deviceJSON(f, `{"token":"aabbcc0011","platform":"macos"}`); w.Code != http.StatusNoContent { |
| + t.Fatal("re-registration refused", w.Code) |
| + } |
| + if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count); err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 1 { |
| + t.Fatal("re-registration made a second row", count) |
| + } |
| + var stored string |
| + f.app.db.Writer().QueryRow("SELECT token FROM device_tokens").Scan(&stored) |
| + if stored != "aabbcc0011" { |
| + t.Fatal("token not normalised to lower case", stored) |
| + } |
| +} |
| + |
| +func TestADeviceRegistrationMustBeHexAndAKnownPlatform(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + for name, body := range map[string]string{ |
| + "not hex": `{"token":"zzzz","platform":"ios"}`, |
| + "empty token": `{"token":"","platform":"ios"}`, |
| + "no platform": `{"token":"aabb"}`, |
| + "bad platform": `{"token":"aabb","platform":"android"}`, |
| + "too long": `{"token":"` + strings.Repeat("ab", 200) + `","platform":"ios"}`, |
| + "not json": `hello`, |
| + } { |
| + if w := deviceJSON(f, body); w.Code == http.StatusNoContent { |
| + t.Errorf("accepted %s", name) |
| + } |
| + } |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| + if count != 0 { |
| + t.Fatal("a refused registration still stored something", count) |
| + } |
| +} |
| + |
| +func TestADeviceRegistrationNeedsBothLocksAndASession(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + // A form content type is the cross-site shape an HTML form can send. |
| + form := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader("")) |
| + form.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + form.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + form.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, form) |
| + if w.Code != http.StatusUnsupportedMediaType { |
| + t.Fatal("form post accepted", w.Code) |
| + } |
| + // And no origin is csrf.Protect's refusal, before the handler. |
| + bare := httptest.NewRequest("POST", homeTestOrigin+"/native/devices", strings.NewReader(`{"token":"aabb","platform":"ios"}`)) |
| + bare.Header.Set("Content-Type", "application/json") |
| + for _, c := range f.cookies { |
| + bare.AddCookie(c) |
| + } |
| + w = httptest.NewRecorder() |
| + f.handler.ServeHTTP(w, bare) |
| + if w.Code == http.StatusNoContent { |
| + t.Fatal("origin-less registration accepted") |
| + } |
| + // Signed out entirely. |
| + g := newHomeRouterFixture(t) |
| + if w := deviceJSON(g, `{"token":"aabb","platform":"ios"}`); w.Code == http.StatusNoContent { |
| + t.Fatal("signed-out registration accepted") |
| + } |
| +} |
| + |
| +// The decision this whole feature turns on: who is told. |
| +func TestAKnockSkipsMembersWhoAreAlreadyLooking(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + if w := deviceJSON(f, `{"token":"aabbcc","platform":"macos"}`); w.Code != http.StatusNoContent { |
| + t.Fatal(w.Code) |
| + } |
| + var personID int64 |
| + if err := f.app.db.Writer().QueryRow("SELECT person_id FROM device_tokens").Scan(&personID); err != nil { |
| + t.Fatal(err) |
| + } |
| + pusher := &recordingPusher{} |
| + f.app.push = pusher |
| + |
| + // In the room: the queue is on their screen already, and a push |
| + // would be a second telling of something they can see. |
| + f.app.knocked("room1", []int64{personID}, "Bea") |
| + if got := pusher.all(); len(got) != 0 { |
| + t.Fatal("pushed to a member who was watching", got) |
| + } |
| + |
| + // Not in the room: this is the case the feature exists for, and the |
| + // case that was impossible before it. |
| + f.app.knocked("room1", nil, "Bea") |
| + got := pusher.all() |
| + if len(got) != 1 || got[0] != "aabbcc|macos|Bea" { |
| + t.Fatal("absent member was not told", got) |
| + } |
| +} |
| + |
| +func TestAKnockDeletesATokenAppleSaysIsGone(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + deviceJSON(f, `{"token":"deadbeef","platform":"ios"}`) |
| + f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"deadbeef": apns.Gone}} |
| + f.app.knocked("room1", nil, "Bea") |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| + if count != 0 { |
| + t.Fatal("a token Apple reported unregistered was kept", count) |
| + } |
| +} |
| + |
| +func TestAKnockKeepsATokenWhenAppleIsMerelyUnreachable(t *testing.T) { |
| + // The distinction that matters: an outage must never be read as "the |
| + // device is gone", or one bad afternoon empties the table. |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + deviceJSON(f, `{"token":"abcdef","platform":"ios"}`) |
| + f.app.push = &recordingPusher{verdicts: map[string]apns.Verdict{"abcdef": apns.Retry}} |
| + f.app.knocked("room1", nil, "Bea") |
| + var count int |
| + f.app.db.Writer().QueryRow("SELECT count(*) FROM device_tokens").Scan(&count) |
| + if count != 1 { |
| + t.Fatal("a retryable refusal deleted the token", count) |
| + } |
| +} |
| + |
| +func TestNoPushKeyIsQuietRatherThanBroken(t *testing.T) { |
| + // Nil push is every deployment without a key, and it must be a |
| + // no-op: knocks still reach everyone in the room over their streams, |
| + // exactly as before any of this existed. |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + deviceJSON(f, `{"token":"aabb","platform":"ios"}`) |
| + f.app.push = nil |
| + f.app.knocked("room1", nil, "Bea") |
| +} |
diff --git a/internal/calls/migrations/0005_create_device_tokens.sql b/internal/calls/migrations/0005_create_device_tokens.sql| new file mode 100644 |
| index 0000000..9a055ae |
| --- /dev/null |
| +++ b/internal/calls/migrations/0005_create_device_tokens.sql |
| @@ -0,0 +1,6 @@ |
| +CREATE TABLE `device_tokens` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`token` text NOT NULL,`platform` text NOT NULL,`created_at` datetime,`last_seen` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_device_tokens_token` ON `device_tokens`(`token`); |
| + |
| +CREATE INDEX `idx_device_tokens_person_id` ON `device_tokens`(`person_id`); |
| + |
diff --git a/internal/calls/migrations/schema.sql b/internal/calls/migrations/schema.sql| index 9147b74..e5030d4 100644 |
| --- a/internal/calls/migrations/schema.sql |
| +++ b/internal/calls/migrations/schema.sql |
| @@ -3,6 +3,8 @@ |
| CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NULL,`operator_person_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime,`revoked_at` datetime); |
| +CREATE TABLE `device_tokens` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`token` text NOT NULL,`platform` text NOT NULL,`created_at` datetime,`last_seen` datetime); |
| + |
| CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text); |
| CREATE TABLE `meeting_defaults` (`id` integer PRIMARY KEY AUTOINCREMENT,`require_signin` numeric NOT NULL DEFAULT false,`waiting_room` numeric NOT NULL DEFAULT false); |
| @@ -19,6 +21,10 @@ CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`); |
| CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`); |
| +CREATE INDEX `idx_device_tokens_person_id` ON `device_tokens`(`person_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_device_tokens_token` ON `device_tokens`(`token`); |
| + |
| CREATE UNIQUE INDEX `idx_grant_meeting_person` ON `guest_grants`(`meeting_id`,`person_id`); |
| CREATE INDEX `idx_guest_grants_expires_at` ON `guest_grants`(`expires_at`); |
diff --git a/internal/calls/models.go b/internal/calls/models.go| index 6a50f82..6dbf30c 100644 |
| --- a/internal/calls/models.go |
| +++ b/internal/calls/models.go |
| @@ -14,6 +14,7 @@ var Models = []any{ |
| &PendingJoin{}, |
| &Agent{}, |
| &MeetingDefaults{}, |
| + &DeviceToken{}, |
| } |
| // Person is a verified identity or an anonymous guest. Anonymous guests carry |
| @@ -60,6 +61,35 @@ type MeetingLink struct { |
| RevokedAt *time.Time |
| } |
| +// DeviceToken is one Apple device a person can be reached on when the |
| +// app is not running. It is the only part of the knock notification that |
| +// has to be stored: the queue itself lives in the hub's memory and dies |
| +// with the process, which is exactly why a person outside the room could |
| +// never be told about it. |
| +// |
| +// The token is stored in plaintext, unlike a meeting title. It is not a |
| +// secret about a person — it is an address Apple issues, scoped to one |
| +// app on one device, useless without the push key, and rotated by the |
| +// system whenever it likes. Sealing it would cost the sweep the ability |
| +// to delete by token when Apple reports one unregistered, which is the |
| +// one hygiene operation this table needs. |
| +// |
| +// Unique on the token rather than on (person, token): Apple reissues a |
| +// token to whoever installs the app next, so the same string arriving |
| +// for a different person means the device changed hands and the old row |
| +// is wrong, not that there are two. |
| +type DeviceToken struct { |
| + ID int64 |
| + PersonID int64 `gorm:"index;not null"` |
| + Token string `gorm:"uniqueIndex;not null"` |
| + // "ios" or "macos". Apple keeps one token space per app per device, |
| + // but the two apps have different bundle identifiers and therefore |
| + // different APNs topics, so a token is useless without knowing which. |
| + Platform string `gorm:"not null"` |
| + CreatedAt time.Time |
| + LastSeen time.Time |
| +} |
| + |
| // GuestGrant is a non-member's entry authority for one meeting. One |
| // row per person per meeting. AdmittedAt and AdmittedBy are an audit of |
| // the first admission (slice 2) and confer nothing. |
diff --git a/internal/calls/store.go b/internal/calls/store.go| index 8a9732d..6f69cad 100644 |
| --- a/internal/calls/store.go |
| +++ b/internal/calls/store.go |
| @@ -665,3 +665,54 @@ func (s *store) createGuest(ctx context.Context, name string) (Person, error) { |
| err := s.g.WithContext(ctx).Create(&p).Error |
| return p, err |
| } |
| + |
| +// saveDeviceToken records where a person can be reached when the app is |
| +// not running, and is an upsert on the token rather than on the person: |
| +// Apple reissues a token to whoever installs the app next, so the same |
| +// string arriving under a different person means the device changed |
| +// hands and the old row is wrong. LastSeen moves on every registration, |
| +// which is what lets a token that has gone quiet be swept later. |
| +func (s *store) saveDeviceToken(ctx context.Context, personID int64, token, platform string) error { |
| + now := s.now().UTC() |
| + return s.g.WithContext(ctx).Clauses(clause.OnConflict{ |
| + Columns: []clause.Column{{Name: "token"}}, |
| + DoUpdates: clause.AssignmentColumns([]string{"person_id", "platform", "last_seen"}), |
| + }).Create(&DeviceToken{ |
| + PersonID: personID, Token: token, Platform: platform, CreatedAt: now, LastSeen: now, |
| + }).Error |
| +} |
| + |
| +// deviceTokensFor answers every device a set of people can be reached |
| +// on. Plural because one person has a Mac and a phone, and a knock is |
| +// worth saying on both — whichever they are near. |
| +func (s *store) deviceTokensFor(ctx context.Context, personIDs []int64) ([]DeviceToken, error) { |
| + if len(personIDs) == 0 { |
| + return nil, nil |
| + } |
| + var tokens []DeviceToken |
| + err := s.g.WithContext(ctx).Where("person_id IN ?", personIDs).Find(&tokens).Error |
| + return tokens, err |
| +} |
| + |
| +// forgetDeviceToken deletes one token, which is the only correct answer |
| +// to Apple reporting it unregistered. Deleting by token and not by |
| +// person: the device is gone, the person is not. |
| +func (s *store) forgetDeviceToken(ctx context.Context, token string) error { |
| + return s.g.WithContext(ctx).Where("token = ?", token).Delete(&DeviceToken{}).Error |
| +} |
| + |
| +// memberPersonIDs is everyone who may admit: the people whose address is |
| +// on the team allowlist. A knock is theirs to answer, and nobody else's. |
| +func (s *store) memberPersonIDs(ctx context.Context, allowed allowlist) ([]int64, error) { |
| + var persons []Person |
| + if err := s.g.WithContext(ctx).Where("email NOT LIKE ?", "guest:%").Find(&persons).Error; err != nil { |
| + return nil, err |
| + } |
| + ids := make([]int64, 0, len(persons)) |
| + for _, p := range persons { |
| + if allowed.allows(p.Email) { |
| + ids = append(ids, p.ID) |
| + } |
| + } |
| + return ids, nil |
| +} |
diff --git a/native/README.md b/native/README.md| index d442e03..5f6a204 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -71,13 +71,20 @@ not frontmost. The admission queue is already on screen for a member who is |
| looking at the call; this is for the member who is in another Space, |
| another app, or has the phone in a pocket. |
| -These are **local** notifications, and that is the whole of what is |
| -possible here today: there is no APNs anywhere in this suite — no push |
| -key, no device token registration, no sender — so a member whose app is |
| -*closed* is not reached. That case needs server-side push and a decision |
| -about whether a host who is not in the meeting can be notified at all, |
| -since the waiting queue lives only in the hub's memory and reaches members |
| -over SSE. |
| +A knock also reaches a member whose app is **closed**, where the server |
| +has an Apple push key. The app registers its device token at join |
| +(`POST /native/devices`); the hub calls back when someone begins waiting; |
| +the server pushes to every member who was *not* admitted in that room at |
| +that moment, because the ones who were can already see the queue. |
| + |
| +Without a key the server sends nothing and says nothing, which is where |
| +this app was before push existed: knocks still reach everyone in the room |
| +over their own streams. It is configured with `CALLS_APNS_KEY` (the .p8 |
| +as Apple issues it, via `carlos secrets set`), `CALLS_APNS_KEY_ID`, |
| +`CALLS_APNS_TEAM_ID`, `CALLS_APNS_TOPIC_IOS`, `CALLS_APNS_TOPIC_MACOS` |
| +and optionally `CALLS_APNS_SANDBOX`. All five of the first are required |
| +together — a half-configured push is refused at boot rather than |
| +discovered on the first knock. |
| Someone who arrived while the queue was visible is never announced later, |
| so tabbing away does not produce a pile of notifications about people who |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 55909c3..9235300 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -101,6 +101,10 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| currentCamera = media.currentCamera |
| audio.refresh() |
| KnockNotice.requestPermission() |
| + // Asking Apple at join, once there is a session to register the |
| + // answer against. Apple replies on its own schedule, at the app |
| + // delegate, and may never reply at all. |
| + RemoteKnocks.register() |
| try client.start() |
| } |
| public func close() { |
diff --git a/native/apple/Sources/CallsKit/CallsModel.swift b/native/apple/Sources/CallsKit/CallsModel.swift| index 555ba08..c1fb867 100644 |
| --- a/native/apple/Sources/CallsKit/CallsModel.swift |
| +++ b/native/apple/Sources/CallsKit/CallsModel.swift |
| @@ -197,6 +197,27 @@ import Foundation |
| } |
| } |
| } |
| + /// Hands the server an Apple device token, so a knock reaches this |
| + /// device when the app is closed. Called from the app delegate when |
| + /// Apple answers, which may be long after the join that asked, and |
| + /// may never happen at all. |
| + /// |
| + /// Silent on failure on purpose: nobody asked for this and there is |
| + /// nothing they could do about it. The consequence of failing is no |
| + /// push, which is what they had before push existed. |
| + public func registerDevice(token: Data) { |
| + guard signedIn, !origin.isEmpty, !cookies.isEmpty else { return } |
| + let origin = self.origin, cookies = self.cookies |
| + let hex = RemoteKnocks.hex(token) |
| + let platform = RemoteKnocks.platform |
| + Task.detached { |
| + // gomobile renders a func returning only error as BOOL plus an |
| + // NSErrorPointer. Both are dropped: see above. |
| + var error: NSError? |
| + BridgeRegisterDevice(origin, cookies, hex, platform, &error) |
| + } |
| + } |
| + |
| public func join() { |
| callEpoch = UUID() |
| let operation = callEpoch |
diff --git a/native/apple/Sources/CallsKit/RemoteKnocks.swift b/native/apple/Sources/CallsKit/RemoteKnocks.swift| new file mode 100644 |
| index 0000000..030e551 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/RemoteKnocks.swift |
| @@ -0,0 +1,51 @@ |
| +import Foundation |
| + |
| +#if os(iOS) |
| + import UIKit |
| +#else |
| + import AppKit |
| +#endif |
| + |
| +/// Registering this device with Apple, and then with Calls, so that a |
| +/// knock can reach it when the app is closed. |
| +/// |
| +/// The local half — KnockWatch — only reaches an app that is running. |
| +/// This is the other half, and it is the only one that can help the |
| +/// person whose Mac is asleep or whose phone is in a pocket with Calls |
| +/// not open. |
| +/// |
| +/// Everything here is best effort and nothing is ever shown to anyone. A |
| +/// device that could not register is a device that will not be told |
| +/// about a knock, which is exactly where every device was before this |
| +/// existed. It is never a reason to fail a join, and never a sheet. |
| +@MainActor public enum RemoteKnocks { |
| + /// The platform name the server stores alongside the token. The two |
| + /// apps have different bundle identifiers and therefore different APNs |
| + /// topics, so a token is useless without it. |
| + public static var platform: String { |
| + #if os(iOS) |
| + return "ios" |
| + #else |
| + return "macos" |
| + #endif |
| + } |
| + |
| + /// Asks Apple for a device token. The answer arrives at the app |
| + /// delegate, asynchronously and possibly never — without the push |
| + /// entitlement, or on a build Apple will not issue one for, the |
| + /// failure callback is what comes back instead. |
| + public static func register() { |
| + #if os(iOS) |
| + UIApplication.shared.registerForRemoteNotifications() |
| + #else |
| + NSApplication.shared.registerForRemoteNotifications() |
| + #endif |
| + } |
| + |
| + /// Apple's token is opaque bytes; the server stores it as lower-case |
| + /// hex, which is the form every APNs provider uses and the form the |
| + /// registration endpoint validates. |
| + public static func hex(_ token: Data) -> String { |
| + token.map { String(format: "%02x", $0) }.joined() |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index c059dfa..1f4daf9 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -17,6 +17,7 @@ import WebRTC |
| Window("Calls", id: "main") { |
| ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() } |
| .onOpenURL { model.openMeetingURL($0) } |
| + .onAppear { CallsAppDelegate.model = model } |
| } |
| .defaultSize(width: 1000, height: 760) |
| .commands { |
| @@ -27,6 +28,25 @@ import WebRTC |
| } |
| final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| + /// Set by the app so Apple's answer can reach the session that asked. |
| + /// Weak-ish by convention rather than by keyword: the model outlives |
| + /// the delegate for the whole life of the process. |
| + @MainActor static var model: CallsModel? |
| + |
| + func application( |
| + _ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data |
| + ) { |
| + Task { @MainActor in CallsAppDelegate.model?.registerDevice(token: token) } |
| + } |
| + |
| + func application( |
| + _ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error |
| + ) { |
| + // Expected on any build without the push entitlement, which is every |
| + // ad-hoc signed local build. Silent: the person did not ask for this |
| + // and the consequence is simply no push. |
| + } |
| + |
| func applicationDidFinishLaunching(_ notification: Notification) { |
| if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png") |
| ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png") |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index 0d30843..8784a66 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -103,3 +103,15 @@ func (c *Client) Send(typ, to string, generation int64, payload string) error { |
| } |
| func (c *Client) Leave() { c.room.Leave() } |
| func (c *Client) Close() { c.room.Close() } |
| + |
| +// RegisterDevice hands the server an Apple device token so a knock can |
| +// reach this device when the app is closed. Best effort: the caller |
| +// ignores the error beyond logging, because a device that did not |
| +// register is simply one that will not be pushed to. |
| +func RegisterDevice(origin, cookiesJSON, token, platform string) error { |
| + jar, err := sessionJar(origin, cookiesJSON) |
| + if err != nil { |
| + return err |
| + } |
| + return callscore.RegisterDevice(context.Background(), origin, &http.Client{Jar: jar}, token, platform) |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index d094e07..6bad8fd 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -2,13 +2,35 @@ import CallsKit |
| import SwiftUI |
| import WebRTC |
| +/// The delegate exists for one reason: Apple hands a device token to |
| +/// the application delegate and nowhere else, so a pure SwiftUI app has |
| +/// no way to receive one. |
| +final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { |
| + @MainActor static var model: CallsModel? |
| + |
| + func application( |
| + _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data |
| + ) { |
| + Task { @MainActor in CallsPhoneDelegate.model?.registerDevice(token: token) } |
| + } |
| + |
| + func application( |
| + _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error |
| + ) { |
| + // Expected in the simulator and on any build without the push |
| + // entitlement. Silent, for the same reason as on the Mac. |
| + } |
| +} |
| + |
| @main struct CallsPhoneApp: App { |
| + @UIApplicationDelegateAdaptor(CallsPhoneDelegate.self) private var appDelegate |
| @StateObject private var model = CallsModel() |
| @Environment(\.scenePhase) private var scenePhase |
| var body: some Scene { |
| WindowGroup { |
| PhoneHome(model: model) |
| .onOpenURL { model.openMeetingURL($0) } |
| + .onAppear { CallsPhoneDelegate.model = model } |
| .onChange(of: scenePhase) { _, phase in |
| // This milestone is foreground-only. Browser sign-in is kept alive; |
| // an active call leaves cleanly instead of silently sending audio. |