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