pairling 0.2.8 → 0.2.10
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/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairlingd.py +106 -17
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +33 -9
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +145 -5
- package/payload/mac/connectd/internal/gateway/proxy.go +16 -2
- package/payload/mac/connectd/internal/gateway/proxy_test.go +103 -4
- package/payload-manifest.json +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairling",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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.
|
|
44
|
-
"@pairling/runtime-darwin-x64": "0.2.
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.2.10",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.2.10"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0d3be68
|
package/payload/mac/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.10
|
|
@@ -728,6 +728,7 @@ POST_ONLY_ENDPOINTS = {
|
|
|
728
728
|
"/pair/reauth-claim",
|
|
729
729
|
"/pair/revoke",
|
|
730
730
|
"/pair/rotate-token",
|
|
731
|
+
"/pair/bind-node",
|
|
731
732
|
"/aperture-cli/open",
|
|
732
733
|
"/open",
|
|
733
734
|
"/inject",
|
|
@@ -811,6 +812,12 @@ PROOF_REQUIRED_ENDPOINTS = HIGH_RISK_ENDPOINTS | {
|
|
|
811
812
|
"/phone-tools/availability",
|
|
812
813
|
"/phone-tools/next",
|
|
813
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",
|
|
814
821
|
}
|
|
815
822
|
|
|
816
823
|
MAX_REQUEST_BODY_BYTES = 1_000_000
|
|
@@ -1046,7 +1053,31 @@ def _connectd_peer_node_id(headers) -> str:
|
|
|
1046
1053
|
return value
|
|
1047
1054
|
|
|
1048
1055
|
|
|
1049
|
-
def
|
|
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:
|
|
1050
1081
|
if DEVICE_REGISTRY is None or auth_result is None or not getattr(auth_result, "ok", False):
|
|
1051
1082
|
return False
|
|
1052
1083
|
device_id = str(getattr(auth_result, "device_id", "") or "").strip()
|
|
@@ -1056,9 +1087,26 @@ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool
|
|
|
1056
1087
|
return False
|
|
1057
1088
|
if not _pairdrop_gateway_provenance_ok(headers, client_address):
|
|
1058
1089
|
return False
|
|
1090
|
+
provenance = _connectd_peer_provenance(headers)
|
|
1059
1091
|
node_id = _connectd_peer_node_id(headers)
|
|
1060
1092
|
if not node_id:
|
|
1061
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
|
|
1062
1110
|
try:
|
|
1063
1111
|
return bool(DEVICE_REGISTRY.set_tailnet_node_id_if_absent(device_id, node_id))
|
|
1064
1112
|
except Exception:
|
|
@@ -2352,6 +2400,23 @@ def _pairling_connect_health() -> dict:
|
|
|
2352
2400
|
return _cached_probe("pairling_connect_health", _HEALTH_PROBE_CACHE_SECONDS, probe)
|
|
2353
2401
|
|
|
2354
2402
|
|
|
2403
|
+
def _coordinator_from_pairling_connect(connect: dict) -> dict:
|
|
2404
|
+
ready = bool(connect.get("ready"))
|
|
2405
|
+
return {
|
|
2406
|
+
"role": "primary_coordinator",
|
|
2407
|
+
"host": DEFAULT_COORDINATOR_HOST,
|
|
2408
|
+
"posture": "ready" if ready else "unknown",
|
|
2409
|
+
"severity": "ok" if ready else "unknown",
|
|
2410
|
+
"summary": (
|
|
2411
|
+
"Pairling Connect route is ready."
|
|
2412
|
+
if ready
|
|
2413
|
+
else "Pairling Connect route is not ready."
|
|
2414
|
+
),
|
|
2415
|
+
"stale": False,
|
|
2416
|
+
"tailnet_axis": "pairling_connect",
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
|
|
2355
2420
|
# Guardian checks whose failure is fully compensated by a ready Pairling
|
|
2356
2421
|
# Connect route: both only measure the standalone-Tailscale axis.
|
|
2357
2422
|
_TAILNET_AXIS_CHECK_IDS = {"tailscale_ip", "daemon_reachable"}
|
|
@@ -2507,11 +2572,9 @@ def _routez_payload(auth_result=None) -> dict:
|
|
|
2507
2572
|
|
|
2508
2573
|
|
|
2509
2574
|
def _health_payload(full_power: bool = False, authenticated: bool = False, auth_result=None) -> dict:
|
|
2510
|
-
state, path, age, error = _read_guardian_state()
|
|
2511
|
-
power_state = _normalize_guardian_state(state)
|
|
2512
|
-
coordinator = _coordinator_from_guardian(power_state, age, error)
|
|
2513
2575
|
connect = _pairling_connect_health()
|
|
2514
|
-
|
|
2576
|
+
power_state = None
|
|
2577
|
+
coordinator = _coordinator_from_pairling_connect(connect)
|
|
2515
2578
|
if connect.get("summary") is not None:
|
|
2516
2579
|
coordinator = dict(coordinator)
|
|
2517
2580
|
coordinator["pairling_connect"] = connect["summary"]
|
|
@@ -2551,11 +2614,6 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
|
|
|
2551
2614
|
"high_risk_count": 0,
|
|
2552
2615
|
"updated_at": _time.time(),
|
|
2553
2616
|
}
|
|
2554
|
-
if full_power:
|
|
2555
|
-
payload["guardian_path"] = path
|
|
2556
|
-
payload["guardian_error"] = error
|
|
2557
|
-
payload["guardian_sample_age_seconds"] = age
|
|
2558
|
-
payload["power_state"] = power_state
|
|
2559
2617
|
return payload
|
|
2560
2618
|
|
|
2561
2619
|
|
|
@@ -2575,11 +2633,11 @@ def _cached_health_payload(full_power: bool = False, authenticated: bool = False
|
|
|
2575
2633
|
|
|
2576
2634
|
|
|
2577
2635
|
def _mac_health_alert_snapshot() -> dict:
|
|
2578
|
-
|
|
2579
|
-
coordinator =
|
|
2580
|
-
|
|
2581
|
-
coordinator
|
|
2582
|
-
|
|
2636
|
+
connect = _pairling_connect_health()
|
|
2637
|
+
coordinator = _coordinator_from_pairling_connect(connect)
|
|
2638
|
+
if connect.get("summary") is not None:
|
|
2639
|
+
coordinator = dict(coordinator)
|
|
2640
|
+
coordinator["pairling_connect"] = connect["summary"]
|
|
2583
2641
|
return {
|
|
2584
2642
|
"ok": coordinator.get("posture") in ("ready", "warning"),
|
|
2585
2643
|
"schema_version": 1,
|
|
@@ -2611,7 +2669,7 @@ def _health_diff_digest(payload: dict) -> str:
|
|
|
2611
2669
|
def _orchestration_preflight_from_health(health: dict) -> tuple[dict, dict]:
|
|
2612
2670
|
power_state = health.get("power_state") if isinstance(health.get("power_state"), dict) else None
|
|
2613
2671
|
if power_state is None:
|
|
2614
|
-
power_state =
|
|
2672
|
+
power_state = {}
|
|
2615
2673
|
coordinator = health.get("coordinator") or {}
|
|
2616
2674
|
route = (health.get("routes") or [{}])[0]
|
|
2617
2675
|
runtime_info = health.get("runtime") if isinstance(health.get("runtime"), dict) else {}
|
|
@@ -7397,6 +7455,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7397
7455
|
self.send_error(405, "POST required")
|
|
7398
7456
|
return
|
|
7399
7457
|
|
|
7458
|
+
proof_verified = False
|
|
7400
7459
|
if (
|
|
7401
7460
|
self.pairling_auth is not None
|
|
7402
7461
|
and _requires_request_proof(u.path, self.command)
|
|
@@ -7432,9 +7491,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7432
7491
|
},
|
|
7433
7492
|
}, status=proof_result.status)
|
|
7434
7493
|
return
|
|
7494
|
+
proof_verified = True
|
|
7435
7495
|
|
|
7436
7496
|
if self.pairling_auth is not None:
|
|
7437
|
-
_maybe_persist_tailnet_node_id(
|
|
7497
|
+
_maybe_persist_tailnet_node_id(
|
|
7498
|
+
self.headers,
|
|
7499
|
+
self.client_address,
|
|
7500
|
+
self.pairling_auth,
|
|
7501
|
+
proof_verified=proof_verified,
|
|
7502
|
+
)
|
|
7438
7503
|
|
|
7439
7504
|
if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
|
|
7440
7505
|
max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
|
|
@@ -7490,6 +7555,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7490
7555
|
self._handle_pair_revoke(q)
|
|
7491
7556
|
elif u.path == "/pair/rotate-token":
|
|
7492
7557
|
self._handle_pair_rotate_token(q)
|
|
7558
|
+
elif u.path == "/pair/bind-node":
|
|
7559
|
+
self._handle_pair_bind_node(q)
|
|
7493
7560
|
elif u.path == "/healthz":
|
|
7494
7561
|
self._handle_healthz(q)
|
|
7495
7562
|
elif u.path == "/power-state":
|
|
@@ -8430,6 +8497,28 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8430
8497
|
return
|
|
8431
8498
|
self._send_json({"ok": True, "device_id": device_id, "token": token})
|
|
8432
8499
|
|
|
8500
|
+
def _handle_pair_bind_node(self, q):
|
|
8501
|
+
# Minimal proof-required POST whose sole purpose is to trip the
|
|
8502
|
+
# interactive provenance bind. The bind itself already ran in
|
|
8503
|
+
# _maybe_persist_tailnet_node_id BEFORE routing (it fires only when this
|
|
8504
|
+
# request was bearer-authed AND passed request-proof, which a POST to a
|
|
8505
|
+
# PROOF_REQUIRED_ENDPOINTS path is). Here we only report whether the
|
|
8506
|
+
# device now carries a tailnet_node_id. NO other side effects.
|
|
8507
|
+
if self.pairling_auth is None:
|
|
8508
|
+
self._send_json({
|
|
8509
|
+
"ok": False,
|
|
8510
|
+
"error": {
|
|
8511
|
+
"code": "missing_token",
|
|
8512
|
+
"message": "Authorization: Bearer token required",
|
|
8513
|
+
},
|
|
8514
|
+
}, status=401)
|
|
8515
|
+
return
|
|
8516
|
+
bound = bool(
|
|
8517
|
+
DEVICE_REGISTRY
|
|
8518
|
+
and DEVICE_REGISTRY.tailnet_node_id(self.pairling_auth.device_id)
|
|
8519
|
+
)
|
|
8520
|
+
self._send_json({"ok": True, "tailnet_node_id_bound": bound})
|
|
8521
|
+
|
|
8433
8522
|
def _handle_power_state(self, q):
|
|
8434
8523
|
self._send_json(_cached_health_payload(
|
|
8435
8524
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -34,7 +34,7 @@ func TestPeerNodeResolverUsesWhoIsRemoteAddr(t *testing.T) {
|
|
|
34
34
|
},
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
nodeID, ok := resolver.PeerNodeID(context.Background(), "100.64.0.50:12345")
|
|
37
|
+
nodeID, provenance, ok := resolver.PeerNodeID(context.Background(), "100.64.0.50:12345")
|
|
38
38
|
if !ok {
|
|
39
39
|
t.Fatal("resolver should accept tagged peer")
|
|
40
40
|
}
|
|
@@ -44,10 +44,13 @@ func TestPeerNodeResolverUsesWhoIsRemoteAddr(t *testing.T) {
|
|
|
44
44
|
if nodeID != "nPeerCNTRL" {
|
|
45
45
|
t.Fatalf("node ID = %q", nodeID)
|
|
46
46
|
}
|
|
47
|
+
if provenance != "tagged" {
|
|
48
|
+
t.Fatalf("provenance = %q, want tagged", provenance)
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
func TestWhoIsResolvesPeerStableID(t *testing.T) {
|
|
50
|
-
nodeID, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
|
|
53
|
+
nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
|
|
51
54
|
Node: &tailcfg.Node{
|
|
52
55
|
StableID: tailcfg.StableNodeID("nPeerCNTRL"),
|
|
53
56
|
Tags: []string{"tag:pairling-phone"},
|
|
@@ -60,6 +63,9 @@ func TestWhoIsResolvesPeerStableID(t *testing.T) {
|
|
|
60
63
|
if nodeID != "nPeerCNTRL" {
|
|
61
64
|
t.Fatalf("node ID = %q, want nPeerCNTRL", nodeID)
|
|
62
65
|
}
|
|
66
|
+
if provenance != "tagged" {
|
|
67
|
+
t.Fatalf("provenance = %q, want tagged", provenance)
|
|
68
|
+
}
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
func TestAssertsGrantedPhoneTagBeforePersist(t *testing.T) {
|
|
@@ -72,15 +78,149 @@ func TestAssertsGrantedPhoneTagBeforePersist(t *testing.T) {
|
|
|
72
78
|
}
|
|
73
79
|
for _, tc := range cases {
|
|
74
80
|
t.Run(tc.name, func(t *testing.T) {
|
|
75
|
-
nodeID, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
|
|
81
|
+
nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
|
|
76
82
|
Node: &tailcfg.Node{
|
|
77
83
|
StableID: tailcfg.StableNodeID("nPeerCNTRL"),
|
|
78
84
|
Tags: tc.tags,
|
|
79
85
|
},
|
|
80
86
|
})
|
|
81
|
-
if ok || nodeID != "" {
|
|
82
|
-
t.Fatalf("wrongly accepted node ID %q with tags %#v", nodeID, tc.tags)
|
|
87
|
+
if ok || nodeID != "" || provenance != "" {
|
|
88
|
+
t.Fatalf("wrongly accepted node ID %q provenance %q with tags %#v", nodeID, provenance, tc.tags)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// TestPeerNodeIDProvenanceFromWhoIs covers the interactive-sign-in provenance
|
|
95
|
+
// path: an untagged, user-owned iOS node whose WhoIs hostname starts with
|
|
96
|
+
// pairling-ios- is admitted as "interactive", while a tagged phone is admitted
|
|
97
|
+
// as "tagged". Non-Pairling untagged nodes and OS-mismatched nodes are rejected.
|
|
98
|
+
func TestPeerNodeIDProvenanceFromWhoIs(t *testing.T) {
|
|
99
|
+
cases := []struct {
|
|
100
|
+
name string
|
|
101
|
+
node *tailcfg.Node
|
|
102
|
+
wantNodeID string
|
|
103
|
+
wantProvenance string
|
|
104
|
+
wantOK bool
|
|
105
|
+
}{
|
|
106
|
+
{
|
|
107
|
+
name: "tagged phone",
|
|
108
|
+
node: &tailcfg.Node{
|
|
109
|
+
StableID: tailcfg.StableNodeID("nPeerCNTRL"),
|
|
110
|
+
Tags: []string{"tag:pairling-phone"},
|
|
111
|
+
},
|
|
112
|
+
wantNodeID: "nPeerCNTRL",
|
|
113
|
+
wantProvenance: "tagged",
|
|
114
|
+
wantOK: true,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "untagged interactive ios computed name",
|
|
118
|
+
node: &tailcfg.Node{
|
|
119
|
+
StableID: tailcfg.StableNodeID("nInteractiveIOS"),
|
|
120
|
+
ComputedName: "pairling-ios-b702bb49",
|
|
121
|
+
},
|
|
122
|
+
wantNodeID: "nInteractiveIOS",
|
|
123
|
+
wantProvenance: "interactive",
|
|
124
|
+
wantOK: true,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "untagged non-pairling laptop",
|
|
128
|
+
node: &tailcfg.Node{
|
|
129
|
+
StableID: tailcfg.StableNodeID("nLaptop"),
|
|
130
|
+
ComputedName: "my-laptop",
|
|
131
|
+
},
|
|
132
|
+
wantNodeID: "",
|
|
133
|
+
wantProvenance: "",
|
|
134
|
+
wantOK: false,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "untagged pairling-ios prefix but macOS hostinfo rejected",
|
|
138
|
+
node: &tailcfg.Node{
|
|
139
|
+
StableID: tailcfg.StableNodeID("nFakeIOS"),
|
|
140
|
+
ComputedName: "pairling-ios-x",
|
|
141
|
+
Hostinfo: (&tailcfg.Hostinfo{OS: "macOS"}).View(),
|
|
142
|
+
},
|
|
143
|
+
wantNodeID: "",
|
|
144
|
+
wantProvenance: "",
|
|
145
|
+
wantOK: false,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "empty stable id",
|
|
149
|
+
node: &tailcfg.Node{
|
|
150
|
+
StableID: tailcfg.StableNodeID(""),
|
|
151
|
+
Tags: []string{"tag:pairling-phone"},
|
|
152
|
+
},
|
|
153
|
+
wantNodeID: "",
|
|
154
|
+
wantProvenance: "",
|
|
155
|
+
wantOK: false,
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
for _, tc := range cases {
|
|
159
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
160
|
+
nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{Node: tc.node})
|
|
161
|
+
if ok != tc.wantOK {
|
|
162
|
+
t.Fatalf("ok = %t, want %t (nodeID=%q provenance=%q)", ok, tc.wantOK, nodeID, provenance)
|
|
163
|
+
}
|
|
164
|
+
if nodeID != tc.wantNodeID {
|
|
165
|
+
t.Fatalf("nodeID = %q, want %q", nodeID, tc.wantNodeID)
|
|
166
|
+
}
|
|
167
|
+
if provenance != tc.wantProvenance {
|
|
168
|
+
t.Fatalf("provenance = %q, want %q", provenance, tc.wantProvenance)
|
|
83
169
|
}
|
|
84
170
|
})
|
|
85
171
|
}
|
|
86
172
|
}
|
|
173
|
+
|
|
174
|
+
// TestPeerNodeIDInteractiveAcceptsIOSHostinfo confirms an untagged Pairling iOS
|
|
175
|
+
// node with Hostinfo OS reported as "iOS" (case-insensitive) is still admitted
|
|
176
|
+
// as interactive — the OS check only rejects a non-empty, non-iOS OS.
|
|
177
|
+
func TestPeerNodeIDInteractiveAcceptsIOSHostinfo(t *testing.T) {
|
|
178
|
+
nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
|
|
179
|
+
Node: &tailcfg.Node{
|
|
180
|
+
StableID: tailcfg.StableNodeID("nIOS"),
|
|
181
|
+
ComputedName: "pairling-ios-abc123",
|
|
182
|
+
Hostinfo: (&tailcfg.Hostinfo{OS: "iOS"}).View(),
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
if !ok {
|
|
186
|
+
t.Fatal("untagged pairling iOS node with iOS Hostinfo should be admitted")
|
|
187
|
+
}
|
|
188
|
+
if nodeID != "nIOS" {
|
|
189
|
+
t.Fatalf("nodeID = %q, want nIOS", nodeID)
|
|
190
|
+
}
|
|
191
|
+
if provenance != "interactive" {
|
|
192
|
+
t.Fatalf("provenance = %q, want interactive", provenance)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// TestPeerNodeIDInteractiveFallsBackToHostinfoHostname confirms that when
|
|
197
|
+
// ComputedName is empty, the WhoIs hostname is derived from a valid Hostinfo's
|
|
198
|
+
// Hostname() and the pairling-ios- prefix is still honored.
|
|
199
|
+
func TestPeerNodeIDInteractiveFallsBackToHostinfoHostname(t *testing.T) {
|
|
200
|
+
nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
|
|
201
|
+
Node: &tailcfg.Node{
|
|
202
|
+
StableID: tailcfg.StableNodeID("nIOSHost"),
|
|
203
|
+
Hostinfo: (&tailcfg.Hostinfo{Hostname: "pairling-ios-fallback", OS: "iOS"}).View(),
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
if !ok {
|
|
207
|
+
t.Fatal("untagged pairling iOS node identified via Hostinfo hostname should be admitted")
|
|
208
|
+
}
|
|
209
|
+
if nodeID != "nIOSHost" {
|
|
210
|
+
t.Fatalf("nodeID = %q, want nIOSHost", nodeID)
|
|
211
|
+
}
|
|
212
|
+
if provenance != "interactive" {
|
|
213
|
+
t.Fatalf("provenance = %q, want interactive", provenance)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// TestPeerNodeIDRejectsNilNode confirms a nil WhoIs or nil Node yields the
|
|
218
|
+
// empty-rejection tuple.
|
|
219
|
+
func TestPeerNodeIDRejectsNilNode(t *testing.T) {
|
|
220
|
+
for _, who := range []*apitype.WhoIsResponse{nil, {Node: nil}} {
|
|
221
|
+
nodeID, provenance, ok := peerNodeIDFromWhoIs(who)
|
|
222
|
+
if ok || nodeID != "" || provenance != "" {
|
|
223
|
+
t.Fatalf("nil node wrongly accepted: nodeID=%q provenance=%q ok=%t", nodeID, provenance, ok)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -21,6 +21,18 @@ const pairDropSmallFileMaxBodyBytes int64 = 10 * 1024 * 1024
|
|
|
21
21
|
const pairDropUploadChunkMaxBodyBytes int64 = 1024 * 1024
|
|
22
22
|
const peerNodeHeader = "X-Pairling-Peer-Node"
|
|
23
23
|
|
|
24
|
+
// peerProvenanceHeader tells pairlingd how connectd identified the peer node:
|
|
25
|
+
// "tagged" (the old minted tag:pairling-phone path) or "interactive" (an
|
|
26
|
+
// untagged, user-owned Pairling iOS node admitted via the D2 sign-in path).
|
|
27
|
+
// connectd deletes any inbound copy before re-injecting the resolver's value, so
|
|
28
|
+
// a client can never forge it.
|
|
29
|
+
const peerProvenanceHeader = "X-Pairling-Peer-Provenance"
|
|
30
|
+
|
|
31
|
+
const (
|
|
32
|
+
provenanceTagged = "tagged"
|
|
33
|
+
provenanceInteractive = "interactive"
|
|
34
|
+
)
|
|
35
|
+
|
|
24
36
|
// funnelOriginHeader marks a request that arrived over the public Funnel
|
|
25
37
|
// listener. connectd sets it only on the funnel handler and deletes any inbound
|
|
26
38
|
// copy first; every other handler deletes it, so a client can never forge it.
|
|
@@ -50,7 +62,7 @@ type Logger interface {
|
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
type PeerNodeResolver interface {
|
|
53
|
-
PeerNodeID(ctx context.Context, remoteAddr string) (string, bool)
|
|
65
|
+
PeerNodeID(ctx context.Context, remoteAddr string) (nodeID string, provenance string, ok bool)
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
type Event struct {
|
|
@@ -222,14 +234,16 @@ func (h *Handler) rewrite(r *httputil.ProxyRequest) {
|
|
|
222
234
|
r.Out.Host = h.upstream.Host
|
|
223
235
|
r.Out.Header.Del("X-Forwarded-For")
|
|
224
236
|
r.Out.Header.Del(peerNodeHeader)
|
|
237
|
+
r.Out.Header.Del(peerProvenanceHeader)
|
|
225
238
|
r.Out.Header.Del(funnelOriginHeader)
|
|
226
239
|
if h.mode == ExposureModeFunnelBootstrap {
|
|
227
240
|
r.Out.Header.Set(funnelOriginHeader, "1")
|
|
228
241
|
}
|
|
229
242
|
if h.peerNodeResolver != nil {
|
|
230
|
-
if nodeID, ok := h.peerNodeResolver.PeerNodeID(in.Context(), in.RemoteAddr); ok {
|
|
243
|
+
if nodeID, provenance, ok := h.peerNodeResolver.PeerNodeID(in.Context(), in.RemoteAddr); ok {
|
|
231
244
|
if nodeID = strings.TrimSpace(nodeID); nodeID != "" {
|
|
232
245
|
r.Out.Header.Set(peerNodeHeader, nodeID)
|
|
246
|
+
r.Out.Header.Set(peerProvenanceHeader, provenance)
|
|
233
247
|
}
|
|
234
248
|
}
|
|
235
249
|
}
|
|
@@ -21,9 +21,9 @@ type recordingLogger struct {
|
|
|
21
21
|
events []Event
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
type peerNodeResolverFunc func(context.Context, string) (string, bool)
|
|
24
|
+
type peerNodeResolverFunc func(context.Context, string) (string, string, bool)
|
|
25
25
|
|
|
26
|
-
func (f peerNodeResolverFunc) PeerNodeID(ctx context.Context, remoteAddr string) (string, bool) {
|
|
26
|
+
func (f peerNodeResolverFunc) PeerNodeID(ctx context.Context, remoteAddr string) (string, string, bool) {
|
|
27
27
|
return f(ctx, remoteAddr)
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -451,9 +451,11 @@ func TestPairlingConnectStripsForgedPeerNodeHeader(t *testing.T) {
|
|
|
451
451
|
|
|
452
452
|
func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
|
|
453
453
|
var forwardedHeader string
|
|
454
|
+
var forwardedProvenance string
|
|
454
455
|
var resolvedRemote string
|
|
455
456
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
456
457
|
forwardedHeader = r.Header.Get("X-Pairling-Peer-Node")
|
|
458
|
+
forwardedProvenance = r.Header.Get("X-Pairling-Peer-Provenance")
|
|
457
459
|
w.WriteHeader(http.StatusOK)
|
|
458
460
|
}))
|
|
459
461
|
defer upstream.Close()
|
|
@@ -465,9 +467,9 @@ func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
|
|
|
465
467
|
Upstream: upstreamURL,
|
|
466
468
|
MaxBodyBytes: 1024,
|
|
467
469
|
Mode: ExposureModePairlingConnect,
|
|
468
|
-
PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, remoteAddr string) (string, bool) {
|
|
470
|
+
PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, remoteAddr string) (string, string, bool) {
|
|
469
471
|
resolvedRemote = remoteAddr
|
|
470
|
-
return "nPeerCNTRL", true
|
|
472
|
+
return "nPeerCNTRL", "tagged", true
|
|
471
473
|
}),
|
|
472
474
|
})
|
|
473
475
|
if err != nil {
|
|
@@ -489,6 +491,103 @@ func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
|
|
|
489
491
|
if forwardedHeader != "nPeerCNTRL" {
|
|
490
492
|
t.Fatalf("peer-node header = %q, want trusted resolver value", forwardedHeader)
|
|
491
493
|
}
|
|
494
|
+
if forwardedProvenance != "tagged" {
|
|
495
|
+
t.Fatalf("peer-provenance header = %q, want tagged", forwardedProvenance)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// TestRewriteStripsForgedProvenanceAndReinjectsFromResolver proves the rewrite
|
|
500
|
+
// step deletes BOTH client-supplied X-Pairling-Peer-Node and
|
|
501
|
+
// X-Pairling-Peer-Provenance headers, then re-injects them from the resolver's
|
|
502
|
+
// trusted return values. A forged "tagged" provenance from the client must not
|
|
503
|
+
// survive when the resolver reports "interactive".
|
|
504
|
+
func TestRewriteStripsForgedProvenanceAndReinjectsFromResolver(t *testing.T) {
|
|
505
|
+
var forwardedHeader string
|
|
506
|
+
var forwardedProvenance string
|
|
507
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
508
|
+
forwardedHeader = r.Header.Get("X-Pairling-Peer-Node")
|
|
509
|
+
forwardedProvenance = r.Header.Get("X-Pairling-Peer-Provenance")
|
|
510
|
+
w.WriteHeader(http.StatusOK)
|
|
511
|
+
}))
|
|
512
|
+
defer upstream.Close()
|
|
513
|
+
upstreamURL, err := url.Parse(upstream.URL)
|
|
514
|
+
if err != nil {
|
|
515
|
+
t.Fatal(err)
|
|
516
|
+
}
|
|
517
|
+
handler, err := NewHandler(Options{
|
|
518
|
+
Upstream: upstreamURL,
|
|
519
|
+
MaxBodyBytes: 1024,
|
|
520
|
+
Mode: ExposureModePairlingConnect,
|
|
521
|
+
PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, _ string) (string, string, bool) {
|
|
522
|
+
return "nResolverIOS", "interactive", true
|
|
523
|
+
}),
|
|
524
|
+
})
|
|
525
|
+
if err != nil {
|
|
526
|
+
t.Fatal(err)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
|
|
530
|
+
req.RemoteAddr = "100.64.0.50:12345"
|
|
531
|
+
req.Header.Set("Authorization", "Bearer device-token")
|
|
532
|
+
req.Header.Set("X-Pairling-Peer-Node", "forged-node")
|
|
533
|
+
req.Header.Set("X-Pairling-Peer-Provenance", "tagged")
|
|
534
|
+
rec := httptest.NewRecorder()
|
|
535
|
+
handler.ServeHTTP(rec, req)
|
|
536
|
+
if rec.Code != http.StatusOK {
|
|
537
|
+
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
|
538
|
+
}
|
|
539
|
+
if forwardedHeader != "nResolverIOS" {
|
|
540
|
+
t.Fatalf("peer-node header = %q, want resolver value nResolverIOS", forwardedHeader)
|
|
541
|
+
}
|
|
542
|
+
if forwardedProvenance != "interactive" {
|
|
543
|
+
t.Fatalf("peer-provenance header = %q, want resolver value interactive", forwardedProvenance)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// TestRewriteInjectsNoHeadersWhenResolverRejects proves that when the resolver
|
|
548
|
+
// returns ok=false, NEITHER the peer-node NOR the peer-provenance header reaches
|
|
549
|
+
// the upstream, and any client-supplied copies are stripped.
|
|
550
|
+
func TestRewriteInjectsNoHeadersWhenResolverRejects(t *testing.T) {
|
|
551
|
+
var sawNode bool
|
|
552
|
+
var sawProvenance bool
|
|
553
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
554
|
+
_, sawNode = r.Header["X-Pairling-Peer-Node"]
|
|
555
|
+
_, sawProvenance = r.Header["X-Pairling-Peer-Provenance"]
|
|
556
|
+
w.WriteHeader(http.StatusOK)
|
|
557
|
+
}))
|
|
558
|
+
defer upstream.Close()
|
|
559
|
+
upstreamURL, err := url.Parse(upstream.URL)
|
|
560
|
+
if err != nil {
|
|
561
|
+
t.Fatal(err)
|
|
562
|
+
}
|
|
563
|
+
handler, err := NewHandler(Options{
|
|
564
|
+
Upstream: upstreamURL,
|
|
565
|
+
MaxBodyBytes: 1024,
|
|
566
|
+
Mode: ExposureModePairlingConnect,
|
|
567
|
+
PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, _ string) (string, string, bool) {
|
|
568
|
+
return "", "", false
|
|
569
|
+
}),
|
|
570
|
+
})
|
|
571
|
+
if err != nil {
|
|
572
|
+
t.Fatal(err)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
|
|
576
|
+
req.RemoteAddr = "100.64.0.50:12345"
|
|
577
|
+
req.Header.Set("Authorization", "Bearer device-token")
|
|
578
|
+
req.Header.Set("X-Pairling-Peer-Node", "forged-node")
|
|
579
|
+
req.Header.Set("X-Pairling-Peer-Provenance", "interactive")
|
|
580
|
+
rec := httptest.NewRecorder()
|
|
581
|
+
handler.ServeHTTP(rec, req)
|
|
582
|
+
if rec.Code != http.StatusOK {
|
|
583
|
+
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
|
584
|
+
}
|
|
585
|
+
if sawNode {
|
|
586
|
+
t.Fatal("peer-node header reached upstream when resolver rejected")
|
|
587
|
+
}
|
|
588
|
+
if sawProvenance {
|
|
589
|
+
t.Fatal("peer-provenance header reached upstream when resolver rejected")
|
|
590
|
+
}
|
|
492
591
|
}
|
|
493
592
|
|
|
494
593
|
func TestPairlingConnectModeMatchesEndpointContract(t *testing.T) {
|
package/payload-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"connectd": {
|
|
3
3
|
"darwin-arm64": {
|
|
4
|
-
"sha256": "
|
|
4
|
+
"sha256": "9b2ed8873ae42e33426590c19b6bbae9ab6e42c9a103f2978a9f4b3486f95986",
|
|
5
5
|
"team_id": "965AVD34A3"
|
|
6
6
|
},
|
|
7
7
|
"darwin-x64": {
|
|
8
|
-
"sha256": "
|
|
8
|
+
"sha256": "affe30386b854c5449b6a985ac678203945d756c3a4c418f62fea62d41b310db",
|
|
9
9
|
"team_id": "965AVD34A3"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"path": "payload/mac/SOURCE_REVISION",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "6d11e38b8ce99a27fd026588dec66a7bed33016085d28076bacd7c597e6611e9"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "payload/mac/VERSION",
|
|
27
|
-
"sha256": "
|
|
27
|
+
"sha256": "011cfdb24c667a75861adfffaabe68e66358d982f437b47270ef09d86c6c6cd7"
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"path": "payload/mac/companiond/app_attest_lan.py",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
"path": "payload/mac/companiond/pairlingd.py",
|
|
99
|
-
"sha256": "
|
|
99
|
+
"sha256": "297d1558ecd6bb4cdbadfe073e396564af723173e0cb7b4085ecc852fd889def"
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
"path": "payload/mac/companiond/providers/__init__.py",
|
|
@@ -200,11 +200,11 @@
|
|
|
200
200
|
},
|
|
201
201
|
{
|
|
202
202
|
"path": "payload/mac/connectd/cmd/pairling-connectd/main.go",
|
|
203
|
-
"sha256": "
|
|
203
|
+
"sha256": "019288bd16c6e900c60206ee4739f9ae5ed640ea73cd57db6a1f0d618c9bf00c"
|
|
204
204
|
},
|
|
205
205
|
{
|
|
206
206
|
"path": "payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go",
|
|
207
|
-
"sha256": "
|
|
207
|
+
"sha256": "eb1f44e588c2d70e2660f805ee6b7fe09aa793365c0ae442164785a8241c35d8"
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
"path": "payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go",
|
|
@@ -232,11 +232,11 @@
|
|
|
232
232
|
},
|
|
233
233
|
{
|
|
234
234
|
"path": "payload/mac/connectd/internal/gateway/proxy.go",
|
|
235
|
-
"sha256": "
|
|
235
|
+
"sha256": "b852408a35b71a0b62554cc93f413c3352034432f14529ab3ddbe85fa5ee49c8"
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
238
|
"path": "payload/mac/connectd/internal/gateway/proxy_test.go",
|
|
239
|
-
"sha256": "
|
|
239
|
+
"sha256": "f3a6b1974c1ccba8e5e380d18c31a5aca61458b6d4a1075865b8978e86e54209"
|
|
240
240
|
},
|
|
241
241
|
{
|
|
242
242
|
"path": "payload/mac/connectd/internal/runtime/config.go",
|
|
@@ -296,8 +296,8 @@
|
|
|
296
296
|
}
|
|
297
297
|
],
|
|
298
298
|
"package": "pairling",
|
|
299
|
-
"package_version": "0.2.
|
|
299
|
+
"package_version": "0.2.10",
|
|
300
300
|
"schema_version": 1,
|
|
301
301
|
"source_dirty": false,
|
|
302
|
-
"source_revision": "
|
|
302
|
+
"source_revision": "0d3be68"
|
|
303
303
|
}
|