pairling 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +11 -9
  2. package/bin/pairling.mjs +5 -2
  3. package/package.json +3 -3
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/pairling_connectd_status.py +57 -7
  7. package/payload/mac/companiond/pairling_devices.py +35 -0
  8. package/payload/mac/companiond/pairling_pairing.py +67 -20
  9. package/payload/mac/companiond/pairlingd.py +269 -16
  10. package/payload/mac/companiond/push_dispatcher.py +31 -1
  11. package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
  12. package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
  13. package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
  14. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
  15. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
  16. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
  17. package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
  18. package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
  19. package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
  22. package/payload/mac/connectd/internal/runtime/config.go +19 -0
  23. package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
  24. package/payload/mac/connectd/internal/status/status.go +67 -1
  25. package/payload/mac/connectd/internal/status/status_test.go +138 -0
  26. package/payload/mac/install/install-runtime.sh +299 -20
  27. package/payload/mac/install/render-launchd.py +54 -10
  28. package/payload-manifest.json +62 -20
@@ -57,6 +57,7 @@ import copy
57
57
  import secrets
58
58
  import shlex
59
59
  import signal
60
+ import socket
60
61
  import sqlite3
61
62
  import subprocess
62
63
  import sys
@@ -125,6 +126,23 @@ except Exception:
125
126
  RelayClaimVerifier = None
126
127
  relay_claims_required = None
127
128
 
129
+ # Fail loud at boot: the PSK pair-claim path cannot carry a relay attested-claim
130
+ # ticket, so a relay-required config breaks every PSK pairing with an
131
+ # attested_claim_required 403, and the modern PSK-first client has no fallback.
132
+ # Surface it at startup instead of silently at pair time. Default-off, so this
133
+ # only fires on an explicit opt-in.
134
+ if relay_claims_required is not None and relay_claims_required():
135
+ import sys as _sys
136
+ print(
137
+ "PAIRLING STARTUP WARNING: PAIRLING_RELAY_CLAIMS_REQUIRED is set, but "
138
+ "/pair/psk-claim cannot carry a relay attested-claim ticket. Every PSK "
139
+ "pairing will fail with attested_claim_required 403 and the PSK-first "
140
+ "client has no fallback. Disable relay-required pairing, or add ticket "
141
+ "support to the PSK claim path, before pairing over PSK.",
142
+ file=_sys.stderr,
143
+ flush=True,
144
+ )
145
+
128
146
  try:
129
147
  from push_dispatcher import PairlingPushDispatcher, PushDispatcherError
130
148
  except Exception:
