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"`