pairling 0.2.7 → 0.2.8
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 +10 -11
- package/bin/pairling.mjs +1 -4
- package/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairlingd.py +1 -143
- package/payload/mac/install/install-runtime.sh +113 -294
- package/payload/mac/install/render-launchd.py +2 -45
- package/payload/mac/install/uninstall-runtime.sh +32 -0
- package/payload-manifest.json +10 -32
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +0 -121
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +0 -418
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +0 -894
|
@@ -11,6 +11,11 @@ USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
|
|
|
11
11
|
CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
|
|
12
12
|
PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
|
|
13
13
|
SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
|
|
14
|
+
# Legacy: the silent-join mint broker, removed from the product. Torn down below.
|
|
15
|
+
MINTD_SYSTEM_LABEL="dev.pairling.mintd"
|
|
16
|
+
MINTD_SYSTEM_PLIST="/Library/LaunchDaemons/$MINTD_SYSTEM_LABEL.plist"
|
|
17
|
+
MINTD_SYSTEM_DIR="/Library/Application Support/Pairling/mint"
|
|
18
|
+
MINTD_SERVICE_ACCOUNT="_pairling_mint"
|
|
14
19
|
YES="false"
|
|
15
20
|
DELETE_STATE="false"
|
|
16
21
|
DELETE_LOGS="false"
|
|
@@ -98,6 +103,32 @@ bootout_system() {
|
|
|
98
103
|
fi
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
# Legacy teardown: the silent-join mint broker (dev.pairling.mintd) was removed
|
|
107
|
+
# from the product. Machines that ran the old `enable-silent-join` still carry a
|
|
108
|
+
# root LaunchDaemon, a stored Tailscale OAuth secret under the system mint dir,
|
|
109
|
+
# and the _pairling_mint role account. Remove all three. Best-effort, sudo-gated.
|
|
110
|
+
teardown_legacy_mintd() {
|
|
111
|
+
if [[ ! -f "$MINTD_SYSTEM_PLIST" && ! -d "$MINTD_SYSTEM_DIR" ]] \
|
|
112
|
+
&& ! id -u "$MINTD_SERVICE_ACCOUNT" >/dev/null 2>&1; then
|
|
113
|
+
return
|
|
114
|
+
fi
|
|
115
|
+
if is_dry_run; then
|
|
116
|
+
printf 'dry-run: would remove the legacy silent-join mint broker (%s, %s, user %s)\n' \
|
|
117
|
+
"$MINTD_SYSTEM_PLIST" "$MINTD_SYSTEM_DIR" "$MINTD_SERVICE_ACCOUNT"
|
|
118
|
+
return
|
|
119
|
+
fi
|
|
120
|
+
if sudo -n true >/dev/null 2>&1; then
|
|
121
|
+
sudo launchctl bootout "system/$MINTD_SYSTEM_LABEL" >/dev/null 2>&1 || true
|
|
122
|
+
sudo launchctl bootout system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
|
|
123
|
+
sudo rm -f "$MINTD_SYSTEM_PLIST"
|
|
124
|
+
sudo rm -rf "$MINTD_SYSTEM_DIR"
|
|
125
|
+
sudo /usr/sbin/sysadminctl -deleteUser "$MINTD_SERVICE_ACCOUNT" >/dev/null 2>&1 || true
|
|
126
|
+
printf 'Removed the legacy silent-join mint broker.\n'
|
|
127
|
+
else
|
|
128
|
+
printf 'Skipping legacy mint-broker removal: passwordless sudo is unavailable.\n' >&2
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
101
132
|
confirm
|
|
102
133
|
|
|
103
134
|
bootout_user "$PAIRLING_DAEMON_LABEL" "$USER_PLIST"
|
|
@@ -107,6 +138,7 @@ rm -f "$USER_PLIST"
|
|
|
107
138
|
rm -f "$CONNECTD_USER_PLIST"
|
|
108
139
|
rm -f "$PTYBROKER_USER_PLIST"
|
|
109
140
|
bootout_system "$PAIRLING_GUARDIAN_LABEL" "$SYSTEM_PLIST"
|
|
141
|
+
teardown_legacy_mintd
|
|
110
142
|
|
|
111
143
|
rm -rf "$APP_SUPPORT/pair" 2>/dev/null || true
|
|
112
144
|
|
package/payload-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"connectd": {
|
|
3
3
|
"darwin-arm64": {
|
|
4
|
-
"sha256": "
|
|
4
|
+
"sha256": "d4d9439eae6cfa244b491b5086050f932fe78058e5ac18a876b80b20ea8517ad",
|
|
5
5
|
"team_id": "965AVD34A3"
|
|
6
6
|
},
|
|
7
7
|
"darwin-x64": {
|
|
8
|
-
"sha256": "
|
|
8
|
+
"sha256": "936c7046bfc3cd84289881444da57ad025d5c734c44a01c2dcbd9eb2afc7cbb8",
|
|
9
9
|
"team_id": "965AVD34A3"
|
|
10
10
|
}
|
|
11
11
|
},
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"path": "payload/mac/SOURCE_REVISION",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "354ac93fcb550a4bf353e3857282bafb747f158ce1ad6e7068a5d85c5cd071bd"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "payload/mac/VERSION",
|
|
27
|
-
"sha256": "
|
|
27
|
+
"sha256": "283571d2642fc7b3befd294f45d53b896a1d54d34c86235b13151192b380606a"
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"path": "payload/mac/companiond/app_attest_lan.py",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
"path": "payload/mac/companiond/pairlingd.py",
|
|
99
|
-
"sha256": "
|
|
99
|
+
"sha256": "e69188ec651d0a04af5cad73c9dd0a81dfa6455b6e32f8b733c412d11a4ead9e"
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
"path": "payload/mac/companiond/providers/__init__.py",
|
|
@@ -210,18 +210,6 @@
|
|
|
210
210
|
"path": "payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go",
|
|
211
211
|
"sha256": "dc5b6d3a8d11f38bcc198287bdbc95f058d35792ca6cf34bc49ca0bed22bfacf"
|
|
212
212
|
},
|
|
213
|
-
{
|
|
214
|
-
"path": "payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go",
|
|
215
|
-
"sha256": "64fd4527c97397fce10793001af8d59ac3154c75c7ed9e1b532f8f2e4bf88bd4"
|
|
216
|
-
},
|
|
217
|
-
{
|
|
218
|
-
"path": "payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go",
|
|
219
|
-
"sha256": "ca49b8ab8216eeec770ad6e9e8111aca819a9a88b050d7eefbdf69c0fad20376"
|
|
220
|
-
},
|
|
221
|
-
{
|
|
222
|
-
"path": "payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go",
|
|
223
|
-
"sha256": "14a9af6b9575ba89314ce05f2fe465844fea3bb40b3eba5e0d33d059608bfb1f"
|
|
224
|
-
},
|
|
225
213
|
{
|
|
226
214
|
"path": "payload/mac/connectd/go.mod",
|
|
227
215
|
"sha256": "c96748d396598b0952b4c0d43f7f85ca3a56f4019761088267421b22518d5905"
|
|
@@ -284,7 +272,7 @@
|
|
|
284
272
|
},
|
|
285
273
|
{
|
|
286
274
|
"path": "payload/mac/install/install-runtime.sh",
|
|
287
|
-
"sha256": "
|
|
275
|
+
"sha256": "40ffa67a3833ce4342c241ca4f1e7dec471a86b0c80359746cd75a8df2e33f60"
|
|
288
276
|
},
|
|
289
277
|
{
|
|
290
278
|
"path": "payload/mac/install/psk_dependency_check.py",
|
|
@@ -292,11 +280,11 @@
|
|
|
292
280
|
},
|
|
293
281
|
{
|
|
294
282
|
"path": "payload/mac/install/render-launchd.py",
|
|
295
|
-
"sha256": "
|
|
283
|
+
"sha256": "29db1aa46d62cd23ba27ab283bb990d168bfc08fb82cfc681046dc7b887d85fe"
|
|
296
284
|
},
|
|
297
285
|
{
|
|
298
286
|
"path": "payload/mac/install/uninstall-runtime.sh",
|
|
299
|
-
"sha256": "
|
|
287
|
+
"sha256": "0edeb5336a71c235d8796a8e59072c6d8983d196de5aae0cfcb4b0c3560fa33f"
|
|
300
288
|
},
|
|
301
289
|
{
|
|
302
290
|
"path": "payload/mac/mcp/phone_tools.py",
|
|
@@ -307,19 +295,9 @@
|
|
|
307
295
|
"sha256": "5ebcd63fc53114ace518807c2221e562e65237e57945a76c457f5931a5791cc1"
|
|
308
296
|
}
|
|
309
297
|
],
|
|
310
|
-
"mintd": {
|
|
311
|
-
"darwin-arm64": {
|
|
312
|
-
"sha256": "326d1d42a0b40bdb60687f7b7d190c12c520106e47542054b1b807d397e41fe9",
|
|
313
|
-
"team_id": "965AVD34A3"
|
|
314
|
-
},
|
|
315
|
-
"darwin-x64": {
|
|
316
|
-
"sha256": "477cf4d9b88f1e8303e5545eb3c03460e0c75b284f9d628641cb80e2be14af38",
|
|
317
|
-
"team_id": "965AVD34A3"
|
|
318
|
-
}
|
|
319
|
-
},
|
|
320
298
|
"package": "pairling",
|
|
321
|
-
"package_version": "0.2.
|
|
299
|
+
"package_version": "0.2.8",
|
|
322
300
|
"schema_version": 1,
|
|
323
301
|
"source_dirty": false,
|
|
324
|
-
"source_revision": "
|
|
302
|
+
"source_revision": "c07a6fd"
|
|
325
303
|
}
|
|
@@ -1,121 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,418 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"bytes"
|
|
5
|
-
"context"
|
|
6
|
-
"encoding/json"
|
|
7
|
-
"fmt"
|
|
8
|
-
"net"
|
|
9
|
-
"net/http"
|
|
10
|
-
"os"
|
|
11
|
-
"path/filepath"
|
|
12
|
-
"regexp"
|
|
13
|
-
"strconv"
|
|
14
|
-
"sync"
|
|
15
|
-
"time"
|
|
16
|
-
|
|
17
|
-
"github.com/tailscale/peercred"
|
|
18
|
-
"golang.org/x/oauth2"
|
|
19
|
-
"golang.org/x/oauth2/clientcredentials"
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
const phoneTag = "tag:pairling-phone"
|
|
23
|
-
|
|
24
|
-
var pairIDPattern = regexp.MustCompile(`^pair_[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
|
25
|
-
|
|
26
|
-
var peerUID = func(conn net.Conn) (string, error) {
|
|
27
|
-
creds, err := peercred.Get(conn)
|
|
28
|
-
if err != nil {
|
|
29
|
-
return "", err
|
|
30
|
-
}
|
|
31
|
-
uid, ok := creds.UserID()
|
|
32
|
-
if !ok {
|
|
33
|
-
return "", fmt.Errorf("peer uid unavailable")
|
|
34
|
-
}
|
|
35
|
-
return uid, nil
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
type BrokerConfig struct {
|
|
39
|
-
SecretPath string
|
|
40
|
-
StatePath string
|
|
41
|
-
AuditPath string
|
|
42
|
-
AlertPath string
|
|
43
|
-
OAuthURL string
|
|
44
|
-
APIBaseURL string
|
|
45
|
-
Now func() time.Time
|
|
46
|
-
LockStatus func(context.Context) (bool, error)
|
|
47
|
-
HTTPClient *http.Client
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
type Broker struct {
|
|
51
|
-
cfg BrokerConfig
|
|
52
|
-
mu sync.Mutex
|
|
53
|
-
tokenSource oauth2.TokenSource
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
type clientSecret struct {
|
|
57
|
-
ClientID string `json:"client_id"`
|
|
58
|
-
ClientSecret string `json:"client_secret"`
|
|
59
|
-
Scopes []string `json:"scopes,omitempty"`
|
|
60
|
-
Tags []string `json:"tags,omitempty"`
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
type MintResult struct {
|
|
64
|
-
AuthKey string `json:"authkey"`
|
|
65
|
-
KeyID string `json:"key_id"`
|
|
66
|
-
ExpiresAt int64 `json:"expires_at"`
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
type socketRequest struct {
|
|
70
|
-
Op string `json:"op"`
|
|
71
|
-
PairID string `json:"pair_id"`
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
type socketResponse struct {
|
|
75
|
-
OK bool `json:"ok"`
|
|
76
|
-
AuthKey string `json:"authkey,omitempty"`
|
|
77
|
-
KeyID string `json:"key_id,omitempty"`
|
|
78
|
-
ExpiresAt int64 `json:"expires_at,omitempty"`
|
|
79
|
-
Error string `json:"error,omitempty"`
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
type brokerState struct {
|
|
83
|
-
SuccessfulPairs map[string]int64 `json:"successful_pairs,omitempty"`
|
|
84
|
-
SuccessfulMints []int64 `json:"successful_mints,omitempty"`
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
|
88
|
-
if cfg.OAuthURL == "" {
|
|
89
|
-
cfg.OAuthURL = "https://api.tailscale.com/api/v2/oauth/token"
|
|
90
|
-
}
|
|
91
|
-
if cfg.APIBaseURL == "" {
|
|
92
|
-
cfg.APIBaseURL = "https://api.tailscale.com/api/v2"
|
|
93
|
-
}
|
|
94
|
-
if cfg.Now == nil {
|
|
95
|
-
cfg.Now = time.Now
|
|
96
|
-
}
|
|
97
|
-
if cfg.HTTPClient == nil {
|
|
98
|
-
cfg.HTTPClient = http.DefaultClient
|
|
99
|
-
}
|
|
100
|
-
return &Broker{cfg: cfg}, nil
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
func writeClientSecret(path string, secret clientSecret) error {
|
|
104
|
-
data, err := json.MarshalIndent(secret, "", " ")
|
|
105
|
-
if err != nil {
|
|
106
|
-
return err
|
|
107
|
-
}
|
|
108
|
-
data = append(data, '\n')
|
|
109
|
-
return os.WriteFile(path, data, 0o600)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
func readClientSecret(path string) (clientSecret, error) {
|
|
113
|
-
data, err := os.ReadFile(path)
|
|
114
|
-
if err != nil {
|
|
115
|
-
return clientSecret{}, err
|
|
116
|
-
}
|
|
117
|
-
var secret clientSecret
|
|
118
|
-
if err := json.Unmarshal(data, &secret); err != nil {
|
|
119
|
-
return clientSecret{}, err
|
|
120
|
-
}
|
|
121
|
-
if secret.ClientID == "" || secret.ClientSecret == "" {
|
|
122
|
-
return clientSecret{}, fmt.Errorf("missing client_id or client_secret")
|
|
123
|
-
}
|
|
124
|
-
return secret, nil
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
func (b *Broker) MintPhoneKey(ctx context.Context, pairID string) (MintResult, error) {
|
|
128
|
-
if !pairIDPattern.MatchString(pairID) {
|
|
129
|
-
return MintResult{}, fmt.Errorf("invalid pair_id")
|
|
130
|
-
}
|
|
131
|
-
b.mu.Lock()
|
|
132
|
-
defer b.mu.Unlock()
|
|
133
|
-
state, err := b.loadState()
|
|
134
|
-
if err != nil {
|
|
135
|
-
return MintResult{}, err
|
|
136
|
-
}
|
|
137
|
-
now := b.cfg.Now().Unix()
|
|
138
|
-
if state.SuccessfulPairs[pairID] != 0 {
|
|
139
|
-
_ = b.alert(map[string]any{
|
|
140
|
-
"event": "duplicate_pair_id",
|
|
141
|
-
"ts": now,
|
|
142
|
-
"pair_id": pairID,
|
|
143
|
-
})
|
|
144
|
-
return MintResult{}, fmt.Errorf("pair_id already minted")
|
|
145
|
-
}
|
|
146
|
-
state.SuccessfulMints = pruneSince(state.SuccessfulMints, now-24*60*60)
|
|
147
|
-
if countSince(state.SuccessfulMints, now-10*60) >= 3 {
|
|
148
|
-
_ = b.alert(map[string]any{
|
|
149
|
-
"event": "mint_rate_limited",
|
|
150
|
-
"ts": now,
|
|
151
|
-
"pair_id": pairID,
|
|
152
|
-
"window": "10m",
|
|
153
|
-
})
|
|
154
|
-
return MintResult{}, fmt.Errorf("mint rate limited")
|
|
155
|
-
}
|
|
156
|
-
if len(state.SuccessfulMints) >= 12 {
|
|
157
|
-
_ = b.alert(map[string]any{
|
|
158
|
-
"event": "mint_rate_limited",
|
|
159
|
-
"ts": now,
|
|
160
|
-
"pair_id": pairID,
|
|
161
|
-
"window": "24h",
|
|
162
|
-
})
|
|
163
|
-
return MintResult{}, fmt.Errorf("mint rate limited")
|
|
164
|
-
}
|
|
165
|
-
locked, err := b.lockEnabled(ctx)
|
|
166
|
-
if err != nil {
|
|
167
|
-
return MintResult{}, err
|
|
168
|
-
}
|
|
169
|
-
if locked {
|
|
170
|
-
return MintResult{}, fmt.Errorf("tailnet lock enabled; unsigned minting is disabled")
|
|
171
|
-
}
|
|
172
|
-
secret, err := readClientSecret(b.cfg.SecretPath)
|
|
173
|
-
if err != nil {
|
|
174
|
-
return MintResult{}, err
|
|
175
|
-
}
|
|
176
|
-
token, err := b.token(ctx, secret)
|
|
177
|
-
if err != nil {
|
|
178
|
-
return MintResult{}, err
|
|
179
|
-
}
|
|
180
|
-
body := map[string]any{
|
|
181
|
-
"capabilities": map[string]any{
|
|
182
|
-
"devices": map[string]any{
|
|
183
|
-
"create": map[string]any{
|
|
184
|
-
"reusable": false,
|
|
185
|
-
"ephemeral": false,
|
|
186
|
-
"preauthorized": true,
|
|
187
|
-
"tags": []string{phoneTag},
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
"expirySeconds": 600,
|
|
192
|
-
}
|
|
193
|
-
payload, err := json.Marshal(body)
|
|
194
|
-
if err != nil {
|
|
195
|
-
return MintResult{}, err
|
|
196
|
-
}
|
|
197
|
-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.cfg.APIBaseURL+"/tailnet/-/keys", bytes.NewReader(payload))
|
|
198
|
-
if err != nil {
|
|
199
|
-
return MintResult{}, err
|
|
200
|
-
}
|
|
201
|
-
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
|
202
|
-
req.Header.Set("Content-Type", "application/json")
|
|
203
|
-
resp, err := b.cfg.HTTPClient.Do(req)
|
|
204
|
-
if err != nil {
|
|
205
|
-
return MintResult{}, err
|
|
206
|
-
}
|
|
207
|
-
defer resp.Body.Close()
|
|
208
|
-
if resp.StatusCode != http.StatusOK {
|
|
209
|
-
return MintResult{}, fmt.Errorf("tailscale key mint failed: %s", resp.Status)
|
|
210
|
-
}
|
|
211
|
-
var out struct {
|
|
212
|
-
ID string `json:"id"`
|
|
213
|
-
Key string `json:"key"`
|
|
214
|
-
Expires string `json:"expires"`
|
|
215
|
-
}
|
|
216
|
-
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
217
|
-
return MintResult{}, err
|
|
218
|
-
}
|
|
219
|
-
exp, _ := time.Parse(time.RFC3339, out.Expires)
|
|
220
|
-
result := MintResult{AuthKey: out.Key, KeyID: out.ID, ExpiresAt: exp.Unix()}
|
|
221
|
-
state.SuccessfulPairs[pairID] = now
|
|
222
|
-
state.SuccessfulMints = append(state.SuccessfulMints, now)
|
|
223
|
-
if err := b.saveState(state); err != nil {
|
|
224
|
-
return MintResult{}, err
|
|
225
|
-
}
|
|
226
|
-
if err := b.audit(map[string]any{
|
|
227
|
-
"event": "mint_success",
|
|
228
|
-
"ts": now,
|
|
229
|
-
"pair_id": pairID,
|
|
230
|
-
"key_id": out.ID,
|
|
231
|
-
}); err != nil {
|
|
232
|
-
return MintResult{}, err
|
|
233
|
-
}
|
|
234
|
-
return result, nil
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
func pruneSince(times []int64, cutoff int64) []int64 {
|
|
238
|
-
kept := times[:0]
|
|
239
|
-
for _, ts := range times {
|
|
240
|
-
if ts >= cutoff {
|
|
241
|
-
kept = append(kept, ts)
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return kept
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
func countSince(times []int64, cutoff int64) int {
|
|
248
|
-
count := 0
|
|
249
|
-
for _, ts := range times {
|
|
250
|
-
if ts >= cutoff {
|
|
251
|
-
count++
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return count
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
func (b *Broker) token(ctx context.Context, secret clientSecret) (*oauth2.Token, error) {
|
|
258
|
-
if b.tokenSource == nil {
|
|
259
|
-
oauth := clientcredentials.Config{
|
|
260
|
-
ClientID: secret.ClientID,
|
|
261
|
-
ClientSecret: secret.ClientSecret,
|
|
262
|
-
TokenURL: b.cfg.OAuthURL,
|
|
263
|
-
Scopes: []string{"auth_keys"},
|
|
264
|
-
}
|
|
265
|
-
ctx = context.WithValue(ctx, oauth2.HTTPClient, b.cfg.HTTPClient)
|
|
266
|
-
b.tokenSource = oauth2.ReuseTokenSource(nil, oauth.TokenSource(ctx))
|
|
267
|
-
}
|
|
268
|
-
return b.tokenSource.Token()
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
func (b *Broker) lockEnabled(ctx context.Context) (bool, error) {
|
|
272
|
-
if b.cfg.LockStatus == nil {
|
|
273
|
-
return false, nil
|
|
274
|
-
}
|
|
275
|
-
return b.cfg.LockStatus(ctx)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
func (b *Broker) ServeUnix(ctx context.Context, socketPath string, authorizedUID int) error {
|
|
279
|
-
if authorizedUID < 0 {
|
|
280
|
-
return fmt.Errorf("authorized uid required")
|
|
281
|
-
}
|
|
282
|
-
if err := os.MkdirAll(filepath.Dir(socketPath), 0o750); err != nil {
|
|
283
|
-
return err
|
|
284
|
-
}
|
|
285
|
-
_ = os.Remove(socketPath)
|
|
286
|
-
l, err := net.Listen("unix", socketPath)
|
|
287
|
-
if err != nil {
|
|
288
|
-
return err
|
|
289
|
-
}
|
|
290
|
-
defer func() {
|
|
291
|
-
_ = l.Close()
|
|
292
|
-
_ = os.Remove(socketPath)
|
|
293
|
-
}()
|
|
294
|
-
_ = os.Chmod(socketPath, 0o660)
|
|
295
|
-
go func() {
|
|
296
|
-
<-ctx.Done()
|
|
297
|
-
_ = l.Close()
|
|
298
|
-
}()
|
|
299
|
-
for {
|
|
300
|
-
conn, err := l.Accept()
|
|
301
|
-
if err != nil {
|
|
302
|
-
if ctx.Err() != nil {
|
|
303
|
-
return nil
|
|
304
|
-
}
|
|
305
|
-
return err
|
|
306
|
-
}
|
|
307
|
-
go b.handleConn(ctx, conn, authorizedUID)
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
func (b *Broker) handleConn(ctx context.Context, conn net.Conn, authorizedUID int) {
|
|
312
|
-
defer conn.Close()
|
|
313
|
-
uid, err := peerUID(conn)
|
|
314
|
-
if err != nil {
|
|
315
|
-
_ = json.NewEncoder(conn).Encode(socketResponse{OK: false, Error: "peercred_unavailable"})
|
|
316
|
-
return
|
|
317
|
-
}
|
|
318
|
-
if err := authorizePeer(uid, authorizedUID); err != nil {
|
|
319
|
-
_ = b.alert(map[string]any{"event": "unexpected_peer_uid", "ts": b.cfg.Now().Unix(), "uid": uid})
|
|
320
|
-
_ = json.NewEncoder(conn).Encode(socketResponse{OK: false, Error: "unauthorized_peer"})
|
|
321
|
-
return
|
|
322
|
-
}
|
|
323
|
-
var req socketRequest
|
|
324
|
-
if err := json.NewDecoder(conn).Decode(&req); err != nil {
|
|
325
|
-
_ = json.NewEncoder(conn).Encode(socketResponse{OK: false, Error: "bad_json"})
|
|
326
|
-
return
|
|
327
|
-
}
|
|
328
|
-
if req.Op != "mint_phone_key" || req.PairID == "" {
|
|
329
|
-
_ = json.NewEncoder(conn).Encode(socketResponse{OK: false, Error: "bad_request"})
|
|
330
|
-
return
|
|
331
|
-
}
|
|
332
|
-
res, err := b.MintPhoneKey(ctx, req.PairID)
|
|
333
|
-
if err != nil {
|
|
334
|
-
_ = json.NewEncoder(conn).Encode(socketResponse{OK: false, Error: err.Error()})
|
|
335
|
-
return
|
|
336
|
-
}
|
|
337
|
-
_ = json.NewEncoder(conn).Encode(socketResponse{
|
|
338
|
-
OK: true,
|
|
339
|
-
AuthKey: res.AuthKey,
|
|
340
|
-
KeyID: res.KeyID,
|
|
341
|
-
ExpiresAt: res.ExpiresAt,
|
|
342
|
-
})
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
func authorizePeer(uid string, authorizedUID int) error {
|
|
346
|
-
if uid != strconv.Itoa(authorizedUID) {
|
|
347
|
-
return fmt.Errorf("unexpected peer uid")
|
|
348
|
-
}
|
|
349
|
-
return nil
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
func (b *Broker) loadState() (brokerState, error) {
|
|
353
|
-
state := brokerState{SuccessfulPairs: map[string]int64{}}
|
|
354
|
-
if b.cfg.StatePath == "" {
|
|
355
|
-
return state, nil
|
|
356
|
-
}
|
|
357
|
-
data, err := os.ReadFile(b.cfg.StatePath)
|
|
358
|
-
if os.IsNotExist(err) {
|
|
359
|
-
return state, nil
|
|
360
|
-
}
|
|
361
|
-
if err != nil {
|
|
362
|
-
return state, err
|
|
363
|
-
}
|
|
364
|
-
if err := json.Unmarshal(data, &state); err != nil {
|
|
365
|
-
return state, err
|
|
366
|
-
}
|
|
367
|
-
if state.SuccessfulPairs == nil {
|
|
368
|
-
state.SuccessfulPairs = map[string]int64{}
|
|
369
|
-
}
|
|
370
|
-
return state, nil
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
func (b *Broker) saveState(state brokerState) error {
|
|
374
|
-
if b.cfg.StatePath == "" {
|
|
375
|
-
return nil
|
|
376
|
-
}
|
|
377
|
-
if err := os.MkdirAll(filepath.Dir(b.cfg.StatePath), 0o700); err != nil {
|
|
378
|
-
return err
|
|
379
|
-
}
|
|
380
|
-
data, err := json.MarshalIndent(state, "", " ")
|
|
381
|
-
if err != nil {
|
|
382
|
-
return err
|
|
383
|
-
}
|
|
384
|
-
data = append(data, '\n')
|
|
385
|
-
return os.WriteFile(b.cfg.StatePath, data, 0o600)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
func (b *Broker) audit(record map[string]any) error {
|
|
389
|
-
return appendJSONL(b.cfg.AuditPath, record, 0o700, 0o600)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
func (b *Broker) alert(record map[string]any) error {
|
|
393
|
-
if err := b.audit(record); err != nil {
|
|
394
|
-
return err
|
|
395
|
-
}
|
|
396
|
-
return appendJSONL(b.cfg.AlertPath, record, 0o750, 0o640)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
func appendJSONL(path string, record map[string]any, dirMode, fileMode os.FileMode) error {
|
|
400
|
-
if path == "" {
|
|
401
|
-
return nil
|
|
402
|
-
}
|
|
403
|
-
if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil {
|
|
404
|
-
return err
|
|
405
|
-
}
|
|
406
|
-
data, err := json.Marshal(record)
|
|
407
|
-
if err != nil {
|
|
408
|
-
return err
|
|
409
|
-
}
|
|
410
|
-
data = append(data, '\n')
|
|
411
|
-
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, fileMode)
|
|
412
|
-
if err != nil {
|
|
413
|
-
return err
|
|
414
|
-
}
|
|
415
|
-
defer f.Close()
|
|
416
|
-
_, err = f.Write(data)
|
|
417
|
-
return err
|
|
418
|
-
}
|