pairling 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
package status
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/url"
|
|
8
|
+
"regexp"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const (
|
|
15
|
+
SchemaVersion = 2
|
|
16
|
+
DefaultListenPort = 7773
|
|
17
|
+
DefaultConnectdVersion = "2026-05-24"
|
|
18
|
+
DefaultControlURLMode = "tailscale_saas"
|
|
19
|
+
CustomControlURLMode = "custom"
|
|
20
|
+
RouteIDPairlingConnect = "pairling-connect-tailnet"
|
|
21
|
+
RouteSourceConnectd = "pairling_connectd"
|
|
22
|
+
RouteKindTailnet = "tailnet"
|
|
23
|
+
RouteStatusReady = "ready"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
type AdvertisedRoute struct {
|
|
27
|
+
ID string `json:"id"`
|
|
28
|
+
Kind string `json:"kind"`
|
|
29
|
+
Source string `json:"source"`
|
|
30
|
+
Priority int `json:"priority"`
|
|
31
|
+
BaseURL string `json:"base_url"`
|
|
32
|
+
Host string `json:"host"`
|
|
33
|
+
Port int `json:"port"`
|
|
34
|
+
Status string `json:"status"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Snapshot struct {
|
|
38
|
+
OK bool `json:"ok"`
|
|
39
|
+
SchemaVersion int `json:"schema_version"`
|
|
40
|
+
AuthState string `json:"auth_state"`
|
|
41
|
+
Hostname string `json:"hostname"`
|
|
42
|
+
TailnetIP string `json:"tailnet_ip,omitempty"`
|
|
43
|
+
TailnetIPCount int `json:"tailnet_ip_count"`
|
|
44
|
+
AuthURLPresent bool `json:"auth_url_present"`
|
|
45
|
+
ControlURLMode string `json:"control_url_mode"`
|
|
46
|
+
UpstreamReachable bool `json:"upstream_reachable"`
|
|
47
|
+
ListenerRunning bool `json:"listener_running"`
|
|
48
|
+
GatewayHealthy bool `json:"gateway_healthy"`
|
|
49
|
+
ListenPort int `json:"listen_port"`
|
|
50
|
+
ConnectdVersion string `json:"connectd_version"`
|
|
51
|
+
AdvertisedRoutes []AdvertisedRoute `json:"advertised_routes"`
|
|
52
|
+
LastError string `json:"last_error,omitempty"`
|
|
53
|
+
LastGatewayFailure string `json:"last_gateway_failure,omitempty"`
|
|
54
|
+
LastGatewayFailureAt string `json:"last_gateway_failure_at,omitempty"`
|
|
55
|
+
UpdatedAt string `json:"updated_at"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Store struct {
|
|
59
|
+
mu sync.RWMutex
|
|
60
|
+
snapshot Snapshot
|
|
61
|
+
authURL string
|
|
62
|
+
gatewaySuccessStreak int
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func NewStore(hostname string) *Store {
|
|
66
|
+
return &Store{
|
|
67
|
+
snapshot: Snapshot{
|
|
68
|
+
OK: true,
|
|
69
|
+
SchemaVersion: SchemaVersion,
|
|
70
|
+
AuthState: "starting",
|
|
71
|
+
Hostname: hostname,
|
|
72
|
+
ControlURLMode: DefaultControlURLMode,
|
|
73
|
+
GatewayHealthy: true,
|
|
74
|
+
ListenPort: DefaultListenPort,
|
|
75
|
+
ConnectdVersion: DefaultConnectdVersion,
|
|
76
|
+
AdvertisedRoutes: []AdvertisedRoute{},
|
|
77
|
+
UpdatedAt: nowString(),
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (s *Store) SetControlURLMode(mode string) {
|
|
83
|
+
s.update(func(snapshot *Snapshot) {
|
|
84
|
+
if mode == "" {
|
|
85
|
+
mode = DefaultControlURLMode
|
|
86
|
+
}
|
|
87
|
+
snapshot.ControlURLMode = mode
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (s *Store) SetListenPort(port int) {
|
|
92
|
+
s.update(func(snapshot *Snapshot) {
|
|
93
|
+
if port <= 0 {
|
|
94
|
+
port = DefaultListenPort
|
|
95
|
+
}
|
|
96
|
+
snapshot.ListenPort = port
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (s *Store) SetConnectdVersion(version string) {
|
|
101
|
+
s.update(func(snapshot *Snapshot) {
|
|
102
|
+
if version == "" {
|
|
103
|
+
version = DefaultConnectdVersion
|
|
104
|
+
}
|
|
105
|
+
snapshot.ConnectdVersion = version
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func (s *Store) SetAuthPending(message string) {
|
|
110
|
+
s.update(func(snapshot *Snapshot) {
|
|
111
|
+
snapshot.AuthState = "pending"
|
|
112
|
+
if authURL, ok := extractAuthURL(message); ok {
|
|
113
|
+
s.authURL = authURL
|
|
114
|
+
snapshot.AuthURLPresent = true
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func (s *Store) SetAuthenticated() {
|
|
120
|
+
s.update(func(snapshot *Snapshot) {
|
|
121
|
+
snapshot.AuthState = "authenticated"
|
|
122
|
+
s.authURL = ""
|
|
123
|
+
snapshot.AuthURLPresent = false
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func (s *Store) SetTailnetIP(ip string) {
|
|
128
|
+
s.update(func(snapshot *Snapshot) {
|
|
129
|
+
snapshot.TailnetIP = ip
|
|
130
|
+
if ip == "" {
|
|
131
|
+
snapshot.TailnetIPCount = 0
|
|
132
|
+
} else {
|
|
133
|
+
snapshot.TailnetIPCount = 1
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func (s *Store) SetUpstreamReachable(reachable bool) {
|
|
139
|
+
s.update(func(snapshot *Snapshot) {
|
|
140
|
+
snapshot.UpstreamReachable = reachable
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func (s *Store) SetListenerRunning(running bool) {
|
|
145
|
+
s.update(func(snapshot *Snapshot) {
|
|
146
|
+
snapshot.ListenerRunning = running
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func (s *Store) RecordGatewayEvent(method, path string, status int, outcome string) {
|
|
151
|
+
s.update(func(snapshot *Snapshot) {
|
|
152
|
+
method = strings.ToUpper(strings.TrimSpace(method))
|
|
153
|
+
path = strings.TrimSpace(path)
|
|
154
|
+
outcome = strings.TrimSpace(outcome)
|
|
155
|
+
if gatewayEventIsFailure(path, status, outcome) {
|
|
156
|
+
s.gatewaySuccessStreak = 0
|
|
157
|
+
snapshot.GatewayHealthy = false
|
|
158
|
+
snapshot.LastGatewayFailure = redact(fmt.Sprintf("%s %s status=%d outcome=%s", method, path, status, outcome))
|
|
159
|
+
snapshot.LastGatewayFailureAt = nowString()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if gatewayEventIsRecoveryProbe(path, status, outcome) {
|
|
163
|
+
s.gatewaySuccessStreak = 1
|
|
164
|
+
snapshot.GatewayHealthy = true
|
|
165
|
+
snapshot.LastGatewayFailure = ""
|
|
166
|
+
snapshot.LastGatewayFailureAt = ""
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func (s *Store) SetLastError(message string) {
|
|
173
|
+
s.update(func(snapshot *Snapshot) {
|
|
174
|
+
snapshot.LastError = redact(message)
|
|
175
|
+
if message != "" {
|
|
176
|
+
snapshot.OK = false
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func (s *Store) Snapshot() Snapshot {
|
|
182
|
+
s.mu.RLock()
|
|
183
|
+
defer s.mu.RUnlock()
|
|
184
|
+
return s.snapshot
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func (s *Store) AuthURLForOpen() (string, bool) {
|
|
188
|
+
s.mu.RLock()
|
|
189
|
+
defer s.mu.RUnlock()
|
|
190
|
+
if s.authURL == "" || !s.snapshot.AuthURLPresent {
|
|
191
|
+
return "", false
|
|
192
|
+
}
|
|
193
|
+
return validateAuthURL(s.authURL)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func (s *Store) Handler() http.Handler {
|
|
197
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
198
|
+
if r.Method != http.MethodGet {
|
|
199
|
+
http.Error(w, "GET required", http.StatusMethodNotAllowed)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
w.Header().Set("Content-Type", "application/json")
|
|
203
|
+
_ = json.NewEncoder(w).Encode(s.Snapshot())
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func (s *Store) update(fn func(*Snapshot)) {
|
|
208
|
+
s.mu.Lock()
|
|
209
|
+
defer s.mu.Unlock()
|
|
210
|
+
fn(&s.snapshot)
|
|
211
|
+
s.snapshot.AdvertisedRoutes = advertisedRoutes(s.snapshot)
|
|
212
|
+
s.snapshot.UpdatedAt = nowString()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func advertisedRoutes(snapshot Snapshot) []AdvertisedRoute {
|
|
216
|
+
if snapshot.AuthState != "authenticated" || snapshot.TailnetIP == "" || !snapshot.ListenerRunning || !snapshot.UpstreamReachable || !snapshot.GatewayHealthy {
|
|
217
|
+
return []AdvertisedRoute{}
|
|
218
|
+
}
|
|
219
|
+
port := snapshot.ListenPort
|
|
220
|
+
if port <= 0 {
|
|
221
|
+
port = DefaultListenPort
|
|
222
|
+
}
|
|
223
|
+
return []AdvertisedRoute{{
|
|
224
|
+
ID: RouteIDPairlingConnect,
|
|
225
|
+
Kind: RouteKindTailnet,
|
|
226
|
+
Source: RouteSourceConnectd,
|
|
227
|
+
Priority: 100,
|
|
228
|
+
BaseURL: fmt.Sprintf("http://%s:%d", snapshot.TailnetIP, port),
|
|
229
|
+
Host: snapshot.TailnetIP,
|
|
230
|
+
Port: port,
|
|
231
|
+
Status: RouteStatusReady,
|
|
232
|
+
}}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func gatewayEventIsFailure(path string, status int, outcome string) bool {
|
|
236
|
+
path = strings.TrimSpace(path)
|
|
237
|
+
if path != "/routez" {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
outcome = strings.ToLower(strings.TrimSpace(outcome))
|
|
241
|
+
if outcome == "upstream_error" || outcome == "validation_failed" || outcome == "timeout" {
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
if status >= 500 {
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func gatewayEventIsRecoveryProbe(path string, status int, outcome string) bool {
|
|
251
|
+
outcome = strings.ToLower(strings.TrimSpace(outcome))
|
|
252
|
+
return path == "/routez" && outcome == "forwarded" && status >= 200 && status < 400
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func nowString() string {
|
|
256
|
+
return time.Now().UTC().Format(time.RFC3339)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
var secretPattern = regexp.MustCompile(`(?i)(sk-[A-Za-z0-9._-]+|[A-Za-z0-9._-]*secret[A-Za-z0-9._-]*|[A-Za-z0-9._-]*token[A-Za-z0-9._-]*)`)
|
|
260
|
+
var authURLPattern = regexp.MustCompile(`https://login\.tailscale\.com/a/[A-Za-z0-9._~!$&'()*+,;=:@%/?-]+`)
|
|
261
|
+
var bearerPattern = regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._~+/=-]+`)
|
|
262
|
+
var tailscaleAuthKeyPattern = regexp.MustCompile(`(?i)tskey-[A-Za-z0-9._-]+`)
|
|
263
|
+
|
|
264
|
+
func extractAuthURL(value string) (string, bool) {
|
|
265
|
+
raw := authURLPattern.FindString(value)
|
|
266
|
+
if raw == "" {
|
|
267
|
+
return "", false
|
|
268
|
+
}
|
|
269
|
+
return validateAuthURL(raw)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func ValidAuthURL(value string) bool {
|
|
273
|
+
_, ok := validateAuthURL(value)
|
|
274
|
+
return ok
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func validateAuthURL(raw string) (string, bool) {
|
|
278
|
+
parsed, err := url.Parse(raw)
|
|
279
|
+
if err != nil {
|
|
280
|
+
return "", false
|
|
281
|
+
}
|
|
282
|
+
if parsed.Scheme != "https" || strings.ToLower(parsed.Host) != "login.tailscale.com" {
|
|
283
|
+
return "", false
|
|
284
|
+
}
|
|
285
|
+
if parsed.User != nil || parsed.Fragment != "" || !strings.HasPrefix(parsed.Path, "/a/") || len(parsed.Path) <= len("/a/") {
|
|
286
|
+
return "", false
|
|
287
|
+
}
|
|
288
|
+
return raw, true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func redact(value string) string {
|
|
292
|
+
value = authURLPattern.ReplaceAllString(value, "https://login.tailscale.com/a/[redacted]")
|
|
293
|
+
value = bearerPattern.ReplaceAllString(value, "Bearer [redacted]")
|
|
294
|
+
value = tailscaleAuthKeyPattern.ReplaceAllString(value, "[redacted]")
|
|
295
|
+
return secretPattern.ReplaceAllString(value, "[redacted]")
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func Redact(value string) string {
|
|
299
|
+
return redact(value)
|
|
300
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
package status
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestStoreServesHelperReadableSnapshotWithoutSecrets(t *testing.T) {
|
|
12
|
+
store := NewStore("pairling-inst-abcdef")
|
|
13
|
+
store.SetAuthPending("https://login.tailscale.com/a/secret-auth-token")
|
|
14
|
+
store.SetTailnetIP("100.64.0.10")
|
|
15
|
+
store.SetUpstreamReachable(true)
|
|
16
|
+
store.SetListenerRunning(true)
|
|
17
|
+
store.SetLastError("provider key sk-secret leaked elsewhere")
|
|
18
|
+
|
|
19
|
+
rec := httptest.NewRecorder()
|
|
20
|
+
store.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
|
|
21
|
+
|
|
22
|
+
if rec.Code != http.StatusOK {
|
|
23
|
+
t.Fatalf("status = %d, want 200", rec.Code)
|
|
24
|
+
}
|
|
25
|
+
var body Snapshot
|
|
26
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
if body.Hostname != "pairling-inst-abcdef" || body.TailnetIP != "100.64.0.10" {
|
|
30
|
+
t.Fatalf("bad snapshot: %+v", body)
|
|
31
|
+
}
|
|
32
|
+
if body.SchemaVersion != 2 || body.AuthState != "pending" || !body.AuthURLPresent || !body.UpstreamReachable || !body.ListenerRunning {
|
|
33
|
+
t.Fatalf("bad status fields: %+v", body)
|
|
34
|
+
}
|
|
35
|
+
if body.TailnetIPCount != 1 || body.ControlURLMode != DefaultControlURLMode || body.ListenPort != DefaultListenPort {
|
|
36
|
+
t.Fatalf("bad v2 status fields: %+v", body)
|
|
37
|
+
}
|
|
38
|
+
if len(body.AdvertisedRoutes) != 0 {
|
|
39
|
+
t.Fatalf("pending auth should not advertise routes: %+v", body.AdvertisedRoutes)
|
|
40
|
+
}
|
|
41
|
+
raw := rec.Body.String()
|
|
42
|
+
for _, forbidden := range []string{"secret-auth-token", "sk-secret"} {
|
|
43
|
+
if strings.Contains(raw, forbidden) {
|
|
44
|
+
t.Fatalf("status leaked %q: %s", forbidden, raw)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestStoreAdvertisesRouteOnlyWhenReady(t *testing.T) {
|
|
50
|
+
store := NewStore("pairling-inst-abcdef")
|
|
51
|
+
store.SetAuthPending("open https://login.tailscale.com/a/example-token")
|
|
52
|
+
store.SetListenPort(7773)
|
|
53
|
+
store.SetTailnetIP("100.64.0.10")
|
|
54
|
+
store.SetListenerRunning(true)
|
|
55
|
+
store.SetUpstreamReachable(true)
|
|
56
|
+
|
|
57
|
+
if got := store.Snapshot().AdvertisedRoutes; len(got) != 0 {
|
|
58
|
+
t.Fatalf("unauthenticated status advertised routes: %+v", got)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
store.SetAuthenticated()
|
|
62
|
+
snapshot := store.Snapshot()
|
|
63
|
+
if len(snapshot.AdvertisedRoutes) != 1 {
|
|
64
|
+
t.Fatalf("advertised routes = %+v, want one ready route", snapshot.AdvertisedRoutes)
|
|
65
|
+
}
|
|
66
|
+
route := snapshot.AdvertisedRoutes[0]
|
|
67
|
+
if route.ID != RouteIDPairlingConnect || route.Source != RouteSourceConnectd || route.Kind != RouteKindTailnet {
|
|
68
|
+
t.Fatalf("bad route identity: %+v", route)
|
|
69
|
+
}
|
|
70
|
+
if route.BaseURL != "http://100.64.0.10:7773" || route.Host != "100.64.0.10" || route.Port != 7773 || route.Status != RouteStatusReady {
|
|
71
|
+
t.Fatalf("bad route fields: %+v", route)
|
|
72
|
+
}
|
|
73
|
+
if snapshot.AuthURLPresent {
|
|
74
|
+
t.Fatalf("authenticated snapshot should clear auth URL presence: %+v", snapshot)
|
|
75
|
+
}
|
|
76
|
+
if authURL, ok := store.AuthURLForOpen(); ok || authURL != "" {
|
|
77
|
+
t.Fatalf("authenticated status should clear raw auth URL, got ok=%t url=%q", ok, authURL)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func TestStoreSuppressesAdvertisedRouteAfterRecentGatewayValidationFailure(t *testing.T) {
|
|
82
|
+
store := NewStore("pairling-inst-abcdef")
|
|
83
|
+
store.SetAuthenticated()
|
|
84
|
+
store.SetTailnetIP("100.64.0.10")
|
|
85
|
+
store.SetListenerRunning(true)
|
|
86
|
+
store.SetUpstreamReachable(true)
|
|
87
|
+
|
|
88
|
+
store.RecordGatewayEvent("GET", "/routez", 502, "upstream_error")
|
|
89
|
+
snapshot := store.Snapshot()
|
|
90
|
+
if snapshot.GatewayHealthy {
|
|
91
|
+
t.Fatalf("gateway should be unhealthy after route validation failure: %+v", snapshot)
|
|
92
|
+
}
|
|
93
|
+
if snapshot.LastGatewayFailure == "" {
|
|
94
|
+
t.Fatalf("gateway failure should be exposed as redacted diagnostic: %+v", snapshot)
|
|
95
|
+
}
|
|
96
|
+
if got := snapshot.AdvertisedRoutes; len(got) != 0 {
|
|
97
|
+
t.Fatalf("gateway failure advertised routes: %+v", got)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
store.RecordGatewayEvent("GET", "/routez", 200, "forwarded")
|
|
101
|
+
snapshot = store.Snapshot()
|
|
102
|
+
if !snapshot.GatewayHealthy {
|
|
103
|
+
t.Fatalf("gateway should recover after fresh successful routez proof: %+v", snapshot)
|
|
104
|
+
}
|
|
105
|
+
if len(snapshot.AdvertisedRoutes) != 1 {
|
|
106
|
+
t.Fatalf("recovered gateway did not advertise route: %+v", snapshot.AdvertisedRoutes)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestStoreDoesNotPoisonRouteHealthForProductEndpointFailures(t *testing.T) {
|
|
111
|
+
store := NewStore("pairling-inst-abcdef")
|
|
112
|
+
store.SetAuthenticated()
|
|
113
|
+
store.SetTailnetIP("100.64.0.10")
|
|
114
|
+
store.SetListenerRunning(true)
|
|
115
|
+
store.SetUpstreamReachable(true)
|
|
116
|
+
|
|
117
|
+
store.RecordGatewayEvent("POST", "/push/test", 500, "upstream_error")
|
|
118
|
+
snapshot := store.Snapshot()
|
|
119
|
+
if !snapshot.GatewayHealthy {
|
|
120
|
+
t.Fatalf("product endpoint 5xx should not poison route health: %+v", snapshot)
|
|
121
|
+
}
|
|
122
|
+
if snapshot.LastGatewayFailure != "" {
|
|
123
|
+
t.Fatalf("product endpoint failure should not be stored as route failure: %+v", snapshot)
|
|
124
|
+
}
|
|
125
|
+
if len(snapshot.AdvertisedRoutes) != 1 {
|
|
126
|
+
t.Fatalf("product endpoint failure should not suppress advertised routes: %+v", snapshot.AdvertisedRoutes)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestStoreRecoversRouteHealthAfterFreshSuccessfulRouteProof(t *testing.T) {
|
|
131
|
+
store := NewStore("pairling-inst-abcdef")
|
|
132
|
+
store.SetAuthenticated()
|
|
133
|
+
store.SetTailnetIP("100.64.0.10")
|
|
134
|
+
store.SetListenerRunning(true)
|
|
135
|
+
store.SetUpstreamReachable(true)
|
|
136
|
+
|
|
137
|
+
store.RecordGatewayEvent("GET", "/routez", 502, "upstream_error")
|
|
138
|
+
if store.Snapshot().GatewayHealthy {
|
|
139
|
+
t.Fatalf("route proof failure should mark route unhealthy")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
store.RecordGatewayEvent("GET", "/routez", 200, "forwarded")
|
|
143
|
+
snapshot := store.Snapshot()
|
|
144
|
+
if !snapshot.GatewayHealthy {
|
|
145
|
+
t.Fatalf("fresh routez proof should recover route health: %+v", snapshot)
|
|
146
|
+
}
|
|
147
|
+
if len(snapshot.AdvertisedRoutes) != 1 {
|
|
148
|
+
t.Fatalf("fresh routez proof should restore advertised route: %+v", snapshot.AdvertisedRoutes)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func TestStoreKeepsRawAuthURLPrivateForLocalOpenOnly(t *testing.T) {
|
|
153
|
+
store := NewStore("pairling-inst-abcdef")
|
|
154
|
+
rawAuthURL := "https://login.tailscale.com/a/secret-auth-token?next=pairling"
|
|
155
|
+
store.SetAuthPending("Approve Pairling Connect at " + rawAuthURL)
|
|
156
|
+
|
|
157
|
+
authURL, ok := store.AuthURLForOpen()
|
|
158
|
+
if !ok || authURL != rawAuthURL {
|
|
159
|
+
t.Fatalf("auth URL for local open = %q, %t; want raw in-memory URL", authURL, ok)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
rec := httptest.NewRecorder()
|
|
163
|
+
store.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/status", nil))
|
|
164
|
+
if strings.Contains(rec.Body.String(), "secret-auth-token") || strings.Contains(rec.Body.String(), "login.tailscale.com/a/") {
|
|
165
|
+
t.Fatalf("status response leaked raw auth URL: %s", rec.Body.String())
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func TestStoreRejectsInvalidAuthURLForLocalOpen(t *testing.T) {
|
|
170
|
+
store := NewStore("pairling-inst-abcdef")
|
|
171
|
+
store.SetAuthPending("Approve Pairling Connect at http://login.tailscale.com/a/not-secure")
|
|
172
|
+
|
|
173
|
+
if authURL, ok := store.AuthURLForOpen(); ok || authURL != "" {
|
|
174
|
+
t.Fatalf("invalid auth URL should not be available for local open, got ok=%t url=%q", ok, authURL)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
snapshot := store.Snapshot()
|
|
178
|
+
if snapshot.AuthURLPresent {
|
|
179
|
+
t.Fatalf("invalid auth URL should not set presence: %+v", snapshot)
|
|
180
|
+
}
|
|
181
|
+
if !ValidAuthURL("https://login.tailscale.com/a/example-token?next=pairling") {
|
|
182
|
+
t.Fatal("valid auth URL was rejected")
|
|
183
|
+
}
|
|
184
|
+
if ValidAuthURL("https://login.tailscale.com.evil/a/example-token") {
|
|
185
|
+
t.Fatal("evil host was accepted")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestStoreSuppressesAdvertisedRouteForDegradedStates(t *testing.T) {
|
|
190
|
+
cases := []struct {
|
|
191
|
+
name string
|
|
192
|
+
configure func(*Store)
|
|
193
|
+
}{
|
|
194
|
+
{
|
|
195
|
+
name: "auth pending",
|
|
196
|
+
configure: func(store *Store) {
|
|
197
|
+
store.SetAuthPending("open https://login.tailscale.com/a/example")
|
|
198
|
+
store.SetTailnetIP("100.64.0.10")
|
|
199
|
+
store.SetListenerRunning(true)
|
|
200
|
+
store.SetUpstreamReachable(true)
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "missing tailnet IP",
|
|
205
|
+
configure: func(store *Store) {
|
|
206
|
+
store.SetAuthenticated()
|
|
207
|
+
store.SetListenerRunning(true)
|
|
208
|
+
store.SetUpstreamReachable(true)
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "listener down",
|
|
213
|
+
configure: func(store *Store) {
|
|
214
|
+
store.SetAuthenticated()
|
|
215
|
+
store.SetTailnetIP("100.64.0.10")
|
|
216
|
+
store.SetUpstreamReachable(true)
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "upstream down",
|
|
221
|
+
configure: func(store *Store) {
|
|
222
|
+
store.SetAuthenticated()
|
|
223
|
+
store.SetTailnetIP("100.64.0.10")
|
|
224
|
+
store.SetListenerRunning(true)
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for _, tc := range cases {
|
|
230
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
231
|
+
store := NewStore("pairling-inst-abcdef")
|
|
232
|
+
tc.configure(store)
|
|
233
|
+
if got := store.Snapshot().AdvertisedRoutes; len(got) != 0 {
|
|
234
|
+
t.Fatalf("degraded state advertised routes: %+v", got)
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func TestRedactRemovesAuthURLsAndBearerMaterial(t *testing.T) {
|
|
241
|
+
cases := map[string][]string{
|
|
242
|
+
"open https://login.tailscale.com/a/abc123DEF456 to authenticate": {
|
|
243
|
+
"abc123DEF456",
|
|
244
|
+
},
|
|
245
|
+
"Authorization: Bearer device-token-value": {
|
|
246
|
+
"device-token-value",
|
|
247
|
+
},
|
|
248
|
+
"auth key tskey-auth-k9uFq_secret_part": {
|
|
249
|
+
"tskey-auth-k9uFq_secret_part",
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for input, forbidden := range cases {
|
|
254
|
+
t.Run(input, func(t *testing.T) {
|
|
255
|
+
redacted := Redact(input)
|
|
256
|
+
for _, value := range forbidden {
|
|
257
|
+
if strings.Contains(redacted, value) {
|
|
258
|
+
t.Fatalf("redacted output leaked %q: %s", value, redacted)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}
|