@@ -282,6 +300,8 @@ os.environ["PATH"] = (
282
300
 
283
301
  PORT = RUNTIME_PORT
284
302
  HOME = Path.home()
303
+ MINT_SOCKET_PATH = "/Library/Application Support/Pairling/run/mintd/mintd.sock"
304
+ MINT_ALERT_PATH = Path("/Library/Application Support/Pairling/run/mintd/alerts.jsonl")
285
305
  DEFAULT_COORDINATOR_HOST = (
286
306
  os.environ.get("PAIRLING_HOSTNAME")
287
307
  or os.environ.get("COMPANION_COORDINATOR_HOST")
@@ -882,9 +902,18 @@ def _is_pairdrop_path(path: str) -> bool:
882
902
  return path == "/pairdrop/events" or path.startswith("/pairdrop/")
883
903
 
884
904
 
885
- def _pairdrop_gateway_provenance_ok(headers) -> bool:
905
+ def _pairdrop_gateway_provenance_ok(headers, client_address=None) -> bool:
886
906
  getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
887
- return str(getter("X-Pairling-Connect-Gateway", "") or "") == "pairling-connectd"
907
+ header_ok = str(getter("X-Pairling-Connect-Gateway", "") or "") == "pairling-connectd"
908
+ if not header_ok:
909
+ return False
910
+ # The gateway header alone is spoofable, so in the default loopback-only bind
911
+ # also require the loopback hop from connectd. Under PAIRLING_BIND_MODE=all a
912
+ # direct-LAN client is non-loopback, so waive the loopback requirement there
913
+ # to avoid breaking that path.
914
+ if os.environ.get("PAIRLING_BIND_MODE", "loopback").strip().lower() == "all":
915
+ return True
916
+ return _loopback_client_address(client_address)
888
917
 
889
918
 
890
919
  def _is_pairdrop_mutation(path: str, method: str) -> bool:
@@ -990,6 +1019,54 @@ def _bearer_token(headers) -> str | None:
990
1019
  return None
991
1020
 
992
1021
 
1022
+ def _loopback_client_address(client_address) -> bool:
1023
+ if not client_address:
1024
+ return False
1025
+ return str(client_address[0]) in ("127.0.0.1", "::1")
1026
+
1027
+
1028
+ def _funnel_origin_request(headers, client_address) -> bool:
1029
+ """True only when a request bears connectd's funnel-origin marker AND arrives
1030
+ over the loopback hop from connectd. The marker alone is spoofable, so the
1031
+ loopback peer is required: the connectd-to-pairlingd hop is loopback and is
1032
+ unreachable from the internet, so an internet client cannot forge the marker.
1033
+ A funnel-enabled install uses this to hard-require App Attest (Increment 5)
1034
+ and to refuse /pair/start for funnel-origin requests (Increment 4)."""
1035
+ if not _loopback_client_address(client_address):
1036
+ return False
1037
+ getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
1038
+ return str(getter("X-Pairling-Funnel-Origin", "") or "").strip() == "1"
1039
+
1040
+
1041
+ def _connectd_peer_node_id(headers) -> str:
1042
+ getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
1043
+ value = str(getter("X-Pairling-Peer-Node", "") or "").strip()
1044
+ if not value or len(value) > 128:
1045
+ return ""
1046
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
1047
+ return ""
1048
+ return value
1049
+
1050
+
1051
+ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool:
1052
+ if DEVICE_REGISTRY is None or auth_result is None or not getattr(auth_result, "ok", False):
1053
+ return False
1054
+ device_id = str(getattr(auth_result, "device_id", "") or "").strip()
1055
+ if not device_id:
1056
+ return False
1057
+ if not _loopback_client_address(client_address):
1058
+ return False
1059
+ if not _pairdrop_gateway_provenance_ok(headers, client_address):
1060
+ return False
1061
+ node_id = _connectd_peer_node_id(headers)
1062
+ if not node_id:
1063
+ return False
1064
+ try:
1065
+ return bool(DEVICE_REGISTRY.set_tailnet_node_id_if_absent(device_id, node_id))
1066
+ except Exception:
1067
+ return False
1068
+
1069
+
993
1070
  def _auth_cache_key(token: str, *, method: str, path: str, required_scopes: set[str]) -> tuple | None:
994
1071
  if AUTH_RESULT_CACHE_SECONDS <= 0:
995
1072
  return None
@@ -2064,6 +2141,81 @@ def _lan_ips(power_state: dict | None = None) -> list[str]:
2064
2141
  return _cached_probe("lan_ips", _HEALTH_PROBE_CACHE_SECONDS, _probe_lan_ips)
2065
2142
 
2066
2143
 
2144
+ def _mint_phone_authkey(pair_id: str) -> dict | None:
2145
+ if not _pairling_mint_enabled():
2146
+ return None
2147
+ path = os.environ.get("PAIRLING_MINT_SOCKET") or MINT_SOCKET_PATH
2148
+ try:
2149
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
2150
+ sock.settimeout(float(os.environ.get("PAIRLING_MINT_TIMEOUT_SECONDS", "0.8")))
2151
+ sock.connect(path)
2152
+ sock.sendall(json.dumps({"op": "mint_phone_key", "pair_id": pair_id}).encode("utf-8") + b"\n")
2153
+ with sock.makefile("rb") as fh:
2154
+ raw = fh.readline(8192)
2155
+ payload = json.loads(raw.decode("utf-8"))
2156
+ except Exception:
2157
+ return None
2158
+ if not isinstance(payload, dict) or not payload.get("ok"):
2159
+ return None
2160
+ key = str(payload.get("authkey") or "")
2161
+ if not key:
2162
+ return None
2163
+ try:
2164
+ expires_at = int(payload.get("expires_at") or 0)
2165
+ except (TypeError, ValueError):
2166
+ expires_at = 0
2167
+ return {
2168
+ "authkey": key,
2169
+ "key_id": payload.get("key_id"),
2170
+ "expires_at": expires_at,
2171
+ }
2172
+
2173
+
2174
+ def _pairling_mint_enabled() -> bool:
2175
+ return os.environ.get("PAIRLING_MINT_ENABLED", "").strip().lower() in {"1", "true", "yes"}
2176
+
2177
+
2178
+ def _silent_join_capability(mint_enabled: bool, connectd_status: dict | None) -> tuple[bool, str]:
2179
+ """D1: whether a cellular/funnel phone can complete a silent join. Minting must
2180
+ be enabled, and the tailnet must not be under network lock (mintd refuses to
2181
+ mint under lock, mintd.go:165-171). Pure, so the phone is told up front."""
2182
+ if not mint_enabled:
2183
+ return False, "mint_disabled"
2184
+ if connectd_status and connectd_status.get("tailnet_lock_enabled"):
2185
+ return False, "tailnet_lock"
2186
+ return True, ""
2187
+
2188
+
2189
+ def _mintd_alert_snapshot(limit: int = 5) -> list[dict]:
2190
+ path = Path(os.environ.get("PAIRLING_MINT_ALERT_PATH") or MINT_ALERT_PATH)
2191
+ try:
2192
+ lines = path.read_text().splitlines()
2193
+ except Exception:
2194
+ return []
2195
+ allowed = {"event", "ts", "pair_id", "key_id", "uid", "window"}
2196
+ alerts: list[dict] = []
2197
+ for line in lines[-max(1, limit):]:
2198
+ try:
2199
+ item = json.loads(line)
2200
+ except Exception:
2201
+ continue
2202
+ if not isinstance(item, dict):
2203
+ continue
2204
+ sanitized = {key: item[key] for key in allowed if key in item}
2205
+ if sanitized:
2206
+ alerts.append(sanitized)
2207
+ return alerts
2208
+
2209
+
2210
+ def _attach_mintd_alerts(coordinator: dict) -> dict:
2211
+ alerts = _mintd_alert_snapshot()
2212
+ if not alerts:
2213
+ return coordinator
2214
+ out = dict(coordinator)
2215
+ out["mintd_alerts"] = alerts
2216
+ return out
2217
+
2218
+
2067
2219
  def _guardian_listener_entries(power_state: dict | None) -> list[str]:
2068
2220
  if not isinstance(power_state, dict):
2069
2221
  return []
@@ -2440,6 +2592,7 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
2440
2592
  if connect.get("summary") is not None:
2441
2593
  coordinator = dict(coordinator)
2442
2594
  coordinator["pairling_connect"] = connect["summary"]
2595
+ coordinator = _attach_mintd_alerts(coordinator)
2443
2596
  routes = _health_routes(coordinator, power_state)
2444
2597
  ok = coordinator.get("posture") in ("ready", "warning")
2445
2598
  runtime_info = _runtime_info_snapshot()
@@ -2505,6 +2658,7 @@ def _mac_health_alert_snapshot() -> dict:
2505
2658
  coordinator = _apply_pairling_connect_posture(
2506
2659
  coordinator, _normalize_guardian_state(state), _pairling_connect_health()
2507
2660
  )
2661
+ coordinator = _attach_mintd_alerts(coordinator)
2508
2662
  return {
2509
2663
  "ok": coordinator.get("posture") in ("ready", "warning"),
2510
2664
  "schema_version": 1,
@@ -7302,7 +7456,7 @@ class Handler(BaseHTTPRequestHandler):
7302
7456
  }, status=401)
7303
7457
  return
7304
7458
 
7305
- if _is_pairdrop_path(u.path) and not _pairdrop_gateway_provenance_ok(self.headers):
7459
+ if _is_pairdrop_path(u.path) and not _pairdrop_gateway_provenance_ok(self.headers, self.client_address):
7306
7460
  admission.release()
7307
7461
  self._send_json({
7308
7462
  "ok": False,
@@ -7358,6 +7512,9 @@ class Handler(BaseHTTPRequestHandler):
7358
7512
  }, status=proof_result.status)
7359
7513
  return
7360
7514
 
7515
+ if self.pairling_auth is not None:
7516
+ _maybe_persist_tailnet_node_id(self.headers, self.client_address, self.pairling_auth)
7517
+
7361
7518
  if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
7362
7519
  max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
7363
7520
  allowed, retry = _request_rate_check(f"{self.pairling_auth.device_id}:{u.path}", max_per_min=max_per_min)
@@ -7393,7 +7550,13 @@ class Handler(BaseHTTPRequestHandler):
7393
7550
  elif u.path == "/manifest":
7394
7551
  self._handle_manifest(q)
7395
7552
  elif u.path == "/pair/start":
7396
- self._handle_pair_start(q)
7553
+ if _funnel_origin_request(self.headers, self.client_address):
7554
+ # Belt-and-suspenders: /pair/start returns the 192-bit secret
7555
+ # in plaintext and must never be served to a funnel-origin
7556
+ # request, even if connectd's allowlist ever drifted.
7557
+ self._send_json({"ok": False, "error": {"code": "funnel_forbidden", "message": "pair start is not available over funnel"}}, status=403)
7558
+ else:
7559
+ self._handle_pair_start(q)
7397
7560
  elif u.path == "/pair/claim":
7398
7561
  self._handle_pair_claim(q)
7399
7562
  elif u.path == "/pair/psk-claim":
@@ -8043,8 +8206,13 @@ class Handler(BaseHTTPRequestHandler):
8043
8206
  },
