pairling 0.2.7 → 0.2.9
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 +82 -145
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +33 -9
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +145 -5
- package/payload/mac/connectd/internal/gateway/proxy.go +16 -2
- package/payload/mac/connectd/internal/gateway/proxy_test.go +103 -4
- 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 +14 -36
- 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
|
@@ -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
|
-
}
|