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.
Files changed (28) hide show
  1. package/README.md +11 -9
  2. package/bin/pairling.mjs +5 -2
  3. package/package.json +3 -3
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/pairling_connectd_status.py +57 -7
  7. package/payload/mac/companiond/pairling_devices.py +35 -0
  8. package/payload/mac/companiond/pairling_pairing.py +67 -20
  9. package/payload/mac/companiond/pairlingd.py +269 -16
  10. package/payload/mac/companiond/push_dispatcher.py +31 -1
  11. package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
  12. package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
  13. package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
  14. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
  15. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
  16. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
  17. package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
  18. package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
  19. package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
  22. package/payload/mac/connectd/internal/runtime/config.go +19 -0
  23. package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
  24. package/payload/mac/connectd/internal/status/status.go +67 -1
  25. package/payload/mac/connectd/internal/status/status_test.go +138 -0
  26. package/payload/mac/install/install-runtime.sh +299 -20
  27. package/payload/mac/install/render-launchd.py +54 -10
  28. package/payload-manifest.json +62 -20
@@ -2,6 +2,8 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
5
7
  "encoding/json"
6
8
  "errors"
7
9
  "flag"
@@ -23,6 +25,8 @@ import (
23
25
  "dev.pairling/connectd/internal/gateway"
24
26
  runtimecfg "dev.pairling/connectd/internal/runtime"
25
27
  "dev.pairling/connectd/internal/status"
28
+ "tailscale.com/client/tailscale/apitype"
29
+ "tailscale.com/ipn/ipnstate"
26
30
  "tailscale.com/tsnet"
27
31
  )
28
32
 
@@ -37,7 +41,7 @@ func run(args []string) int {
37
41
  home, _ := os.UserHomeDir()
38
42
  appSupport := runtimecfg.DefaultAppSupportRoot(home)
39
43
  defaultStateDir := runtimecfg.DefaultStateDir(home)
40
- defaultHostname := runtimecfg.HostnameFromInstallID(runtimecfg.LoadInstallID(appSupport))
44
+ defaultHostname := runtimecfg.StableHostname(appSupport, defaultStateDir)
41
45
 
42
46
  upstreamRaw := fs.String("upstream", "http://127.0.0.1:7773", "Pairling daemon upstream URL")
43
47
  listenAddr := fs.String("listen", ":7773", "tailnet-only service listen address")
@@ -52,6 +56,12 @@ func run(args []string) int {
52
56
  // expiry disabled — no 180-day re-auth cliff, no per-node REST call.
53
57
  // Empty keeps the legacy interactive browser-auth path (back-compat).
54
58
  authKeyTag := fs.String("auth-key-tag", defaultAuthKeyTag(), "tag applied to this node when registering with an auth key")
59
+ funnelDefault := false
60
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("PAIRLING_CONNECT_FUNNEL"))) {
61
+ case "1", "true", "on", "yes":
62
+ funnelDefault = true
63
+ }
64
+ funnelEnabled := fs.Bool("funnel", funnelDefault, "expose a public Tailscale Funnel listener for the pre-pair bootstrap claim (off by default)")
55
65
 
