pairling 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +11 -9
  2. package/bin/pairling.mjs +5 -2
  3. package/package.json +3 -3
  4. package/payload/mac/SOURCE_BRANCH +1 -1
  5. package/payload/mac/SOURCE_REVISION +1 -1
  6. package/payload/mac/VERSION +1 -1
  7. package/payload/mac/companiond/pairling_connectd_status.py +57 -7
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +67 -20
  10. package/payload/mac/companiond/pairlingd.py +269 -16
  11. package/payload/mac/companiond/push_dispatcher.py +31 -1
  12. package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
  13. package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
  14. package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
  15. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
  16. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
  17. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
  18. package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
  19. package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
  20. package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
  21. package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
  22. package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
  23. package/payload/mac/connectd/internal/runtime/config.go +19 -0
  24. package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
  25. package/payload/mac/connectd/internal/status/status.go +67 -1
  26. package/payload/mac/connectd/internal/status/status_test.go +138 -0
  27. package/payload/mac/install/install-runtime.sh +299 -20
  28. package/payload/mac/install/render-launchd.py +54 -10
  29. package/payload-manifest.json +63 -21
@@ -1,8 +1,11 @@
1
1
  package gateway
2
2
 
3
3
  import (
4
+ "bytes"
5
+ "context"
4
6
  "encoding/json"
5
7
  "errors"
8
+ "io"
6
9
  "net"
7
10
  "net/http"
8
11
  "net/http/httputil"
@@ -16,6 +19,12 @@ const defaultMaxBodyBytes int64 = 1_000_000
16
19
  const prePairMaxBodyBytes int64 = 16 * 1024
17
20
  const pairDropSmallFileMaxBodyBytes int64 = 10 * 1024 * 1024
18
21
  const pairDropUploadChunkMaxBodyBytes int64 = 1024 * 1024
22
+ const peerNodeHeader = "X-Pairling-Peer-Node"
23
+
24
+ // funnelOriginHeader marks a request that arrived over the public Funnel
25
+ // listener. connectd sets it only on the funnel handler and deletes any inbound
26
+ // copy first; every other handler deletes it, so a client can never forge it.
27
+ const funnelOriginHeader = "X-Pairling-Funnel-Origin"
19
28
 
20
29
  // Chat attachment uploads (POST /upload) carry whole photos/short videos in
21
30
  // one shot — the 1MB default rejected most camera photos with 413.
@@ -27,6 +36,11 @@ const (
27
36
  ExposureModePostPair ExposureMode = "post_pair"
28
37
  ExposureModePrePair ExposureMode = "pre_pair"
29
38
  ExposureModePairlingConnect ExposureMode = "pairling_connect"
39
+ // ExposureModeFunnelBootstrap is the public Funnel surface. It is the most
40
+ // restrictive mode: only the minimal bootstrap claim plus health probes, with
41
+ // no bearer post-pair fallthrough. Used only by the separate ListenFunnel
42
+ // handler, never by the tailnet listener.
43
+ ExposureModeFunnelBootstrap ExposureMode = "funnel_bootstrap"
30
44
  )
31
45
 
32
46
  // Logger receives metadata-only gateway events. Event intentionally excludes
@@ -35,6 +49,10 @@ type Logger interface {
35
49
  Log(Event)
36
50
  }
37
51
 
52
+ type PeerNodeResolver interface {
53
+ PeerNodeID(ctx context.Context, remoteAddr string) (string, bool)
54
+ }
55
+
38
56
  type Event struct {
39
57
  Method string
40
58
  Path string
@@ -43,20 +61,31 @@ type Event struct {
43
61
  }
44
62
 
45
63
  type Options struct {
46
- Upstream *url.URL
47
- MaxBodyBytes int64
48
- Mode ExposureMode
49
- Logger Logger
50
- RateLimiter RateLimiter
64
+ Upstream *url.URL
65
+ MaxBodyBytes int64
66
+ Mode ExposureMode
67
+ Logger Logger
68
+ RateLimiter RateLimiter
69
+ PeerNodeResolver PeerNodeResolver
70
+ // FunnelMacIDHash, when set, is returned in the synthesized funnel-mode
71
+ // /health and /manifest responses so a phone can confirm it reached the Mac
72
+ // named in its QR, without the upstream's identity fields ever being exposed.
73
+ FunnelMacIDHash string
74
+ // FunnelLimiter, when set, owns identity-independent rate limiting on the
75
+ // funnel claim path. Used instead of RateLimiter for the funnel handler.
76
+ FunnelLimiter *FunnelLimiter
51
77
  }
52
78
 
53
79
  type Handler struct {
54
- upstream *url.URL
55
- maxBodyBytes int64
56
- mode ExposureMode
57
- logger Logger
58
- rateLimiter RateLimiter
59
- proxy *httputil.ReverseProxy
80
+ upstream *url.URL
81
+ maxBodyBytes int64
82
+ mode ExposureMode
83
+ logger Logger
84
+ rateLimiter RateLimiter
85
+ peerNodeResolver PeerNodeResolver
86
+ funnelMacIDHash string
87
+ funnelLimiter *FunnelLimiter
88
+ proxy *httputil.ReverseProxy
60
89
  }
61
90
 
62
91
  type RateLimiter interface {
@@ -84,16 +113,19 @@ func NewHandler(opts Options) (*Handler, error) {
84
113
  if mode == "" {
85
114
  mode = ExposureModePostPair
86
115
  }
87
- if mode != ExposureModePostPair && mode != ExposureModePrePair && mode != ExposureModePairlingConnect {
116
+ if mode != ExposureModePostPair && mode != ExposureModePrePair && mode != ExposureModePairlingConnect && mode != ExposureModeFunnelBootstrap {
88
117
  return nil, errors.New("unknown exposure mode")
89
118
  }
90
119
  upstream := *opts.Upstream
91
120
  h := &Handler{
92
- upstream: &upstream,
93
- maxBodyBytes: maxBody,
94
- mode: mode,
95
- logger: opts.Logger,
96
- rateLimiter: opts.RateLimiter,
121
+ upstream: &upstream,
122
+ maxBodyBytes: maxBody,
123
+ mode: mode,
124
+ logger: opts.Logger,
125
+ rateLimiter: opts.RateLimiter,
126
+ peerNodeResolver: opts.PeerNodeResolver,
127
+ funnelMacIDHash: opts.FunnelMacIDHash,
128
+ funnelLimiter: opts.FunnelLimiter,
97
129
  }
98
130
  h.proxy = &httputil.ReverseProxy{
99
131
  Rewrite: h.rewrite,
@@ -103,6 +135,26 @@ func NewHandler(opts Options) (*Handler, error) {
103
135
  return h, nil
104
136
  }
105
137
 
138
+ // isFunnelSynthesizedPath reports the funnel-mode GET paths connectd answers
139
+ // itself with a minimal body, so the upstream's identity, version, install
140
+ // path, and route topology never reach the public surface. /healthz shares the
141
+ // sensitive /health payload upstream, so it is synthesized too. /readyz is left
142
+ // to proxy because it is the warmup readiness probe and carries no identity.
143
+ func isFunnelSynthesizedPath(path string) bool {
144
+ return path == "/health" || path == "/healthz" || path == "/manifest"
145
+ }
146
+
147
+ func (h *Handler) writeFunnelHealth(w http.ResponseWriter, r *http.Request, path string) {
148
+ body := map[string]any{"ok": true}
149
+ if (path == "/health" || path == "/manifest") && h.funnelMacIDHash != "" {
150
+ body["mac_id_hash"] = h.funnelMacIDHash
151
+ }
152
+ w.Header().Set("Content-Type", "application/json")
153
+ w.WriteHeader(http.StatusOK)
154
+ _ = json.NewEncoder(w).Encode(body)
155
+ h.log(r, http.StatusOK, "funnel_synthesized")
156
+ }
157
+
106
158
  func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
107
159
  path := r.URL.EscapedPath()
108
160
  if path == "" {
@@ -120,6 +172,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
120
172
  h.reject(w, r, http.StatusNotFound, "path_not_allowed")
121
173
  return
122
174
  }
175
+ if h.mode == ExposureModeFunnelBootstrap && r.Method == http.MethodGet && isFunnelSynthesizedPath(path) {
176
+ h.writeFunnelHealth(w, r, path)
177
+ return
178
+ }
179
+ if h.funnelLimiter != nil && h.mode == ExposureModeFunnelBootstrap && r.Method == http.MethodPost && isPrePairClaimPath(path) {
180
+ body, err := io.ReadAll(io.LimitReader(r.Body, prePairMaxBodyBytes+1))
181
+ if err != nil || int64(len(body)) > prePairMaxBodyBytes {
182
+ h.reject(w, r, http.StatusRequestEntityTooLarge, "request_too_large")
183
+ return
184
+ }
185
+ release, ok := h.funnelLimiter.Acquire(extractPairID(body))
186
+ if !ok {
187
+ h.reject(w, r, http.StatusTooManyRequests, "rate_limited")
188
+ return
189
+ }
190
+ defer release()
191
+ r.Body = io.NopCloser(bytes.NewReader(body))
192
+ r.ContentLength = int64(len(body))
193
+ }
123
194
  if h.rateLimiter != nil && h.rateLimitPath(r.Method, path) && !h.rateLimiter.Allow(r.RemoteAddr, r.Method, path) {
124
195
  h.reject(w, r, http.StatusTooManyRequests, "rate_limited")
125
196
  return
@@ -150,10 +221,122 @@ func (h *Handler) rewrite(r *httputil.ProxyRequest) {
150
221
  }
151
222
  r.Out.Host = h.upstream.Host
152
223
  r.Out.Header.Del("X-Forwarded-For")
224
+ r.Out.Header.Del(peerNodeHeader)
225
+ r.Out.Header.Del(funnelOriginHeader)
226
+ if h.mode == ExposureModeFunnelBootstrap {
227
+ r.Out.Header.Set(funnelOriginHeader, "1")
228
+ }
229
+ if h.peerNodeResolver != nil {
230
+ if nodeID, ok := h.peerNodeResolver.PeerNodeID(in.Context(), in.RemoteAddr); ok {
231
+ if nodeID = strings.TrimSpace(nodeID); nodeID != "" {
232
+ r.Out.Header.Set(peerNodeHeader, nodeID)
233
+ }
234
+ }
235
+ }
153
236
  r.Out.Header.Set("X-Pairling-Connect-Gateway", "pairling-connectd")
154
237
  r.SetXForwarded()
155
238
  }
156
239
 
240
+ // FunnelLimiter owns identity-independent rate limiting for the public Funnel
241
+ // claim path. The real client IP is unrecoverable over tsnet.ListenFunnel, so
242
+ // none of these limits depend on it: a global per-minute ceiling (a circuit
243
+ // breaker), a per-pair_id cap (matching the 5-attempt lockout, so a victim whose
244
+ // pair_id is unknown to an attacker is unaffected), and an in-flight ECDH
245
+ // concurrency cap (so a pair_id spray cannot force unbounded P-256/HKDF work).
246
+ type FunnelLimiter struct {
247
+ mu sync.Mutex
248
+ now func() time.Time
249
+ window time.Duration
250
+ globalLimit int
251
+ perPairLimit int
252
+ globalHits []time.Time
253
+ perPair map[string][]time.Time
254
+ ecdhSem chan struct{}
255
+ }
256
+
257
+ func NewFunnelLimiter(globalPerMinute, perPairMax, ecdhConcurrency int) *FunnelLimiter {
258
+ if globalPerMinute <= 0 {
259
+ globalPerMinute = 120
260
+ }
261
+ if perPairMax <= 0 {
262
+ perPairMax = 5
263
+ }
264
+ if ecdhConcurrency <= 0 {
265
+ ecdhConcurrency = 6
266
+ }
267
+ return &FunnelLimiter{
268
+ now: time.Now,
269
+ window: time.Minute,
270
+ globalLimit: globalPerMinute,
271
+ perPairLimit: perPairMax,
272
+ perPair: map[string][]time.Time{},
273
+ ecdhSem: make(chan struct{}, ecdhConcurrency),
274
+ }
275
+ }
276
+
277
+ // Acquire enforces the three caps and acquires an ECDH slot. It returns a
278
+ // release func that frees the slot when the claim finishes. ok is false when any
279
+ // budget is exhausted, in which case nothing is held.
280
+ func (l *FunnelLimiter) Acquire(pairID string) (release func(), ok bool) {
281
+ if l == nil {
282
+ return func() {}, true
283
+ }
284
+ select {
285
+ case l.ecdhSem <- struct{}{}:
286
+ default:
287
+ return nil, false
288
+ }
289
+ l.mu.Lock()
290
+ now := l.now()
291
+ cutoff := now.Add(-l.window)
292
+ l.globalHits = pruneTimes(l.globalHits, cutoff)
293
+ ph := pruneTimes(l.perPair[pairID], cutoff)
294
+ if len(l.globalHits) >= l.globalLimit || len(ph) >= l.perPairLimit {
295
+ l.perPair[pairID] = ph
296
+ l.mu.Unlock()
297
+ <-l.ecdhSem
298
+ return nil, false
299
+ }
300
+ l.globalHits = append(l.globalHits, now)
301
+ l.perPair[pairID] = append(ph, now)
302
+ if len(l.perPair) > 4096 {
303
+ for k, v := range l.perPair {
304
+ stale := true
305
+ for _, t := range v {
306
+ if t.After(cutoff) {
307
+ stale = false
308
+ break
309
+ }
310
+ }
311
+ if stale {
312
+ delete(l.perPair, k)
313
+ }
314
+ }
315
+ }
316
+ l.mu.Unlock()
317
+ return func() { <-l.ecdhSem }, true
318
+ }
319
+
320
+ func pruneTimes(times []time.Time, cutoff time.Time) []time.Time {
321
+ kept := times[:0]
322
+ for _, t := range times {
323
+ if t.After(cutoff) {
324
+ kept = append(kept, t)
325
+ }
326
+ }
327
+ return kept
328
+ }
329
+
330
+ func extractPairID(body []byte) string {
331
+ var obj struct {
332
+ PairID string `json:"pair_id"`
333
+ }
334
+ if json.Unmarshal(body, &obj) != nil {
335
+ return ""
336
+ }
337
+ return obj.PairID
338
+ }
339
+
157
340
  func (h *Handler) proxyError(w http.ResponseWriter, r *http.Request, err error) {
158
341
  h.reject(w, r, http.StatusBadGateway, "upstream_error")
159
342
  }
@@ -204,6 +387,8 @@ func (h *Handler) allowed(method, path string, header http.Header) bool {
204
387
  switch h.mode {
205
388
  case ExposureModePrePair:
206
389
  return prePairAllowed(method, path)
390
+ case ExposureModeFunnelBootstrap:
391
+ return funnelBootstrapAllowed(method, path)
207
392
  case ExposureModePairlingConnect:
208
393
  if path == "/pair/start" {
209
394
  return false
@@ -221,6 +406,8 @@ func (h *Handler) allowedForAnyMethod(path string, header http.Header) bool {
221
406
  switch h.mode {
222
407
  case ExposureModePrePair:
223
408
  return prePairAllowed(http.MethodGet, path) || prePairAllowed(http.MethodPost, path)
409
+ case ExposureModeFunnelBootstrap:
410
+ return funnelBootstrapAllowed(http.MethodGet, path) || funnelBootstrapAllowed(http.MethodPost, path)
224
411
  case ExposureModePairlingConnect:
225
412
  if path == "/pair/start" {
226
413
  return true
@@ -235,7 +422,7 @@ func (h *Handler) allowedForAnyMethod(path string, header http.Header) bool {
235
422
  }
236
423
 
237
424
  func (h *Handler) requestBodyLimit(method, path string) int64 {
238
- if method == http.MethodPost && isPrePairClaimPath(path) && (h.mode == ExposureModePrePair || h.mode == ExposureModePairlingConnect) {
425
+ if method == http.MethodPost && isPrePairClaimPath(path) && (h.mode == ExposureModePrePair || h.mode == ExposureModePairlingConnect || h.mode == ExposureModeFunnelBootstrap) {
239
426
  if h.maxBodyBytes <= 0 || prePairMaxBodyBytes < h.maxBodyBytes {
240
427
  return prePairMaxBodyBytes
241
428
  }
@@ -259,7 +446,7 @@ func (h *Handler) requestBodyLimit(method, path string) int64 {
259
446
  }
260
447
 
261
448
  func (h *Handler) rateLimitPath(method, path string) bool {
262
- return method == http.MethodPost && isPrePairClaimPath(path) && (h.mode == ExposureModePrePair || h.mode == ExposureModePairlingConnect)
449
+ return method == http.MethodPost && isPrePairClaimPath(path) && (h.mode == ExposureModePrePair || h.mode == ExposureModePairlingConnect || h.mode == ExposureModeFunnelBootstrap)
263
450
  }
264
451
 
265
452
  func prePairAllowed(method, path string) bool {
@@ -273,6 +460,33 @@ func prePairAllowed(method, path string) bool {
273
460
  }
274
461
  }
275
462
 
463
+ // funnelBootstrapAllowed is the public Funnel surface: the most restrictive mode.
464
+ // It is a strict subset of the pre-pair set, declared explicitly so it can never
465
+ // inherit a widening of prePairGetPaths/prePairPostPaths. It excludes /routez
466
+ // (route-topology leak), /pair/claim (legacy plaintext), /pair/start, and the
467
+ // reauth paths. There is no bearer post-pair fallthrough.
468
+ func funnelBootstrapAllowed(method, path string) bool {
469
+ switch method {
470
+ case http.MethodGet:
471
+ return funnelBootstrapGetPaths[path]
472
+ case http.MethodPost:
473
+ return funnelBootstrapPostPaths[path]
474
+ default:
475
+ return false
476
+ }
477
+ }
478
+
479
+ var funnelBootstrapGetPaths = map[string]bool{
480
+ "/health": true,
481
+ "/healthz": true,
482
+ "/readyz": true,
483
+ "/manifest": true,
484
+ }
485
+
486
+ var funnelBootstrapPostPaths = map[string]bool{
487
+ "/pair/psk-claim": true,
488
+ }
489
+
276
490
  func isPrePairClaimPath(path string) bool {
277
491
  return path == "/pair/claim" || path == "/pair/psk-claim"
278
492
  }
@@ -1,6 +1,7 @@
1
1
  package gateway
2
2
 
3
3
  import (
4
+ "context"
4
5
  "encoding/json"
5
6
  "fmt"
6
7
  "io"
@@ -20,6 +21,12 @@ type recordingLogger struct {
20
21
  events []Event
21
22
  }
22
23
 
24
+ type peerNodeResolverFunc func(context.Context, string) (string, bool)
25
+
26
+ func (f peerNodeResolverFunc) PeerNodeID(ctx context.Context, remoteAddr string) (string, bool) {
27
+ return f(ctx, remoteAddr)
28
+ }
29
+
23
30
  func (l *recordingLogger) Log(event Event) {
24
31
  l.mu.Lock()
25
32
  defer l.mu.Unlock()
@@ -420,6 +427,70 @@ func TestPairlingConnectModeRequiresBearerForPostPairEndpointsAndRejectsRemotePa
420
427
  }
421
428
  }
422
429
 
430
+ func TestPairlingConnectStripsForgedPeerNodeHeader(t *testing.T) {
431
+ var forwardedHeader string
432
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
433
+ forwardedHeader = r.Header.Get("X-Pairling-Peer-Node")
434
+ w.WriteHeader(http.StatusOK)
435
+ }))
436
+ defer upstream.Close()
437
+ handler := newTestHandlerWithMode(t, upstream.URL, 1024, nil, ExposureModePairlingConnect, nil)
438
+
439
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{"tailnet_node_id":"forged-body"}`))
440
+ req.Header.Set("Authorization", "Bearer device-token")
441
+ req.Header.Set("X-Pairling-Peer-Node", "forged-header")
442
+ rec := httptest.NewRecorder()
443
+ handler.ServeHTTP(rec, req)
444
+ if rec.Code != http.StatusOK {
445
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
446
+ }
447
+ if forwardedHeader != "" {
448
+ t.Fatalf("forged peer-node header forwarded to upstream: %q", forwardedHeader)
449
+ }
450
+ }
451
+
452
+ func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
453
+ var forwardedHeader string
454
+ var resolvedRemote string
455
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
456
+ forwardedHeader = r.Header.Get("X-Pairling-Peer-Node")
457
+ w.WriteHeader(http.StatusOK)
458
+ }))
459
+ defer upstream.Close()
460
+ upstreamURL, err := url.Parse(upstream.URL)
461
+ if err != nil {
462
+ t.Fatal(err)
463
+ }
464
+ handler, err := NewHandler(Options{
465
+ Upstream: upstreamURL,
466
+ MaxBodyBytes: 1024,
467
+ Mode: ExposureModePairlingConnect,
468
+ PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, remoteAddr string) (string, bool) {
469
+ resolvedRemote = remoteAddr
470
+ return "nPeerCNTRL", true
471
+ }),
472
+ })
473
+ if err != nil {
474
+ t.Fatal(err)
475
+ }
476
+
477
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
478
+ req.RemoteAddr = "100.64.0.50:12345"
479
+ req.Header.Set("Authorization", "Bearer device-token")
480
+ req.Header.Set("X-Pairling-Peer-Node", "forged-header")
481
+ rec := httptest.NewRecorder()
482
+ handler.ServeHTTP(rec, req)
483
+ if rec.Code != http.StatusOK {
484
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
485
+ }
486
+ if resolvedRemote != "100.64.0.50:12345" {
487
+ t.Fatalf("resolver remote addr = %q", resolvedRemote)
488
+ }
489
+ if forwardedHeader != "nPeerCNTRL" {
490
+ t.Fatalf("peer-node header = %q, want trusted resolver value", forwardedHeader)
491
+ }
492
+ }
493
+
423
494
  func TestPairlingConnectModeMatchesEndpointContract(t *testing.T) {
424
495
  contract := loadEndpointContractForTesting(t)
425
496
  var forwarded []string
@@ -56,6 +56,25 @@ func HostnameFromInstallID(installID string) string {
56
56
  return "pairling-" + slug
57
57
  }
58
58
 
59
+ // StableHostname returns a tailnet hostname that survives a regenerated
60
+ // install_id. It persists the first computed hostname under the state directory
61
+ // and reuses it thereafter, so a reinstall that only changes install_id does not
62
+ // change the *.ts.net name, which would otherwise reset the Funnel certificate.
63
+ // If the state directory is wiped, a fresh node and name are created regardless.
64
+ func StableHostname(appSupportRoot, stateDir string) string {
65
+ path := filepath.Join(stateDir, "hostname")
66
+ if data, err := os.ReadFile(path); err == nil {
67
+ if name := strings.TrimSpace(string(data)); name != "" {
68
+ return name
69
+ }
70
+ }
71
+ name := HostnameFromInstallID(LoadInstallID(appSupportRoot))
72
+ if err := os.MkdirAll(stateDir, 0o700); err == nil {
73
+ _ = os.WriteFile(path, []byte(name+"\n"), 0o600)
74
+ }
75
+ return name
76
+ }
77
+
59
78
  func loadInstallIDCandidate(path string) string {
60
79
  data, err := os.ReadFile(path)
61
80
  if err != nil {
@@ -1,6 +1,7 @@
1
1
  package runtime
2
2
 
3
3
  import (
4
+ "os"
4
5
  "path/filepath"
5
6
  "testing"
6
7
  )
@@ -27,3 +28,27 @@ func TestHostnameFromInstallIDFallsBackWhenInstallIDMissing(t *testing.T) {
27
28
  t.Fatalf("hostname fallback is empty: %q", got)
28
29
  }
29
30
  }
31
+
32
+ func TestStableHostnamePersistsAcrossInstallIDChange(t *testing.T) {
33
+ dir := t.TempDir()
34
+ appSupport := filepath.Join(dir, "appsupport")
35
+ stateDir := filepath.Join(dir, "state")
36
+ if err := os.MkdirAll(appSupport, 0o700); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ if err := os.WriteFile(filepath.Join(appSupport, "config.json"), []byte(`{"install_id":"inst_first123"}`), 0o600); err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ first := StableHostname(appSupport, stateDir)
43
+ if first == "" || first == "pairling-" {
44
+ t.Fatalf("first hostname empty: %q", first)
45
+ }
46
+ // Simulated reinstall: install_id changes, but the persisted hostname must win.
47
+ if err := os.WriteFile(filepath.Join(appSupport, "config.json"), []byte(`{"install_id":"inst_second999"}`), 0o600); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ second := StableHostname(appSupport, stateDir)
51
+ if second != first {
52
+ t.Fatalf("hostname changed after install_id change: %q -> %q", first, second)
53
+ }
54
+ }
@@ -20,7 +20,9 @@ const (
20
20
  RouteIDPairlingConnect = "pairling-connect-tailnet"
21
21
  RouteSourceConnectd = "pairling_connectd"
22
22
  RouteKindTailnet = "tailnet"
23
+ RouteKindFunnel = "funnel"
23
24
  RouteStatusReady = "ready"
25
+ RouteIDFunnel = "pairling-connect-funnel"
24
26
  )
25
27
 
26
28
  type AdvertisedRoute struct {
@@ -39,7 +41,12 @@ type Snapshot struct {
39
41
  SchemaVersion int `json:"schema_version"`
40
42
  AuthState string `json:"auth_state"`
41
43
  Hostname string `json:"hostname"`
44
+ FunnelHostname string `json:"funnel_hostname,omitempty"`
42
45
  TailnetIP string `json:"tailnet_ip,omitempty"`
46
+ TailnetNodeID string `json:"tailnet_node_id,omitempty"`
47
+ Tags []string `json:"tags,omitempty"`
48
+ TailnetIPs []string `json:"tailnet_ips,omitempty"`
49
+ TailnetLockEnabled *bool `json:"tailnet_lock_enabled,omitempty"`
43
50
  TailnetIPCount int `json:"tailnet_ip_count"`
44
51
  AuthURLPresent bool `json:"auth_url_present"`
45
52
  ControlURLMode string `json:"control_url_mode"`
@@ -115,6 +122,15 @@ func (s *Store) SetConnectdVersion(version string) {
115
122
  })
116
123
  }
117
124
 
125
+ // SetFunnelHostname records the public *.ts.net hostname of the Funnel listener.
126
+ // Empty when Funnel is disabled, which keeps the snapshot and advertised routes
127
+ // byte-identical to the no-funnel build.
128
+ func (s *Store) SetFunnelHostname(host string) {
129
+ s.update(func(snapshot *Snapshot) {
130
+ snapshot.FunnelHostname = strings.TrimSpace(host)
131
+ })
132
+ }
133
+
118
134
  func (s *Store) SetAuthPending(message string) {
119
135
  s.update(func(snapshot *Snapshot) {
120
136
  snapshot.AuthState = "pending"
@@ -144,6 +160,20 @@ func (s *Store) SetTailnetIP(ip string) {
144
160
  })
145
161
  }
146
162
 
163
+ func (s *Store) SetTailnetIdentity(nodeID string, tags, ips []string) {
164
+ s.update(func(snapshot *Snapshot) {
165
+ snapshot.TailnetNodeID = sanitizeIdentityValue(nodeID)
166
+ snapshot.Tags = sanitizeIdentityValues(tags)
167
+ snapshot.TailnetIPs = sanitizeIdentityValues(ips)
168
+ })
169
+ }
170
+
171
+ func (s *Store) SetTailnetLockEnabled(enabled bool) {
172
+ s.update(func(snapshot *Snapshot) {
173
+ snapshot.TailnetLockEnabled = &enabled
174
+ })
175
+ }
176
+
147
177
  func (s *Store) SetUpstreamReachable(reachable bool) {
148
178
  s.update(func(snapshot *Snapshot) {
149
179
  snapshot.UpstreamReachable = reachable
@@ -229,7 +259,7 @@ func advertisedRoutes(snapshot Snapshot) []AdvertisedRoute {
229
259
  if port <= 0 {
230
260
  port = DefaultListenPort
231
261
  }
232
- return []AdvertisedRoute{{
262
+ routes := []AdvertisedRoute{{
233
263
  ID: RouteIDPairlingConnect,
234
264
  Kind: RouteKindTailnet,
235
265
  Source: RouteSourceConnectd,
@@ -239,6 +269,22 @@ func advertisedRoutes(snapshot Snapshot) []AdvertisedRoute {
239
269
  Port: port,
240
270
  Status: RouteStatusReady,
241
271
  }}
272
+ // Additive funnel route, lowest priority so it is used only for the off-LAN
273
+ // bootstrap and dropped once the tailnet route is reachable. Present only when
274
+ // Funnel is enabled (a hostname is set); the health gate above already holds.
275
+ if host := strings.TrimSpace(snapshot.FunnelHostname); host != "" {
276
+ routes = append(routes, AdvertisedRoute{
277
+ ID: RouteIDFunnel,
278
+ Kind: RouteKindFunnel,
279
+ Source: RouteSourceConnectd,
280
+ Priority: 10,
281
+ BaseURL: "https://" + host,
282
+ Host: host,
283
+ Port: 443,
284
+ Status: RouteStatusReady,
285
+ })
286
+ }
287
+ return routes
242
288
  }
243
289
 
244
290
  func gatewayEventIsFailure(path string, status int, outcome string) bool {
@@ -269,6 +315,26 @@ var secretPattern = regexp.MustCompile(`(?i)(sk-[A-Za-z0-9._-]+|[A-Za-z0-9._-]*s
269
315
  var authURLPattern = regexp.MustCompile(`https://login\.tailscale\.com/a/[A-Za-z0-9._~!$&'()*+,;=:@%/?-]+`)
270
316
  var bearerPattern = regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._~+/=-]+`)
271
317
  var tailscaleAuthKeyPattern = regexp.MustCompile(`(?i)tskey-[A-Za-z0-9._-]+`)
318
+ var identitySecretPattern = regexp.MustCompile(`(?i)(authkey|nlprivate)`)
319
+
320
+ func sanitizeIdentityValues(values []string) []string {
321
+ if len(values) == 0 {
322
+ return nil
323
+ }
324
+ cleaned := make([]string, 0, len(values))
325
+ for _, value := range values {
326
+ cleaned = append(cleaned, sanitizeIdentityValue(value))
327
+ }
328
+ return cleaned
329
+ }
330
+
331
+ func sanitizeIdentityValue(value string) string {
332
+ value = strings.TrimSpace(value)
333
+ if identitySecretPattern.MatchString(value) {
334
+ return "[redacted]"
335
+ }
336
+ return redact(value)
337
+ }
272
338
 
273
339
  func extractAuthURL(value string) (string, bool) {
274
340
  raw := authURLPattern.FindString(value)