feat: enhance portal agent functionality and configuration
rabbitprincess committed
May 7, 2026 at 23:41 UTC
5f0102dbad61d1ef9528d57bed0f5d6b22c6194c
9 files changed
+469
-225
cmd/portal-tunnel/README.md
+1
-1
@@ -112,7 +112,7 @@ Flags:
112
113
Runs Portal as a managed long-lived tunnel agent.
114
115
-- `portal agent run` reads the platform default config path, installs or updates the OS service, and opens the dashboard when run from an interactive terminal.
115
+- `portal agent run` reads the platform default config path, installs or updates the OS service, starts it in the background, and exits after the agent is ready.
116
- `portal agent run --config config.toml --foreground` runs the agent in the current terminal and opens the dashboard when the terminal is interactive.
117
- `portal agent dashboard` attaches to an already running agent.
118
- The service process owns multiple tunnel definitions from one `config.toml`.
cmd/portal-tunnel/agent.go
+18
-16
@@ -99,9 +99,6 @@ func runAgentRunCommand(args []string) error {
99
if err != nil {
100
return err
101
}
102
- if agentCLIInteractive() {
103
- return agent.RunDashboard(configPath, cfg.Agent.StateDir)
104
- }
102
103
fmt.Fprintf(os.Stdout, "Portal agent running at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
104
return nil
@@ -185,8 +182,13 @@ func runAgentStopCommand(args []string) error {
182
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
183
defer cancel()
184
188
- _ = agent.Shutdown(ctx, resolvedStateDir)
185
+ shutdownErr := agent.Shutdown(ctx, resolvedStateDir)
186
if err := service.StopDisable(ctx, cfg.Agent.ServiceName); err != nil {
187
+ if shutdownErr == nil {
188
+ fmt.Fprintf(os.Stderr, "Warning: agent stopped, but service manager cleanup failed: %v\n", err)
189
+ fmt.Fprintln(os.Stdout, "Portal agent stopped.")
190
+ return nil
191
+ }
192
return fmt.Errorf("stop portal agent service: %w", err)
193
}
194
fmt.Fprintln(os.Stdout, "Portal agent stopped.")
@@ -280,23 +282,23 @@ func agentCLIInteractive() bool {
282
}
283
284
func loadAgentCommandConfig(configPath, stateDir string) (agent.Config, string, error) {
285
+ configPath = strings.TrimSpace(configPath)
286
+ stateDir = strings.TrimSpace(stateDir)
287
if stateDir != "" && configPath == "" {
288
cfg := agent.Config{Agent: agent.AgentConfig{StateDir: stateDir, ServiceName: agent.DefaultServiceName}}
289
return cfg, stateDir, nil
290
}
287
- if configPath != "" {
288
- cfg, err := agent.LoadConfig(configPath)
289
- if err != nil {
290
- return agent.Config{}, "", err
291
- }
292
- if stateDir != "" {
293
- cfg.Agent.StateDir = stateDir
294
- }
295
- return cfg, cfg.Agent.StateDir, nil
291
+ if configPath == "" {
292
+ configPath = service.DefaultConfigPath()
293
+ }
294
+ cfg, err := agent.LoadConfig(configPath)
295
+ if err != nil {
296
+ return agent.Config{}, "", err
297
+ }
298
+ if stateDir != "" {
299
+ cfg.Agent.StateDir = stateDir
300
}
297
- defaultStateDir := service.DefaultDataDir()
298
- cfg := agent.Config{Agent: agent.AgentConfig{StateDir: defaultStateDir, ServiceName: agent.DefaultServiceName}}
299
- return cfg, defaultStateDir, nil
301
+ return cfg, cfg.Agent.StateDir, nil
302
}
303
304
func printAgentUsage(w io.Writer) {
cmd/portal-tunnel/agent/config.go
+152
-104
@@ -6,6 +6,7 @@ import (
6
"os"
7
"path/filepath"
8
"strings"
9
+ "unicode"
10
11
"github.com/knadh/koanf/parsers/toml/v2"
12
"github.com/knadh/koanf/providers/file"
@@ -21,12 +22,18 @@ const (
22
23
defaultIdentityFilename = "identity.json"
24
defaultTargetAddr = "127.0.0.1:3000"
25
+ agentPathInvalidChars = `<>:"/\|?*`
26
)
27
28
type Config struct {
29
sourcePath string
28
- Agent AgentConfig `koanf:"agent"`
29
- Tunnels []TunnelConfig `koanf:"tunnels"`
30
+ Agent AgentConfig
31
+ Tunnels []TunnelConfig
32
+}
33
+
34
+type configDocument struct {
35
+ Agent AgentConfig `koanf:"agent"`
36
+ Tunnels []TunnelConfig `koanf:"tunnels"`
37
}
38
39
type AgentConfig struct {
@@ -41,6 +48,7 @@ type TunnelConfig struct {
48
TargetAddr string `koanf:"target"`
49
HTTPRoutes []HTTPRouteConfig `koanf:"http_routes"`
50
RelayURLs []string `koanf:"relays"`
51
+ SeedRelayURLs []string `koanf:"seed_relays"`
52
Discovery *bool `koanf:"discovery"`
53
IdentityPath string `koanf:"identity_path"`
54
IdentityJSON string `koanf:"identity_json"`
@@ -64,98 +72,124 @@ type HTTPRouteConfig struct {
72
}
73
74
func LoadConfig(path string) (Config, error) {
75
+ absPath, err := ensureConfigDocument(path)
76
+ if err != nil {
77
+ return Config{}, err
78
+ }
79
+ doc, _, err := readConfigDocument(absPath)
80
+ if err != nil {
81
+ return Config{}, err
82
+ }
83
+ return resolveConfigDocument(absPath, doc)
84
+}
85
+
86
+func ensureConfigDocument(path string) (string, error) {
87
path = strings.TrimSpace(path)
88
if path == "" {
89
path = service.DefaultConfigPath()
90
}
91
absPath, err := filepath.Abs(path)
92
if err != nil {
73
- return Config{}, err
93
+ return "", err
94
}
95
configDir := filepath.Dir(absPath)
96
if err := os.MkdirAll(configDir, 0o755); err != nil {
77
- return Config{}, fmt.Errorf("create agent config directory %q: %w", configDir, err)
97
+ return "", fmt.Errorf("create agent config directory %q: %w", configDir, err)
98
}
99
if _, err := os.Stat(absPath); err != nil {
100
if errors.Is(err, os.ErrNotExist) {
81
- data := fmt.Sprintf(`[agent]
82
-state_dir = %q
83
-control_addr = %q
84
-service_name = %q
85
-
86
-[[tunnels]]
87
-id = "default"
88
-name = "default"
89
-target = %q
90
-discovery = true
91
-`, service.DefaultDataDir(), DefaultControlAddr, DefaultServiceName, defaultTargetAddr)
92
- if err := os.WriteFile(absPath, []byte(data), 0o644); err != nil {
93
- return Config{}, fmt.Errorf("create default agent config %q: %w", absPath, err)
101
+ if err := writeConfigDocument(absPath, 0o644, defaultConfigDocument()); err != nil {
102
+ return "", fmt.Errorf("create default agent config %q: %w", absPath, err)
103
}
104
} else {
96
- return Config{}, err
105
+ return "", err
106
}
107
}
108
+ return absPath, nil
109
+}
110
100
- k := koanf.New(".")
101
- if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
102
- return Config{}, err
103
- }
104
-
105
- var cfg Config
106
- if err := k.Unmarshal("", &cfg); err != nil {
107
- return Config{}, err
111
+func defaultConfigDocument() configDocument {
112
+ discovery := true
113
+ return configDocument{
114
+ Agent: AgentConfig{
115
+ StateDir: service.DefaultDataDir(),
116
+ ControlAddr: DefaultControlAddr,
117
+ ServiceName: DefaultServiceName,
118
+ },
119
+ Tunnels: []TunnelConfig{{
120
+ ID: "default",
121
+ Name: "default",
122
+ TargetAddr: defaultTargetAddr,
123
+ Discovery: &discovery,
124
+ }},
125
}
109
- cfg.sourcePath = absPath
110
- if err := cfg.ApplyDefaults(absPath); err != nil {
111
- return Config{}, err
112
- }
113
- return cfg, cfg.Validate()
126
}
127
116
-func loadConfigDocument(path string) (Config, string, os.FileMode, error) {
117
- path = strings.TrimSpace(path)
118
- if path == "" {
119
- path = service.DefaultConfigPath()
120
- }
121
- absPath, err := filepath.Abs(path)
128
+func loadConfigDocument(path string) (configDocument, string, os.FileMode, error) {
129
+ absPath, err := ensureConfigDocument(path)
130
if err != nil {
123
- return Config{}, "", 0, err
131
+ return configDocument{}, "", 0, err
132
}
125
- if _, err := os.Stat(absPath); err != nil {
126
- if errors.Is(err, os.ErrNotExist) {
127
- if _, err := LoadConfig(absPath); err != nil {
128
- return Config{}, "", 0, err
129
- }
130
- } else {
131
- return Config{}, "", 0, err
132
- }
133
+ doc, mode, err := readConfigDocument(absPath)
134
+ if err != nil {
135
+ return configDocument{}, "", 0, err
136
}
137
+ return doc, absPath, mode, nil
138
+}
139
+
140
+func readConfigDocument(absPath string) (configDocument, os.FileMode, error) {
141
info, err := os.Stat(absPath)
142
if err != nil {
136
- return Config{}, "", 0, err
143
+ return configDocument{}, 0, err
144
}
145
data, err := os.ReadFile(absPath)
146
if err != nil {
140
- return Config{}, "", 0, err
147
+ return configDocument{}, 0, err
148
}
149
143
- var cfg Config
150
+ var doc configDocument
151
if strings.TrimSpace(string(data)) != "" {
152
k := koanf.New(".")
153
if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
147
- return Config{}, "", 0, err
154
+ return configDocument{}, 0, err
155
}
149
- if err := k.Unmarshal("", &cfg); err != nil {
150
- return Config{}, "", 0, err
156
+ if err := k.Unmarshal("", &doc); err != nil {
157
+ return configDocument{}, 0, err
158
}
159
}
153
- cfg.sourcePath = absPath
154
- return cfg, absPath, info.Mode().Perm(), nil
160
+ return doc, info.Mode().Perm(), nil
161
}
162
157
-func writeConfigDocument(path string, mode os.FileMode, cfg Config) error {
158
- data, err := toml.Parser().Marshal(configDocumentMap(cfg))
163
+func resolveConfigDocument(path string, doc configDocument) (Config, error) {
164
+ cfg := Config{
165
+ sourcePath: path,
166
+ Agent: doc.Agent,
167
+ Tunnels: append([]TunnelConfig(nil), doc.Tunnels...),
168
+ }
169
+ for i := range cfg.Tunnels {
170
+ tunnel := &cfg.Tunnels[i]
171
+ tunnel.HTTPRoutes = append([]HTTPRouteConfig(nil), tunnel.HTTPRoutes...)
172
+ tunnel.RelayURLs = append([]string(nil), tunnel.RelayURLs...)
173
+ tunnel.SeedRelayURLs = append([]string(nil), tunnel.SeedRelayURLs...)
174
+ tunnel.MultiHop = append([]string(nil), tunnel.MultiHop...)
175
+ tunnel.Tags = append([]string(nil), tunnel.Tags...)
176
+ if tunnel.Discovery != nil {
177
+ value := *tunnel.Discovery
178
+ tunnel.Discovery = &value
179
+ }
180
+ if tunnel.BanMITM != nil {
181
+ value := *tunnel.BanMITM
182
+ tunnel.BanMITM = &value
183
+ }
184
+ }
185
+ if err := cfg.ApplyDefaults(path); err != nil {
186
+ return Config{}, err
187
+ }
188
+ return cfg, cfg.Validate()
189
+}
190
+
191
+func writeConfigDocument(path string, mode os.FileMode, doc configDocument) error {
192
+ data, err := toml.Parser().Marshal(configDocumentMap(doc))
193
if err != nil {
194
return err
195
}
@@ -165,25 +199,22 @@ func writeConfigDocument(path string, mode os.FileMode, cfg Config) error {
199
return os.WriteFile(path, data, mode)
200
}
201
168
-func configDocumentMap(cfg Config) map[string]any {
169
- return map[string]any{
170
- "agent": agentConfigDocumentMap(cfg.Agent),
171
- "tunnels": tunnelConfigDocumentMaps(cfg.Tunnels),
172
- }
173
-}
202
+func configDocumentMap(doc configDocument) map[string]any {
203
+ agent := make(map[string]any)
204
+ addStringDocumentField(agent, "state_dir", doc.Agent.StateDir)
205
+ addStringDocumentField(agent, "control_addr", doc.Agent.ControlAddr)
206
+ addStringDocumentField(agent, "service_name", doc.Agent.ServiceName)
207
175
-func agentConfigDocumentMap(cfg AgentConfig) map[string]any {
176
- out := make(map[string]any)
177
- addStringDocumentField(out, "state_dir", cfg.StateDir)
178
- addStringDocumentField(out, "control_addr", cfg.ControlAddr)
179
- addStringDocumentField(out, "service_name", cfg.ServiceName)
180
- return out
181
-}
208
+ tunnels := make([]map[string]any, 0, len(doc.Tunnels))
209
+ for _, tunnel := range doc.Tunnels {
210
+ tunnels = append(tunnels, tunnelConfigDocumentMap(tunnel))
211
+ }
212
183
-func tunnelConfigDocumentMaps(tunnels []TunnelConfig) []map[string]any {
184
- out := make([]map[string]any, 0, len(tunnels))
185
- for _, tunnel := range tunnels {
186
- out = append(out, tunnelConfigDocumentMap(tunnel))
213
+ out := map[string]any{
214
+ "tunnels": tunnels,
215
+ }
216
+ if len(agent) > 0 {
217
+ out["agent"] = agent
218
}
219
return out
220
}
@@ -204,6 +235,7 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
235
out["http_routes"] = routes
236
}
237
addStringSliceDocumentField(out, "relays", cfg.RelayURLs)
238
+ addStringSliceDocumentField(out, "seed_relays", cfg.SeedRelayURLs)
239
if cfg.Discovery != nil {
240
out["discovery"] = *cfg.Discovery
241
}
@@ -248,41 +280,15 @@ func addStringSliceDocumentField(out map[string]any, key string, value []string)
280
}
281
}
282
251
-func validateConfigDocument(path string, cfg Config) error {
252
- next := cloneConfig(cfg)
253
- if err := next.ApplyDefaults(path); err != nil {
254
- return err
255
- }
256
- return next.Validate()
257
-}
258
-
259
-func cloneConfig(cfg Config) Config {
260
- next := cfg
261
- next.Tunnels = append([]TunnelConfig(nil), cfg.Tunnels...)
262
- for i := range next.Tunnels {
263
- tunnel := &next.Tunnels[i]
264
- tunnel.HTTPRoutes = append([]HTTPRouteConfig(nil), tunnel.HTTPRoutes...)
265
- tunnel.RelayURLs = append([]string(nil), tunnel.RelayURLs...)
266
- tunnel.MultiHop = append([]string(nil), tunnel.MultiHop...)
267
- tunnel.Tags = append([]string(nil), tunnel.Tags...)
268
- if tunnel.Discovery != nil {
269
- value := *tunnel.Discovery
270
- tunnel.Discovery = &value
271
- }
272
- if tunnel.BanMITM != nil {
273
- value := *tunnel.BanMITM
274
- tunnel.BanMITM = &value
275
- }
276
- }
277
- return next
278
-}
279
-
283
func (cfg *Config) ApplyDefaults(configPath string) error {
284
configDir := "."
285
if absConfig, err := filepath.Abs(strings.TrimSpace(configPath)); err == nil {
286
configDir = filepath.Dir(absConfig)
287
}
288
289
+ cfg.Agent.StateDir = strings.TrimSpace(cfg.Agent.StateDir)
290
+ cfg.Agent.ControlAddr = strings.TrimSpace(cfg.Agent.ControlAddr)
291
+ cfg.Agent.ServiceName = strings.TrimSpace(cfg.Agent.ServiceName)
292
if strings.TrimSpace(cfg.Agent.StateDir) == "" {
293
cfg.Agent.StateDir = service.DefaultDataDir()
294
} else if !filepath.IsAbs(cfg.Agent.StateDir) {
@@ -324,6 +330,13 @@ func (cfg *Config) ApplyDefaults(configPath string) error {
330
}
331
t.RelayURLs = relays
332
}
333
+ if len(t.SeedRelayURLs) > 0 {
334
+ seedRelays, err := utils.NormalizeRelayURLs(t.SeedRelayURLs...)
335
+ if err != nil {
336
+ return fmt.Errorf("tunnel %q seed_relays: %w", t.ID, err)
337
+ }
338
+ t.SeedRelayURLs = utils.FilterRelayURLs(seedRelays, t.RelayURLs)
339
+ }
340
for idx, relayURL := range t.MultiHop {
341
normalized, err := utils.NormalizeRelayURL(relayURL)
342
if err != nil {
@@ -342,6 +355,9 @@ func (cfg Config) Validate() error {
355
if strings.TrimSpace(cfg.Agent.ControlAddr) == "" {
356
return errors.New("agent.control_addr is required")
357
}
358
+ if err := validateAgentPathComponent("agent.service_name", cfg.Agent.ServiceName); err != nil {
359
+ return err
360
+ }
361
if len(cfg.Tunnels) == 0 {
362
return errors.New("at least one tunnel is required")
363
}
@@ -360,8 +376,8 @@ func (cfg Config) Validate() error {
376
}
377
378
func (cfg TunnelConfig) Validate() error {
363
- if strings.TrimSpace(cfg.ID) == "" {
364
- return errors.New("tunnel id is required")
379
+ if err := validateAgentPathComponent("tunnel id", cfg.ID); err != nil {
380
+ return err
381
}
382
if strings.TrimSpace(cfg.TargetAddr) == "" && len(cfg.HTTPRoutes) == 0 {
383
return fmt.Errorf("tunnel %q requires target or http_routes", cfg.ID)
@@ -381,6 +397,18 @@ func (cfg TunnelConfig) Validate() error {
397
if len(cfg.MultiHop) > 0 && cfg.MultiHopDepth > 1 {
398
return fmt.Errorf("tunnel %q cannot combine multi_hop and multi_hop_depth", cfg.ID)
399
}
400
+ if (len(cfg.MultiHop) > 0 || cfg.MultiHopDepth > 1) && (cfg.UDPEnabled || cfg.TCPEnabled) {
401
+ return fmt.Errorf("tunnel %q multi-hop supports only the default stream transport", cfg.ID)
402
+ }
403
+ if len(cfg.MultiHop) > 0 {
404
+ uniqueMultiHop, err := utils.NormalizeRelayURLs(cfg.MultiHop...)
405
+ if err != nil {
406
+ return fmt.Errorf("tunnel %q multi_hop: %w", cfg.ID, err)
407
+ }
408
+ if len(uniqueMultiHop) != len(cfg.MultiHop) {
409
+ return fmt.Errorf("tunnel %q multi_hop relay repeated", cfg.ID)
410
+ }
411
+ }
412
for _, route := range cfg.HTTPRoutes {
413
if strings.TrimSpace(route.Prefix) == "" || strings.TrimSpace(route.Upstream) == "" {
414
return fmt.Errorf("tunnel %q http_routes require prefix and upstream", cfg.ID)
@@ -388,3 +416,23 @@ func (cfg TunnelConfig) Validate() error {
416
}
417
return nil
418
}
419
+
420
+func validateAgentPathComponent(name, value string) error {
421
+ value = strings.TrimSpace(value)
422
+ if value == "" {
423
+ return fmt.Errorf("%s is required", name)
424
+ }
425
+ if value == "." || value == ".." {
426
+ return fmt.Errorf("%s cannot be %q", name, value)
427
+ }
428
+ for _, r := range value {
429
+ if invalidAgentPathComponentRune(r) {
430
+ return fmt.Errorf("%s contains invalid character %q", name, r)
431
+ }
432
+ }
433
+ return nil
434
+}
435
+
436
+func invalidAgentPathComponentRune(r rune) bool {
437
+ return unicode.IsSpace(r) || r < 0x20 || r == 0x7f || strings.ContainsRune(agentPathInvalidChars, r)
438
+}
cmd/portal-tunnel/agent/dashboard.go
+9
@@ -139,6 +139,9 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
139
m.err = msg.err
140
if msg.err == nil {
141
m.status = msg.status
142
+ if strings.TrimSpace(msg.status.ConfigPath) != "" {
143
+ m.configPath = msg.status.ConfigPath
144
+ }
145
m.clampSelection()
146
}
147
return m, nil
@@ -630,6 +633,12 @@ func (m agentDashboardModel) layout() agentDashboardView {
633
if m.mode != agentDashboardNormalMode {
634
layout.addLine(m.input.View())
635
}
636
+ if strings.TrimSpace(m.status.ConfigPath) != "" {
637
+ layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Config: "+m.status.ConfigPath, width))
638
+ }
639
+ if strings.TrimSpace(m.status.ControlAddr) != "" {
640
+ layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Control: "+m.status.ControlAddr, width))
641
+ }
642
layout.addLine("")
643
if m.height > 0 {
644
bodyHeight = max(1, m.height-len(layout.lines))
cmd/portal-tunnel/agent/manager.go
+220
-94
@@ -9,14 +9,17 @@ import (
9
"slices"
10
"strings"
11
"sync"
12
- "unicode"
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
@@ -94,64 +97,88 @@ func (m *manager) Stop(ctx context.Context) error {
97
}
98
99
func (m *manager) AddRelay(id, relayURL string) error {
97
- exposure, err := m.runningExposure(id)
100
+ relayURL, err := utils.NormalizeRelayURL(relayURL)
101
if err != nil {
102
return err
103
}
101
- return exposure.AddRelay(relayURL)
104
+ return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
105
+ relayURLs, err := utils.MergeRelayURLs(tunnel.RelayURLs, nil, []string{relayURL})
106
+ if err != nil {
107
+ return err
108
+ }
109
+ tunnel.RelayURLs = relayURLs
110
+ tunnel.SeedRelayURLs = utils.RemoveRelayURL(tunnel.SeedRelayURLs, relayURL)
111
+ return nil
112
+ })
113
}
114
115
func (m *manager) RemoveRelay(id, relayURL string) error {
105
- exposure, err := m.runningExposure(id)
116
+ relayURL, err := utils.NormalizeRelayURL(relayURL)
117
if err != nil {
118
return err
119
}
109
- return exposure.RemoveRelay(relayURL)
120
+ return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
121
+ relayURLs, err := utils.NormalizeRelayURLs(tunnel.RelayURLs...)
122
+ if err != nil {
123
+ return err
124
+ }
125
+ tunnel.RelayURLs = utils.RemoveRelayURL(relayURLs, relayURL)
126
+ tunnel.SeedRelayURLs = utils.RemoveRelayURL(tunnel.SeedRelayURLs, relayURL)
127
+ return removeRelayFromTunnelRoute(tunnel, relayURL)
128
+ })
129
}
130
131
func (m *manager) SeedRelay(id, relayURL string) error {
113
- exposure, err := m.runningExposure(id)
132
+ relayURL, err := utils.NormalizeRelayURL(relayURL)
133
if err != nil {
134
return err
135
}
117
- return exposure.SeedRelay(relayURL)
136
+ return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
137
+ relayURLs, err := utils.NormalizeRelayURLs(tunnel.RelayURLs...)
138
+ if err != nil {
139
+ return err
140
+ }
141
+ tunnel.RelayURLs = utils.RemoveRelayURL(relayURLs, relayURL)
142
+ seedRelayURLs, err := utils.MergeRelayURLs(tunnel.SeedRelayURLs, nil, []string{relayURL})
143
+ if err != nil {
144
+ return err
145
+ }
146
+ tunnel.SeedRelayURLs = seedRelayURLs
147
+ return removeRelayFromTunnelRoute(tunnel, relayURL)
148
+ })
149
}
150
151
func (m *manager) SetMultiHop(id string, relayURLs []string) error {
121
- exposure, err := m.runningExposure(id)
152
+ multiHop, err := utils.NormalizeRelayURLs(relayURLs...)
153
if err != nil {
123
- return err
154
+ return fmt.Errorf("normalize multi-hop relay url: %w", err)
155
}
125
- return exposure.SetMultiHop(relayURLs)
126
-}
127
-
128
-func (m *manager) runningExposure(id string) (*sdk.Exposure, error) {
129
- id = strings.TrimSpace(id)
130
- m.mu.RLock()
131
- tunnel := m.tunnels[id]
132
- m.mu.RUnlock()
133
- if tunnel == nil {
134
- return nil, fmt.Errorf("unknown tunnel %q", id)
156
+ if len(multiHop) != len(relayURLs) {
157
+ return errors.New("multi-hop relay url repeated")
158
}
136
- tunnel.mu.RLock()
137
- tunnelID := tunnel.cfg.ID
138
- exposure := tunnel.exposure
139
- tunnel.mu.RUnlock()
140
- if exposure == nil {
141
- return nil, fmt.Errorf("tunnel %q is not running", tunnelID)
159
+ if len(multiHop) == 1 {
160
+ return errors.New("multi-hop requires at least entry and exit relay urls")
161
}
143
- return exposure, nil
162
+ return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
163
+ tunnel.MultiHop = append([]string(nil), multiHop...)
164
+ tunnel.MultiHopDepth = 0
165
+ return nil
166
+ })
167
}
168
169
func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
170
m.configMu.Lock()
171
defer m.configMu.Unlock()
172
150
- cfg, path, mode, err := m.loadConfigDocument()
173
+ doc, cfg, path, mode, err := m.loadConfigDocument()
174
if err != nil {
175
return err
176
}
154
- m.preserveCurrentIdentityPaths(&cfg)
177
+ if len(doc.Tunnels) == 1 && strings.TrimSpace(doc.Tunnels[0].IdentityPath) == "" {
178
+ if len(cfg.Tunnels) == 1 {
179
+ doc.Tunnels[0].IdentityPath = cfg.Tunnels[0].IdentityPath
180
+ }
181
+ }
182
id := strings.TrimSpace(req.ID)
183
name := strings.TrimSpace(req.Name)
184
if id == "" {
@@ -160,8 +187,8 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
187
if id == "" {
188
return errors.New("tunnel name is required")
189
}
163
- if strings.ContainsAny(id, " \t\r\n/") {
164
- return errors.New("tunnel id cannot contain whitespace or slash")
190
+ if err := validateAgentPathComponent("tunnel id", id); err != nil {
191
+ return err
192
}
193
target := strings.TrimSpace(req.TargetAddr)
194
if target == "" {
@@ -170,21 +197,23 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
197
if name == "" {
198
name = id
199
}
200
+ relayURLs, err := utils.NormalizeRelayURLs(req.RelayURLs...)
201
+ if err != nil {
202
+ return err
203
+ }
204
discovery := true
205
tunnelCfg := TunnelConfig{
206
ID: id,
207
Name: name,
208
TargetAddr: target,
178
- RelayURLs: append([]string(nil), req.RelayURLs...),
209
+ RelayURLs: relayURLs,
210
Discovery: &discovery,
211
}
181
- for _, tunnel := range cfg.Tunnels {
182
- if tunnel.ID == tunnelCfg.ID {
183
- return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
184
- }
212
+ if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
213
+ return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
214
}
186
- cfg.Tunnels = append(cfg.Tunnels, tunnelCfg)
187
- return m.writeConfigAndApply(path, mode, cfg)
215
+ doc.Tunnels = append(doc.Tunnels, tunnelCfg)
216
+ return m.writeConfigAndApply(path, mode, doc)
217
}
218
219
func agentTunnelID(name string) string {
@@ -192,22 +221,67 @@ func agentTunnelID(name string) string {
221
var out strings.Builder
222
dash := false
223
for _, r := range name {
195
- if r == '/' || unicode.IsSpace(r) {
224
+ if invalidAgentPathComponentRune(r) {
225
if out.Len() > 0 && !dash {
226
out.WriteByte('-')
227
dash = true
228
}
229
continue
230
}
202
- if r < 0x20 {
203
- continue
204
- }
231
out.WriteRune(r)
232
dash = false
233
}
234
return strings.Trim(out.String(), "-")
235
}
236
237
+func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error) error {
238
+ id = strings.TrimSpace(id)
239
+ if err := validateAgentPathComponent("tunnel id", id); err != nil {
240
+ return err
241
+ }
242
+
243
+ m.configMu.Lock()
244
+ defer m.configMu.Unlock()
245
+
246
+ doc, cfg, path, mode, err := m.loadConfigDocument()
247
+ if err != nil {
248
+ return err
249
+ }
250
+ index := slices.IndexFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == id })
251
+ if index < 0 {
252
+ return fmt.Errorf("tunnel %q not found", id)
253
+ }
254
+ before := doc.Tunnels[index]
255
+ if err := update(&doc.Tunnels[index]); err != nil {
256
+ return err
257
+ }
258
+ if reflect.DeepEqual(before, doc.Tunnels[index]) {
259
+ return nil
260
+ }
261
+ return m.writeConfigAndApply(path, mode, doc)
262
+}
263
+
264
+func removeRelayFromTunnelRoute(tunnel *TunnelConfig, relayURL string) error {
265
+ if len(tunnel.MultiHop) == 0 {
266
+ return nil
267
+ }
268
+ multiHop, err := utils.NormalizeRelayURLs(tunnel.MultiHop...)
269
+ if err != nil {
270
+ return fmt.Errorf("normalize multi-hop relay url: %w", err)
271
+ }
272
+ nextMultiHop := utils.RemoveRelayURL(multiHop, relayURL)
273
+ if len(nextMultiHop) == len(multiHop) {
274
+ tunnel.MultiHop = nextMultiHop
275
+ return nil
276
+ }
277
+ if len(nextMultiHop) < 2 {
278
+ nextMultiHop = nil
279
+ }
280
+ tunnel.MultiHop = nextMultiHop
281
+ tunnel.MultiHopDepth = 0
282
+ return nil
283
+}
284
+
285
func (m *manager) DeleteTunnel(id string) error {
286
m.configMu.Lock()
287
defer m.configMu.Unlock()
@@ -216,74 +290,63 @@ func (m *manager) DeleteTunnel(id string) error {
290
if id == "" {
291
return errors.New("tunnel id is required")
292
}
219
- cfg, path, mode, err := m.loadConfigDocument()
293
+ doc, cfg, path, mode, err := m.loadConfigDocument()
294
if err != nil {
295
return err
296
}
223
- m.preserveCurrentIdentityPaths(&cfg)
224
- if len(cfg.Tunnels) <= 1 {
297
+ if len(doc.Tunnels) <= 1 {
298
return errors.New("cannot delete the last tunnel")
299
}
300
228
- next := cfg.Tunnels[:0]
229
- found := false
230
- for _, tunnel := range cfg.Tunnels {
231
- if tunnel.ID == id {
232
- found = true
301
+ index := slices.IndexFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == id })
302
+ if index < 0 {
303
+ return fmt.Errorf("tunnel %q not found", id)
304
+ }
305
+ next := doc.Tunnels[:0]
306
+ for i, tunnel := range doc.Tunnels {
307
+ if i == index {
308
continue
309
}
310
next = append(next, tunnel)
311
}
237
- if !found {
238
- return fmt.Errorf("tunnel %q not found", id)
239
- }
240
- cfg.Tunnels = next
241
- return m.writeConfigAndApply(path, mode, cfg)
312
+ doc.Tunnels = next
313
+ return m.writeConfigAndApply(path, mode, doc)
314
}
315
244
-func (m *manager) loadConfigDocument() (Config, string, os.FileMode, error) {
316
+func (m *manager) loadConfigDocument() (configDocument, Config, string, os.FileMode, error) {
317
m.mu.RLock()
318
configPath := m.cfg.sourcePath
319
m.mu.RUnlock()
248
- return loadConfigDocument(configPath)
249
-}
250
-
251
-func (m *manager) preserveCurrentIdentityPaths(cfg *Config) {
252
- m.mu.RLock()
253
- identityPathByID := make(map[string]string, len(m.cfg.Tunnels))
254
- for _, tunnel := range m.cfg.Tunnels {
255
- if strings.TrimSpace(tunnel.IdentityPath) != "" {
256
- identityPathByID[tunnel.ID] = tunnel.IdentityPath
257
- }
320
+ doc, path, mode, err := loadConfigDocument(configPath)
321
+ if err != nil {
322
+ return configDocument{}, Config{}, "", 0, err
323
}
259
- m.mu.RUnlock()
260
-
261
- for i := range cfg.Tunnels {
262
- tunnel := &cfg.Tunnels[i]
263
- if strings.TrimSpace(tunnel.IdentityPath) != "" {
264
- continue
265
- }
266
- if identityPath := identityPathByID[tunnel.ID]; identityPath != "" {
267
- tunnel.IdentityPath = identityPath
268
- }
324
+ cfg, err := resolveConfigDocument(path, doc)
325
+ if err != nil {
326
+ return configDocument{}, Config{}, "", 0, err
327
}
328
+ return doc, cfg, path, mode, nil
329
}
330
272
-func (m *manager) writeConfigAndApply(path string, mode os.FileMode, cfg Config) error {
273
- if err := validateConfigDocument(path, cfg); err != nil {
274
- return err
275
- }
276
- if err := writeConfigDocument(path, mode, cfg); err != nil {
331
+func (m *manager) writeConfigAndApply(path string, mode os.FileMode, doc configDocument) error {
332
+ next, err := resolveConfigDocument(path, doc)
333
+ if err != nil {
334
return err
335
}
279
- next, err := LoadConfig(path)
280
- if err != nil {
336
+ if err := writeConfigDocument(path, mode, doc); err != nil {
337
return err
338
}
339
return m.ApplyConfig(next)
340
}
341
342
func (m *manager) ApplyConfig(cfg Config) error {
343
+ type liveUpdate struct {
344
+ tunnel *managedTunnel
345
+ cfg TunnelConfig
346
+ relayChanged bool
347
+ multiHopChanged bool
348
+ }
349
+
350
m.mu.Lock()
351
m.cfg = cfg
352
rootCtx := m.rootCtx
@@ -294,6 +357,7 @@ func (m *manager) ApplyConfig(cfg Config) error {
357
toStop := make([]*managedTunnel, 0)
358
toStart := make([]*managedTunnel, 0)
359
toUpdate := make([]*managedTunnel, 0)
360
+ var liveUpdates []liveUpdate
361
for id, tunnel := range m.tunnels {
362
tunnelCfg, ok := next[id]
363
if !ok {
@@ -302,9 +366,28 @@ func (m *manager) ApplyConfig(cfg Config) error {
366
continue
367
}
368
tunnel.mu.Lock()
305
- if !reflect.DeepEqual(tunnel.cfg, tunnelCfg) {
306
- tunnel.cfg = tunnelCfg
307
- toUpdate = append(toUpdate, tunnel)
369
+ previous := tunnel.cfg
370
+ if !reflect.DeepEqual(previous, tunnelCfg) {
371
+ staticPrevious := previous
372
+ staticNext := tunnelCfg
373
+ staticPrevious.RelayURLs = nil
374
+ staticPrevious.SeedRelayURLs = nil
375
+ staticPrevious.MultiHop = nil
376
+ staticNext.RelayURLs = nil
377
+ staticNext.SeedRelayURLs = nil
378
+ staticNext.MultiHop = nil
379
+ if reflect.DeepEqual(staticPrevious, staticNext) {
380
+ tunnel.cfg = tunnelCfg
381
+ liveUpdates = append(liveUpdates, liveUpdate{
382
+ tunnel: tunnel,
383
+ cfg: tunnelCfg,
384
+ relayChanged: !slices.Equal(previous.RelayURLs, tunnelCfg.RelayURLs) || !slices.Equal(previous.SeedRelayURLs, tunnelCfg.SeedRelayURLs),
385
+ multiHopChanged: !slices.Equal(previous.MultiHop, tunnelCfg.MultiHop),
386
+ })
387
+ } else {
388
+ tunnel.cfg = tunnelCfg
389
+ toUpdate = append(toUpdate, tunnel)
390
+ }
391
}
392
tunnel.mu.Unlock()
393
delete(next, id)
@@ -325,7 +408,12 @@ func (m *manager) ApplyConfig(cfg Config) error {
408
for _, tunnel := range append(toStart, toUpdate...) {
409
tunnel.Start(rootCtx)
410
}
328
- return nil
411
+
412
+ var liveErr error
413
+ for _, update := range liveUpdates {
414
+ liveErr = errors.Join(liveErr, update.tunnel.applyLiveConfig(update.cfg, update.relayChanged, update.multiHopChanged))
415
+ }
416
+ return liveErr
417
}
418
419
func (m *manager) Snapshot() types.AgentStatusResponse {
@@ -345,6 +433,7 @@ func (m *manager) Snapshot() types.AgentStatusResponse {
433
})
434
435
return types.AgentStatusResponse{
436
+ ConfigPath: m.cfg.sourcePath,
437
ControlAddr: m.controlAddr,
438
Tunnels: statuses,
439
}
@@ -364,6 +453,32 @@ func newTunnel(cfg TunnelConfig) *managedTunnel {
453
return &managedTunnel{cfg: cfg}
454
}
455
456
+func (t *managedTunnel) applyLiveConfig(cfg TunnelConfig, relayChanged, multiHopChanged bool) error {
457
+ t.mu.RLock()
458
+ exposure := t.exposure
459
+ t.mu.RUnlock()
460
+ if exposure == nil {
461
+ return nil
462
+ }
463
+
464
+ var err error
465
+ if relayChanged {
466
+ err = errors.Join(err, exposure.SetRelayConfig(cfg.RelayURLs, cfg.SeedRelayURLs))
467
+ }
468
+ if multiHopChanged {
469
+ err = errors.Join(err, exposure.SetMultiHop(cfg.MultiHop))
470
+ }
471
+
472
+ t.mu.Lock()
473
+ if err == nil {
474
+ t.lastError = ""
475
+ } else {
476
+ t.lastError = err.Error()
477
+ }
478
+ t.mu.Unlock()
479
+ return err
480
+}
481
+
482
func (t *managedTunnel) Start(parent context.Context) {
483
t.mu.Lock()
484
if t.done != nil {
@@ -449,16 +564,26 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
564
}
565
566
func (t *managedTunnel) runLoop(ctx context.Context) {
452
- err := t.runOnce(ctx)
567
+ for {
568
+ err := t.runOnce(ctx)
569
+
570
+ t.mu.Lock()
571
+ t.exposure = nil
572
+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
573
+ t.lastError = ""
574
+ } else {
575
+ t.lastError = err.Error()
576
+ }
577
+ t.mu.Unlock()
578
454
- t.mu.Lock()
455
- t.exposure = nil
456
- if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
457
- t.lastError = ""
458
- } else {
459
- t.lastError = err.Error()
579
+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
580
+ return
581
+ }
582
+ log.Warn().Err(err).Msg("managed tunnel stopped with error; retrying")
583
+ if !utils.SleepOrDone(ctx, managedTunnelRetryInterval) {
584
+ return
585
+ }
586
}
461
- t.mu.Unlock()
587
}
588
589
func (t *managedTunnel) runOnce(ctx context.Context) error {
@@ -477,6 +602,7 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
602
}
603
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
604
RelayURLs: append([]string(nil), cfg.RelayURLs...),
605
+ SeedRelayURLs: append([]string(nil), cfg.SeedRelayURLs...),
606
Discovery: discovery,
607
IdentityPath: cfg.IdentityPath,
608
IdentityJSON: cfg.IdentityJSON,
docs/src/routes/cli-reference/+page.md
+2
-2
@@ -160,8 +160,8 @@ portal agent dashboard
160
portal agent stop
161
```
162
163
-`portal agent run` reads or creates the platform default `config.toml`, installs or updates the OS-managed service, starts it, and opens the dashboard when invoked from an interactive terminal. Use `--foreground` for local debugging without service registration.
164
-Use `portal agent dashboard` to attach to an already running local agent. When using `--foreground`, keep that process running in one terminal and open the dashboard from another.
163
+`portal agent run` reads or creates the platform default `config.toml`, installs or updates the OS-managed service, starts it in the background, and exits after the agent is ready. Use `--foreground` for local debugging without service registration.
164
+Use `portal agent dashboard` to attach to an already running managed agent. With `--foreground` in an interactive terminal, the dashboard attaches in the same process.
165
166
**Subcommands:**
167
docs/src/routes/configuration/+page.md
+1
@@ -208,6 +208,7 @@ Tunnel fields mirror `portal expose` flags:
208
| `target` | string | Local TCP target, equivalent to the `portal expose <target>` argument |
209
| `http_routes` | table array | HTTP route mappings; cannot be combined with `target` or `udp` |
210
| `relays` | string array | Explicit relay API URLs |
211
+| `seed_relays` | string array | Discovery seed relay API URLs that are not attached as active relays |
212
| `discovery` | bool | Include registry and relay discovery expansion |
213
| `multi_hop` | string array | Ordered multi-hop relay path |
214
| `multi_hop_depth` | int | Automatically select one multi-hop route with this depth |
sdk/expose.go
+65
-8
@@ -51,8 +51,9 @@ type Exposure struct {
51
}
52
53
type ExposeConfig struct {
54
- RelayURLs []string
55
- Discovery bool
54
+ RelayURLs []string
55
+ SeedRelayURLs []string
56
+ Discovery bool
57
58
IdentityPath string
59
IdentityJSON string
@@ -79,6 +80,11 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
80
if err != nil {
81
return nil, err
82
}
83
+ seedOnlyRelayURLs, err := utils.NormalizeRelayURLs(cfg.SeedRelayURLs...)
84
+ if err != nil {
85
+ return nil, err
86
+ }
87
+ seedOnlyRelayURLs = utils.FilterRelayURLs(seedOnlyRelayURLs, explicitRelayURLs)
88
var multiHop []string
89
for _, input := range cfg.MultiHop {
90
relayURL, err := utils.NormalizeRelayURL(input)
@@ -107,14 +113,17 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
113
var relaySetURLs []string
114
if len(multiHop) > 0 {
115
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
110
- relaySetURLs = append([]string(nil), multiHop...)
116
+ relaySetURLs, err = utils.MergeRelayURLs(multiHop, nil, seedOnlyRelayURLs)
117
+ if err != nil {
118
+ return nil, err
119
+ }
120
} else if cfg.MultiHopDepth > 1 {
112
- relaySetURLs, err = utils.ResolvePortalRelayURLs(explicitRelayURLs, cfg.Discovery)
121
+ relaySetURLs, err = utils.ResolvePortalRelayURLs(append(append([]string(nil), explicitRelayURLs...), seedOnlyRelayURLs...), cfg.Discovery)
122
if err != nil {
123
return nil, err
124
}
125
} else {
117
- relaySetURLs, err = utils.ResolvePortalRelayURLs(explicitRelayURLs, cfg.Discovery)
126
+ relaySetURLs, err = utils.ResolvePortalRelayURLs(append(append([]string(nil), explicitRelayURLs...), seedOnlyRelayURLs...), cfg.Discovery)
127
if err != nil {
128
return nil, err
129
}
@@ -153,6 +162,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
162
done: exposureCtx.Done(),
163
identity: identity,
164
explicitRelays: explicitRelayURLs,
165
+ seedOnlyRelays: seedOnlyRelayURLs,
166
TargetAddr: targetAddr,
167
UDPAddr: udpAddr,
168
udpEnabled: cfg.UDPEnabled,
@@ -168,7 +178,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
178
relayListeners: make(map[string]*listener, initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)),
179
}
180
171
- if cfg.Discovery || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
181
+ if cfg.Discovery || len(seedOnlyRelayURLs) > 0 || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
182
refresher := discovery.NewRefresher(exposure.relaySet, nil)
183
if err := refresher.Refresh(ctx, nil); err != nil {
184
_ = exposure.Close()
@@ -176,14 +186,14 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
186
}
187
}
188
179
- if len(listenerRelayURLs) > 0 || cfg.Discovery || cfg.MultiHopDepth > 1 {
189
+ if len(listenerRelayURLs) > 0 || cfg.Discovery || len(seedOnlyRelayURLs) > 0 || cfg.MultiHopDepth > 1 {
190
if err := exposure.reconcileRelayListeners(true); err != nil {
191
_ = exposure.Close()
192
return nil, err
193
}
194
}
195
186
- if cfg.Discovery || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
196
+ if cfg.Discovery || len(seedOnlyRelayURLs) > 0 || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
197
go exposure.runDiscoveryLoop(exposureCtx)
198
}
199
@@ -321,6 +331,53 @@ func (e *Exposure) SeedRelay(relayURL string) error {
331
return e.reconcileRelayListeners(false)
332
}
333
334
+func (e *Exposure) SetRelayConfig(relayURLs, seedRelayURLs []string) error {
335
+ relayURLs, err := utils.NormalizeRelayURLs(relayURLs...)
336
+ if err != nil {
337
+ return err
338
+ }
339
+ seedRelayURLs, err = utils.NormalizeRelayURLs(seedRelayURLs...)
340
+ if err != nil {
341
+ return err
342
+ }
343
+ seedRelayURLs = utils.FilterRelayURLs(seedRelayURLs, relayURLs)
344
+ if e.closed() {
345
+ return net.ErrClosed
346
+ }
347
+ if e.relaySet == nil {
348
+ return errors.New("exposure relay set is not initialized")
349
+ }
350
+
351
+ e.listenerMu.RLock()
352
+ currentRelays := append([]string(nil), e.explicitRelays...)
353
+ currentSeedRelays := append([]string(nil), e.seedOnlyRelays...)
354
+ e.listenerMu.RUnlock()
355
+
356
+ desiredRelayURLs, err := utils.MergeRelayURLs(relayURLs, nil, seedRelayURLs)
357
+ if err != nil {
358
+ return err
359
+ }
360
+ currentRelayURLs, err := utils.MergeRelayURLs(currentRelays, nil, currentSeedRelays)
361
+ if err != nil {
362
+ return err
363
+ }
364
+
365
+ e.listenerMu.Lock()
366
+ e.explicitRelays = append([]string(nil), relayURLs...)
367
+ e.seedOnlyRelays = append([]string(nil), seedRelayURLs...)
368
+ e.listenerMu.Unlock()
369
+
370
+ for _, relayURL := range desiredRelayURLs {
371
+ e.relaySet.AllowRelayURL(relayURL)
372
+ e.relaySet.AddBootstrapRelayURL(relayURL)
373
+ }
374
+ for _, relayURL := range utils.FilterRelayURLs(currentRelayURLs, desiredRelayURLs) {
375
+ e.relaySet.BanRelayURL(relayURL)
376
+ e.relaySet.RemoveBootstrapRelayURL(relayURL)
377
+ }
378
+ return e.reconcileRelayListeners(len(relayURLs) > 0)
379
+}
380
+
381
func (e *Exposure) SetMultiHop(relayURLs []string) error {
382
multiHop := make([]string, 0, len(relayURLs))
383
for _, input := range relayURLs {
types/agent.go
+1
@@ -1,6 +1,7 @@
1
package types
2
3
type AgentStatusResponse struct {
4
+ ConfigPath string `json:"config_path,omitempty"`
5
ControlAddr string `json:"control_addr"`
6
Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
7
}