pairling 0.2.7 → 0.2.9

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.
package/README.md CHANGED
@@ -27,11 +27,11 @@ Then open Pairling on your iPhone and scan the QR code that `setup` prints.
27
27
  - Stages the runtime under `~/Library/Application Support/Pairling/runtime/`
28
28
  (versioned releases, atomic `current` symlink flip, `pairling rollback`).
29
29
  - Installs user-domain LaunchAgents (`dev.pairling.companiond`,
30
- `dev.pairling.connectd`). No root. The optional power guardian and the
31
- optional silent-join mint broker are separate, explicit, sudo-gated steps.
30
+ `dev.pairling.connectd`). No root. The optional power guardian is a separate,
31
+ explicit, sudo-gated step.
32
32
  - Verifies the payload against the package's integrity manifest and verifies
33
- the Developer ID signature of the bundled `pairling-connectd` and
34
- `pairling-tailnet-mintd` binaries before staging — fail closed.
33
+ the Developer ID signature of the bundled `pairling-connectd` binary before
34
+ staging — fail closed.
35
35
 
36
36
  ## Commands
37
37
 
@@ -51,9 +51,8 @@ pairling uninstall [--yes]
51
51
  appears in a published manifest.
52
52
  - **Provenance:** releases are published via npm Trusted Publishing (OIDC) with
53
53
  provenance attestations. Verify with `npm audit signatures`.
54
- - **Readable payload:** the runtime is Python/bash source plus two signed Go
55
- binaries (`pairling-connectd` and the `pairling-tailnet-mintd` broker);
56
- inspect it with `npm pack pairling --dry-run`.
54
+ - **Readable payload:** the runtime is Python/bash source plus one signed Go
55
+ binary (`pairling-connectd`); inspect it with `npm pack pairling --dry-run`.
57
56
  - **Integrity chain:** CI records SHA-256 of every payload file in
58
57
  `payload-manifest.json`; `pairling setup` re-verifies before staging;
59
58
  `pairling doctor` re-verifies the staged runtime and the binary signature.
@@ -62,10 +61,10 @@ pairling uninstall [--yes]
62
61
 
63
62
  ## Platform packages
64
63
 
65
- The compiled runtime binaries (`pairling-connectd` and the optional
66
- `pairling-tailnet-mintd` silent-join broker) ship as platform-filtered optional
67
- dependencies: `@pairling/runtime-darwin-arm64` and `@pairling/runtime-darwin-x64`
68
- — signed, notarized, and hash-pinned by each package's integrity manifest.
64
+ The compiled runtime binary (`pairling-connectd`) ships via platform-filtered
65
+ optional dependencies: `@pairling/runtime-darwin-arm64` and
66
+ `@pairling/runtime-darwin-x64` — signed, notarized, and hash-pinned by each
67
+ package's integrity manifest.
69
68
 
70
69
  ## Links
71
70
 
