pairling 0.2.8 → 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/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairlingd.py +81 -2
- 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.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.
|
|
44
|
-
"@pairling/runtime-darwin-x64": "0.2.
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.2.9",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.2.9"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
c97bbab
|
package/payload/mac/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.9
|
|
@@ -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:
|
|
@@ -7397,6 +7445,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7397
7445
|
self.send_error(405, "POST required")
|
|
7398
7446
|
return
|
|
7399
7447
|
|
|
7448
|
+
proof_verified = False
|
|
7400
7449
|
if (
|
|
7401
7450
|
self.pairling_auth is not None
|
|
7402
7451
|
and _requires_request_proof(u.path, self.command)
|
|
@@ -7432,9 +7481,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7432
7481
|
},
|
|
7433
7482
|
}, status=proof_result.status)
|
|
7434
7483
|
return
|
|
7484
|
+
proof_verified = True
|
|
7435
7485
|
|
|
7436
7486
|
if self.pairling_auth is not None:
|
|
7437
|
-
_maybe_persist_tailnet_node_id(
|
|
7487
|
+
_maybe_persist_tailnet_node_id(
|
|
7488
|
+
self.headers,
|
|
7489
|
+
self.client_address,
|
|
7490
|
+
self.pairling_auth,
|
|
7491
|
+
proof_verified=proof_verified,
|
|
7492
|
+
)
|
|
7438
7493
|
|
|
7439
7494
|
if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
|
|
7440
7495
|
max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
|
|
@@ -7490,6 +7545,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7490
7545
|
self._handle_pair_revoke(q)
|
|
7491
7546
|
elif u.path == "/pair/rotate-token":
|
|
7492
7547
|
self._handle_pair_rotate_token(q)
|
|
7548
|
+
elif u.path == "/pair/bind-node":
|
|
7549
|
+
self._handle_pair_bind_node(q)
|
|
7493
7550
|
elif u.path == "/healthz":
|
|
7494
7551
|
self._handle_healthz(q)
|
|
7495
7552
|
elif u.path == "/power-state":
|
|
@@ -8430,6 +8487,28 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8430
8487
|
return
|
|
8431
8488
|
self._send_json({"ok": True, "device_id": device_id, "token": token})
|
|
8432
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
|
+
|
|
8433
8512
|
def _handle_power_state(self, q):
|
|
8434
8513
|
self._send_json(_cached_health_payload(
|
|
8435
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
|
-
|
|
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": "3ee070f12619390609a8c76e0ae4803ab1bc48c9f77361b8f27a4125846f38e3",
|
|
5
5
|
"team_id": "965AVD34A3"
|
|
6
6
|
},
|
|
7
7
|
"darwin-x64": {
|
|
8
|
-
"sha256": "
|
|
8
|
+
"sha256": "c1ee211c3da71200d7855c9246ea04cd25fda702f7fb33a366b4d51bdaf9e230",
|
|
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": "29c2c816c529155cc86f26d44a8e8d79fc170fc26ade74fac3780554b5c864ff"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "payload/mac/VERSION",
|
|
27
|
-
"sha256": "
|
|
27
|
+
"sha256": "2f2b333be8858cb384114251b668bb1c9e45a0bb8393600b7ad4093d0e8e72f6"
|
|
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": "ca1c5551dd235990d4ff8d8b9c3c3f25e912ca9c22c7003f50c9fe1ccb6bf02e"
|
|
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.9",
|
|
300
300
|
"schema_version": 1,
|
|
301
301
|
"source_dirty": false,
|
|
302
|
-
"source_revision": "
|
|
302
|
+
"source_revision": "c97bbab"
|
|
303
303
|
}
|