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.
@@ -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) {