pairling 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/bin/pairling.mjs +5 -2
- package/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairling_connectd_status.py +57 -7
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +67 -20
- package/payload/mac/companiond/pairlingd.py +269 -16
- package/payload/mac/companiond/push_dispatcher.py +31 -1
- package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
- package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
- package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
- package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
- package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
- package/payload/mac/connectd/internal/runtime/config.go +19 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
- package/payload/mac/connectd/internal/status/status.go +67 -1
- package/payload/mac/connectd/internal/status/status_test.go +138 -0
- package/payload/mac/install/install-runtime.sh +299 -20
- package/payload/mac/install/render-launchd.py +54 -10
- package/payload-manifest.json +62 -20
|
@@ -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
|
|
47
|
-
MaxBodyBytes
|
|
48
|
-
Mode
|
|
49
|
-
Logger
|
|
50
|
-
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
|
|
55
|
-
maxBodyBytes
|
|
56
|
-
mode
|
|
57
|
-
logger
|
|
58
|
-
rateLimiter
|
|
59
|
-
|
|
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:
|
|
93
|
-
maxBodyBytes:
|
|
94
|
-
mode:
|
|
95
|
-
logger:
|
|
96
|
-
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
|
-
|
|
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)
|