package/bin/pairling.mjs CHANGED
@@ -92,7 +92,6 @@ function detectRosetta() {
92
92
  function shimEnv() {
93
93
  const runtimeDir = runtimePackageDir();
94
94
  const connectd = runtimeDir ? join(runtimeDir, "bin", "pairling-connectd") : null;
95
- const mintd = runtimeDir ? join(runtimeDir, "bin", "pairling-tailnet-mintd") : null;
96
95
  const vendoredPython = runtimeDir ? join(runtimeDir, "python", "bin", "python3") : null;
97
96
  return {
98
97
  packageRoot,
@@ -101,7 +100,6 @@ function shimEnv() {
101
100
  payloadRoot,
102
101
  runtimePackageDir: runtimeDir,
103
102
  connectdPath: connectd && existsSync(connectd) ? connectd : null,
104
- mintdPath: mintd && existsSync(mintd) ? mintd : null,
105
103
  vendoredPython: vendoredPython && existsSync(vendoredPython) ? vendoredPython : null,
106
104
  stagedCli: stagedCliPath(),
107
105
  stagedRuntimeVersion: stagedRuntimeVersion(),
@@ -189,7 +187,7 @@ function main() {
189
187
 
190
188
  if (existsSync(payloadCli)) {
191
189
  const env = shimEnv();
192
- if (!env.runtimePackageDir || !env.connectdPath || !env.mintdPath) {
190
+ if (!env.runtimePackageDir || !env.connectdPath) {
193
191
  process.stderr.write(
194
192
  [
195
193
  "pairling: the platform runtime package is missing or incomplete.",
@@ -208,7 +206,6 @@ function main() {
208
206
  delegate(payloadCli, args, {
209
207
  PAIRLING_REPO_ROOT: join(payloadRoot, "."),
210
208
  PAIRLING_CONNECTD_PREBUILT: env.connectdPath,
211
- PAIRLING_MINTD_PREBUILT: env.mintdPath,
212
209
  PAIRLING_DAEMON_PYTHON: env.vendoredPython,
213
210
  });
214
211
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairling",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Pair your iPhone with the AI coding agents running on your Mac. CLI and local runtime installer for the Pairling iOS app.",
5
5
  "keywords": [
6
6
  "pairling",
@@ -40,7 +40,7 @@
40
40
  "url": "https://github.com/mergimg0/pairling-helper"
41
41
  },
42
42
  "optionalDependencies": {
43
- "@pairling/runtime-darwin-arm64": "0.2.7",
44
- "@pairling/runtime-darwin-x64": "0.2.7"
43
+ "@pairling/runtime-darwin-arm64": "0.2.9",
44
+ "@pairling/runtime-darwin-x64": "0.2.9"
45
45
  }
46
46
  }
@@ -1 +1 @@
1
- 102b7cf
1
+ c97bbab
@@ -1 +1 @@
1
- 0.2.7
1
+ 0.2.9
@@ -300,8 +300,6 @@ os.environ["PATH"] = (
300
300
 
301
301
  PORT = RUNTIME_PORT
302
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")
305
303
  DEFAULT_COORDINATOR_HOST = (
306
304
  os.environ.get("PAIRLING_HOSTNAME")
307
305
  or os.environ.get("COMPANION_COORDINATOR_HOST")
@@ -730,6 +728,7 @@ POST_ONLY_ENDPOINTS = {
730
728
  "/pair/reauth-claim",
731
729
  "/pair/revoke",
732
730
  "/pair/rotate-token",
731
+ "/pair/bind-node",
733
732
  "/aperture-cli/open",
734
733
  "/open",
735
734
  "/inject",
@@ -813,6 +812,12 @@ PROOF_REQUIRED_ENDPOINTS = HIGH_RISK_ENDPOINTS | {
813
812
  "/phone-tools/availability",
814
813
  "/phone-tools/next",
815
814
  "/phone-tools/result",
815
+ # Minimal proof-required POST whose only purpose is to trip the interactive
816
+ # provenance bind in _maybe_persist_tailnet_node_id. The iOS post-pair proof
817
+ # probes the embedded route with GET (never proof-required), so the bind for
818
+ # an untagged D2 interactive node never fired. A proof-required POST here
819
+ # reaches proof_verified=True and binds the device's tailnet_node_id.
820
+ "/pair/bind-node",
816
821
  }
817
822
 
818
823
  MAX_REQUEST_BODY_BYTES = 1_000_000
@@ -1048,7 +1053,31 @@ def _connectd_peer_node_id(headers) -> str:
1048
1053
  return value
1049
1054
 
1050
1055
 
1051
- def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool:
1056
+ def _connectd_peer_provenance(headers):
1057
+ """Parse the connectd-injected X-Pairling-Peer-Provenance header strictly.
1058
+
1059
+ connectd strips any client-supplied copy first, so when this header is
1060
+ present it is trustworthy. Three outcomes, kept deliberately distinct:
1061
+
1062
+ * header ABSENT -> return None. Legacy-eligible: an old connectd that sends
1063
+ X-Pairling-Peer-Node but no provenance keeps the bearer-only bind.
1064
+ * header EXACTLY "tagged" (tag:pairling-phone minted node) or "interactive"
1065
+ (untagged Pairling iOS D2 node) -> return that exact value.
1066
+ * header PRESENT but any other value (including empty) -> return "" as an
1067
+ invalid sentinel. Distinct from None so a present-but-invalid value is
1068
+ rejected, never silently downgraded to the legacy path.
1069
+ """
1070
+ getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
1071
+ raw = getter("X-Pairling-Peer-Provenance", None)
1072
+ if raw is None:
1073
+ return None
1074
+ value = str(raw).strip()
1075
+ if value in ("tagged", "interactive"):
1076
+ return value
1077
+ return ""
1078
+
1079
+
1080
+ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result, proof_verified=False) -> bool:
1052
1081
  if DEVICE_REGISTRY is None or auth_result is None or not getattr(auth_result, "ok", False):
1053
1082
  return False
1054
1083
  device_id = str(getattr(auth_result, "device_id", "") or "").strip()
@@ -1058,9 +1087,26 @@ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool
1058
1087
  return False
1059
1088
  if not _pairdrop_gateway_provenance_ok(headers, client_address):
1060
1089
  return False
1090
+ provenance = _connectd_peer_provenance(headers)
1061
1091
  node_id = _connectd_peer_node_id(headers)
1062
1092
  if not node_id:
1063
1093
  return False
1094
+ if provenance is None:
1095
+ # Legacy: an old connectd sent a peer-node header but no provenance.
1096
+ # Bind after bearer auth, preserving the pre-Stage-2 behavior.
1097
+ pass
1098
+ elif provenance == "tagged":
1099
+ # tag:pairling-phone minted node: bearer auth is sufficient to bind.
1100
+ pass
1101
+ elif provenance == "interactive":
1102
+ # Untagged, less-trusted Pairling iOS D2 node: bind only after the
1103
+ # request also passed request-proof, never on bearer auth alone.
1104
+ if not proof_verified:
1105
+ return False
1106
+ else:
1107
+ # Present-but-invalid provenance value: reject. Do NOT downgrade to the
1108
+ # legacy path just because the value is unrecognized.
1109
+ return False
1064
1110
  try:
1065
1111
  return bool(DEVICE_REGISTRY.set_tailnet_node_id_if_absent(device_id, node_id))
1066
1112
  except Exception:
@@ -2141,81 +2187,6 @@ def _lan_ips(power_state: dict | None = None) -> list[str]:
2141
2187
  return _cached_probe("lan_ips", _HEALTH_PROBE_CACHE_SECONDS, _probe_lan_ips)
2142
2188
 
2143
2189
 
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
-
2219
2190
  def _guardian_listener_entries(power_state: dict | None) -> list[str]:
2220
2191
  if not isinstance(power_state, dict):
2221
2192
  return []
@@ -2592,7 +2563,6 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
2592
2563
  if connect.get("summary") is not None:
2593
2564
  coordinator = dict(coordinator)
2594
2565
  coordinator["pairling_connect"] = connect["summary"]
2595
- coordinator = _attach_mintd_alerts(coordinator)
2596
2566
  routes = _health_routes(coordinator, power_state)
2597
2567
  ok = coordinator.get("posture") in ("ready", "warning")
2598
2568
  runtime_info = _runtime_info_snapshot()
@@ -2658,7 +2628,6 @@ def _mac_health_alert_snapshot() -> dict:
2658
2628
  coordinator = _apply_pairling_connect_posture(
2659
2629
  coordinator, _normalize_guardian_state(state), _pairling_connect_health()
2660
2630
  )
2661
- coordinator = _attach_mintd_alerts(coordinator)
2662
2631
  return {
2663
2632
  "ok": coordinator.get("posture") in ("ready", "warning"),
2664
2633
  "schema_version": 1,
@@ -7476,6 +7445,7 @@ class Handler(BaseHTTPRequestHandler):
7476
7445
  self.send_error(405, "POST required")
7477
7446
  return
7478
7447
 
7448
+ proof_verified = False
7479
7449
  if (
7480
7450
  self.pairling_auth is not None
7481
7451
  and _requires_request_proof(u.path, self.command)
@@ -7511,9 +7481,15 @@ class Handler(BaseHTTPRequestHandler):
7511
7481
  },
7512
7482
  }, status=proof_result.status)
7513
7483
  return
7484
+ proof_verified = True
7514
7485
 
7515
7486
  if self.pairling_auth is not None:
7516
- _maybe_persist_tailnet_node_id(self.headers, self.client_address, self.pairling_auth)
7487
+ _maybe_persist_tailnet_node_id(
7488
+ self.headers,
7489
+ self.client_address,
7490
+ self.pairling_auth,
7491
+ proof_verified=proof_verified,
7492
+ )
7517
7493
 
7518
7494
  if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
7519
7495
  max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
@@ -7569,6 +7545,8 @@ class Handler(BaseHTTPRequestHandler):
7569
7545
  self._handle_pair_revoke(q)
7570
7546
  elif u.path == "/pair/rotate-token":
7571
7547
  self._handle_pair_rotate_token(q)
7548
+ elif u.path == "/pair/bind-node":
7549
+ self._handle_pair_bind_node(q)
7572
7550
  elif u.path == "/healthz":
7573
7551
  self._handle_healthz(q)
7574
7552
  elif u.path == "/power-state":
@@ -8236,15 +8214,6 @@ class Handler(BaseHTTPRequestHandler):
8236
8214
  else {"ok": False, "reason": "advertiser_unavailable"}
8237
8215
  )
8238
8216
  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
- )
8248
8217
  except ValueError as exc:
8249
8218
  self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
8250
8219
  return
@@ -8271,9 +8240,7 @@ class Handler(BaseHTTPRequestHandler):
8271
8240
  "pairing_nonce": started.pairing_nonce,
8272
8241
  "attest_challenge": started.attest_challenge,
8273
8242
  "mac_ake_pub": started.mac_ake_pub,
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,
8243
+ "pv": "2" if started.mac_ake_pub else "1",
8277
8244
  },
8278
8245
  })
8279
8246
 
@@ -8393,55 +8360,6 @@ class Handler(BaseHTTPRequestHandler):
8393
8360
  proof_secret_nonce, enc_proof_secret = PAIRING_STORE.seal_psk_token(
8394
8361
  k_token, claim.device.proof_secret, proof_secret_aad
8395
8362
  )
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
8445
8363
  if PAIRING_ADVERTISER is not None:
8446
8364
  PAIRING_ADVERTISER.stop()
8447
8365
  except PairingError as exc:
@@ -8481,9 +8399,6 @@ class Handler(BaseHTTPRequestHandler):
8481
8399
  "routes": runtime_routes,
8482
8400
  },
8483
8401
  }
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
8402
  self._send_json(response)
8488
8403
 
8489
8404
  def _handle_pair_reauth_challenge(self, q):
@@ -8572,6 +8487,28 @@ class Handler(BaseHTTPRequestHandler):
8572
8487
  return
8573
8488
  self._send_json({"ok": True, "device_id": device_id, "token": token})
8574
8489
 
8490
+ def _handle_pair_bind_node(self, q):
8491
+ # Minimal proof-required POST whose sole purpose is to trip the
8492
+ # interactive provenance bind. The bind itself already ran in
8493
+ # _maybe_persist_tailnet_node_id BEFORE routing (it fires only when this
8494
+ # request was bearer-authed AND passed request-proof, which a POST to a
8495
+ # PROOF_REQUIRED_ENDPOINTS path is). Here we only report whether the
8496
+ # device now carries a tailnet_node_id. NO other side effects.
8497
+ if self.pairling_auth is None:
8498
+ self._send_json({
8499
+ "ok": False,
8500
+ "error": {
8501
+ "code": "missing_token",
8502
+ "message": "Authorization: Bearer token required",
8503
+ },
8504
+ }, status=401)
8505
+ return
8506
+ bound = bool(
8507
+ DEVICE_REGISTRY
8508
+ and DEVICE_REGISTRY.tailnet_node_id(self.pairling_auth.device_id)
8509
+ )
8510
+ self._send_json({"ok": True, "tailnet_node_id_bound": bound})
8511
+
8575
8512
  def _handle_power_state(self, q):
8576
8513
  self._send_json(_cached_health_payload(
8577
8514
  full_power=True,
@@ -484,35 +484,59 @@ type tailscalePeerNodeResolver struct {
484
484
  localClient func() (whoIsClient, error)
485
485
  }
486
486
 
487
- func (r tailscalePeerNodeResolver) PeerNodeID(ctx context.Context, remoteAddr string) (string, bool) {
487
+ func (r tailscalePeerNodeResolver) PeerNodeID(ctx context.Context, remoteAddr string) (string, string, bool) {
488
488
  if r.localClient == nil {
489
- return "", false
489
+ return "", "", false
490
490
  }
491
491
  lc, err := r.localClient()
492
492
  if err != nil || lc == nil {
493
- return "", false
493
+ return "", "", false
494
494
  }
495
495
  who, err := lc.WhoIs(ctx, remoteAddr)
496
496
  if err != nil {
497
- return "", false
497
+ return "", "", false
498
498
  }
499
499
  return peerNodeIDFromWhoIs(who)
500
500
  }
501
501
 
502
- func peerNodeIDFromWhoIs(who *apitype.WhoIsResponse) (string, bool) {
502
+ // peerNodeIDFromWhoIs resolves a peer's tailnet node ID and its provenance from
503
+ // a WhoIs response. The node ID is always the WhoIs StableID; it is never
504
+ // derived from any client-controlled value. Two provenance paths are admitted:
505
+ //
506
+ // - "tagged": the old minted path, where the node carries tag:pairling-phone.
507
+ // - "interactive": an untagged, user-owned Pairling iOS node from the D2
508
+ // sign-in path, identified by a WhoIs hostname with the pairling-ios- prefix.
509
+ //
510
+ // connectd's hostname gate here is defense-in-depth; pairlingd performs the real
511
+ // bearer + request-proof gate. As a guard against a non-iOS node spoofing the
512
+ // pairling-ios- hostname, an untagged node is rejected when its Hostinfo reports
513
+ // a non-empty OS that is not iOS.
514
+ func peerNodeIDFromWhoIs(who *apitype.WhoIsResponse) (string, string, bool) {
503
515
  if who == nil || who.Node == nil {
504
- return "", false
516
+ return "", "", false
505
517
  }
506
518
  nodeID := strings.TrimSpace(string(who.Node.StableID))
507
519
  if nodeID == "" {
508
- return "", false
520
+ return "", "", false
509
521
  }
510
522
  for _, tag := range who.Node.Tags {
511
523
  if tag == "tag:pairling-phone" {
512
- return nodeID, true
524
+ return nodeID, "tagged", true
513
525
  }
514
526
  }
515
- return "", false
527
+ hostname := who.Node.ComputedName
528
+ if hostname == "" && who.Node.Hostinfo.Valid() {
529
+ hostname = who.Node.Hostinfo.Hostname()
530
+ }
531
+ if strings.HasPrefix(strings.ToLower(hostname), "pairling-ios-") {
532
+ if who.Node.Hostinfo.Valid() {
533
+ if os := who.Node.Hostinfo.OS(); os != "" && !strings.EqualFold(os, "iOS") {
534
+ return "", "", false
535
+ }
536
+ }
537
+ return nodeID, "interactive", true
538
+ }
539
+ return "", "", false
516
540
  }
517
541
 
518
542
  func nodeIdentityFromStatus(st *ipnstate.Status) NodeIdentity {