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 }