56
66
  if err := fs.Parse(args); err != nil {
57
67
  if errors.Is(err, flag.ErrHelp) {
@@ -115,6 +125,9 @@ func run(args []string) int {
115
125
  Mode: gateway.ExposureModePairlingConnect,
116
126
  Logger: gatewayLogger{store: statusStore},
117
127
  RateLimiter: gateway.NewMemoryRateLimiter(20, 5*time.Minute),
128
+ PeerNodeResolver: tailscalePeerNodeResolver{localClient: func() (whoIsClient, error) {
129
+ return srv.LocalClient()
130
+ }},
118
131
  })
119
132
  if err != nil {
120
133
  log.Printf("cannot create gateway: %v", err)
@@ -131,6 +144,61 @@ func run(args []string) int {
131
144
  statusStore.SetListenerRunning(true)
132
145
  go monitorTailnetIPs(ctx, srv, statusStore)
133
146
 
147
+ // Increment 1: optional public Funnel listener for the pre-pair bootstrap
148
+ // claim, off by default. A SEPARATE handler in ExposureModeFunnelBootstrap,
149
+ // never the tailnet pairling_connect handler, so the bearer post-pair surface
150
+ // is structurally unreachable over Funnel. A failure to open is logged and
151
+ // recorded but does not bring down the tailnet listener.
152
+ // Increment 8: PSK-required boot precondition. On a funnel-enabled install the
153
+ // legacy plaintext /pair/claim must be impossible, so refuse to open the
154
+ // public listener unless PSK is required (the daemon default). A PSK-off
155
+ // misconfiguration would otherwise risk a secret-on-the-wire claim path.
156
+ pskRequired := true
157
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("PAIRLING_PSK_REQUIRED"))) {
158
+ case "0", "false", "no", "off":
159
+ pskRequired = false
160
+ }
161
+ if *funnelEnabled && !pskRequired {
162
+ log.Printf("refusing to open funnel listener: PAIRLING_PSK_REQUIRED must be on for a funnel-enabled install")
163
+ }
164
+ var funnelServer *http.Server
165
+ if *funnelEnabled && pskRequired {
166
+ funnelMacIDHash := ""
167
+ if id := strings.TrimSpace(runtimecfg.LoadInstallID(appSupport)); id != "" {
168
+ sum := sha256.Sum256([]byte(id))
169
+ funnelMacIDHash = hex.EncodeToString(sum[:])
170
+ }
171
+ funnelHandler, ferr := gateway.NewHandler(gateway.Options{
172
+ Upstream: upstream,
173
+ MaxBodyBytes: *maxBodyBytes,
174
+ Mode: gateway.ExposureModeFunnelBootstrap,
175
+ Logger: gatewayLogger{store: statusStore},
176
+ FunnelLimiter: gateway.NewFunnelLimiter(120, 5, 6),
177
+ FunnelMacIDHash: funnelMacIDHash,
178
+ })
179
+ if ferr != nil {
180
+ log.Printf("cannot create funnel gateway: %v", ferr)
181
+ return 1
182
+ }
183
+ funnelLn, ferr := srv.ListenFunnel("tcp", ":443", tsnet.FunnelOnly())
184
+ if ferr != nil {
185
+ statusStore.SetLastError(ferr.Error())
186
+ log.Printf("cannot start funnel listener: %v", ferr)
187
+ } else {
188
+ funnelServer = &http.Server{Handler: funnelHandler, ReadHeaderTimeout: 10 * time.Second}
189
+ if domains := srv.CertDomains(); len(domains) > 0 {
190
+ statusStore.SetFunnelHostname(domains[0])
191
+ log.Printf("pairling-connectd funnel listener open host=%s", domains[0])
192
+ }
193
+ go func() {
194
+ if serr := funnelServer.Serve(funnelLn); serr != nil && !errors.Is(serr, http.ErrServerClosed) {
195
+ statusStore.SetLastError(serr.Error())
196
+ log.Printf("funnel server stopped: %v", serr)
197
+ }
198
+ }()
199
+ }
200
+ }
201
+
134
202
  log.Printf("pairling-connectd hostname=%s state_dir=%s listen=%s upstream=%s status=%s", *hostname, *stateDir, *listenAddr, upstream.String(), *statusAddr)
135
203
  server := &http.Server{
136
204
  Handler: handler,
@@ -145,6 +213,7 @@ func run(args []string) int {
145
213
  select {
146
214
  case <-ctx.Done():
147
215
  shutdownHTTPServer(server)
216
+ shutdownHTTPServer(funnelServer)
148
217
  return 0
149
218
  case err := <-errCh:
150
219
  if err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -366,12 +435,28 @@ func monitorTailnetIPs(ctx context.Context, srv *tsnet.Server, store *status.Sto
366
435
  ticker := time.NewTicker(2 * time.Second)
367
436
  defer ticker.Stop()
368
437
  update := func() {
438
+ identity := NodeIdentity{}
439
+ if lc, err := srv.LocalClient(); err == nil {
440
+ if lock, err := lc.NetworkLockStatus(ctx); err == nil {
441
+ store.SetTailnetLockEnabled(lock.Enabled)
442
+ }
443
+ if st, err := lc.StatusWithoutPeers(ctx); err == nil {
444
+ identity = nodeIdentityFromStatus(st)
445
+ store.SetTailnetIdentity(identity.NodeID, identity.Tags, identity.TailnetIPs)
446
+ }
447
+ }
448
+ if len(identity.TailnetIPs) > 0 {
449
+ store.SetTailnetIP(identity.TailnetIPs[0])
450
+ store.SetAuthenticated()
451
+ return
452
+ }
369
453
  ip4, _ := srv.TailscaleIPs()
370
454
  if ip4.IsValid() {
371
455
  store.SetTailnetIP(ip4.String())
372
456
  store.SetAuthenticated()
373
457
  return
374
458
  }
459
+ store.SetTailnetIdentity("", nil, nil)
375
460
  store.SetAuthPending("")
376
461
  }
377
462
  update()
@@ -384,3 +469,67 @@ func monitorTailnetIPs(ctx context.Context, srv *tsnet.Server, store *status.Sto
384
469
  }
385
470
  }
386
471
  }
472
+
473
+ type NodeIdentity struct {
474
+ NodeID string
475
+ Tags []string
476
+ TailnetIPs []string
477
+ }
478
+
479
+ type whoIsClient interface {
480
+ WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error)
481
+ }
482
+
483
+ type tailscalePeerNodeResolver struct {
484
+ localClient func() (whoIsClient, error)
485
+ }
486
+
487
+ func (r tailscalePeerNodeResolver) PeerNodeID(ctx context.Context, remoteAddr string) (string, bool) {
488
+ if r.localClient == nil {
489
+ return "", false
490
+ }
491
+ lc, err := r.localClient()
492
+ if err != nil || lc == nil {
493
+ return "", false
494
+ }
495
+ who, err := lc.WhoIs(ctx, remoteAddr)
496
+ if err != nil {
497
+ return "", false
498
+ }
499
+ return peerNodeIDFromWhoIs(who)
500
+ }
501
+
502
+ func peerNodeIDFromWhoIs(who *apitype.WhoIsResponse) (string, bool) {
503
+ if who == nil || who.Node == nil {
504
+ return "", false
505
+ }
506
+ nodeID := strings.TrimSpace(string(who.Node.StableID))
507
+ if nodeID == "" {
508
+ return "", false
509
+ }
510
+ for _, tag := range who.Node.Tags {
511
+ if tag == "tag:pairling-phone" {
512
+ return nodeID, true
513
+ }
514
+ }
515
+ return "", false
516
+ }
517
+
518
+ func nodeIdentityFromStatus(st *ipnstate.Status) NodeIdentity {
519
+ if st == nil || st.Self == nil {
520
+ return NodeIdentity{}
521
+ }
522
+ self := st.Self
523
+ identity := NodeIdentity{NodeID: string(self.ID)}
524
+ if self.Tags != nil {
525
+ for _, tag := range self.Tags.All() {
526
+ identity.Tags = append(identity.Tags, tag)
527
+ }
528
+ }
529
+ for _, ip := range self.TailscaleIPs {
530
+ if ip.IsValid() {
531
+ identity.TailnetIPs = append(identity.TailnetIPs, ip.String())
532
+ }
533
+ }
534
+ return identity
535
+ }
@@ -0,0 +1,86 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+
7
+ "tailscale.com/client/tailscale/apitype"
8
+ "tailscale.com/tailcfg"
9
+ )
10
+
11
+ type fakeWhoIsClient struct {
12
+ addr string
13
+ resp *apitype.WhoIsResponse
14
+ err error
15
+ }
16
+
17
+ func (f *fakeWhoIsClient) WhoIs(_ context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
18
+ f.addr = remoteAddr
19
+ return f.resp, f.err
20
+ }
21
+
22
+ func TestPeerNodeResolverUsesWhoIsRemoteAddr(t *testing.T) {
23
+ fake := &fakeWhoIsClient{
24
+ resp: &apitype.WhoIsResponse{
25
+ Node: &tailcfg.Node{
26
+ StableID: tailcfg.StableNodeID("nPeerCNTRL"),
27
+ Tags: []string{"tag:pairling-phone"},
28
+ },
29
+ },
30
+ }
31
+ resolver := tailscalePeerNodeResolver{
32
+ localClient: func() (whoIsClient, error) {
33
+ return fake, nil
34
+ },
35
+ }
36
+
37
+ nodeID, ok := resolver.PeerNodeID(context.Background(), "100.64.0.50:12345")
38
+ if !ok {
39
+ t.Fatal("resolver should accept tagged peer")
40
+ }
41
+ if fake.addr != "100.64.0.50:12345" {
42
+ t.Fatalf("WhoIs addr = %q", fake.addr)
43
+ }
44
+ if nodeID != "nPeerCNTRL" {
45
+ t.Fatalf("node ID = %q", nodeID)
46
+ }
47
+ }
48
+
49
+ func TestWhoIsResolvesPeerStableID(t *testing.T) {
50
+ nodeID, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
51
+ Node: &tailcfg.Node{
52
+ StableID: tailcfg.StableNodeID("nPeerCNTRL"),
53
+ Tags: []string{"tag:pairling-phone"},
54
+ },
55
+ })
56
+
57
+ if !ok {
58
+ t.Fatal("WhoIs peer should resolve")
59
+ }
60
+ if nodeID != "nPeerCNTRL" {
61
+ t.Fatalf("node ID = %q, want nPeerCNTRL", nodeID)
62
+ }
63
+ }
64
+
65
+ func TestAssertsGrantedPhoneTagBeforePersist(t *testing.T) {
66
+ cases := []struct {
67
+ name string
68
+ tags []string
69
+ }{
70
+ {name: "untagged"},
71
+ {name: "wrong tag", tags: []string{"tag:pairling-connect"}},
72
+ }
73
+ for _, tc := range cases {
74
+ t.Run(tc.name, func(t *testing.T) {
75
+ nodeID, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
76
+ Node: &tailcfg.Node{
77
+ StableID: tailcfg.StableNodeID("nPeerCNTRL"),
78
+ Tags: tc.tags,
79
+ },
80
+ })
81
+ if ok || nodeID != "" {
82
+ t.Fatalf("wrongly accepted node ID %q with tags %#v", nodeID, tc.tags)
83
+ }
84
+ })
85
+ }
86
+ }
@@ -0,0 +1,121 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "flag"
8
+ "fmt"
9
+ "log"
10
+ "net/http"
11
+ "os"
12
+ "os/exec"
13
+ "os/signal"
14
+ "strconv"
15
+ "strings"
16
+ "syscall"
17
+ "time"
18
+ )
19
+
20
+ func main() {
21
+ var (
22
+ secretPath = flag.String("secret-path", "/Library/Application Support/Pairling/mint/client_secret.json", "OAuth client credential JSON")
23
+ socketPath = flag.String("socket-path", "/Library/Application Support/Pairling/run/mintd/mintd.sock", "Unix socket path")
24
+ statePath = flag.String("state-path", "/Library/Application Support/Pairling/mint/state.json", "persistent rate-limit state JSON")
25
+ auditPath = flag.String("audit-path", "/Library/Application Support/Pairling/mint/audit.jsonl", "audit JSONL path")
26
+ alertPath = flag.String("alert-path", "/Library/Application Support/Pairling/run/mintd/alerts.jsonl", "health-readable alert JSONL path")
27
+ apiBaseURL = flag.String("api-base-url", "https://api.tailscale.com/api/v2", "Tailscale API base URL")
28
+ oauthURL = flag.String("oauth-url", "https://api.tailscale.com/api/v2/oauth/token", "Tailscale OAuth token URL")
29
+ authorizedUID = flag.Int("authorized-uid", -1, "only this peer uid may request mints")
30
+ )
31
+ flag.Parse()
32
+
33
+ b, err := NewBroker(BrokerConfig{
34
+ SecretPath: *secretPath,
35
+ StatePath: *statePath,
36
+ AuditPath: *auditPath,
37
+ AlertPath: *alertPath,
38
+ OAuthURL: *oauthURL,
39
+ APIBaseURL: *apiBaseURL,
40
+ LockStatus: defaultLockStatus,
41
+ })
42
+ if err != nil {
43
+ log.Fatal(err)
44
+ }
45
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
46
+ defer stop()
47
+ if err := b.ServeUnix(ctx, *socketPath, *authorizedUID); err != nil {
48
+ log.Fatal(err)
49
+ }
50
+ }
51
+
52
+ func defaultLockStatus(ctx context.Context) (bool, error) {
53
+ if locked, err := lockStatusFromConnectdStatus(ctx, "http://127.0.0.1:7774/status"); err == nil {
54
+ return locked, nil
55
+ }
56
+ return lockStatusFromCandidates(ctx, []string{
57
+ "/opt/homebrew/bin/tailscale",
58
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
59
+ "tailscale",
60
+ })
61
+ }
62
+
63
+ func lockStatusFromConnectdStatus(ctx context.Context, statusURL string) (bool, error) {
64
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
65
+ if err != nil {
66
+ return false, err
67
+ }
68
+ resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
69
+ if err != nil {
70
+ return false, err
71
+ }
72
+ defer resp.Body.Close()
73
+ if resp.StatusCode != http.StatusOK {
74
+ return false, fmt.Errorf("connectd status returned %s", resp.Status)
75
+ }
76
+ var body struct {
77
+ TailnetLockEnabled *bool `json:"tailnet_lock_enabled"`
78
+ }
79
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
80
+ return false, err
81
+ }
82
+ if body.TailnetLockEnabled == nil {
83
+ return false, errors.New("connectd status omitted tailnet_lock_enabled")
84
+ }
85
+ return *body.TailnetLockEnabled, nil
86
+ }
87
+
88
+ func lockStatusFromCandidates(ctx context.Context, candidates []string) (bool, error) {
89
+ var errs []error
90
+ for _, bin := range candidates {
91
+ out, err := exec.CommandContext(ctx, bin, "lock", "status").CombinedOutput()
92
+ if err != nil {
93
+ errs = append(errs, fmt.Errorf("%s: %w", bin, err))
94
+ continue
95
+ }
96
+ text := strings.ToLower(string(out))
97
+ if strings.Contains(text, "tailscale gui failed to start") {
98
+ errs = append(errs, fmt.Errorf("%s: gui unavailable", bin))
99
+ continue
100
+ }
101
+ if strings.Contains(text, "not enabled") || strings.Contains(text, "disabled") {
102
+ return false, nil
103
+ }
104
+ if strings.Contains(text, "enabled") {
105
+ return true, nil
106
+ }
107
+ return false, fmt.Errorf("unrecognized tailscale lock status: %q", strings.TrimSpace(string(out)))
108
+ }
109
+ return false, errors.New("tailscale lock status unavailable: " + joinErrors(errs))
110
+ }
111
+
112
+ func joinErrors(errs []error) string {
113
+ parts := make([]string, 0, len(errs))
114
+ for _, err := range errs {
115
+ parts = append(parts, err.Error())
116
+ }
117
+ if len(parts) == 0 {
118
+ return "no candidates"
119
+ }
120
+ return strconv.Quote(strings.Join(parts, "; "))
121
+ }