feat: refactor agent configuration and relay management, remove seed relays
Kim committed
May 8, 2026 at 11:08 UTC
85ac1c947486a8f8987a744a0d4c1b5f5e12f943
12 files changed
+116
-379
cmd/portal-tunnel/agent.go
+1
-1
@@ -291,7 +291,7 @@ func loadAgentCommandConfig(configPath, stateDir string) (agent.Config, string,
291
if configPath == "" {
292
configPath = service.DefaultConfigPath()
293
}
294
- cfg, err := agent.LoadConfig(configPath)
294
+ cfg, err := agent.LoadExistingConfig(configPath)
295
if err != nil {
296
return agent.Config{}, "", err
297
}
cmd/portal-tunnel/agent/config.go
+55
-68
@@ -27,13 +27,8 @@ const (
27
28
type Config struct {
29
sourcePath string
30
- Agent AgentConfig
31
- Tunnels []TunnelConfig
32
-}
33
-
34
-type configDocument struct {
35
- Agent AgentConfig `koanf:"agent"`
36
- Tunnels []TunnelConfig `koanf:"tunnels"`
30
+ Agent AgentConfig `koanf:"agent"`
31
+ Tunnels []TunnelConfig `koanf:"tunnels"`
32
}
33
34
type AgentConfig struct {
@@ -48,7 +43,6 @@ type TunnelConfig struct {
43
TargetAddr string `koanf:"target"`
44
HTTPRoutes []HTTPRouteConfig `koanf:"http_routes"`
45
RelayURLs []string `koanf:"relays"`
51
- SeedRelayURLs []string `koanf:"seed_relays"`
46
Discovery *bool `koanf:"discovery"`
47
IdentityPath string `koanf:"identity_path"`
48
IdentityJSON string `koanf:"identity_json"`
@@ -76,11 +70,33 @@ func LoadConfig(path string) (Config, error) {
70
if err != nil {
71
return Config{}, err
72
}
79
- doc, _, err := readConfigDocument(absPath)
73
+ cfg, _, err := readConfigDocument(absPath)
74
if err != nil {
75
return Config{}, err
76
}
83
- return resolveConfigDocument(absPath, doc)
77
+ return cfg, nil
78
+}
79
+
80
+func LoadExistingConfig(path string) (Config, error) {
81
+ path = strings.TrimSpace(path)
82
+ if path == "" {
83
+ path = service.DefaultConfigPath()
84
+ }
85
+ absPath, err := filepath.Abs(path)
86
+ if err != nil {
87
+ return Config{}, err
88
+ }
89
+ if _, err := os.Stat(absPath); err != nil {
90
+ if errors.Is(err, os.ErrNotExist) {
91
+ return Config{}, fmt.Errorf("agent config %q does not exist; run `portal agent run` to create it", absPath)
92
+ }
93
+ return Config{}, err
94
+ }
95
+ cfg, _, err := readConfigDocument(absPath)
96
+ if err != nil {
97
+ return Config{}, err
98
+ }
99
+ return cfg, nil
100
}
101
102
func ensureConfigDocument(path string) (string, error) {
@@ -98,7 +114,7 @@ func ensureConfigDocument(path string) (string, error) {
114
}
115
if _, err := os.Stat(absPath); err != nil {
116
if errors.Is(err, os.ErrNotExist) {
101
- if err := writeConfigDocument(absPath, 0o644, defaultConfigDocument()); err != nil {
117
+ if err := writeConfigDocument(absPath, 0o644, defaultConfig()); err != nil {
118
return "", fmt.Errorf("create default agent config %q: %w", absPath, err)
119
}
120
} else {
@@ -108,9 +124,9 @@ func ensureConfigDocument(path string) (string, error) {
124
return absPath, nil
125
}
126
111
-func defaultConfigDocument() configDocument {
127
+func defaultConfig() Config {
128
discovery := true
113
- return configDocument{
129
+ return Config{
130
Agent: AgentConfig{
131
StateDir: service.DefaultDataDir(),
132
ControlAddr: DefaultControlAddr,
@@ -125,71 +141,50 @@ func defaultConfigDocument() configDocument {
141
}
142
}
143
128
-func loadConfigDocument(path string) (configDocument, string, os.FileMode, error) {
144
+func loadConfigDocument(path string) (Config, string, os.FileMode, error) {
145
absPath, err := ensureConfigDocument(path)
146
if err != nil {
131
- return configDocument{}, "", 0, err
147
+ return Config{}, "", 0, err
148
}
133
- doc, mode, err := readConfigDocument(absPath)
149
+ cfg, mode, err := readConfigDocument(absPath)
150
if err != nil {
135
- return configDocument{}, "", 0, err
151
+ return Config{}, "", 0, err
152
}
137
- return doc, absPath, mode, nil
153
+ return cfg, absPath, mode, nil
154
}
155
140
-func readConfigDocument(absPath string) (configDocument, os.FileMode, error) {
156
+func readConfigDocument(absPath string) (Config, os.FileMode, error) {
157
info, err := os.Stat(absPath)
158
if err != nil {
143
- return configDocument{}, 0, err
159
+ return Config{}, 0, err
160
}
161
data, err := os.ReadFile(absPath)
162
if err != nil {
147
- return configDocument{}, 0, err
163
+ return Config{}, 0, err
164
}
165
150
- var doc configDocument
166
+ var cfg Config
167
if strings.TrimSpace(string(data)) != "" {
168
k := koanf.New(".")
169
if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
154
- return configDocument{}, 0, err
170
+ return Config{}, 0, err
171
}
156
- if err := k.Unmarshal("", &doc); err != nil {
157
- return configDocument{}, 0, err
172
+ if err := k.Unmarshal("", &cfg); err != nil {
173
+ return Config{}, 0, err
174
}
175
}
160
- return doc, info.Mode().Perm(), nil
161
-}
162
-
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...),
176
+ cfg.sourcePath = absPath
177
+ if err := cfg.ApplyDefaults(absPath); err != nil {
178
+ return Config{}, 0, err
179
}
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
180
+ if err := cfg.Validate(); err != nil {
181
+ return Config{}, 0, err
182
}
188
- return cfg, cfg.Validate()
183
+ return cfg, info.Mode().Perm(), nil
184
}
185
191
-func writeConfigDocument(path string, mode os.FileMode, doc configDocument) error {
192
- data, err := toml.Parser().Marshal(configDocumentMap(doc))
186
+func writeConfigDocument(path string, mode os.FileMode, cfg Config) error {
187
+ data, err := toml.Parser().Marshal(configMap(cfg))
188
if err != nil {
189
return err
190
}
@@ -199,14 +194,14 @@ func writeConfigDocument(path string, mode os.FileMode, doc configDocument) erro
194
return os.WriteFile(path, data, mode)
195
}
196
202
-func configDocumentMap(doc configDocument) map[string]any {
197
+func configMap(cfg Config) map[string]any {
198
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)
199
+ addStringDocumentField(agent, "state_dir", cfg.Agent.StateDir)
200
+ addStringDocumentField(agent, "control_addr", cfg.Agent.ControlAddr)
201
+ addStringDocumentField(agent, "service_name", cfg.Agent.ServiceName)
202
208
- tunnels := make([]map[string]any, 0, len(doc.Tunnels))
209
- for _, tunnel := range doc.Tunnels {
203
+ tunnels := make([]map[string]any, 0, len(cfg.Tunnels))
204
+ for _, tunnel := range cfg.Tunnels {
205
tunnels = append(tunnels, tunnelConfigDocumentMap(tunnel))
206
}
207
@@ -235,7 +230,6 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
230
out["http_routes"] = routes
231
}
232
addStringSliceDocumentField(out, "relays", cfg.RelayURLs)
238
- addStringSliceDocumentField(out, "seed_relays", cfg.SeedRelayURLs)
233
if cfg.Discovery != nil {
234
out["discovery"] = *cfg.Discovery
235
}
@@ -330,13 +324,6 @@ func (cfg *Config) ApplyDefaults(configPath string) error {
324
}
325
t.RelayURLs = relays
326
}
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
- }
327
for idx, relayURL := range t.MultiHop {
328
normalized, err := utils.NormalizeRelayURL(relayURL)
329
if err != nil {
cmd/portal-tunnel/agent/control.go
-18
@@ -86,19 +86,6 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
86
}
87
88
switch action {
89
- case "relays/seed":
90
- if !utils.RequireMethod(w, r, http.MethodPost) {
91
- return
92
- }
93
- req, ok := utils.DecodeJSONRequest[types.AgentRelayRequest](w, r, controlRequestBodyLimit)
94
- if !ok {
95
- return
96
- }
97
- if err := s.manager.SeedRelay(tunnelID, req.RelayURL); err != nil {
98
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
99
- return
100
- }
101
- utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
89
case "relays":
90
switch r.Method {
91
case http.MethodPost:
@@ -181,11 +168,6 @@ func RemoveRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error
168
return controlRequest(ctx, stateDir, http.MethodDelete, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
169
}
170
184
-func SeedRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
185
- path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays/seed"
186
- return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
187
-}
188
-
171
func SetMultiHop(ctx context.Context, stateDir, tunnelID string, relayURLs []string) error {
172
path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/multi-hop"
173
if relayURLs == nil {
cmd/portal-tunnel/agent/dashboard.go
+5
-19
@@ -38,7 +38,6 @@ const (
38
agentDashboardActionAddRelay
39
agentDashboardActionDeleteRelay
40
agentDashboardActionAttachRelay
41
- agentDashboardActionDetachRelay
41
agentDashboardActionAddHop
42
agentDashboardActionRemoveHop
43
agentDashboardActionApplyHop
@@ -252,8 +251,6 @@ func (m agentDashboardModel) runAction(action agentDashboardAction, tunnelID, re
251
return m.deleteSelectedRelay()
252
case agentDashboardActionAttachRelay:
253
return m.attachSelectedRelay()
255
- case agentDashboardActionDetachRelay:
256
- return m.detachSelectedRelay()
254
case agentDashboardActionAddHop:
255
return m.addSelectedHop()
256
case agentDashboardActionRemoveHop:
@@ -486,7 +483,7 @@ func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
483
if !ok {
484
return m, nil
485
}
489
- if relayDashboardInUse(relay) {
486
+ if relay.Explicit {
487
return m, nil
488
}
489
return m, agentDashboardRun(func(ctx context.Context) error {
@@ -494,16 +491,6 @@ func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
491
})
492
}
493
497
-func (m agentDashboardModel) detachSelectedRelay() (tea.Model, tea.Cmd) {
498
- tunnel, relay, ok := m.selectedTunnelRelay()
499
- if !ok {
500
- return m, nil
501
- }
502
- return m, agentDashboardRun(func(ctx context.Context) error {
503
- return SeedRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
504
- })
505
-}
506
-
494
func (m agentDashboardModel) addSelectedHop() (tea.Model, tea.Cmd) {
495
tunnel, relay, ok := m.selectedTunnelRelay()
496
if !ok {
@@ -727,15 +714,12 @@ func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardV
714
715
func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width, maxRows int, tunnel types.AgentTunnelStatus) {
716
relay, hasRelay := m.selectedRelayStatus()
730
- inUse := hasRelay && relayDashboardInUse(relay)
731
- attachDisabled := !hasRelay || inUse || relay.Connecting || relay.Banned
732
- detachDisabled := !hasRelay || relay.Banned || (!inUse && !relay.Connecting && relay.Bootstrap)
733
- deleteDisabled := !hasRelay
717
+ attachDisabled := !hasRelay || relay.Explicit || relay.Banned
718
+ deleteDisabled := !hasRelay || !relay.Explicit
719
720
pane.addStyled(width, agentDashboardSectionStyle, "Relays")
721
pane.addButtons(width,
722
agentDashboardButton{label: "Attach", action: agentDashboardActionAttachRelay, disabled: attachDisabled},
738
- agentDashboardButton{label: "Detach", action: agentDashboardActionDetachRelay, disabled: detachDisabled},
723
agentDashboardButton{label: "Add URL", action: agentDashboardActionAddRelay},
724
agentDashboardButton{label: "Remove", action: agentDashboardActionDeleteRelay, disabled: deleteDisabled},
725
)
@@ -1000,6 +984,8 @@ func relayDashboardRole(relay types.AgentRelayStatus) string {
984
switch {
985
case relay.Banned:
986
return "blocked"
987
+ case relay.Explicit:
988
+ return "pinned"
989
case relayDashboardInUse(relay):
990
return "attached"
991
case relay.Connecting:
cmd/portal-tunnel/agent/manager.go
+29
-115
@@ -107,7 +107,6 @@ func (m *manager) AddRelay(id, relayURL string) error {
107
return err
108
}
109
tunnel.RelayURLs = relayURLs
110
- tunnel.SeedRelayURLs = utils.RemoveRelayURL(tunnel.SeedRelayURLs, relayURL)
110
return nil
111
})
112
}
@@ -123,27 +122,6 @@ func (m *manager) RemoveRelay(id, relayURL string) error {
122
return err
123
}
124
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 {
132
- relayURL, err := utils.NormalizeRelayURL(relayURL)
133
- if err != nil {
134
- return err
135
- }
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
125
return removeRelayFromTunnelRoute(tunnel, relayURL)
126
})
127
}
@@ -170,15 +148,10 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
148
m.configMu.Lock()
149
defer m.configMu.Unlock()
150
173
- doc, cfg, path, mode, err := m.loadConfigDocument()
151
+ cfg, path, mode, err := m.loadConfigDocument()
152
if err != nil {
153
return err
154
}
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
- }
155
id := strings.TrimSpace(req.ID)
156
name := strings.TrimSpace(req.Name)
157
if id == "" {
@@ -212,8 +185,8 @@ func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
185
if slices.ContainsFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == tunnelCfg.ID }) {
186
return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
187
}
215
- doc.Tunnels = append(doc.Tunnels, tunnelCfg)
216
- return m.writeConfigAndApply(path, mode, doc)
188
+ cfg.Tunnels = append(cfg.Tunnels, tunnelCfg)
189
+ return m.writeConfigAndApply(path, mode, cfg)
190
}
191
192
func agentTunnelID(name string) string {
@@ -243,7 +216,7 @@ func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error
216
m.configMu.Lock()
217
defer m.configMu.Unlock()
218
246
- doc, cfg, path, mode, err := m.loadConfigDocument()
219
+ cfg, path, mode, err := m.loadConfigDocument()
220
if err != nil {
221
return err
222
}
@@ -251,14 +224,14 @@ func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error
224
if index < 0 {
225
return fmt.Errorf("tunnel %q not found", id)
226
}
254
- before := doc.Tunnels[index]
255
- if err := update(&doc.Tunnels[index]); err != nil {
227
+ before := cfg.Tunnels[index]
228
+ if err := update(&cfg.Tunnels[index]); err != nil {
229
return err
230
}
258
- if reflect.DeepEqual(before, doc.Tunnels[index]) {
231
+ if reflect.DeepEqual(before, cfg.Tunnels[index]) {
232
return nil
233
}
261
- return m.writeConfigAndApply(path, mode, doc)
234
+ return m.writeConfigAndApply(path, mode, cfg)
235
}
236
237
func removeRelayFromTunnelRoute(tunnel *TunnelConfig, relayURL string) error {
@@ -290,11 +263,11 @@ func (m *manager) DeleteTunnel(id string) error {
263
if id == "" {
264
return errors.New("tunnel id is required")
265
}
293
- doc, cfg, path, mode, err := m.loadConfigDocument()
266
+ cfg, path, mode, err := m.loadConfigDocument()
267
if err != nil {
268
return err
269
}
297
- if len(doc.Tunnels) <= 1 {
270
+ if len(cfg.Tunnels) <= 1 {
271
return errors.New("cannot delete the last tunnel")
272
}
273
@@ -302,51 +275,43 @@ func (m *manager) DeleteTunnel(id string) error {
275
if index < 0 {
276
return fmt.Errorf("tunnel %q not found", id)
277
}
305
- next := doc.Tunnels[:0]
306
- for i, tunnel := range doc.Tunnels {
278
+ next := cfg.Tunnels[:0]
279
+ for i, tunnel := range cfg.Tunnels {
280
if i == index {
281
continue
282
}
283
next = append(next, tunnel)
284
}
312
- doc.Tunnels = next
313
- return m.writeConfigAndApply(path, mode, doc)
285
+ cfg.Tunnels = next
286
+ return m.writeConfigAndApply(path, mode, cfg)
287
}
288
316
-func (m *manager) loadConfigDocument() (configDocument, Config, string, os.FileMode, error) {
289
+func (m *manager) loadConfigDocument() (Config, string, os.FileMode, error) {
290
m.mu.RLock()
291
configPath := m.cfg.sourcePath
292
m.mu.RUnlock()
320
- doc, path, mode, err := loadConfigDocument(configPath)
321
- if err != nil {
322
- return configDocument{}, Config{}, "", 0, err
323
- }
324
- cfg, err := resolveConfigDocument(path, doc)
293
+ cfg, path, mode, err := loadConfigDocument(configPath)
294
if err != nil {
326
- return configDocument{}, Config{}, "", 0, err
295
+ return Config{}, "", 0, err
296
}
328
- return doc, cfg, path, mode, nil
297
+ return cfg, path, mode, nil
298
}
299
331
-func (m *manager) writeConfigAndApply(path string, mode os.FileMode, doc configDocument) error {
332
- next, err := resolveConfigDocument(path, doc)
333
- if err != nil {
300
+func (m *manager) writeConfigAndApply(path string, mode os.FileMode, cfg Config) error {
301
+ cfg.sourcePath = path
302
+ if err := cfg.ApplyDefaults(path); err != nil {
303
+ return err
304
+ }
305
+ if err := cfg.Validate(); err != nil {
306
return err
307
}
336
- if err := writeConfigDocument(path, mode, doc); err != nil {
308
+ if err := writeConfigDocument(path, mode, cfg); err != nil {
309
return err
310
}
339
- return m.ApplyConfig(next)
311
+ return m.ApplyConfig(cfg)
312
}
313
314
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
-
315
m.mu.Lock()
316
m.cfg = cfg
317
rootCtx := m.rootCtx
@@ -357,7 +322,6 @@ func (m *manager) ApplyConfig(cfg Config) error {
322
toStop := make([]*managedTunnel, 0)
323
toStart := make([]*managedTunnel, 0)
324
toUpdate := make([]*managedTunnel, 0)
360
- var liveUpdates []liveUpdate
325
for id, tunnel := range m.tunnels {
326
tunnelCfg, ok := next[id]
327
if !ok {
@@ -368,26 +332,8 @@ func (m *manager) ApplyConfig(cfg Config) error {
332
tunnel.mu.Lock()
333
previous := tunnel.cfg
334
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
- }
335
+ tunnel.cfg = tunnelCfg
336
+ toUpdate = append(toUpdate, tunnel)
337
}
338
tunnel.mu.Unlock()
339
delete(next, id)
@@ -408,12 +354,7 @@ func (m *manager) ApplyConfig(cfg Config) error {
354
for _, tunnel := range append(toStart, toUpdate...) {
355
tunnel.Start(rootCtx)
356
}
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
357
+ return nil
358
}
359
360
func (m *manager) Snapshot() types.AgentStatusResponse {
@@ -453,32 +394,6 @@ func newTunnel(cfg TunnelConfig) *managedTunnel {
394
return &managedTunnel{cfg: cfg}
395
}
396
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
-
397
func (t *managedTunnel) Start(parent context.Context) {
398
t.mu.Lock()
399
if t.done != nil {
@@ -602,7 +517,6 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
517
}
518
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
519
RelayURLs: append([]string(nil), cfg.RelayURLs...),
605
- SeedRelayURLs: append([]string(nil), cfg.SeedRelayURLs...),
520
Discovery: discovery,
521
IdentityPath: cfg.IdentityPath,
522
IdentityJSON: cfg.IdentityJSON,
docs/src/routes/configuration/+page.md
-1
@@ -208,7 +208,6 @@ 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 |
211
| `discovery` | bool | Include registry and relay discovery expansion |
212
| `multi_hop` | string array | Ordered multi-hop relay path |
213
| `multi_hop_depth` | int | Automatically select one multi-hop route with this depth |
go.mod
+1
-1
@@ -1,6 +1,6 @@
1
module github.com/gosuda/portal-tunnel/v2
2
3
-go 1.26.2
3
+go 1.26.3
4
5
require (
6
cloud.google.com/go/compute/metadata v0.9.0
portal/discovery/mols.go
-4
@@ -387,10 +387,6 @@ func (p MOLSRelayPolicy) SelectPriorityWithTrace(states []RelayState, cs ClientS
387
explicit = append(explicit, relayURL)
388
continue
389
}
390
- if slices.Contains(cs.SuppressedRelayURLs, relayURL) {
391
- continue
392
- }
393
-
390
if state.hasObservedDescriptor() {
391
if !state.Descriptor.ExpiresAt.After(now) {
392
trace.Suppressed = append(trace.Suppressed, relayURL)
portal/discovery/relaystate.go
-3
@@ -162,9 +162,6 @@ func (state RelayState) hasObservedDescriptor() bool {
162
163
type ClientState struct {
164
ExplicitRelayURLs []string
165
- // SuppressedRelayURLs are discovery seeds that must not be auto-selected
166
- // as active relays unless they are also explicit.
167
- SuppressedRelayURLs []string
165
// MaxActiveRelays caps auto-selected relays. Zero or negative values use
166
// the policy default of 3.
167
MaxActiveRelays int
sdk/expose.go
+23
-148
@@ -28,7 +28,6 @@ type Exposure struct {
28
29
identity types.Identity
30
explicitRelays []string
31
- seedOnlyRelays []string
31
TargetAddr string
32
UDPAddr string
33
udpEnabled bool
@@ -51,9 +50,8 @@ type Exposure struct {
50
}
51
52
type ExposeConfig struct {
54
- RelayURLs []string
55
- SeedRelayURLs []string
56
- Discovery bool
53
+ RelayURLs []string
54
+ Discovery bool
55
56
IdentityPath string
57
IdentityJSON string
@@ -80,11 +78,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
78
if err != nil {
79
return nil, err
80
}
83
- seedOnlyRelayURLs, err := utils.NormalizeRelayURLs(cfg.SeedRelayURLs...)
84
- if err != nil {
85
- return nil, err
86
- }
87
- seedOnlyRelayURLs = utils.FilterRelayURLs(seedOnlyRelayURLs, explicitRelayURLs)
81
var multiHop []string
82
for _, input := range cfg.MultiHop {
83
relayURL, err := utils.NormalizeRelayURL(input)
@@ -113,17 +106,14 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
106
var relaySetURLs []string
107
if len(multiHop) > 0 {
108
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
116
- relaySetURLs, err = utils.MergeRelayURLs(multiHop, nil, seedOnlyRelayURLs)
117
- if err != nil {
118
- return nil, err
119
- }
109
+ relaySetURLs = append([]string(nil), multiHop...)
110
} else if cfg.MultiHopDepth > 1 {
121
- relaySetURLs, err = utils.ResolvePortalRelayURLs(append(append([]string(nil), explicitRelayURLs...), seedOnlyRelayURLs...), cfg.Discovery)
111
+ relaySetURLs, err = utils.ResolvePortalRelayURLs(explicitRelayURLs, cfg.Discovery)
112
if err != nil {
113
return nil, err
114
}
115
} else {
126
- relaySetURLs, err = utils.ResolvePortalRelayURLs(append(append([]string(nil), explicitRelayURLs...), seedOnlyRelayURLs...), cfg.Discovery)
116
+ relaySetURLs, err = utils.ResolvePortalRelayURLs(explicitRelayURLs, cfg.Discovery)
117
if err != nil {
118
return nil, err
119
}
@@ -162,7 +152,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
152
done: exposureCtx.Done(),
153
identity: identity,
154
explicitRelays: explicitRelayURLs,
165
- seedOnlyRelays: seedOnlyRelayURLs,
155
TargetAddr: targetAddr,
156
UDPAddr: udpAddr,
157
udpEnabled: cfg.UDPEnabled,
@@ -178,7 +167,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
167
relayListeners: make(map[string]*listener, initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)),
168
}
169
181
- if cfg.Discovery || len(seedOnlyRelayURLs) > 0 || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
170
+ if cfg.Discovery || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
171
refresher := discovery.NewRefresher(exposure.relaySet, nil)
172
if err := refresher.Refresh(ctx, nil); err != nil {
173
_ = exposure.Close()
@@ -186,14 +175,14 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
175
}
176
}
177
189
- if len(listenerRelayURLs) > 0 || cfg.Discovery || len(seedOnlyRelayURLs) > 0 || cfg.MultiHopDepth > 1 {
178
+ if len(listenerRelayURLs) > 0 || cfg.Discovery || cfg.MultiHopDepth > 1 {
179
if err := exposure.reconcileRelayListeners(true); err != nil {
180
_ = exposure.Close()
181
return nil, err
182
}
183
}
184
196
- if cfg.Discovery || len(seedOnlyRelayURLs) > 0 || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
185
+ if cfg.Discovery || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
186
go exposure.runDiscoveryLoop(exposureCtx)
187
}
188
@@ -220,13 +209,6 @@ func (e *Exposure) AddRelay(relayURL string) error {
209
}
210
211
e.listenerMu.Lock()
223
- nextSeedOnlyRelays := make([]string, 0, len(e.seedOnlyRelays))
224
- for _, existing := range e.seedOnlyRelays {
225
- if existing != relayURL {
226
- nextSeedOnlyRelays = append(nextSeedOnlyRelays, existing)
227
- }
228
- }
229
- e.seedOnlyRelays = nextSeedOnlyRelays
212
if !slices.Contains(e.explicitRelays, relayURL) {
213
e.explicitRelays = append(append([]string(nil), e.explicitRelays...), relayURL)
214
}
@@ -259,13 +241,6 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
241
}
242
}
243
e.explicitRelays = nextRelays
262
- nextSeedOnlyRelays := make([]string, 0, len(e.seedOnlyRelays))
263
- for _, existing := range e.seedOnlyRelays {
264
- if existing != relayURL {
265
- nextSeedOnlyRelays = append(nextSeedOnlyRelays, existing)
266
- }
267
- }
268
- e.seedOnlyRelays = nextSeedOnlyRelays
244
if slices.Contains(e.multiHop, relayURL) {
245
nextMultiHop := make([]string, 0, len(e.multiHop))
246
for _, existing := range e.multiHop {
@@ -286,98 +261,6 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
261
return e.reconcileRelayListeners(false)
262
}
263
289
-// SeedRelay keeps a relay as a discovery seed while removing it from the
290
-// active relay pool for this exposure.
291
-func (e *Exposure) SeedRelay(relayURL string) error {
292
- relayURL, err := utils.NormalizeRelayURL(relayURL)
293
- if err != nil {
294
- return err
295
- }
296
- if e.closed() {
297
- return net.ErrClosed
298
- }
299
- if e.relaySet == nil {
300
- return errors.New("exposure relay set is not initialized")
301
- }
302
-
303
- e.listenerMu.Lock()
304
- nextRelays := make([]string, 0, len(e.explicitRelays))
305
- for _, existing := range e.explicitRelays {
306
- if existing != relayURL {
307
- nextRelays = append(nextRelays, existing)
308
- }
309
- }
310
- e.explicitRelays = nextRelays
311
- if !slices.Contains(e.seedOnlyRelays, relayURL) {
312
- e.seedOnlyRelays = append(append([]string(nil), e.seedOnlyRelays...), relayURL)
313
- }
314
- if slices.Contains(e.multiHop, relayURL) {
315
- nextMultiHop := make([]string, 0, len(e.multiHop))
316
- for _, existing := range e.multiHop {
317
- if existing != relayURL {
318
- nextMultiHop = append(nextMultiHop, existing)
319
- }
320
- }
321
- if len(nextMultiHop) < 2 {
322
- nextMultiHop = nil
323
- }
324
- e.multiHop = nextMultiHop
325
- e.multiHopDepth = 0
326
- }
327
- e.listenerMu.Unlock()
328
-
329
- e.relaySet.AllowRelayURL(relayURL)
330
- e.relaySet.AddBootstrapRelayURL(relayURL)
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
-
264
func (e *Exposure) SetMultiHop(relayURLs []string) error {
265
multiHop := make([]string, 0, len(relayURLs))
266
for _, input := range relayURLs {
@@ -409,13 +292,6 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
292
}
293
294
e.listenerMu.Lock()
412
- nextSeedOnlyRelays := make([]string, 0, len(e.seedOnlyRelays))
413
- for _, existing := range e.seedOnlyRelays {
414
- if !slices.Contains(multiHop, existing) {
415
- nextSeedOnlyRelays = append(nextSeedOnlyRelays, existing)
416
- }
417
- }
418
- e.seedOnlyRelays = nextSeedOnlyRelays
295
e.multiHop = append([]string(nil), multiHop...)
296
e.multiHopDepth = 0
297
e.listenerMu.Unlock()
@@ -474,6 +350,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
350
}
351
}
352
multiHop := append([]string(nil), e.multiHop...)
353
+ explicitRelays := append([]string(nil), e.explicitRelays...)
354
e.listenerMu.RUnlock()
355
356
relayByURL := make(map[string]types.AgentRelayStatus, len(listeners))
@@ -484,6 +361,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
361
}
362
snap := types.AgentRelayStatus{
363
RelayURL: relayURL,
364
+ Explicit: slices.Contains(explicitRelays, relayURL),
365
Connecting: true,
366
}
367
if lease, ok := listener.leaseSnapshot(); ok {
@@ -501,6 +379,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
379
}
380
snap := relayByURL[relayURL]
381
snap.RelayURL = relayURL
382
+ snap.Explicit = slices.Contains(explicitRelays, relayURL)
383
snap.Bootstrap = state.Bootstrap
384
snap.Banned = state.Banned
385
snap.SupportsOverlay = state.Descriptor.SupportsOverlay
@@ -770,24 +649,21 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
649
e.listenerMu.Lock()
650
multiHop = append([]string(nil), e.multiHop...)
651
explicitRelays := append([]string(nil), e.explicitRelays...)
773
- seedOnlyRelays := append([]string(nil), e.seedOnlyRelays...)
652
if len(multiHop) > 0 {
653
listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
776
- ExplicitRelayURLs: explicitRelays,
777
- SuppressedRelayURLs: seedOnlyRelays,
778
- MaxActiveRelays: e.maxActiveRelays,
779
- RequireUDP: e.udpEnabled,
780
- RequireTCP: e.tcpEnabled,
781
- LocalAddress: e.identity.Address,
654
+ ExplicitRelayURLs: explicitRelays,
655
+ MaxActiveRelays: e.maxActiveRelays,
656
+ RequireUDP: e.udpEnabled,
657
+ RequireTCP: e.tcpEnabled,
658
+ LocalAddress: e.identity.Address,
659
})
660
if exitRelayURL := multiHop[len(multiHop)-1]; !slices.Contains(listenerRelayURLs, exitRelayURL) {
661
listenerRelayURLs = append(listenerRelayURLs, exitRelayURL)
662
}
663
} else if e.multiHopDepth > 1 {
664
multiHop = e.relaySet.PriorityMultiHop(discovery.ClientState{
788
- SuppressedRelayURLs: seedOnlyRelays,
789
- MultiHopDepth: e.multiHopDepth,
790
- LocalAddress: e.identity.Address,
665
+ MultiHopDepth: e.multiHopDepth,
666
+ LocalAddress: e.identity.Address,
667
})
668
if len(multiHop) < e.multiHopDepth {
669
e.listenerMu.Unlock()
@@ -796,12 +672,11 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
672
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
673
} else {
674
listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
799
- ExplicitRelayURLs: explicitRelays,
800
- SuppressedRelayURLs: seedOnlyRelays,
801
- MaxActiveRelays: e.maxActiveRelays,
802
- RequireUDP: e.udpEnabled,
803
- RequireTCP: e.tcpEnabled,
804
- LocalAddress: e.identity.Address,
675
+ ExplicitRelayURLs: explicitRelays,
676
+ MaxActiveRelays: e.maxActiveRelays,
677
+ RequireUDP: e.udpEnabled,
678
+ RequireTCP: e.tcpEnabled,
679
+ LocalAddress: e.identity.Address,
680
})
681
}
682
staleRelayListeners := make(map[string]*listener)
sdk/expose_test.go
+1
-1
@@ -126,7 +126,7 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
126
}
127
}
128
129
-func TestExposureRemoveRelayDetachesRunningListener(t *testing.T) {
129
+func TestExposureRemoveRelayStopsRunningListener(t *testing.T) {
130
const relayA = "https://relay-a.example"
131
132
relayAURL, err := url.Parse(relayA)
types/agent.go
+1
@@ -19,6 +19,7 @@ type AgentTunnelStatus struct {
19
type AgentRelayStatus struct {
20
RelayURL string `json:"relay_url"`
21
PublicURL string `json:"public_url,omitempty"`
22
+ Explicit bool `json:"explicit,omitempty"`
23
Connecting bool `json:"connecting"`
24
Bootstrap bool `json:"bootstrap"`
25
Banned bool `json:"banned"`