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
|
@@ -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.
|
|
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
|
+
}
|