8044
8207
  }, status=503)
8045
8208
  return
8046
- # P0-B: rate-limit unauthenticated pair starts per source IP so an
8047
- # on-LAN attacker cannot mint a flood of invitations.
8209
+ # Rate-limit pair starts. NOTE: self.client_address[0] is the loopback
8210
+ # peer for any request proxied through connectd (the upstream is
8211
+ # 127.0.0.1), so this is a GLOBAL circuit breaker, not a per-source-IP
8212
+ # defense. /pair/start is loopback-only today (connectd denies it at the
8213
+ # gateway), so only local callers reach it. Real per-client throttling for
8214
+ # the public funnel path is owned by connectd (see the funnel-hardening
8215
+ # plan, Increment 2); do not rely on this key for per-attacker isolation.
8048
8216
  allowed, retry_after = _request_rate_check(
8049
8217
  f"pair_start:{self.client_address[0]}", max_per_min=5
8050
8218
  )
@@ -8068,6 +8236,15 @@ class Handler(BaseHTTPRequestHandler):
8068
8236
  else {"ok": False, "reason": "advertiser_unavailable"}
8069
8237
  )
8070
8238
  pairling_connect_routes = self._pairling_connect_routes()
8239
+ connectd_status = {}
8240
+ if fetch_connectd_status is not None:
8241
+ try:
8242
+ connectd_status = fetch_connectd_status(timeout_seconds=0.7) or {}
8243
+ except Exception:
8244
+ connectd_status = {}
8245
+ silent_join_available, silent_join_reason = _silent_join_capability(
8246
+ _pairling_mint_enabled(), connectd_status
8247
+ )
8071
8248
  except ValueError as exc:
