main
go 780 lines 18.3 KB
Raw
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "reflect"
9 "slices"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/rs/zerolog/log"
15
16 "github.com/gosuda/portal-tunnel/v2/sdk"
17 "github.com/gosuda/portal-tunnel/v2/types"
18 "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 const managedTunnelRetryInterval = 30 * time.Second
22
23 type manager struct {
24 controlAddr string
25
26 configMu sync.Mutex
27
28 mu sync.RWMutex
29 cfg Config
30 tunnels map[string]*managedTunnel
31 rootCtx context.Context
32 }
33
34 func newManager(cfg Config, controlAddr string) *manager {
35 manager := &manager{
36 controlAddr: controlAddr,
37 cfg: cfg,
38 tunnels: make(map[string]*managedTunnel, len(cfg.Tunnels)),
39 }
40 for _, tunnelCfg := range cfg.Tunnels {
41 manager.tunnels[tunnelCfg.ID] = newTunnel(tunnelCfg)
42 }
43 return manager
44 }
45
46 func (m *manager) Start(ctx context.Context) {
47 m.mu.Lock()
48 m.rootCtx = ctx
49 m.mu.Unlock()
50
51 m.mu.RLock()
52 tunnels := make([]*managedTunnel, 0, len(m.tunnels))
53 for _, tunnel := range m.tunnels {
54 tunnels = append(tunnels, tunnel)
55 }
56 m.mu.RUnlock()
57
58 for _, tunnel := range tunnels {
59 tunnel.Start(ctx)
60 }
61 }
62
63 func (m *manager) Stop(ctx context.Context) error {
64 m.mu.RLock()
65 tunnels := make([]*managedTunnel, 0, len(m.tunnels))
66 for _, tunnel := range m.tunnels {
67 tunnels = append(tunnels, tunnel)
68 }
69 m.mu.RUnlock()
70
71 var wg sync.WaitGroup
72 wg.Add(len(tunnels))
73 for _, tunnel := range tunnels {
74 go func(t *managedTunnel) {
75 defer wg.Done()
76 if err := t.Stop(ctx); err != nil {
77 t.mu.RLock()
78 tunnelID := t.cfg.ID
79 t.mu.RUnlock()
80 log.Warn().Err(err).Str("tunnel_id", tunnelID).Msg("stop tunnel")
81 }
82 }(tunnel)
83 }
84
85 done := make(chan struct{})
86 go func() {
87 wg.Wait()
88 close(done)
89 }()
90
91 select {
92 case <-done:
93 return nil
94 case <-ctx.Done():
95 return ctx.Err()
96 }
97 }
98
99 func (m *manager) ConnectRelay(id, relayURL string) error {
100 id = strings.TrimSpace(id)
101 if err := validateAgentPathComponent("tunnel id", id); err != nil {
102 return err
103 }
104
105 m.mu.RLock()
106 tunnel := m.tunnels[id]
107 m.mu.RUnlock()
108 if tunnel == nil {
109 return fmt.Errorf("tunnel %q not found", id)
110 }
111 return tunnel.ConnectRelay(relayURL)
112 }
113
114 func (m *manager) DisconnectRelay(id, relayURL string) error {
115 id = strings.TrimSpace(id)
116 if err := validateAgentPathComponent("tunnel id", id); err != nil {
117 return err
118 }
119
120 m.mu.RLock()
121 tunnel := m.tunnels[id]
122 m.mu.RUnlock()
123 if tunnel == nil {
124 return fmt.Errorf("tunnel %q not found", id)
125 }
126 return tunnel.DisconnectRelay(relayURL)
127 }
128
129 func (m *manager) SetMultiHop(id string, relayURLs []string) error {
130 id = strings.TrimSpace(id)
131 multiHop, err := utils.NormalizeRelayURLs(relayURLs...)
132 if err != nil {
133 return fmt.Errorf("normalize multi-hop relay url: %w", err)
134 }
135 if len(multiHop) != len(relayURLs) {
136 return errors.New("multi-hop relay url repeated")
137 }
138 if len(multiHop) == 1 {
139 return errors.New("multi-hop requires at least entry and exit relay urls")
140 }
141 if err := m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
142 tunnel.MultiHop = append([]string(nil), multiHop...)
143 tunnel.MultiHopDepth = 0
144 return nil
145 }); err != nil {
146 return err
147 }
148
149 m.mu.RLock()
150 tunnel := m.tunnels[id]
151 m.mu.RUnlock()
152 if tunnel == nil {
153 return fmt.Errorf("tunnel %q not found", id)
154 }
155 return tunnel.SetMultiHop(multiHop)
156 }
157
158 func (m *manager) UpdateTunnel(id string, req types.AgentTunnelUpdateRequest) error {
159 if req.Empty() {
160 return errors.New("tunnel update requires at least one field")
161 }
162 updateMetadata := req.Metadata != nil && !req.Metadata.Empty()
163 updateMaxActiveRelays := req.MaxActiveRelays != nil
164 if err := m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
165 if req.MaxActiveRelays != nil {
166 if *req.MaxActiveRelays <= 0 {
167 return errors.New("max_active_relays must be a positive integer")
168 }
169 tunnel.MaxActiveRelays = *req.MaxActiveRelays
170 }
171 if req.Metadata != nil {
172 if req.Metadata.Description != nil {
173 tunnel.Description = strings.TrimSpace(*req.Metadata.Description)
174 }
175 if req.Metadata.Owner != nil {
176 tunnel.Owner = strings.TrimSpace(*req.Metadata.Owner)
177 }
178 if req.Metadata.Thumbnail != nil {
179 tunnel.Thumbnail = strings.TrimSpace(*req.Metadata.Thumbnail)
180 }
181 if req.Metadata.Tags != nil {
182 tunnel.Tags = normalizeAgentMetadataTags(*req.Metadata.Tags)
183 }
184 if req.Metadata.Hide != nil {
185 tunnel.Hide = *req.Metadata.Hide
186 }
187 }
188 return nil
189 }); err != nil {
190 return err
191 }
192
193 id = strings.TrimSpace(id)
194 m.mu.RLock()
195 tunnel := m.tunnels[id]
196 m.mu.RUnlock()
197 if tunnel == nil {
198 return fmt.Errorf("tunnel %q not found", id)
199 }
200 return tunnel.UpdateSettings(updateMetadata, updateMaxActiveRelays)
201 }
202
203 func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
204 m.configMu.Lock()
205 defer m.configMu.Unlock()
206
207 cfg, path, mode, err := m.loadConfigDocument()
208 if err != nil {
209 return err
210 }
211 id := strings.TrimSpace(req.ID)
212 name := strings.TrimSpace(req.Name)
213 if id == "" {
214 id = agentTunnelID(name)
215 }
216 if id == "" {
217 return errors.New("tunnel name is required")
218 }
219 if err := validateAgentPathComponent("tunnel id", id); err != nil {
220 return err
221 }
222 target := strings.TrimSpace(req.TargetAddr)
223 httpRoutes := make([]HTTPRouteConfig, 0, len(req.HTTPRoutes))
224 for _, route := range req.HTTPRoutes {
225 httpRoutes = append(httpRoutes, HTTPRouteConfig{
226 Prefix: strings.TrimSpace(route.Prefix),
227 Upstream: strings.TrimSpace(route.Upstream),
228 Methods: normalizeAgentHTTPRouteMethods(route.Methods),
229 Amount: strings.TrimSpace(route.Amount),
230 })
231 }
232 if target != "" && len(httpRoutes) > 0 {
233 return errors.New("target cannot be combined with http_routes")
234 }
235 if target == "" && len(httpRoutes) == 0 {
236 target = defaultTargetAddr
237 }
238 if name == "" {
239 name = id
240 }
241 relayURLs, err := utils.NormalizeRelayURLs(req.RelayURLs...)
242 if err != nil {
243 return err
244 }
245 discovery := true
246 if req.Discovery != nil {
247 discovery = *req.Discovery
248 }
249 if req.MaxActiveRelays < 0 {
250 return errors.New("max_active_relays cannot be negative")
251 }
252 tunnelCfg := TunnelConfig{
253 ID: id,
254 Name: name,
255 TargetAddr: target,
256 HTTPRoutes: httpRoutes,
257 RelayURLs: relayURLs,
258 Discovery: &discovery,
259 MaxActiveRelays: req.MaxActiveRelays,
260 X402PayTo: strings.TrimSpace(req.X402PayTo),
261 X402Testnet: req.X402Testnet,
262 }
263 if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
264 return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
265 }
266 cfg.Tunnels = append(cfg.Tunnels, tunnelCfg)
267 return m.writeConfigAndApply(path, mode, cfg)
268 }
269
270 func agentTunnelID(name string) string {
271 name = strings.ToLower(strings.TrimSpace(name))
272 var out strings.Builder
273 dash := false
274 for _, r := range name {
275 if invalidAgentPathComponentRune(r) {
276 if out.Len() > 0 && !dash {
277 out.WriteByte('-')
278 dash = true
279 }
280 continue
281 }
282 out.WriteRune(r)
283 dash = false
284 }
285 return strings.Trim(out.String(), "-")
286 }
287
288 func normalizeAgentHTTPRouteMethods(methods []string) []string {
289 out := make([]string, 0, len(methods))
290 for _, raw := range methods {
291 method := strings.ToUpper(strings.TrimSpace(raw))
292 if method != "" && !slices.Contains(out, method) {
293 out = append(out, method)
294 }
295 }
296 return out
297 }
298
299 func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error) error {
300 id = strings.TrimSpace(id)
301 if err := validateAgentPathComponent("tunnel id", id); err != nil {
302 return err
303 }
304
305 m.configMu.Lock()
306 defer m.configMu.Unlock()
307
308 cfg, path, mode, err := m.loadConfigDocument()
309 if err != nil {
310 return err
311 }
312 index := slices.IndexFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == id })
313 if index < 0 {
314 return fmt.Errorf("tunnel %q not found", id)
315 }
316 before := cfg.Tunnels[index]
317 if err := update(&cfg.Tunnels[index]); err != nil {
318 return err
319 }
320 if reflect.DeepEqual(before, cfg.Tunnels[index]) {
321 return nil
322 }
323 cfg.sourcePath = path
324 if err := cfg.ApplyDefaults(path); err != nil {
325 return err
326 }
327 if err := cfg.Validate(); err != nil {
328 return err
329 }
330 if err := writeConfigDocument(path, mode, cfg); err != nil {
331 return err
332 }
333
334 nextTunnelCfg := cfg.Tunnels[index]
335 m.mu.Lock()
336 m.cfg = cfg
337 if tunnel := m.tunnels[id]; tunnel != nil {
338 tunnel.mu.Lock()
339 tunnel.cfg = nextTunnelCfg
340 tunnel.mu.Unlock()
341 }
342 m.mu.Unlock()
343 return nil
344 }
345
346 func (m *manager) DeleteTunnel(id string) error {
347 m.configMu.Lock()
348 defer m.configMu.Unlock()
349
350 id = strings.TrimSpace(id)
351 if id == "" {
352 return errors.New("tunnel id is required")
353 }
354 cfg, path, mode, err := m.loadConfigDocument()
355 if err != nil {
356 return err
357 }
358
359 index := slices.IndexFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == id })
360 if index < 0 {
361 return fmt.Errorf("tunnel %q not found", id)
362 }
363 next := cfg.Tunnels[:0]
364 for i, tunnel := range cfg.Tunnels {
365 if i == index {
366 continue
367 }
368 next = append(next, tunnel)
369 }
370 cfg.Tunnels = next
371 return m.writeConfigAndApply(path, mode, cfg)
372 }
373
374 func (m *manager) loadConfigDocument() (Config, string, os.FileMode, error) {
375 m.mu.RLock()
376 configPath := m.cfg.sourcePath
377 m.mu.RUnlock()
378 cfg, path, mode, err := loadConfigDocument(configPath)
379 if err != nil {
380 return Config{}, "", 0, err
381 }
382 return cfg, path, mode, nil
383 }
384
385 func (m *manager) writeConfigAndApply(path string, mode os.FileMode, cfg Config) error {
386 cfg.sourcePath = path
387 if err := cfg.ApplyDefaults(path); err != nil {
388 return err
389 }
390 if err := cfg.Validate(); err != nil {
391 return err
392 }
393 if err := writeConfigDocument(path, mode, cfg); err != nil {
394 return err
395 }
396 return m.ApplyConfig(cfg)
397 }
398
399 func (m *manager) ApplyConfig(cfg Config) error {
400 m.mu.Lock()
401 m.cfg = cfg
402 rootCtx := m.rootCtx
403 next := make(map[string]TunnelConfig, len(cfg.Tunnels))
404 for _, tunnelCfg := range cfg.Tunnels {
405 next[tunnelCfg.ID] = tunnelCfg
406 }
407 toStop := make([]*managedTunnel, 0)
408 toStart := make([]*managedTunnel, 0)
409 toUpdate := make([]*managedTunnel, 0)
410 for id, tunnel := range m.tunnels {
411 tunnelCfg, ok := next[id]
412 if !ok {
413 toStop = append(toStop, tunnel)
414 delete(m.tunnels, id)
415 continue
416 }
417 tunnel.mu.Lock()
418 previous := tunnel.cfg
419 if !reflect.DeepEqual(previous, tunnelCfg) {
420 tunnel.cfg = tunnelCfg
421 toUpdate = append(toUpdate, tunnel)
422 }
423 tunnel.mu.Unlock()
424 delete(next, id)
425 }
426 for _, tunnelCfg := range next {
427 tunnel := newTunnel(tunnelCfg)
428 m.tunnels[tunnelCfg.ID] = tunnel
429 toStart = append(toStart, tunnel)
430 }
431 m.mu.Unlock()
432
433 for _, tunnel := range append(toStop, toUpdate...) {
434 _ = tunnel.Stop(context.Background())
435 }
436 if rootCtx == nil {
437 rootCtx = context.Background()
438 }
439 for _, tunnel := range append(toStart, toUpdate...) {
440 tunnel.Start(rootCtx)
441 }
442 return nil
443 }
444
445 func (m *manager) Snapshot() types.AgentStatusResponse {
446 m.mu.RLock()
447 configPath := m.cfg.sourcePath
448 tunnels := make([]*managedTunnel, 0, len(m.tunnels))
449 for _, tunnel := range m.tunnels {
450 tunnels = append(tunnels, tunnel)
451 }
452 m.mu.RUnlock()
453
454 statuses := make([]types.AgentTunnelStatus, 0, len(tunnels))
455 for _, tunnel := range tunnels {
456 statuses = append(statuses, tunnel.Snapshot())
457 }
458 slices.SortFunc(statuses, func(a, b types.AgentTunnelStatus) int {
459 return strings.Compare(a.ID, b.ID)
460 })
461
462 return types.AgentStatusResponse{
463 ConfigPath: configPath,
464 ControlAddr: m.controlAddr,
465 Tunnels: statuses,
466 }
467 }
468
469 type managedTunnel struct {
470 mu sync.RWMutex
471 cfg TunnelConfig
472
473 cancel context.CancelFunc
474 done chan struct{}
475 exposure *sdk.Exposure
476 lastError string
477 runtime types.AgentTunnelStatus
478 }
479
480 func newTunnel(cfg TunnelConfig) *managedTunnel {
481 return &managedTunnel{
482 cfg: cfg,
483 }
484 }
485
486 func (t *managedTunnel) Start(parent context.Context) {
487 t.mu.Lock()
488 if t.done != nil {
489 t.mu.Unlock()
490 return
491 }
492 ctx, cancel := context.WithCancel(parent)
493 t.cancel = cancel
494 t.done = make(chan struct{})
495 done := t.done
496 t.mu.Unlock()
497
498 go func() {
499 defer close(done)
500 t.runLoop(ctx)
501 }()
502 }
503
504 func (t *managedTunnel) Stop(ctx context.Context) error {
505 t.mu.Lock()
506 cancel := t.cancel
507 done := t.done
508 t.cancel = nil
509 t.done = nil
510 if cancel != nil {
511 cancel()
512 }
513 t.mu.Unlock()
514
515 if done == nil {
516 return nil
517 }
518 select {
519 case <-done:
520 return nil
521 case <-ctx.Done():
522 return ctx.Err()
523 }
524 }
525
526 func (t *managedTunnel) ConnectRelay(relayURL string) error {
527 t.mu.RLock()
528 exposure := t.exposure
529 t.mu.RUnlock()
530 if exposure == nil {
531 return nil
532 }
533 return exposure.AddRelay(relayURL)
534 }
535
536 func (t *managedTunnel) DisconnectRelay(relayURL string) error {
537 t.mu.RLock()
538 exposure := t.exposure
539 t.mu.RUnlock()
540 if exposure == nil {
541 return nil
542 }
543 return exposure.RemoveRelay(relayURL)
544 }
545
546 func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
547 t.mu.RLock()
548 exposure := t.exposure
549 t.mu.RUnlock()
550 if exposure == nil {
551 return nil
552 }
553 return exposure.SetMultiHop(relayURLs)
554 }
555
556 func (t *managedTunnel) UpdateSettings(updateMetadata, updateMaxActiveRelays bool) error {
557 t.mu.RLock()
558 exposure := t.exposure
559 cfg := t.cfg
560 t.mu.RUnlock()
561 if exposure == nil {
562 return nil
563 }
564 var err error
565 if updateMetadata {
566 err = errors.Join(err, exposure.UpdateMetadata(metadataFromTunnelConfig(cfg)))
567 }
568 if updateMaxActiveRelays {
569 err = errors.Join(err, exposure.UpdateMaxActiveRelays(cfg.MaxActiveRelays))
570 }
571 return err
572 }
573
574 func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
575 t.mu.RLock()
576 cfg := t.cfg
577 lastError := t.lastError
578 exposure := t.exposure
579 done := t.done
580 runtime := t.runtime
581 t.mu.RUnlock()
582
583 running := false
584 if done != nil {
585 select {
586 case <-done:
587 default:
588 running = true
589 }
590 }
591
592 state := "stopped"
593 switch {
594 case lastError != "":
595 state = "error"
596 case exposure != nil:
597 state = "running"
598 case running:
599 state = "starting"
600 }
601 discovery := true
602 if cfg.Discovery != nil {
603 discovery = *cfg.Discovery
604 }
605
606 status := types.AgentTunnelStatus{
607 ID: cfg.ID,
608 Name: cfg.Name,
609 State: state,
610 TargetAddr: cfg.TargetAddr,
611 LastError: lastError,
612 Discovery: discovery,
613 MaxActiveRelays: cfg.MaxActiveRelays,
614 Metadata: metadataFromTunnelConfig(cfg),
615 MultiHop: append([]string(nil), cfg.MultiHop...),
616 X402PayTo: strings.TrimSpace(cfg.X402PayTo),
617 X402Testnet: cfg.X402Testnet,
618 }
619 if len(cfg.HTTPRoutes) > 0 {
620 status.HTTPRoutes = make([]types.AgentHTTPRoute, 0, len(cfg.HTTPRoutes))
621 for _, route := range cfg.HTTPRoutes {
622 status.HTTPRoutes = append(status.HTTPRoutes, types.AgentHTTPRoute{
623 Prefix: route.Prefix,
624 Upstream: route.Upstream,
625 Methods: append([]string(nil), route.Methods...),
626 Amount: route.Amount,
627 })
628 }
629 }
630 if exposure == nil {
631 if strings.TrimSpace(runtime.Address) != "" {
632 status.Address = runtime.Address
633 }
634 if strings.TrimSpace(runtime.TargetAddr) != "" {
635 status.TargetAddr = runtime.TargetAddr
636 }
637 if cfg.MultiHopDepth > 1 && len(runtime.MultiHop) > 0 {
638 status.MultiHop = append([]string(nil), runtime.MultiHop...)
639 }
640 status.Relays = append([]types.AgentRelayStatus(nil), runtime.Relays...)
641 return status
642 }
643 snapshot := exposure.Snapshot()
644 t.mu.Lock()
645 if t.exposure == exposure {
646 t.runtime = types.AgentTunnelStatus{
647 Address: snapshot.Address,
648 TargetAddr: snapshot.TargetAddr,
649 MaxActiveRelays: snapshot.MaxActiveRelays,
650 MultiHop: append([]string(nil), snapshot.MultiHop...),
651 Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
652 }
653 }
654 t.mu.Unlock()
655
656 status.Address = snapshot.Address
657 status.TargetAddr = snapshot.TargetAddr
658 status.MultiHop = append([]string(nil), snapshot.MultiHop...)
659 status.Relays = append([]types.AgentRelayStatus(nil), snapshot.Relays...)
660 return status
661 }
662
663 func (t *managedTunnel) runLoop(ctx context.Context) {
664 for {
665 err := t.runOnce(ctx)
666
667 t.mu.Lock()
668 t.exposure = nil
669 if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
670 t.lastError = ""
671 } else {
672 t.lastError = err.Error()
673 }
674 t.mu.Unlock()
675
676 if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
677 return
678 }
679 log.Warn().Err(err).Msg("managed tunnel stopped with error; retrying")
680 if !utils.SleepOrDone(ctx, managedTunnelRetryInterval) {
681 return
682 }
683 }
684 }
685
686 func (t *managedTunnel) runOnce(ctx context.Context) error {
687 t.mu.Lock()
688 cfg := t.cfg
689 t.lastError = ""
690 t.mu.Unlock()
691
692 discovery := true
693 if cfg.Discovery != nil {
694 discovery = *cfg.Discovery
695 }
696 banMITM := false
697 if cfg.BanMITM != nil {
698 banMITM = *cfg.BanMITM
699 }
700 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
701 RelayURLs: append([]string(nil), cfg.RelayURLs...),
702 Discovery: discovery,
703 Identity: types.Identity{Name: cfg.Name},
704 IdentityPath: cfg.IdentityPath,
705 IdentityJSON: cfg.IdentityJSON,
706 TargetAddr: cfg.TargetAddr,
707 UDPAddr: cfg.UDPAddr,
708 UDPEnabled: cfg.UDPEnabled,
709 TCPEnabled: cfg.TCPEnabled,
710 MultiHop: append([]string(nil), cfg.MultiHop...),
711 MultiHopDepth: cfg.MultiHopDepth,
712 BanMITM: banMITM,
713 MaxActiveRelays: cfg.MaxActiveRelays,
714 Metadata: metadataFromTunnelConfig(cfg),
715 X402PayTo: cfg.X402PayTo,
716 X402Testnet: cfg.X402Testnet,
717 })
718 if err != nil {
719 return err
720 }
721 snapshot := exposure.Snapshot()
722 t.mu.Lock()
723 t.exposure = exposure
724 t.runtime = types.AgentTunnelStatus{
725 Address: snapshot.Address,
726 TargetAddr: snapshot.TargetAddr,
727 MaxActiveRelays: snapshot.MaxActiveRelays,
728 MultiHop: append([]string(nil), snapshot.MultiHop...),
729 Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
730 }
731 t.lastError = ""
732 t.mu.Unlock()
733
734 defer exposure.Close()
735
736 if len(cfg.HTTPRoutes) > 0 {
737 routes := make([]sdk.HTTPRouteConfig, 0, len(cfg.HTTPRoutes))
738 for _, route := range cfg.HTTPRoutes {
739 routes = append(routes, sdk.HTTPRouteConfig{
740 Prefix: route.Prefix,
741 Upstream: route.Upstream,
742 Methods: route.Methods,
743 Amount: route.Amount,
744 })
745 }
746 err = exposure.RunHTTPRoutes(ctx, routes, "")
747 } else {
748 err = sdk.ProxyExposure(ctx, exposure)
749 }
750 if ctx.Err() != nil || errors.Is(err, context.Canceled) {
751 return ctx.Err()
752 }
753 return err
754 }
755
756 func metadataFromTunnelConfig(cfg TunnelConfig) types.LeaseMetadata {
757 return types.LeaseMetadata{
758 Description: strings.TrimSpace(cfg.Description),
759 Tags: normalizeAgentMetadataTags(cfg.Tags),
760 Owner: strings.TrimSpace(cfg.Owner),
761 Thumbnail: strings.TrimSpace(cfg.Thumbnail),
762 Hide: cfg.Hide,
763 }
764 }
765
766 func normalizeAgentMetadataTags(tags []string) []string {
767 if len(tags) == 0 {
768 return nil
769 }
770 out := make([]string, 0, len(tags))
771 for _, tag := range tags {
772 if tag = strings.TrimSpace(tag); tag != "" {
773 out = append(out, tag)
774 }
775 }
776 if len(out) == 0 {
777 return nil
778 }
779 return out
780 }