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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package gateway
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"net/url"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// TestAdversarialPairlingConnectBypass is an independent skeptic test verifying
|
|
13
|
+
// that ExposureModePairlingConnect denies credential/admin/non-pre-pair paths and
|
|
14
|
+
// path-trick variants before reaching the upstream.
|
|
15
|
+
func TestAdversarialPairlingConnectBypass(t *testing.T) {
|
|
16
|
+
var forwarded []string
|
|
17
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
18
|
+
forwarded = append(forwarded, r.Method+" "+r.URL.Path+"?raw="+r.URL.RawPath)
|
|
19
|
+
w.WriteHeader(http.StatusOK)
|
|
20
|
+
}))
|
|
21
|
+
defer upstream.Close()
|
|
22
|
+
upstreamURL, _ := url.Parse(upstream.URL)
|
|
23
|
+
handler, err := NewHandler(Options{
|
|
24
|
+
Upstream: upstreamURL,
|
|
25
|
+
MaxBodyBytes: 4096,
|
|
26
|
+
Mode: ExposureModePairlingConnect,
|
|
27
|
+
})
|
|
28
|
+
if err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Each case: method, raw target (as sent on the wire), whether we expect it
|
|
33
|
+
// to reach the upstream (forwarded=true) WITHOUT a bearer token.
|
|
34
|
+
cases := []struct {
|
|
35
|
+
name string
|
|
36
|
+
method string
|
|
37
|
+
target string
|
|
38
|
+
wantForward bool
|
|
39
|
+
}{
|
|
40
|
+
// Mint / admin / non-pre-pair — must be denied unauthenticated
|
|
41
|
+
{"mint plain", "POST", "/mint", false},
|
|
42
|
+
{"mint-token", "POST", "/mint-token", false},
|
|
43
|
+
{"pair/start", "POST", "/pair/start", false},
|
|
44
|
+
{"pair/revoke", "POST", "/pair/revoke", false},
|
|
45
|
+
{"pair/rotate-token", "POST", "/pair/rotate-token", false},
|
|
46
|
+
{"spawn-session", "POST", "/spawn-session", false},
|
|
47
|
+
{"internal arbitrary", "GET", "/internal/secrets", false},
|
|
48
|
+
{"send-text no bearer", "POST", "/send-text", false},
|
|
49
|
+
{"sessions no bearer", "GET", "/sessions", false},
|
|
50
|
+
|
|
51
|
+
// Path tricks aiming to smuggle /pair/start or /mint past the allowlist
|
|
52
|
+
// while decoding to a dangerous path upstream.
|
|
53
|
+
{"encoded slash pair start", "POST", "/pair%2Fstart", false},
|
|
54
|
+
{"encoded mint", "POST", "/%6dint", false}, // %6d = 'm'
|
|
55
|
+
{"case pair start", "POST", "/PAIR/START", false},
|
|
56
|
+
{"case mint", "POST", "/MINT", false},
|
|
57
|
+
{"double slash pair start", "POST", "//pair/start", false},
|
|
58
|
+
{"double slash send-text", "POST", "//send-text", false},
|
|
59
|
+
{"trailing slash send-text", "POST", "/send-text/", false},
|
|
60
|
+
{"dotdot traversal to start", "POST", "/pair/claim/../start", false},
|
|
61
|
+
{"dotdot to send-text", "POST", "/healthz/../send-text", false},
|
|
62
|
+
{"encoded dotdot", "POST", "/pair/claim/%2e%2e/start", false},
|
|
63
|
+
{"healthz encoded", "GET", "/health%7a", false}, // %7a = 'z' -> /healthz only if decoded for match
|
|
64
|
+
{"semicolon param", "GET", "/healthz;/../send-text", false},
|
|
65
|
+
{"null byte", "POST", "/send-text%00", false},
|
|
66
|
+
{"trailing dot", "POST", "/send-text.", false},
|
|
67
|
+
|
|
68
|
+
// Allowed pre-pair paths — these SHOULD forward unauthenticated (sanity)
|
|
69
|
+
{"healthz allowed", "GET", "/healthz", true},
|
|
70
|
+
{"health allowed", "GET", "/health", true},
|
|
71
|
+
{"readyz allowed", "GET", "/readyz", true},
|
|
72
|
+
{"routez allowed", "GET", "/routez", true},
|
|
73
|
+
{"manifest allowed", "GET", "/manifest", true},
|
|
74
|
+
{"pair claim allowed", "POST", "/pair/claim", true},
|
|
75
|
+
{"pair psk-claim allowed", "POST", "/pair/psk-claim", true},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for _, tc := range cases {
|
|
79
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
80
|
+
before := len(forwarded)
|
|
81
|
+
rec := httptest.NewRecorder()
|
|
82
|
+
req := httptest.NewRequest(tc.method, "http://pc.local"+tc.target, strings.NewReader("{}"))
|
|
83
|
+
handler.ServeHTTP(rec, req)
|
|
84
|
+
didForward := len(forwarded) > before
|
|
85
|
+
if didForward != tc.wantForward {
|
|
86
|
+
t.Errorf("%s %s: forwarded=%v want=%v (status=%d)", tc.method, tc.target, didForward, tc.wantForward, rec.Code)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
t.Logf("paths that reached upstream: %+v", forwarded)
|
|
92
|
+
// Hard assertion: nothing dangerous must ever appear in the forwarded list.
|
|
93
|
+
joined := fmt.Sprintf("%+v", forwarded)
|
|
94
|
+
for _, danger := range []string{"start", "mint", "spawn", "internal", "revoke", "rotate"} {
|
|
95
|
+
if strings.Contains(strings.ToLower(joined), danger) {
|
|
96
|
+
t.Fatalf("DANGEROUS path reached upstream containing %q: %s", danger, joined)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
package gateway
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"net/url"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// TestFunnelBootstrapAllowlist verifies the public Funnel surface forwards only
|
|
12
|
+
// the minimal bootstrap set and default-denies everything else, including any
|
|
13
|
+
// bearer-authenticated post-pair path. The bearer fallthrough that
|
|
14
|
+
// ExposureModePairlingConnect allows must be structurally unreachable here.
|
|
15
|
+
func TestFunnelBootstrapAllowlist(t *testing.T) {
|
|
16
|
+
forwarded := map[string]bool{}
|
|
17
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
18
|
+
forwarded[r.Method+" "+r.URL.Path] = true
|
|
19
|
+
w.WriteHeader(http.StatusOK)
|
|
20
|
+
}))
|
|
21
|
+
defer upstream.Close()
|
|
22
|
+
upstreamURL, _ := url.Parse(upstream.URL)
|
|
23
|
+
|
|
24
|
+
handler, err := NewHandler(Options{
|
|
25
|
+
Upstream: upstreamURL,
|
|
26
|
+
MaxBodyBytes: 4096,
|
|
27
|
+
Mode: ExposureModeFunnelBootstrap,
|
|
28
|
+
})
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type tc struct {
|
|
34
|
+
method string
|
|
35
|
+
path string
|
|
36
|
+
bearer bool
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
allowed := []tc{
|
|
40
|
+
{http.MethodGet, "/health", false},
|
|
41
|
+
{http.MethodGet, "/healthz", false},
|
|
42
|
+
{http.MethodGet, "/readyz", false},
|
|
43
|
+
{http.MethodGet, "/manifest", false},
|
|
44
|
+
{http.MethodPost, "/pair/psk-claim", false},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Every one of these must be denied, even with a valid-looking bearer, since
|
|
48
|
+
// the funnel mode has no bearer post-pair fallthrough.
|
|
49
|
+
denied := []tc{
|
|
50
|
+
{http.MethodPost, "/pair/start", false},
|
|
51
|
+
{http.MethodPost, "/pair/claim", false},
|
|
52
|
+
{http.MethodPost, "/pair/reauth-challenge", false},
|
|
53
|
+
{http.MethodPost, "/pair/reauth-claim", false},
|
|
54
|
+
{http.MethodGet, "/routez", false},
|
|
55
|
+
{http.MethodPost, "/internal/session-register", true},
|
|
56
|
+
{http.MethodPost, "/spawn-session", true},
|
|
57
|
+
{http.MethodPost, "/send-text", true},
|
|
58
|
+
{http.MethodGet, "/sessions", true},
|
|
59
|
+
{http.MethodPost, "/pairling-tools/run", true},
|
|
60
|
+
{http.MethodPost, "/pair/revoke", true},
|
|
61
|
+
{http.MethodPost, "/pair/rotate-token", true},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
send := func(c tc) *httptest.ResponseRecorder {
|
|
65
|
+
var body string
|
|
66
|
+
if c.method == http.MethodPost {
|
|
67
|
+
body = "{}"
|
|
68
|
+
}
|
|
69
|
+
req := httptest.NewRequest(c.method, c.path, strings.NewReader(body))
|
|
70
|
+
if c.bearer {
|
|
71
|
+
req.Header.Set("Authorization", "Bearer testtoken")
|
|
72
|
+
}
|
|
73
|
+
rec := httptest.NewRecorder()
|
|
74
|
+
handler.ServeHTTP(rec, req)
|
|
75
|
+
return rec
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for _, c := range allowed {
|
|
79
|
+
rec := send(c)
|
|
80
|
+
if rec.Code != http.StatusOK {
|
|
81
|
+
t.Errorf("allowed %s %s: got %d, want 200", c.method, c.path, rec.Code)
|
|
82
|
+
}
|
|
83
|
+
// Note: /health, /healthz, /manifest are answered by connectd (synthesized)
|
|
84
|
+
// and do not reach upstream; that is asserted in the synthesis test.
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for _, c := range denied {
|
|
88
|
+
rec := send(c)
|
|
89
|
+
if rec.Code == http.StatusOK {
|
|
90
|
+
t.Errorf("denied %s %s (bearer=%v): returned 200, must be blocked", c.method, c.path, c.bearer)
|
|
91
|
+
}
|
|
92
|
+
if forwarded[c.method+" "+c.path] {
|
|
93
|
+
t.Errorf("denied %s %s (bearer=%v): reached upstream, must be blocked at the gateway", c.method, c.path, c.bearer)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// TestFunnelBootstrapIsStrictSubsetOfPrePair asserts the funnel set never admits
|
|
99
|
+
// a path the pre-pair set denies, and is strictly smaller (excludes /routez and
|
|
100
|
+
// /pair/claim). This guards against the funnel mode drifting wider than pre-pair.
|
|
101
|
+
func TestFunnelBootstrapIsStrictSubsetOfPrePair(t *testing.T) {
|
|
102
|
+
for _, m := range []string{http.MethodGet, http.MethodPost} {
|
|
103
|
+
for path := range funnelBootstrapGetPaths {
|
|
104
|
+
if m == http.MethodGet && !prePairAllowed(http.MethodGet, path) {
|
|
105
|
+
t.Errorf("funnel GET %s not allowed by pre-pair: funnel must be a subset", path)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for path := range funnelBootstrapPostPaths {
|
|
109
|
+
if m == http.MethodPost && !prePairAllowed(http.MethodPost, path) {
|
|
110
|
+
t.Errorf("funnel POST %s not allowed by pre-pair: funnel must be a subset", path)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if funnelBootstrapGetPaths["/routez"] {
|
|
115
|
+
t.Error("/routez must be excluded from the funnel surface")
|
|
116
|
+
}
|
|
117
|
+
if funnelBootstrapPostPaths["/pair/claim"] {
|
|
118
|
+
t.Error("/pair/claim must be excluded from the funnel surface")
|
|
119
|
+
}
|
|
120
|
+
if funnelBootstrapPostPaths["/pair/start"] {
|
|
121
|
+
t.Error("/pair/start must never be on the funnel surface")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// TestFunnelHealthAndManifestAreSynthesizedNotProxied verifies connectd answers
|
|
126
|
+
// funnel /health and /manifest itself with a minimal body and never proxies the
|
|
127
|
+
// upstream's identity, install path, version, or route topology. /readyz still
|
|
128
|
+
// proxies, since it is the readiness probe.
|
|
129
|
+
func TestFunnelHealthAndManifestAreSynthesizedNotProxied(t *testing.T) {
|
|
130
|
+
upstreamHit := map[string]bool{}
|
|
131
|
+
sensitive := `{"ok":true,"install_id":"inst_secret","computer_name":"Bob-MacBook","runtime":{"install_root":"/Users/bob/x","runtime_version":"0.2.5"},"routes":[{"host":"10.0.0.1"}]}`
|
|
132
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
133
|
+
upstreamHit[r.URL.Path] = true
|
|
134
|
+
w.Header().Set("Content-Type", "application/json")
|
|
135
|
+
_, _ = w.Write([]byte(sensitive))
|
|
136
|
+
}))
|
|
137
|
+
defer upstream.Close()
|
|
138
|
+
upstreamURL, _ := url.Parse(upstream.URL)
|
|
139
|
+
|
|
140
|
+
handler, err := NewHandler(Options{
|
|
141
|
+
Upstream: upstreamURL,
|
|
142
|
+
MaxBodyBytes: 4096,
|
|
143
|
+
Mode: ExposureModeFunnelBootstrap,
|
|
144
|
+
FunnelMacIDHash: "deadbeef",
|
|
145
|
+
})
|
|
146
|
+
if err != nil {
|
|
147
|
+
t.Fatal(err)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for _, path := range []string{"/health", "/manifest"} {
|
|
151
|
+
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
152
|
+
rec := httptest.NewRecorder()
|
|
153
|
+
handler.ServeHTTP(rec, req)
|
|
154
|
+
if rec.Code != http.StatusOK {
|
|
155
|
+
t.Fatalf("%s: got %d", path, rec.Code)
|
|
156
|
+
}
|
|
157
|
+
body := rec.Body.String()
|
|
158
|
+
for _, leak := range []string{"inst_secret", "Bob-MacBook", "install_root", "runtime_version", "routes", "10.0.0.1"} {
|
|
159
|
+
if strings.Contains(body, leak) {
|
|
160
|
+
t.Errorf("%s response leaked %q: %s", path, leak, body)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if !strings.Contains(body, "deadbeef") {
|
|
164
|
+
t.Errorf("%s response missing mac_id_hash: %s", path, body)
|
|
165
|
+
}
|
|
166
|
+
if upstreamHit[path] {
|
|
167
|
+
t.Errorf("%s reached upstream; must be synthesized at connectd", path)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
172
|
+
handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
173
|
+
if !upstreamHit["/readyz"] {
|
|
174
|
+
t.Error("/readyz should be proxied to upstream, not synthesized")
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// TestFunnelLimiterCaps verifies the three identity-independent caps: per-pair_id
|
|
179
|
+
// isolation, the in-flight ECDH concurrency cap, and the global ceiling.
|
|
180
|
+
func TestFunnelLimiterCaps(t *testing.T) {
|
|
181
|
+
// Per-pair cap = 2, global and ecdh high.
|
|
182
|
+
l := NewFunnelLimiter(1000, 2, 100)
|
|
183
|
+
r1, ok := l.Acquire("pairA")
|
|
184
|
+
if !ok {
|
|
185
|
+
t.Fatal("1st pairA should pass")
|
|
186
|
+
}
|
|
187
|
+
r2, ok := l.Acquire("pairA")
|
|
188
|
+
if !ok {
|
|
189
|
+
t.Fatal("2nd pairA should pass")
|
|
190
|
+
}
|
|
191
|
+
if _, ok := l.Acquire("pairA"); ok {
|
|
192
|
+
t.Error("3rd pairA should be capped")
|
|
193
|
+
}
|
|
194
|
+
if rB, ok := l.Acquire("pairB"); !ok {
|
|
195
|
+
t.Error("pairB must be unaffected by pairA's cap (per-pair isolation)")
|
|
196
|
+
} else {
|
|
197
|
+
rB()
|
|
198
|
+
}
|
|
199
|
+
r1()
|
|
200
|
+
r2()
|
|
201
|
+
|
|
202
|
+
// ECDH concurrency cap = 1: an unreleased acquire blocks the next.
|
|
203
|
+
l2 := NewFunnelLimiter(1000, 1000, 1)
|
|
204
|
+
rel, ok := l2.Acquire("x")
|
|
205
|
+
if !ok {
|
|
206
|
+
t.Fatal("first ecdh slot should acquire")
|
|
207
|
+
}
|
|
208
|
+
if _, ok := l2.Acquire("y"); ok {
|
|
209
|
+
t.Error("ecdh cap=1: a second concurrent acquire must fail")
|
|
210
|
+
}
|
|
211
|
+
rel()
|
|
212
|
+
if r, ok := l2.Acquire("z"); !ok {
|
|
213
|
+
t.Error("after release, the ecdh slot should be free")
|
|
214
|
+
} else {
|
|
215
|
+
r()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Global ceiling = 2: a third request fails even with a fresh pair_id.
|
|
219
|
+
l3 := NewFunnelLimiter(2, 1000, 1000)
|
|
220
|
+
if _, ok := l3.Acquire("a"); !ok {
|
|
221
|
+
t.Fatal("global 1 should pass")
|
|
222
|
+
}
|
|
223
|
+
if _, ok := l3.Acquire("b"); !ok {
|
|
224
|
+
t.Fatal("global 2 should pass")
|
|
225
|
+
}
|
|
226
|
+
if _, ok := l3.Acquire("c"); ok {
|
|
227
|
+
t.Error("global ceiling=2: a third request must fail even with a new pair_id")
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// TestFunnelMarkerInjectedAndStripped verifies connectd sets the funnel-origin
|
|
232
|
+
// marker only on the funnel handler (replacing any inbound spoof) and strips it
|
|
233
|
+
// on the tailnet handler.
|
|
234
|
+
func TestFunnelMarkerInjectedAndStripped(t *testing.T) {
|
|
235
|
+
var gotMarker string
|
|
236
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
237
|
+
gotMarker = r.Header.Get("X-Pairling-Funnel-Origin")
|
|
238
|
+
w.WriteHeader(http.StatusOK)
|
|
239
|
+
}))
|
|
240
|
+
defer upstream.Close()
|
|
241
|
+
upstreamURL, _ := url.Parse(upstream.URL)
|
|
242
|
+
|
|
243
|
+
funnel, err := NewHandler(Options{Upstream: upstreamURL, Mode: ExposureModeFunnelBootstrap, FunnelLimiter: NewFunnelLimiter(1000, 1000, 1000)})
|
|
244
|
+
if err != nil {
|
|
245
|
+
t.Fatal(err)
|
|
246
|
+
}
|
|
247
|
+
req := httptest.NewRequest(http.MethodPost, "/pair/psk-claim", strings.NewReader(`{"pair_id":"p"}`))
|
|
248
|
+
req.Header.Set("X-Pairling-Funnel-Origin", "spoofed")
|
|
249
|
+
funnel.ServeHTTP(httptest.NewRecorder(), req)
|
|
250
|
+
if gotMarker != "1" {
|
|
251
|
+
t.Errorf("funnel handler upstream marker = %q, want \"1\" (inbound spoof must be replaced)", gotMarker)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
gotMarker = ""
|
|
255
|
+
tailnet, err := NewHandler(Options{Upstream: upstreamURL, Mode: ExposureModePairlingConnect})
|
|
256
|
+
if err != nil {
|
|
257
|
+
t.Fatal(err)
|
|
258
|
+
}
|
|
259
|
+
req2 := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
260
|
+
req2.Header.Set("X-Pairling-Funnel-Origin", "spoofed")
|
|
261
|
+
tailnet.ServeHTTP(httptest.NewRecorder(), req2)
|
|
262
|
+
if gotMarker != "" {
|
|
263
|
+
t.Errorf("tailnet handler upstream marker = %q, want empty (must be stripped)", gotMarker)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package gateway
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// TestFunnelBootstrapMatchesContract ties funnelBootstrapAllowed to the shared
|
|
12
|
+
// cross-language endpoint contract, so the funnel allowlist cannot drift from the
|
|
13
|
+
// source-of-truth list that the Swift and Python sides also read.
|
|
14
|
+
func TestFunnelBootstrapMatchesContract(t *testing.T) {
|
|
15
|
+
_, thisFile, _, _ := runtime.Caller(0)
|
|
16
|
+
contractPath := filepath.Join(filepath.Dir(thisFile),
|
|
17
|
+
"..", "..", "..", "..", "thoughts", "shared", "contracts", "pairling-connect-endpoints.json")
|
|
18
|
+
data, err := os.ReadFile(contractPath)
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatalf("read contract: %v", err)
|
|
21
|
+
}
|
|
22
|
+
var contract struct {
|
|
23
|
+
Rows []struct {
|
|
24
|
+
Method string `json:"method"`
|
|
25
|
+
SamplePath string `json:"sample_path"`
|
|
26
|
+
ConnectdFunnelBootstrap bool `json:"connectd_funnel_bootstrap"`
|
|
27
|
+
AssertFunnelParity bool `json:"assert_funnel_parity"`
|
|
28
|
+
} `json:"rows"`
|
|
29
|
+
}
|
|
30
|
+
if err := json.Unmarshal(data, &contract); err != nil {
|
|
31
|
+
t.Fatalf("parse contract: %v", err)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
funnelTrueTotal := 0
|
|
35
|
+
checked := 0
|
|
36
|
+
for _, row := range contract.Rows {
|
|
37
|
+
if row.ConnectdFunnelBootstrap {
|
|
38
|
+
funnelTrueTotal++
|
|
39
|
+
}
|
|
40
|
+
if !row.AssertFunnelParity {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
checked++
|
|
44
|
+
got := funnelBootstrapAllowed(row.Method, row.SamplePath)
|
|
45
|
+
if got != row.ConnectdFunnelBootstrap {
|
|
46
|
+
t.Errorf("%s %s: funnelBootstrapAllowed=%v, contract connectd_funnel_bootstrap=%v",
|
|
47
|
+
row.Method, row.SamplePath, got, row.ConnectdFunnelBootstrap)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if checked == 0 {
|
|
51
|
+
t.Fatal("no assert_funnel_parity rows found; the contract is not wired")
|
|
52
|
+
}
|
|
53
|
+
if funnelTrueTotal != 5 {
|
|
54
|
+
t.Errorf("connectd_funnel_bootstrap true rows = %d, want exactly 5 (the funnel set)", funnelTrueTotal)
|
|
55
|
+
}
|
|
56
|
+
}
|