8072
8249
  self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
8073
8250
  return
@@ -8094,7 +8271,9 @@ class Handler(BaseHTTPRequestHandler):
8094
8271
  "pairing_nonce": started.pairing_nonce,
8095
8272
  "attest_challenge": started.attest_challenge,
8096
8273
  "mac_ake_pub": started.mac_ake_pub,
8097
- "pv": "2" if started.mac_ake_pub else "1",
8274
+ "pv": "3" if started.mac_ake_pub and _pairling_mint_enabled() else "2" if started.mac_ake_pub else "1",
8275
+ "silent_join_available": silent_join_available,
8276
+ "silent_join_unavailable_reason": silent_join_reason,
8098
8277
  },
8099
8278
  })
8100
8279
 
@@ -8182,6 +8361,7 @@ class Handler(BaseHTTPRequestHandler):
8182
8361
  try:
8183
8362
  payload = self._read_json_object()
8184
8363
  host_chain = self._pairing_host_chain()
8364
+ funnel_origin = _funnel_origin_request(self.headers, self.client_address)
8185
8365
  claim, k_token, aad, mac_confirm = PAIRING_STORE.psk_claim_pair(
8186
8366
  pair_id=str(payload.get("pair_id") or ""),
8187
8367
  b_pub_b64=str(payload.get("b_pub") or ""),
@@ -8196,8 +8376,72 @@ class Handler(BaseHTTPRequestHandler):
8196
8376
  relay_device_id=payload.get("relay_device_id"),
8197
8377
  relay_required=bool(relay_claims_required and relay_claims_required()),
8198
8378
  relay_claim_verifier=RELAY_CLAIM_VERIFIER,
8379
+ funnel_origin=funnel_origin,
8199
8380
  )
