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.
- package/README.md +11 -9
- package/bin/pairling.mjs +5 -2
- package/package.json +3 -3
- package/payload/mac/SOURCE_BRANCH +1 -1
- 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 +63 -21
|
@@ -8,6 +8,61 @@ import (
|
|
|
8
8
|
"testing"
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
func hasRouteKind(routes []AdvertisedRoute, kind string) bool {
|
|
12
|
+
for _, r := range routes {
|
|
13
|
+
if r.Kind == kind {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestFunnelRouteIsAdditiveAndGated(t *testing.T) {
|
|
21
|
+
s := NewStore("pairling-test")
|
|
22
|
+
s.SetListenerRunning(true)
|
|
23
|
+
s.SetUpstreamReachable(true)
|
|
24
|
+
s.SetTailnetIP("100.64.0.1")
|
|
25
|
+
s.SetAuthenticated()
|
|
26
|
+
|
|
27
|
+
// Funnel disabled: only the tailnet route, and no funnel_hostname in the JSON.
|
|
28
|
+
snap := s.Snapshot()
|
|
29
|
+
if hasRouteKind(snap.AdvertisedRoutes, RouteKindFunnel) {
|
|
30
|
+
t.Error("funnel route present while funnel disabled")
|
|
31
|
+
}
|
|
32
|
+
js, _ := json.Marshal(snap)
|
|
33
|
+
if strings.Contains(string(js), "funnel_hostname") {
|
|
34
|
+
t.Error("funnel_hostname must be omitted when empty")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Enable funnel: the route appears with an https base URL and a lower priority.
|
|
38
|
+
s.SetFunnelHostname("pairling-abc.tail1234.ts.net")
|
|
39
|
+
snap = s.Snapshot()
|
|
40
|
+
var funnel, tailnet *AdvertisedRoute
|
|
41
|
+
for i := range snap.AdvertisedRoutes {
|
|
42
|
+
switch snap.AdvertisedRoutes[i].Kind {
|
|
43
|
+
case RouteKindFunnel:
|
|
44
|
+
funnel = &snap.AdvertisedRoutes[i]
|
|
45
|
+
case RouteKindTailnet:
|
|
46
|
+
tailnet = &snap.AdvertisedRoutes[i]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if funnel == nil || tailnet == nil {
|
|
50
|
+
t.Fatalf("expected tailnet and funnel routes, got %+v", snap.AdvertisedRoutes)
|
|
51
|
+
}
|
|
52
|
+
if funnel.BaseURL != "https://pairling-abc.tail1234.ts.net" {
|
|
53
|
+
t.Errorf("funnel base_url = %q", funnel.BaseURL)
|
|
54
|
+
}
|
|
55
|
+
if funnel.Priority >= tailnet.Priority {
|
|
56
|
+
t.Errorf("funnel priority %d must be below tailnet priority %d", funnel.Priority, tailnet.Priority)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Unhealthy node: no routes advertise, even with a funnel hostname set.
|
|
60
|
+
s.SetUpstreamReachable(false)
|
|
61
|
+
if len(s.Snapshot().AdvertisedRoutes) != 0 {
|
|
62
|
+
t.Error("no routes should advertise when the node is unhealthy")
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
11
66
|
func TestStoreServesHelperReadableSnapshotWithoutSecrets(t *testing.T) {
|
|
12
67
|
store := NewStore("pairling-inst-abcdef")
|
|
13
68
|
store.SetAuthPending("https://login.tailscale.com/a/secret-auth-token")
|
|
@@ -46,6 +101,89 @@ func TestStoreServesHelperReadableSnapshotWithoutSecrets(t *testing.T) {
|
|
|
46
101
|
}
|
|
47
102
|
}
|
|
48
103
|
|
|
104
|
+
func TestSnapshotSerializesTailnetIdentityFields(t *testing.T) {
|
|
105
|
+
store := NewStore("pairling-inst-abcdef")
|
|
106
|
+
store.SetTailnetIdentity("nXb6CNTRL", []string{"tag:pairling-connect"}, []string{"100.79.217.7"})
|
|
107
|
+
|
|
108
|
+
raw, err := json.Marshal(store.Snapshot())
|
|
109
|
+
if err != nil {
|
|
110
|
+
t.Fatal(err)
|
|
111
|
+
}
|
|
112
|
+
body := string(raw)
|
|
113
|
+
for _, want := range []string{
|
|
114
|
+
`"tailnet_node_id":"nXb6CNTRL"`,
|
|
115
|
+
`"tags":["tag:pairling-connect"]`,
|
|
116
|
+
`"tailnet_ips":["100.79.217.7"]`,
|
|
117
|
+
} {
|
|
118
|
+
if !strings.Contains(body, want) {
|
|
119
|
+
t.Fatalf("snapshot missing %s: %s", want, body)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func TestSnapshotSerializesKnownTailnetLockStatus(t *testing.T) {
|
|
125
|
+
store := NewStore("pairling-inst-abcdef")
|
|
126
|
+
store.SetTailnetLockEnabled(false)
|
|
127
|
+
|
|
128
|
+
raw, err := json.Marshal(store.Snapshot())
|
|
129
|
+
if err != nil {
|
|
130
|
+
t.Fatal(err)
|
|
131
|
+
}
|
|
132
|
+
body := string(raw)
|
|
133
|
+
if !strings.Contains(body, `"tailnet_lock_enabled":false`) {
|
|
134
|
+
t.Fatalf("snapshot missing known lock status: %s", body)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestSnapshotOmitsTailnetIdentityWhenUnknown(t *testing.T) {
|
|
139
|
+
raw, err := json.Marshal(NewStore("pairling-inst-abcdef").Snapshot())
|
|
140
|
+
if err != nil {
|
|
141
|
+
t.Fatal(err)
|
|
142
|
+
}
|
|
143
|
+
body := string(raw)
|
|
144
|
+
for _, forbidden := range []string{"tailnet_node_id", "tags", "tailnet_ips"} {
|
|
145
|
+
if strings.Contains(body, forbidden) {
|
|
146
|
+
t.Fatalf("fresh snapshot should omit %q: %s", forbidden, body)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if !strings.Contains(body, `"auth_state":"starting"`) {
|
|
150
|
+
t.Fatalf("fresh snapshot lost coherent auth state: %s", body)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestSetTailnetIdentityIsIdempotentAndReplaces(t *testing.T) {
|
|
155
|
+
store := NewStore("pairling-inst-abcdef")
|
|
156
|
+
store.SetTailnetIdentity("old-node", []string{"tag:old"}, []string{"100.64.0.1"})
|
|
157
|
+
store.SetTailnetIdentity("new-node", []string{"tag:pairling-connect"}, []string{"100.79.217.7", "fd7a:115c:a1e0::1"})
|
|
158
|
+
|
|
159
|
+
snapshot := store.Snapshot()
|
|
160
|
+
if snapshot.TailnetNodeID != "new-node" {
|
|
161
|
+
t.Fatalf("node ID = %q, want new-node", snapshot.TailnetNodeID)
|
|
162
|
+
}
|
|
163
|
+
if got, want := strings.Join(snapshot.Tags, ","), "tag:pairling-connect"; got != want {
|
|
164
|
+
t.Fatalf("tags = %q, want %q", got, want)
|
|
165
|
+
}
|
|
166
|
+
if got, want := strings.Join(snapshot.TailnetIPs, ","), "100.79.217.7,fd7a:115c:a1e0::1"; got != want {
|
|
167
|
+
t.Fatalf("tailnet IPs = %q, want %q", got, want)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestTailnetIdentityCarriesNoSecrets(t *testing.T) {
|
|
172
|
+
store := NewStore("pairling-inst-abcdef")
|
|
173
|
+
store.SetTailnetIdentity("tskey-auth-example", []string{"AuthKey=bad"}, []string{"NLPrivate=bad"})
|
|
174
|
+
|
|
175
|
+
raw, err := json.Marshal(store.Snapshot())
|
|
176
|
+
if err != nil {
|
|
177
|
+
t.Fatal(err)
|
|
178
|
+
}
|
|
179
|
+
body := string(raw)
|
|
180
|
+
for _, forbidden := range []string{"tskey", "authkey", "AuthKey", "NLPrivate"} {
|
|
181
|
+
if strings.Contains(body, forbidden) {
|
|
182
|
+
t.Fatalf("tailnet identity leaked %q: %s", forbidden, body)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
49
187
|
func TestStoreAdvertisesRouteOnlyWhenReady(t *testing.T) {
|
|
50
188
|
store := NewStore("pairling-inst-abcdef")
|
|
51
189
|
store.SetAuthPending("open https://login.tailscale.com/a/example-token")
|
|
@@ -51,6 +51,7 @@ PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
|
|
|
51
51
|
PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
|
|
52
52
|
PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
|
|
53
53
|
PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
|
|
54
|
+
PAIRLING_MINTD_LABEL="dev.pairling.mintd"
|
|
54
55
|
APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
|
|
55
56
|
RUNTIME_ROOT="$APP_SUPPORT/runtime"
|
|
56
57
|
RELEASES_ROOT="$RUNTIME_ROOT/releases"
|
|
@@ -70,6 +71,12 @@ USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
|
|
|
70
71
|
CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
|
|
71
72
|
PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
|
|
72
73
|
SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
|
|
74
|
+
MINTD_SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_MINTD_LABEL.plist"
|
|
75
|
+
MINTD_SYSTEM_ROOT="/Library/Application Support/Pairling"
|
|
76
|
+
MINTD_SECRET_DIR="$MINTD_SYSTEM_ROOT/mint"
|
|
77
|
+
MINTD_RUN_DIR="$MINTD_SYSTEM_ROOT/run/mintd"
|
|
78
|
+
MINTD_SYSTEM_BINARY="$MINTD_SECRET_DIR/pairling-tailnet-mintd"
|
|
79
|
+
MINTD_LOGS_DIR="/Library/Logs/Pairling"
|
|
73
80
|
MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
|
|
74
81
|
MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
|
|
75
82
|
PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
|
|
@@ -306,12 +313,13 @@ copy_release() {
|
|
|
306
313
|
cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
|
|
307
314
|
cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
|
|
308
315
|
build_connectd_binary "$tmp/connectd/pairling-connectd"
|
|
316
|
+
build_mintd_binary "$tmp/connectd/pairling-tailnet-mintd"
|
|
309
317
|
stage_vendored_python "$tmp/python"
|
|
310
318
|
run_staged_psk_dependency_checks "$tmp"
|
|
311
|
-
copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
|
|
319
|
+
copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd" "$tmp/connectd/pairling-tailnet-mintd"
|
|
312
320
|
write_installed_pairling_launcher "$tmp/bin/pairling"
|
|
313
321
|
chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
|
|
314
|
-
chmod 755 "$tmp/connectd/pairling-connectd"
|
|
322
|
+
chmod 755 "$tmp/connectd/pairling-connectd" "$tmp/connectd/pairling-tailnet-mintd"
|
|
315
323
|
chmod 644 "$tmp/companiond/"*.py "$tmp/mcp/"*.py "$tmp/guardian/"*.py
|
|
316
324
|
chmod 644 "$tmp/companiond/providers/"*.py
|
|
317
325
|
chmod 644 "$tmp/companiond/integrations/"*.py "$tmp/companiond/integrations/aperture_cli/"*.py
|
|
@@ -325,6 +333,7 @@ copy_release() {
|
|
|
325
333
|
copy_runtime_source_tree() {
|
|
326
334
|
local mac_root="$1"
|
|
327
335
|
local connectd_binary="$2"
|
|
336
|
+
local mintd_binary="$3"
|
|
328
337
|
mkdir -p \
|
|
329
338
|
"$mac_root/companiond" \
|
|
330
339
|
"$mac_root/companiond/providers" \
|
|
@@ -351,12 +360,13 @@ copy_runtime_source_tree() {
|
|
|
351
360
|
cp -R "$REPO_ROOT/mac/connectd/cmd" "$mac_root/connectd/"
|
|
352
361
|
cp -R "$REPO_ROOT/mac/connectd/internal" "$mac_root/connectd/"
|
|
353
362
|
cp "$connectd_binary" "$mac_root/connectd/bin/pairling-connectd"
|
|
363
|
+
cp "$mintd_binary" "$mac_root/connectd/bin/pairling-tailnet-mintd"
|
|
354
364
|
cp "$REPO_ROOT/mac/guardian/"*.py "$mac_root/guardian/"
|
|
355
365
|
cp "$REPO_ROOT/mac/install/"*.sh "$mac_root/install/"
|
|
356
366
|
cp "$REPO_ROOT/mac/install/"*.py "$mac_root/install/"
|
|
357
367
|
cp "$REPO_ROOT/mac/mcp/"*.py "$mac_root/mcp/"
|
|
358
368
|
cp "$REPO_ROOT/mac/packaging/bin/pairling" "$mac_root/packaging/bin/"
|
|
359
|
-
chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/install/"*.sh "$mac_root/mcp/phone_tools.py" "$mac_root/packaging/bin/pairling"
|
|
369
|
+
chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/connectd/bin/pairling-tailnet-mintd" "$mac_root/install/"*.sh "$mac_root/mcp/phone_tools.py" "$mac_root/packaging/bin/pairling"
|
|
360
370
|
chmod 644 "$mac_root/VERSION" "$mac_root/SOURCE_REVISION" "$mac_root/SOURCE_BRANCH" "$mac_root/SOURCE_DIRTY"
|
|
361
371
|
}
|
|
362
372
|
|
|
@@ -480,6 +490,57 @@ build_connectd_binary() {
|
|
|
480
490
|
)
|
|
481
491
|
}
|
|
482
492
|
|
|
493
|
+
build_mintd_binary() {
|
|
494
|
+
local out="$1"
|
|
495
|
+
local prebuilt_env="${PAIRLING_MINTD_PREBUILT:-}"
|
|
496
|
+
if [[ -n "$prebuilt_env" ]]; then
|
|
497
|
+
if [[ ! -f "$prebuilt_env" ]]; then
|
|
498
|
+
log "ERROR: PAIRLING_MINTD_PREBUILT points at a missing file: $prebuilt_env" >&2
|
|
499
|
+
exit 1
|
|
500
|
+
fi
|
|
501
|
+
local required_team="${PAIRLING_MINTD_TEAM_ID:-${PAIRLING_CONNECTD_TEAM_ID:-965AVD34A3}}"
|
|
502
|
+
if [[ "$required_team" != "-" ]]; then
|
|
503
|
+
if ! /usr/bin/codesign --verify --strict "$prebuilt_env" >/dev/null 2>&1; then
|
|
504
|
+
log "ERROR: mintd binary failed codesign verification; refusing to stage: $prebuilt_env" >&2
|
|
505
|
+
exit 1
|
|
506
|
+
fi
|
|
507
|
+
local team
|
|
508
|
+
team="$(/usr/bin/codesign -dvv "$prebuilt_env" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
|
|
509
|
+
if [[ "$team" != "$required_team" ]]; then
|
|
510
|
+
log "ERROR: mintd binary TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage: $prebuilt_env" >&2
|
|
511
|
+
exit 1
|
|
512
|
+
fi
|
|
513
|
+
fi
|
|
514
|
+
cp "$prebuilt_env" "$out"
|
|
515
|
+
chmod 755 "$out"
|
|
516
|
+
return
|
|
517
|
+
fi
|
|
518
|
+
local prebuilt="$REPO_ROOT/mac/connectd/bin/pairling-tailnet-mintd"
|
|
519
|
+
if [[ -x "$prebuilt" ]]; then
|
|
520
|
+
cp "$prebuilt" "$out"
|
|
521
|
+
chmod 755 "$out"
|
|
522
|
+
return
|
|
523
|
+
fi
|
|
524
|
+
local go_bin
|
|
525
|
+
go_bin="$(command -v go || true)"
|
|
526
|
+
if [[ -z "$go_bin" ]]; then
|
|
527
|
+
for candidate in /opt/homebrew/bin/go /usr/local/go/bin/go /usr/local/bin/go; do
|
|
528
|
+
if [[ -x "$candidate" ]]; then
|
|
529
|
+
go_bin="$candidate"
|
|
530
|
+
break
|
|
531
|
+
fi
|
|
532
|
+
done
|
|
533
|
+
fi
|
|
534
|
+
if [[ -z "$go_bin" ]]; then
|
|
535
|
+
log "ERROR: go is required to build pairling-tailnet-mintd" >&2
|
|
536
|
+
exit 1
|
|
537
|
+
fi
|
|
538
|
+
(
|
|
539
|
+
cd "$REPO_ROOT/mac/connectd"
|
|
540
|
+
"$go_bin" build -o "$out" ./cmd/pairling-tailnet-mintd
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
483
544
|
write_manifest() {
|
|
484
545
|
local root="$1"
|
|
485
546
|
python3 - "$REPO_ROOT" "$root" "$VERSION" "$REVISION" "$BRANCH" "$SOURCE_DIRTY" "$APP_SUPPORT" "$LOGS_ROOT" "$DEVICES_DB" "$PAIRLING_RUNTIME_PORT" <<'PY'
|
|
@@ -535,6 +596,7 @@ for rel in [
|
|
|
535
596
|
"companiond/providers/external.py",
|
|
536
597
|
"companiond/providers/registry.py",
|
|
537
598
|
"connectd/pairling-connectd",
|
|
599
|
+
"connectd/pairling-tailnet-mintd",
|
|
538
600
|
"mcp/phone_tools.py",
|
|
539
601
|
"guardian/companion-power-guardian.py",
|
|
540
602
|
"guardian/guardian_contract.py",
|
|
@@ -567,6 +629,7 @@ manifest = {
|
|
|
567
629
|
"daemon_label": "dev.pairling.companiond",
|
|
568
630
|
"ptybroker_label": "dev.pairling.ptybroker",
|
|
569
631
|
"connectd_label": "dev.pairling.connectd",
|
|
632
|
+
"mintd_label": "dev.pairling.mintd",
|
|
570
633
|
"guardian_label": "dev.pairling.power-guardian",
|
|
571
634
|
},
|
|
572
635
|
"paths": {
|
|
@@ -577,7 +640,6 @@ manifest = {
|
|
|
577
640
|
},
|
|
578
641
|
"migration": {
|
|
579
642
|
"legacy_port": 7723,
|
|
580
|
-
"legacy_daemon_unloaded_by_setup": True,
|
|
581
643
|
"public_v1_dual_bind": False,
|
|
582
644
|
},
|
|
583
645
|
"packaging": {
|
|
@@ -691,6 +753,19 @@ SH
|
|
|
691
753
|
mv "$tmp" "$target"
|
|
692
754
|
}
|
|
693
755
|
|
|
756
|
+
mintd_provisioned() {
|
|
757
|
+
# Architecture B (minting) is enabled in the companiond env once the
|
|
758
|
+
# separate-uid mint broker has been installed by the explicit, consent-gated
|
|
759
|
+
# `pairling enable-silent-join` flow. The broker's LaunchDaemon plist lives in
|
|
760
|
+
# /Library/LaunchDaemons (root:wheel 0644, world-readable), so this steady-
|
|
761
|
+
# state check needs no elevated privileges, and a plain setup never prompts
|
|
762
|
+
# for a password. Architecture A (browser-login) stays the fallback whenever the
|
|
763
|
+
# broker is absent; the credential gate is enforced at install time by
|
|
764
|
+
# enable_silent_join + install_mintd_if_possible.
|
|
765
|
+
if is_dry_run; then return 1; fi
|
|
766
|
+
[[ -f "$MINTD_SYSTEM_PLIST" ]]
|
|
767
|
+
}
|
|
768
|
+
|
|
694
769
|
render_plists() {
|
|
695
770
|
# Prefer the staged vendored interpreter whenever it exists, so start/
|
|
696
771
|
# rollback (which don't re-stage) also run the daemon under dev.pairling.python.
|
|
@@ -698,20 +773,17 @@ render_plists() {
|
|
|
698
773
|
if [[ -x "$CURRENT_LINK/python/bin/python3" ]]; then
|
|
699
774
|
daemon_python="$CURRENT_LINK/python/bin/python3"
|
|
700
775
|
fi
|
|
701
|
-
|
|
702
|
-
--current-root "$CURRENT_LINK"
|
|
703
|
-
--logs-root "$LOGS_ROOT"
|
|
704
|
-
--output-dir "$PLIST_BUILD_DIR"
|
|
705
|
-
--daemon-python "$daemon_python"
|
|
776
|
+
local -a render_args=(
|
|
777
|
+
--current-root "$CURRENT_LINK"
|
|
778
|
+
--logs-root "$LOGS_ROOT"
|
|
779
|
+
--output-dir "$PLIST_BUILD_DIR"
|
|
780
|
+
--daemon-python "$daemon_python"
|
|
706
781
|
--guardian-python "$GUARDIAN_PYTHON_BIN"
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if is_dry_run; then
|
|
711
|
-
log "dry-run: would check legacy predecessor cleanup"
|
|
712
|
-
return
|
|
782
|
+
)
|
|
783
|
+
if mintd_provisioned; then
|
|
784
|
+
render_args+=(--mint-enabled)
|
|
713
785
|
fi
|
|
714
|
-
|
|
786
|
+
python3 "$REPO_ROOT/mac/install/render-launchd.py" "${render_args[@]}"
|
|
715
787
|
}
|
|
716
788
|
|
|
717
789
|
start_user_agent() {
|
|
@@ -1076,6 +1148,16 @@ stop_connectd_agent() {
|
|
|
1076
1148
|
launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
|
|
1077
1149
|
}
|
|
1078
1150
|
|
|
1151
|
+
stop_mintd_daemon() {
|
|
1152
|
+
if is_dry_run; then
|
|
1153
|
+
log "dry-run: would stop $PAIRLING_MINTD_LABEL"
|
|
1154
|
+
return
|
|
1155
|
+
fi
|
|
1156
|
+
if sudo -n true >/dev/null 2>&1; then
|
|
1157
|
+
sudo launchctl bootout system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
|
|
1158
|
+
fi
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1079
1161
|
install_guardian_if_possible() {
|
|
1080
1162
|
local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
|
|
1081
1163
|
if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
|
|
@@ -1098,6 +1180,176 @@ install_guardian_if_possible() {
|
|
|
1098
1180
|
fi
|
|
1099
1181
|
}
|
|
1100
1182
|
|
|
1183
|
+
mintd_uid_in_range() {
|
|
1184
|
+
local uid="$1"
|
|
1185
|
+
[[ "$uid" =~ ^[0-9]+$ && "$uid" -ge 450 && "$uid" -le 499 ]]
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
ensure_mintd_service_account() {
|
|
1189
|
+
if dscl . -read /Users/_pairling_mint >/dev/null 2>&1; then
|
|
1190
|
+
local uid real
|
|
1191
|
+
uid="$(dscl . -read /Users/_pairling_mint UniqueID | awk '{print $2}')"
|
|
1192
|
+
real="$(dscl . -read /Users/_pairling_mint RealName 2>/dev/null | sed '1d;s/^ //')"
|
|
1193
|
+
if ! mintd_uid_in_range "$uid"; then
|
|
1194
|
+
log "Skipping mintd install: _pairling_mint UID $uid is outside 450-499." >&2
|
|
1195
|
+
return 1
|
|
1196
|
+
fi
|
|
1197
|
+
if [[ "$real" != "Pairling Mint Broker" ]]; then
|
|
1198
|
+
log "Skipping mintd install: _pairling_mint RealName is not Pairling Mint Broker." >&2
|
|
1199
|
+
return 1
|
|
1200
|
+
fi
|
|
1201
|
+
return 0
|
|
1202
|
+
fi
|
|
1203
|
+
local uid=""
|
|
1204
|
+
for candidate in $(seq 450 499); do
|
|
1205
|
+
if ! dscl . -list /Users UniqueID | awk -v uid="$candidate" '$2 == uid {found=1} END {exit found ? 0 : 1}'; then
|
|
1206
|
+
uid="$candidate"
|
|
1207
|
+
break
|
|
1208
|
+
fi
|
|
1209
|
+
done
|
|
1210
|
+
if [[ -z "$uid" ]]; then
|
|
1211
|
+
log "Skipping mintd install: no free macOS role-account UID in 450-499." >&2
|
|
1212
|
+
return 1
|
|
1213
|
+
fi
|
|
1214
|
+
local pw
|
|
1215
|
+
pw="$(uuidgen)-$(uuidgen)"
|
|
1216
|
+
sudo sysadminctl -addUser _pairling_mint -fullName "Pairling Mint Broker" -UID "$uid" -GID 20 -shell /usr/bin/false -home /var/empty -password "$pw" -roleAccount >/dev/null
|
|
1217
|
+
unset pw
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
install_mintd_if_possible() {
|
|
1221
|
+
local rendered="$PLIST_BUILD_DIR/$PAIRLING_MINTD_LABEL.plist"
|
|
1222
|
+
local mintd_secret="$MINTD_SECRET_DIR/client_secret.json"
|
|
1223
|
+
if is_dry_run; then
|
|
1224
|
+
log "dry-run: would install $PAIRLING_MINTD_LABEL when privileged setup is available"
|
|
1225
|
+
return
|
|
1226
|
+
fi
|
|
1227
|
+
if ! sudo -n true >/dev/null 2>&1; then
|
|
1228
|
+
log "Skipping mintd install: passwordless sudo is unavailable. Architecture A fallback remains available."
|
|
1229
|
+
return
|
|
1230
|
+
fi
|
|
1231
|
+
if ! sudo test -f "$mintd_secret"; then
|
|
1232
|
+
log "Skipping mintd install: OAuth client secret is not provisioned at $mintd_secret."
|
|
1233
|
+
return
|
|
1234
|
+
fi
|
|
1235
|
+
ensure_mintd_service_account || return
|
|
1236
|
+
sudo chmod 0600 "$mintd_secret"
|
|
1237
|
+
sudo chown _pairling_mint:staff "$mintd_secret"
|
|
1238
|
+
sudo install -d -m 0700 -o _pairling_mint -g staff "$MINTD_SECRET_DIR"
|
|
1239
|
+
sudo install -d -m 0750 -o _pairling_mint -g staff "$MINTD_RUN_DIR"
|
|
1240
|
+
sudo install -d -m 0750 -o _pairling_mint -g staff "$MINTD_LOGS_DIR"
|
|
1241
|
+
sudo install -m 0755 -o root -g wheel "$CURRENT_LINK/connectd/pairling-tailnet-mintd" "$MINTD_SYSTEM_BINARY"
|
|
1242
|
+
sudo cp "$rendered" "$MINTD_SYSTEM_PLIST"
|
|
1243
|
+
sudo chown root:wheel "$MINTD_SYSTEM_PLIST"
|
|
1244
|
+
sudo chmod 644 "$MINTD_SYSTEM_PLIST"
|
|
1245
|
+
sudo launchctl bootout system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
|
|
1246
|
+
sudo launchctl bootstrap system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
|
|
1247
|
+
sudo launchctl kickstart -k "system/$PAIRLING_MINTD_LABEL"
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
enable_silent_join() {
|
|
1251
|
+
local client_secret_path=""
|
|
1252
|
+
local assume_yes="0"
|
|
1253
|
+
while [[ $# -gt 0 ]]; do
|
|
1254
|
+
case "$1" in
|
|
1255
|
+
--client-secret) shift; client_secret_path="${1:-}" ;;
|
|
1256
|
+
--yes) assume_yes="1" ;;
|
|
1257
|
+
--help|-h) log "usage: pairling enable-silent-join [--client-secret PATH] [--yes]"; return 0 ;;
|
|
1258
|
+
*) log "usage: pairling enable-silent-join [--client-secret PATH] [--yes]" >&2; exit 2 ;;
|
|
1259
|
+
esac
|
|
1260
|
+
shift
|
|
1261
|
+
done
|
|
1262
|
+
|
|
1263
|
+
if is_dry_run; then
|
|
1264
|
+
log "dry-run: would explain the mint broker, request one-time consent, and install $PAIRLING_MINTD_LABEL under interactive sudo"
|
|
1265
|
+
return 0
|
|
1266
|
+
fi
|
|
1267
|
+
|
|
1268
|
+
if [[ ! -x "$CURRENT_LINK/connectd/pairling-tailnet-mintd" ]]; then
|
|
1269
|
+
log "ERROR: the mint broker binary is not staged. Run 'pairling setup' first." >&2
|
|
1270
|
+
exit 1
|
|
1271
|
+
fi
|
|
1272
|
+
|
|
1273
|
+
cat <<EOF
|
|
1274
|
+
|
|
1275
|
+
Enable silent tailnet join (Architecture B)
|
|
1276
|
+
-------------------------------------------
|
|
1277
|
+
This installs a small background service, the mint broker ($PAIRLING_MINTD_LABEL).
|
|
1278
|
+
It runs under its own macOS account (_pairling_mint), not as the Pairling daemon
|
|
1279
|
+
and not as you. It holds your Tailscale OAuth client secret so the Pairling
|
|
1280
|
+
daemon never can: the daemon may ask it for one short-lived, single-use phone
|
|
1281
|
+
key per pairing, and nothing more.
|
|
1282
|
+
|
|
1283
|
+
Installing a system service and a dedicated account needs administrator rights,
|
|
1284
|
+
so you approve this one time now. After that, every future pairing joins your
|
|
1285
|
+
tailnet silently, with no browser step.
|
|
1286
|
+
|
|
1287
|
+
You provide a Tailscale OAuth client (scope auth_keys, tag tag:pairling-phone)
|
|
1288
|
+
that you create in your own Tailscale admin console. The secret is stored
|
|
1289
|
+
readable only by _pairling_mint, is never committed, and is never read by the
|
|
1290
|
+
Pairling daemon. If you skip this, pairing still works over the browser path
|
|
1291
|
+
(Architecture A); silent join stays off.
|
|
1292
|
+
|
|
1293
|
+
EOF
|
|
1294
|
+
|
|
1295
|
+
local secret_json=""
|
|
1296
|
+
if [[ -n "$client_secret_path" ]]; then
|
|
1297
|
+
[[ -f "$client_secret_path" ]] || { log "ERROR: --client-secret file not found: $client_secret_path" >&2; exit 1; }
|
|
1298
|
+
secret_json="$(cat "$client_secret_path")"
|
|
1299
|
+
elif [[ -t 0 ]]; then
|
|
1300
|
+
log "Paste the Tailscale OAuth client JSON (with client_id and client_secret), then press Ctrl-D:"
|
|
1301
|
+
secret_json="$(cat)"
|
|
1302
|
+
else
|
|
1303
|
+
log "ERROR: no Tailscale OAuth client provided. Re-run with --client-secret PATH (a JSON file with client_id and client_secret)." >&2
|
|
1304
|
+
exit 1
|
|
1305
|
+
fi
|
|
1306
|
+
|
|
1307
|
+
if ! printf '%s' "$secret_json" | python3 -c 'import json,sys
|
|
1308
|
+
d = json.load(sys.stdin)
|
|
1309
|
+
sys.exit(0 if isinstance(d, dict) and d.get("client_id") and d.get("client_secret") else 1)' >/dev/null 2>&1; then
|
|
1310
|
+
log "ERROR: the provided credential is not valid JSON with non-empty client_id and client_secret." >&2
|
|
1311
|
+
exit 1
|
|
1312
|
+
fi
|
|
1313
|
+
|
|
1314
|
+
if [[ "$assume_yes" != "1" ]]; then
|
|
1315
|
+
if [[ ! -t 0 ]]; then
|
|
1316
|
+
log "ERROR: enabling silent join needs explicit consent. Re-run with --yes to confirm." >&2
|
|
1317
|
+
exit 1
|
|
1318
|
+
fi
|
|
1319
|
+
printf 'Install the mint broker and enable silent join now? [y/N] '
|
|
1320
|
+
local reply=""
|
|
1321
|
+
read -r reply
|
|
1322
|
+
case "$reply" in
|
|
1323
|
+
y|Y|yes|YES) : ;;
|
|
1324
|
+
*) log "Silent join not enabled. Pairing continues over the browser path."; return 0 ;;
|
|
1325
|
+
esac
|
|
1326
|
+
fi
|
|
1327
|
+
|
|
1328
|
+
if ! sudo -v; then
|
|
1329
|
+
log "Administrator approval was not granted. Silent join stays off; pairing still works over the browser path." >&2
|
|
1330
|
+
exit 1
|
|
1331
|
+
fi
|
|
1332
|
+
|
|
1333
|
+
local mintd_secret="$MINTD_SECRET_DIR/client_secret.json"
|
|
1334
|
+
local tmp_secret
|
|
1335
|
+
tmp_secret="$(mktemp)"
|
|
1336
|
+
chmod 600 "$tmp_secret"
|
|
1337
|
+
printf '%s' "$secret_json" > "$tmp_secret"
|
|
1338
|
+
sudo install -d -m 0700 "$MINTD_SECRET_DIR"
|
|
1339
|
+
sudo install -m 0600 "$tmp_secret" "$mintd_secret"
|
|
1340
|
+
rm -f "$tmp_secret"
|
|
1341
|
+
|
|
1342
|
+
install_mintd_if_possible
|
|
1343
|
+
if [[ ! -f "$MINTD_SYSTEM_PLIST" ]]; then
|
|
1344
|
+
log "ERROR: the mint broker did not install. Silent join is not enabled; pairing still works over the browser path." >&2
|
|
1345
|
+
exit 1
|
|
1346
|
+
fi
|
|
1347
|
+
|
|
1348
|
+
render_plists
|
|
1349
|
+
start_user_agent
|
|
1350
|
+
log "Silent tailnet join is enabled. Run 'pairling pair --qr' to pair; future pairings join your tailnet with no browser step."
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1101
1353
|
run_doctor() {
|
|
1102
1354
|
"$REPO_ROOT/mac/install/doctor.sh"
|
|
1103
1355
|
}
|
|
@@ -1131,6 +1383,7 @@ install_runtime() {
|
|
|
1131
1383
|
log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
|
|
1132
1384
|
log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
|
|
1133
1385
|
log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
|
|
1386
|
+
log " Mint LaunchDaemon: $PAIRLING_MINTD_LABEL"
|
|
1134
1387
|
log " runtime port: $PAIRLING_RUNTIME_PORT"
|
|
1135
1388
|
run_compile_checks
|
|
1136
1389
|
run_psk_dependency_checks
|
|
@@ -1140,7 +1393,6 @@ install_runtime() {
|
|
|
1140
1393
|
install_mcp_adapter_shim
|
|
1141
1394
|
install_shell_wrapper
|
|
1142
1395
|
render_plists
|
|
1143
|
-
unload_legacy_daemon
|
|
1144
1396
|
ensure_ptybroker_agent
|
|
1145
1397
|
start_user_agent
|
|
1146
1398
|
start_connectd_agent
|
|
@@ -1152,6 +1404,11 @@ install_runtime() {
|
|
|
1152
1404
|
run_doctor || true
|
|
1153
1405
|
fi
|
|
1154
1406
|
log "Installed Pairling runtime $RELEASE_NAME"
|
|
1407
|
+
if ! mintd_provisioned; then
|
|
1408
|
+
log ""
|
|
1409
|
+
log "Silent tailnet join (no browser step) is available but not yet enabled."
|
|
1410
|
+
log "Turn it on once, using your own Tailscale account: pairling enable-silent-join"
|
|
1411
|
+
fi
|
|
1155
1412
|
if ! is_dry_run; then
|
|
1156
1413
|
log ""
|
|
1157
1414
|
if ! PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS="${PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS:-35}" pair_runtime --qr; then
|
|
@@ -1168,7 +1425,6 @@ status_runtime() {
|
|
|
1168
1425
|
start_runtime() {
|
|
1169
1426
|
ensure_state
|
|
1170
1427
|
render_plists
|
|
1171
|
-
unload_legacy_daemon
|
|
1172
1428
|
ensure_ptybroker_agent
|
|
1173
1429
|
start_user_agent
|
|
1174
1430
|
start_connectd_agent
|
|
@@ -1176,6 +1432,7 @@ start_runtime() {
|
|
|
1176
1432
|
}
|
|
1177
1433
|
|
|
1178
1434
|
stop_runtime() {
|
|
1435
|
+
stop_mintd_daemon
|
|
1179
1436
|
stop_connectd_agent
|
|
1180
1437
|
stop_user_agent
|
|
1181
1438
|
log "Stopped $PAIRLING_DAEMON_LABEL"
|
|
@@ -1285,6 +1542,9 @@ mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_n
|
|
|
1285
1542
|
# payload — the secret never goes on the wire. Without it the phone falls back to the legacy
|
|
1286
1543
|
# plaintext claim, so this field is the bridge that actually makes WS3 engage.
|
|
1287
1544
|
mac_ake_pub = str(payload.get("mac_ake_pub") or (payload.get("claim") or {}).get("mac_ake_pub") or "")
|
|
1545
|
+
# The daemon computes pv authoritatively (3 when minting is enabled server-side,
|
|
1546
|
+
# 2 for PSK-only). Read it from the same top-level-or-claim shape as mac_ake_pub.
|
|
1547
|
+
claim_pv = str(payload.get("pv") or (payload.get("claim") or {}).get("pv") or "")
|
|
1288
1548
|
|
|
1289
1549
|
def is_ats_local_ipv4(value: str) -> bool:
|
|
1290
1550
|
try:
|
|
@@ -1404,9 +1664,15 @@ if pair_id and secret:
|
|
|
1404
1664
|
if mac_ake_pub:
|
|
1405
1665
|
# WS3: out-of-band delivery of the Mac ECDH key + protocol marker. The phone routes
|
|
1406
1666
|
# to PSK-authenticated ECDH (secret never transmitted) when both are present; their
|
|
1407
|
-
# absence is the legacy plaintext claim. pv=
|
|
1667
|
+
# absence is the legacy plaintext claim. pv=3 requests the B sealed-authkey
|
|
1668
|
+
# extension when minting is available; pv=2 remains the PSK-only marker.
|
|
1408
1669
|
pair_params["mac_ake_pub"] = mac_ake_pub
|
|
1409
|
-
|
|
1670
|
+
# Prefer the daemon's authoritative claim.pv so the QR can't downgrade to
|
|
1671
|
+
# pv=2 when the CLI shell lacks PAIRLING_MINT_ENABLED (the daemon has the
|
|
1672
|
+
# mint state, the CLI env may not). Fall back to the env marker only for a
|
|
1673
|
+
# legacy daemon that does not advertise pv.
|
|
1674
|
+
mint_enabled = os.environ.get("PAIRLING_MINT_ENABLED", "").strip().lower() in {"1", "true", "yes"}
|
|
1675
|
+
pair_params["pv"] = claim_pv if claim_pv in {"2", "3"} else ("3" if mint_enabled else "2")
|
|
1410
1676
|
if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
|
|
1411
1677
|
pair_params["route_source"] = "pairling_connectd"
|
|
1412
1678
|
pair_params["route_status"] = "ready"
|
|
@@ -1417,6 +1683,15 @@ if pair_id and secret:
|
|
|
1417
1683
|
pair_params["route_status"] = "degraded"
|
|
1418
1684
|
pair_params["route_kind"] = str(pair_route.get("kind") or pair_route.get("source") or "local")
|
|
1419
1685
|
pair_params["route_contract"] = "pairling-runtime-v1"
|
|
1686
|
+
# D1: carry the silent-join capability so the phone can warn before the QR is
|
|
1687
|
+
# consumed. Only emitted when unavailable (e.g. under tailnet lock); the iOS
|
|
1688
|
+
# parser defaults to available when the param is absent.
|
|
1689
|
+
claim_block = payload.get("claim") or {}
|
|
1690
|
+
if claim_block.get("silent_join_available") is False:
|
|
1691
|
+
pair_params["silent_join_available"] = "false"
|
|
1692
|
+
reason = str(claim_block.get("silent_join_unavailable_reason") or "")
|
|
1693
|
+
if reason:
|
|
1694
|
+
pair_params["silent_join_unavailable_reason"] = reason
|
|
1420
1695
|
manual = {
|
|
1421
1696
|
"base_url": base_url,
|
|
1422
1697
|
"pair_id": pair_id,
|
|
@@ -1715,6 +1990,7 @@ commands:
|
|
|
1715
1990
|
rotate-token <device_id>
|
|
1716
1991
|
logs
|
|
1717
1992
|
diagnose --redact
|
|
1993
|
+
enable-silent-join [--client-secret PATH] [--yes]
|
|
1718
1994
|
uninstall
|
|
1719
1995
|
rollback
|
|
1720
1996
|
EOF
|
|
@@ -1756,6 +2032,9 @@ case "$cmd" in
|
|
|
1756
2032
|
pair)
|
|
1757
2033
|
pair_runtime "$@"
|
|
1758
2034
|
;;
|
|
2035
|
+
enable-silent-join)
|
|
2036
|
+
enable_silent_join "$@"
|
|
2037
|
+
;;
|
|
1759
2038
|
devices)
|
|
1760
2039
|
devices_runtime
|
|
1761
2040
|
;;
|