8200
8381
  nonce, enc_token = PAIRING_STORE.seal_psk_token(k_token, claim.device.token, aad)
8382
+ # Seal proof_secret (the request-proof HMAC key) under K_token for
8383
+ # clients that can unseal it, so it never crosses a plaintext transport
8384
+ # in the clear. The bearer token is already sealed; proof_secret was
8385
+ # not, which leaked it to a passive sniffer on the LAN path. Back-compat:
8386
+ # a client that does not send seal_proof_secret still receives the
8387
+ # cleartext field in the response below.
8388
+ seal_proof = bool(payload.get("seal_proof_secret"))
8389
+ proof_secret_nonce = None
8390
+ enc_proof_secret = None
8391
+ if seal_proof:
8392
+ proof_secret_aad = aad + b"\npairling.psk.proof_secret.v1"
8393
+ proof_secret_nonce, enc_proof_secret = PAIRING_STORE.seal_psk_token(
8394
+ k_token, claim.device.proof_secret, proof_secret_aad
8395
+ )
8396
+ try:
8397
+ protocol_version = int(payload.get("pv") or 0)
8398
+ except (TypeError, ValueError):
8399
+ protocol_version = 0
8400
+ # Increment 5: validate pv to the known set and gate the mint. A
8401
+ # funnel-origin claim mints only with a verified App Attest, so a
8402
+ # script holding a stolen secret cannot obtain a tagged auth key.
8403
+ if protocol_version not in (1, 2, 3):
8404
+ protocol_version = 0
8405
+ mint_allowed = protocol_version >= 3 and (
8406
+ not funnel_origin or claim.direct_attestation_verified
8407
+ )
8408
+ tailnet_authkey = _mint_phone_authkey(str(payload.get("pair_id") or "")) if mint_allowed else None
8409
+ authkey_nonce = None
8410
+ enc_authkey = None
8411
+ if tailnet_authkey:
8412
+ authkey_aad = aad + b"\npairling.psk.enc_authkey.v1"
8413
+ authkey_nonce, enc_authkey = PAIRING_STORE.seal_psk_token(
8414
+ k_token,
8415
+ str(tailnet_authkey.get("authkey") or ""),
8416
+ authkey_aad,
8417
+ )
8418
+ if funnel_origin:
8419
+ # Operator visibility: a remote, funnel-origin join has no
8420
+ # proximity backstop, so make it loud and auditable, and push a
8421
+ # revoke prompt to the already-paired devices (D4).
8422
+ try:
8423
+ import sys as _sys
8424
+ _sys.stderr.write(
8425
+ "PAIRLING FUNNEL JOIN: a remote device paired over Funnel "
8426
+ f"(device_id={claim.device.device_id}); no proximity backstop, "
8427
+ "review and revoke if unexpected.\n"
8428
+ )
8429
+ _sys.stderr.flush()
8430
+ except Exception:
8431
+ pass
8432
+ if PUSH_DISPATCHER is not None:
8433
+ try:
8434
+ PUSH_DISPATCHER.broadcast_alert(
8435
+ exclude_device_id=claim.device.device_id,
8436
+ event_id=f"funnel_join:{claim.device.device_id}",
8437
+ kind="remote_join",
8438
+ route="pairling-connect",
8439
+ title="New device paired remotely",
8440
+ body="A device joined your Mac over the internet. Tap to review or revoke.",
8441
+ pairling_extra={"device_id": claim.device.device_id, "revoke_path": "/pair/revoke"},
8442
+ )
8443
+ except Exception:
8444
+ pass
8201
8445
  if PAIRING_ADVERTISER is not None:
8202
8446
  PAIRING_ADVERTISER.stop()
8203
8447
  except PairingError as exc:
@@ -8211,15 +8455,20 @@ class Handler(BaseHTTPRequestHandler):
8211
8455
  route.get("source") == "pairling_connectd" and route.get("status") == "ready"
8212
8456
  for route in runtime_routes
8213
8457
  ) else "http-local"
8214
- self._send_json({
8458
+ device_block = {
8459
+ "id": claim.device.device_id,
8460
+ "scopes": list(claim.device.scopes),
8461
+ "relay_device_id": claim.relay_device_id,
8462
+ "attestation_status": claim.attestation_status,
8463
+ }
8464
+ if seal_proof and enc_proof_secret is not None and proof_secret_nonce is not None:
8465
+ device_block["enc_proof_secret"] = base64.b64encode(enc_proof_secret).decode("ascii")
8466
+ device_block["proof_secret_nonce"] = base64.b64encode(proof_secret_nonce).decode("ascii")
8467
+ else:
8468
+ device_block["proof_secret"] = claim.device.proof_secret
8469
+ response = {
8215
8470
  "ok": True,
8216
- "device": {
8217
- "id": claim.device.device_id,
8218
- "proof_secret": claim.device.proof_secret,
8219
- "scopes": list(claim.device.scopes),
8220
- "relay_device_id": claim.relay_device_id,
8221
- "attestation_status": claim.attestation_status,
8222
- },
8471
+ "device": device_block,
8223
8472
  "enc_token": base64.b64encode(enc_token).decode("ascii"),
8224
8473
  "nonce": base64.b64encode(nonce).decode("ascii"),
8225
8474
  "mac_confirm": base64.b64encode(mac_confirm).decode("ascii"),
@@ -8231,7 +8480,11 @@ class Handler(BaseHTTPRequestHandler):
8231
8480
  "transport": transport,
8232
8481
  "routes": runtime_routes,
8233
8482
  },
8234
- })
8483
+ }
8484
+ if authkey_nonce and enc_authkey:
8485
+ response["enc_authkey"] = base64.b64encode(enc_authkey).decode("ascii")
8486
+ response["authkey_nonce"] = base64.b64encode(authkey_nonce).decode("ascii")
8487
+ self._send_json(response)
8235
8488
 
8236
8489
  def _handle_pair_reauth_challenge(self, q):
8237
8490
  if REAUTH_STORE is None:
@@ -64,6 +64,7 @@ KIND_CATEGORY = {
64
64
  "mac_route_risk": "PAIRLING_MAC_HEALTH",
65
65
  "worker_pressure": "PAIRLING_WORKER_SENTINEL",
66
66
  "deploy_result": "PAIRLING_TURN_DONE",
67
+ "remote_join": "PAIRLING_REMOTE_JOIN",
67
68
  "push_diagnostic": "PAIRLING_PUSH_DIAGNOSTIC",
68
69
  }
69
70
  KIND_ALERT = {
@@ -78,9 +79,10 @@ KIND_ALERT = {
78
79
  "mac_route_risk": ("Mac route timed out", "The paired Mac route needs attention."),
79
80
  "worker_pressure": ("Pairling worker pressure", "Worker or token pressure needs review."),
80
81
  "deploy_result": ("Deploy result ready", "A build or deploy result is available."),
82
+ "remote_join": ("New device paired remotely", "A device joined your Mac over the internet."),
81
83
  "push_diagnostic": ("Pairling push test", "Push delivery is configured for this device."),
82
84
  }
83
- TIME_SENSITIVE_KINDS = {"session_attention", "mac_health", "worker_sentinel", "action_required", "turn_failed", "tool_risk", "mac_route_risk", "worker_pressure"}
85
+ TIME_SENSITIVE_KINDS = {"session_attention", "mac_health", "worker_sentinel", "action_required", "turn_failed", "tool_risk", "mac_route_risk", "worker_pressure", "remote_join"}
84
86
 
85
87
 
86
88
  class PushDispatcherError(Exception):
@@ -491,6 +493,34 @@ class PairlingPushDispatcher:
491
493
  self.relay_sender = relay_sender or RelayEventSender(now_fn=now_fn)
492
494
  self._lock = threading.RLock()
493
495
 
496
+ def broadcast_alert(self, *, exclude_device_id, event_id, kind, route, title, body, pairling_extra=None):
497
+ """Best-effort: send an alert to every registered device except
498
+ exclude_device_id. Per-device failures are swallowed. Returns a count.
499
+ Used to warn already-paired devices of a remote, funnel-origin join."""
500
+ data = self._read()
501
+ sent = 0
502
+ errors = 0
503
+ for device in data.get("devices", []):
504
+ device_id = str(device.get("device_id") or "")
505
+ token = str(device.get("apns_token") or "")
506
+ if not token or device_id == exclude_device_id:
507
+ continue
508
+ try:
509
+ self.apns_sender.send_alert(
510
+ token=token,
511
+ event_id=f"{event_id}:{device_id}",
512
+ kind=kind,
513
+ route=route,
514
+ title=title,
515
+ body=body,
516
+ pairling_extra=pairling_extra,
517
+ interruption_level="time-sensitive",
518
+ )
519
+ sent += 1
520
+ except Exception:
521
+ errors += 1
522
+ return {"sent": sent, "errors": errors}
523
+
494
524
  def backfill_live_activity_environments(self, *, device_id: str | None = None) -> dict[str, Any]:
495
525
  """Repair older Live Activity token rows that predate explicit APNs environments."""
496
526
  data = self._read()
@@ -0,0 +1,65 @@
1
+ package main
2
+
3
+ import (
4
+ "net/netip"
5
+ "reflect"
6
+ "testing"
7
+
8
+ "tailscale.com/ipn/ipnstate"
9
+ "tailscale.com/tailcfg"
10
+ "tailscale.com/types/views"
11
+ )
12
+
13
+ func TestNodeIdentityFromStatusReadsGrantedSelf(t *testing.T) {
14
+ grantedTags := views.SliceOf([]string{"tag:pairling-connect"})
15
+ requestedTags := []string{"tag:requested-only"}
16
+
17
+ identity := nodeIdentityFromStatus(&ipnstate.Status{
18
+ Self: &ipnstate.PeerStatus{
19
+ ID: tailcfg.StableNodeID("nXb6CNTRL"),
20
+ Tags: &grantedTags,
21
+ TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.79.217.7")},
22
+ },
23
+ })
24
+
25
+ if identity.NodeID != "nXb6CNTRL" {
26
+ t.Fatalf("node ID = %q, want nXb6CNTRL", identity.NodeID)
27
+ }
28
+ if !reflect.DeepEqual(identity.Tags, []string{"tag:pairling-connect"}) {
29
+ t.Fatalf("tags = %#v, want granted tags only", identity.Tags)
30
+ }
31
+ if reflect.DeepEqual(identity.Tags, requestedTags) {
32
+ t.Fatalf("mapper reported requested tags instead of granted tags: %#v", identity.Tags)
33
+ }
34
+ if !reflect.DeepEqual(identity.TailnetIPs, []string{"100.79.217.7"}) {
35
+ t.Fatalf("tailnet IPs = %#v", identity.TailnetIPs)
36
+ }
37
+ }
38
+
39
+ func TestNodeIdentityFromStatusHandlesNilSelf(t *testing.T) {
40
+ for _, st := range []*ipnstate.Status{nil, {}} {
41
+ identity := nodeIdentityFromStatus(st)
42
+ if identity.NodeID != "" || len(identity.Tags) != 0 || len(identity.TailnetIPs) != 0 {
43
+ t.Fatalf("nil self identity = %#v, want zero value", identity)
44
+ }
45
+ }
46
+ }
47
+
48
+ func TestNodeIdentityFromStatusUntaggedNode(t *testing.T) {
49
+ identity := nodeIdentityFromStatus(&ipnstate.Status{
50
+ Self: &ipnstate.PeerStatus{
51
+ ID: tailcfg.StableNodeID("nInteractive"),
52
+ TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.79.217.8")},
53
+ },
54
+ })
55
+
56
+ if identity.NodeID != "nInteractive" {
57
+ t.Fatalf("node ID = %q, want nInteractive", identity.NodeID)
58
+ }
59
+ if len(identity.Tags) != 0 {
60
+ t.Fatalf("untagged node reported tags: %#v", identity.Tags)
61
+ }
62
+ if !reflect.DeepEqual(identity.TailnetIPs, []string{"100.79.217.8"}) {
63
+ t.Fatalf("tailnet IPs = %#v", identity.TailnetIPs)
64
+ }
65
+ }