feat: add agent command for portal tunnel
rabbitprincess committed
Apr 30, 2026 at 22:51 UTC
25b535350e75b0687506b0d4d767792c5358556f
24 files changed
+2358
-21
cmd/portal-tunnel/README.md
+47
@@ -108,6 +108,53 @@ Flags:
108
- `--relays` adds explicit relay URLs, and `--default-relays=false` disables the public registry list for the current listing run.
109
- Unlike `portal expose`, `portal list` does not run the relay discovery expansion loop. It only resolves the registry seed list plus explicit `--relays` values.
110
111
+### `portal agent run [flags]`
112
+
113
+Runs Portal as a managed long-lived tunnel agent.
114
+
115
+- `portal agent run` reads the platform default config path and installs or updates the OS service.
116
+- `portal agent run --config config.toml --foreground` runs the agent in the current terminal without installing a service.
117
+- The service process owns multiple tunnel definitions from one `config.toml`.
118
+- The local control API is bound to loopback and authenticated with a token stored in the agent state directory.
119
+- `portal agent status` reads the same local control API and prints tunnel state, relays, public URLs, and recent errors.
120
+- `portal agent stop` asks the local agent to shut down, then disables/stops the OS service so intentional shutdown is not immediately restarted.
121
+
122
+Default paths:
123
+
124
+| OS | Config | Default identity |
125
+|----|--------|------------------|
126
+| Linux | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
127
+| macOS | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
128
+| Windows | `%ProgramData%\Portal Tunnel\Agent\config.toml` | `%ProgramData%\Portal Tunnel\Agent\identity.json` |
129
+
130
+Example `config.toml`:
131
+
132
+```toml
133
+[agent]
134
+control_addr = "127.0.0.1:4018"
135
+service_name = "portal-agent"
136
+
137
+[[tunnels]]
138
+id = "web"
139
+name = "myapp"
140
+target = "127.0.0.1:3000"
141
+relays = ["https://portal.example.com"]
142
+discovery = false
143
+description = "Managed web tunnel"
144
+tags = ["web"]
145
+```
146
+
147
+Runtime controls:
148
+
149
+```text
150
+portal agent status
151
+portal agent reload
152
+portal agent restart web
153
+portal agent relay-add web https://relay2.example.com
154
+portal agent relay-remove web https://relay2.example.com
155
+portal agent stop
156
+```
157
+
158
Legacy execution compatibility has been removed:
159
160
- Use `portal expose ...` explicitly; bare `portal [flags]` is no longer accepted.
cmd/portal-tunnel/agent.go
new
+391
@@ -0,0 +1,391 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "flag"
8
+ "fmt"
9
+ "io"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+ "text/tabwriter"
14
+ "time"
15
+
16
+ "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent"
17
+ "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent/service"
18
+ "github.com/gosuda/portal-tunnel/v2/types"
19
+ "github.com/gosuda/portal-tunnel/v2/utils"
20
+)
21
+
22
+func runAgentCommand(args []string) error {
23
+ return utils.RunCommands(args, os.Stdout, os.Stderr, printAgentUsage, map[string]utils.CommandFunc{
24
+ "run": runAgentRunCommand,
25
+ "status": runAgentStatusCommand,
26
+ "stop": runAgentStopCommand,
27
+ "reload": runAgentReloadCommand,
28
+ "restart": runAgentRestartCommand,
29
+ "add": runAgentRelayAddCommand,
30
+ "remove": runAgentRelayRemoveCommand,
31
+ "help": utils.MakeHelpCommand(printAgentUsage, []utils.HelpTopic{
32
+ {Name: "run", Usage: printAgentRunUsage},
33
+ {Name: "status", Usage: printAgentStatusUsage},
34
+ {Name: "stop", Usage: printAgentStopUsage},
35
+ }),
36
+ })
37
+}
38
+
39
+func runAgentRunCommand(args []string) error {
40
+ var configPath string
41
+ var serviceMode bool
42
+ var foreground bool
43
+ fs := utils.NewFlagSet("agent run", printAgentRunUsage)
44
+ utils.StringFlag(fs, &configPath, "config", service.DefaultConfigPath(), "Portal agent TOML config path")
45
+ utils.BoolFlag(fs, &serviceMode, "service", false, "Run the foreground service process")
46
+ utils.BoolFlag(fs, &foreground, "foreground", false, "Run in the current process without installing the OS service")
47
+ if err := utils.ParseFlagSet(fs, args, printAgentRunUsage); err != nil {
48
+ if errors.Is(err, flag.ErrHelp) {
49
+ return nil
50
+ }
51
+ return err
52
+ }
53
+ if err := utils.RequireNoArgs(fs.Args(), "agent run"); err != nil {
54
+ printAgentRunUsage(os.Stderr)
55
+ return err
56
+ }
57
+
58
+ cfg, err := agent.LoadConfig(configPath)
59
+ if err != nil {
60
+ return err
61
+ }
62
+ if serviceMode || foreground {
63
+ ctx, stop := utils.SignalContext()
64
+ defer stop()
65
+ return service.Run(ctx, cfg.Agent.ServiceName, func(ctx context.Context) error {
66
+ return agent.Run(ctx, cfg)
67
+ })
68
+ }
69
+
70
+ executable, err := os.Executable()
71
+ if err != nil {
72
+ return err
73
+ }
74
+ executable, err = filepath.Abs(executable)
75
+ if err != nil {
76
+ return err
77
+ }
78
+ configPath, err = filepath.Abs(strings.TrimSpace(configPath))
79
+ if err != nil {
80
+ return err
81
+ }
82
+ def := service.Definition{
83
+ Name: strings.TrimSpace(cfg.Agent.ServiceName),
84
+ DisplayName: "Portal Agent",
85
+ Description: "Manages Portal tunnel definitions and relay membership.",
86
+ Executable: executable,
87
+ Args: []string{"agent", "run", "--service", "--config", configPath},
88
+ WorkingDir: filepath.Dir(configPath),
89
+ }
90
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
91
+ defer cancel()
92
+ if err := service.Install(ctx, def); err != nil {
93
+ return fmt.Errorf("install portal agent service: %w", err)
94
+ }
95
+ if err := service.Start(ctx, cfg.Agent.ServiceName); err != nil {
96
+ return fmt.Errorf("start portal agent service: %w", err)
97
+ }
98
+ status, err := waitAgentStatus(ctx, cfg.Agent.StateDir)
99
+ if err != nil {
100
+ return err
101
+ }
102
+ fmt.Fprintf(os.Stdout, "Portal agent running at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
103
+ return nil
104
+}
105
+
106
+func runAgentStatusCommand(args []string) error {
107
+ var configPath string
108
+ var stateDir string
109
+ var jsonOutput bool
110
+ fs := utils.NewFlagSet("agent status", printAgentStatusUsage)
111
+ utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
112
+ utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
113
+ utils.BoolFlag(fs, &jsonOutput, "json", false, "Print raw JSON status")
114
+ if err := utils.ParseFlagSet(fs, args, printAgentStatusUsage); err != nil {
115
+ if errors.Is(err, flag.ErrHelp) {
116
+ return nil
117
+ }
118
+ return err
119
+ }
120
+ if err := utils.RequireNoArgs(fs.Args(), "agent status"); err != nil {
121
+ printAgentStatusUsage(os.Stderr)
122
+ return err
123
+ }
124
+
125
+ status, err := agentStatusFromFlags(context.Background(), configPath, stateDir)
126
+ if err != nil {
127
+ return err
128
+ }
129
+ if jsonOutput {
130
+ enc := json.NewEncoder(os.Stdout)
131
+ enc.SetIndent("", " ")
132
+ return enc.Encode(status)
133
+ }
134
+ printAgentStatus(os.Stdout, status)
135
+ return nil
136
+}
137
+
138
+func runAgentStopCommand(args []string) error {
139
+ var configPath string
140
+ var stateDir string
141
+ fs := utils.NewFlagSet("agent stop", printAgentStopUsage)
142
+ utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
143
+ utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
144
+ if err := utils.ParseFlagSet(fs, args, printAgentStopUsage); err != nil {
145
+ if errors.Is(err, flag.ErrHelp) {
146
+ return nil
147
+ }
148
+ return err
149
+ }
150
+ if err := utils.RequireNoArgs(fs.Args(), "agent stop"); err != nil {
151
+ printAgentStopUsage(os.Stderr)
152
+ return err
153
+ }
154
+
155
+ cfg, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
156
+ if err != nil {
157
+ return err
158
+ }
159
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
160
+ defer cancel()
161
+
162
+ _ = agent.Shutdown(ctx, resolvedStateDir)
163
+ if err := service.StopDisable(ctx, cfg.Agent.ServiceName); err != nil {
164
+ return fmt.Errorf("stop portal agent service: %w", err)
165
+ }
166
+ fmt.Fprintln(os.Stdout, "Portal agent stopped.")
167
+ return nil
168
+}
169
+
170
+func runAgentRestartCommand(args []string) error {
171
+ configPath, stateDir, tunnelID, err := parseAgentTunnelCommand("agent restart", args, printAgentUsage)
172
+ if err != nil {
173
+ if errors.Is(err, flag.ErrHelp) {
174
+ return nil
175
+ }
176
+ return err
177
+ }
178
+ return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
179
+ return agent.RestartTunnel(ctx, stateDir, tunnelID)
180
+ })
181
+}
182
+
183
+func runAgentReloadCommand(args []string) error {
184
+ var configPath, stateDir string
185
+ fs := utils.NewFlagSet("agent reload", printAgentUsage)
186
+ utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
187
+ utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
188
+ if err := utils.ParseFlagSet(fs, args, printAgentUsage); err != nil {
189
+ if errors.Is(err, flag.ErrHelp) {
190
+ return nil
191
+ }
192
+ return err
193
+ }
194
+ if err := utils.RequireNoArgs(fs.Args(), "agent reload"); err != nil {
195
+ return err
196
+ }
197
+ return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
198
+ return agent.Reload(ctx, stateDir)
199
+ })
200
+}
201
+
202
+func runAgentRelayAddCommand(args []string) error {
203
+ configPath, stateDir, tunnelID, relayURL, err := parseAgentRelayCommand("agent relay-add", args, printAgentUsage)
204
+ if err != nil {
205
+ if errors.Is(err, flag.ErrHelp) {
206
+ return nil
207
+ }
208
+ return err
209
+ }
210
+ return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
211
+ return agent.AddRelay(ctx, stateDir, tunnelID, relayURL)
212
+ })
213
+}
214
+
215
+func runAgentRelayRemoveCommand(args []string) error {
216
+ configPath, stateDir, tunnelID, relayURL, err := parseAgentRelayCommand("agent relay-remove", args, printAgentUsage)
217
+ if err != nil {
218
+ if errors.Is(err, flag.ErrHelp) {
219
+ return nil
220
+ }
221
+ return err
222
+ }
223
+ return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
224
+ return agent.RemoveRelay(ctx, stateDir, tunnelID, relayURL)
225
+ })
226
+}
227
+
228
+func parseAgentTunnelCommand(name string, args []string, usage func(io.Writer)) (string, string, string, error) {
229
+ var configPath, stateDir string
230
+ fs := utils.NewFlagSet(name, usage)
231
+ utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
232
+ utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
233
+ if err := utils.ParseFlagSet(fs, args, usage); err != nil {
234
+ if errors.Is(err, flag.ErrHelp) {
235
+ return "", "", "", flag.ErrHelp
236
+ }
237
+ return "", "", "", err
238
+ }
239
+ if len(fs.Args()) != 1 {
240
+ return "", "", "", errors.New(name + " requires tunnel id")
241
+ }
242
+ return configPath, stateDir, fs.Args()[0], nil
243
+}
244
+
245
+func parseAgentRelayCommand(name string, args []string, usage func(io.Writer)) (string, string, string, string, error) {
246
+ var configPath, stateDir string
247
+ fs := utils.NewFlagSet(name, usage)
248
+ utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
249
+ utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
250
+ if err := utils.ParseFlagSet(fs, args, usage); err != nil {
251
+ if errors.Is(err, flag.ErrHelp) {
252
+ return "", "", "", "", flag.ErrHelp
253
+ }
254
+ return "", "", "", "", err
255
+ }
256
+ if len(fs.Args()) != 2 {
257
+ return "", "", "", "", errors.New(name + " requires tunnel id and relay url")
258
+ }
259
+ return configPath, stateDir, fs.Args()[0], fs.Args()[1], nil
260
+}
261
+
262
+func withAgentControl(configPath, stateDir string, run func(context.Context, string) error) error {
263
+ _, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
264
+ if err != nil {
265
+ return err
266
+ }
267
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
268
+ defer cancel()
269
+ if err := run(ctx, resolvedStateDir); err != nil {
270
+ return err
271
+ }
272
+ fmt.Fprintln(os.Stdout, "Accepted.")
273
+ return nil
274
+}
275
+
276
+func agentStatusFromFlags(ctx context.Context, configPath, stateDir string) (types.AgentStatusResponse, error) {
277
+ _, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
278
+ if err != nil {
279
+ return types.AgentStatusResponse{}, err
280
+ }
281
+ return agent.Status(ctx, resolvedStateDir)
282
+}
283
+
284
+func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
285
+ ticker := time.NewTicker(300 * time.Millisecond)
286
+ defer ticker.Stop()
287
+ var lastErr error
288
+ for {
289
+ status, err := agentStatusFromFlags(ctx, "", stateDir)
290
+ if err == nil {
291
+ return status, nil
292
+ }
293
+ lastErr = err
294
+ select {
295
+ case <-ctx.Done():
296
+ return types.AgentStatusResponse{}, fmt.Errorf("wait for portal agent status: %w", lastErr)
297
+ case <-ticker.C:
298
+ }
299
+ }
300
+}
301
+
302
+func loadAgentCommandConfig(configPath, stateDir string) (agent.Config, string, error) {
303
+ if stateDir != "" && configPath == "" {
304
+ cfg := agent.Config{Agent: agent.AgentConfig{StateDir: stateDir, ServiceName: agent.DefaultServiceName}}
305
+ return cfg, stateDir, nil
306
+ }
307
+ if configPath != "" {
308
+ cfg, err := agent.LoadConfig(configPath)
309
+ if err != nil {
310
+ return agent.Config{}, "", err
311
+ }
312
+ if stateDir != "" {
313
+ cfg.Agent.StateDir = stateDir
314
+ }
315
+ return cfg, cfg.Agent.StateDir, nil
316
+ }
317
+ defaultStateDir := service.DefaultDataDir()
318
+ cfg := agent.Config{Agent: agent.AgentConfig{StateDir: defaultStateDir, ServiceName: agent.DefaultServiceName}}
319
+ return cfg, defaultStateDir, nil
320
+}
321
+
322
+func printAgentStatus(w io.Writer, status types.AgentStatusResponse) {
323
+ fmt.Fprintf(w, "Portal agent %s at %s\n", status.ReleaseVersion, status.ControlAddr)
324
+ table := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
325
+ fmt.Fprintln(table, "TUNNEL\tSTATE\tPUBLIC URLS\tLAST ERROR")
326
+ for _, tunnel := range status.Tunnels {
327
+ publicURLs := "-"
328
+ if len(tunnel.PublicURLs) > 0 {
329
+ publicURLs = strings.Join(tunnel.PublicURLs, ",")
330
+ }
331
+ lastError := tunnel.LastError
332
+ if lastError == "" {
333
+ lastError = "-"
334
+ }
335
+ fmt.Fprintf(table, "%s\t%s\t%s\t%s\n", tunnel.ID, tunnel.State, publicURLs, lastError)
336
+ }
337
+ _ = table.Flush()
338
+}
339
+
340
+func printAgentUsage(w io.Writer) {
341
+ utils.WriteCommandUsage(w,
342
+ []string{
343
+ "portal agent run [flags]",
344
+ "portal agent status [flags]",
345
+ "portal agent stop [flags]",
346
+ "portal agent reload [flags]",
347
+ "portal agent restart [flags] <tunnel-id>",
348
+ "portal agent relay-add [flags] <tunnel-id> <relay-url>",
349
+ "portal agent relay-remove [flags] <tunnel-id> <relay-url>",
350
+ },
351
+ []string{
352
+ "portal agent run",
353
+ "portal agent run --config config.toml --foreground",
354
+ "portal agent status",
355
+ "portal agent stop",
356
+ "portal agent reload",
357
+ "portal agent relay-add web https://portal.example.com",
358
+ },
359
+ )
360
+}
361
+
362
+func printAgentRunUsage(w io.Writer) {
363
+ utils.WriteCommandUsage(w,
364
+ []string{"portal agent run [flags]"},
365
+ []string{
366
+ "portal agent run",
367
+ "portal agent run --config config.toml --foreground",
368
+ },
369
+ )
370
+}
371
+
372
+func printAgentStatusUsage(w io.Writer) {
373
+ utils.WriteCommandUsage(w,
374
+ []string{"portal agent status [flags]"},
375
+ []string{
376
+ "portal agent status",
377
+ "portal agent status --json",
378
+ "portal agent status --config config.toml",
379
+ },
380
+ )
381
+}
382
+
383
+func printAgentStopUsage(w io.Writer) {
384
+ utils.WriteCommandUsage(w,
385
+ []string{"portal agent stop [flags]"},
386
+ []string{
387
+ "portal agent stop",
388
+ "portal agent stop --config config.toml",
389
+ },
390
+ )
391
+}
cmd/portal-tunnel/agent/config.go
new
+208
@@ -0,0 +1,208 @@
1
+package agent
2
+
3
+import (
4
+ "errors"
5
+ "fmt"
6
+ "path/filepath"
7
+ "strings"
8
+ "time"
9
+
10
+ "github.com/knadh/koanf/parsers/toml/v2"
11
+ "github.com/knadh/koanf/providers/file"
12
+ "github.com/knadh/koanf/v2"
13
+
14
+ "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent/service"
15
+ "github.com/gosuda/portal-tunnel/v2/utils"
16
+)
17
+
18
+const (
19
+ DefaultControlAddr = "127.0.0.1:4018"
20
+ DefaultServiceName = "portal-agent"
21
+
22
+ defaultIdentityFilename = "identity.json"
23
+)
24
+
25
+type Config struct {
26
+ sourcePath string
27
+ Agent AgentConfig `koanf:"agent"`
28
+ Tunnels []TunnelConfig `koanf:"tunnels"`
29
+}
30
+
31
+type AgentConfig struct {
32
+ StateDir string `koanf:"state_dir"`
33
+ ControlAddr string `koanf:"control_addr"`
34
+ ServiceName string `koanf:"service_name"`
35
+ RestartDelay string `koanf:"restart_delay"`
36
+}
37
+
38
+type TunnelConfig struct {
39
+ ID string `koanf:"id"`
40
+ Name string `koanf:"name"`
41
+ TargetAddr string `koanf:"target"`
42
+ HTTPRoutes []HTTPRouteConfig `koanf:"http_routes"`
43
+ RelayURLs []string `koanf:"relays"`
44
+ Discovery *bool `koanf:"discovery"`
45
+ IdentityPath string `koanf:"identity_path"`
46
+ IdentityJSON string `koanf:"identity_json"`
47
+ UDPEnabled bool `koanf:"udp"`
48
+ UDPAddr string `koanf:"udp_addr"`
49
+ TCPEnabled bool `koanf:"tcp"`
50
+ MultiHop []string `koanf:"multi_hop"`
51
+ MultiHopDepth int `koanf:"multi_hop_depth"`
52
+ BanMITM *bool `koanf:"ban_mitm"`
53
+ MaxActiveRelays int `koanf:"max_active_relays"`
54
+ Description string `koanf:"description"`
55
+ Tags []string `koanf:"tags"`
56
+ Owner string `koanf:"owner"`
57
+ Thumbnail string `koanf:"thumbnail"`
58
+ Hide bool `koanf:"hide"`
59
+}
60
+
61
+type HTTPRouteConfig struct {
62
+ Prefix string `koanf:"prefix"`
63
+ Upstream string `koanf:"upstream"`
64
+}
65
+
66
+func LoadConfig(path string) (Config, error) {
67
+ path = strings.TrimSpace(path)
68
+ if path == "" {
69
+ path = service.DefaultConfigPath()
70
+ }
71
+ absPath, err := filepath.Abs(path)
72
+ if err != nil {
73
+ return Config{}, err
74
+ }
75
+
76
+ k := koanf.New(".")
77
+ if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
78
+ return Config{}, err
79
+ }
80
+
81
+ var cfg Config
82
+ if err := k.Unmarshal("", &cfg); err != nil {
83
+ return Config{}, err
84
+ }
85
+ cfg.sourcePath = absPath
86
+ if err := cfg.ApplyDefaults(absPath); err != nil {
87
+ return Config{}, err
88
+ }
89
+ return cfg, cfg.Validate()
90
+}
91
+
92
+func (cfg *Config) ApplyDefaults(configPath string) error {
93
+ configDir := "."
94
+ if absConfig, err := filepath.Abs(strings.TrimSpace(configPath)); err == nil {
95
+ configDir = filepath.Dir(absConfig)
96
+ }
97
+
98
+ if strings.TrimSpace(cfg.Agent.StateDir) == "" {
99
+ cfg.Agent.StateDir = service.DefaultDataDir()
100
+ } else if !filepath.IsAbs(cfg.Agent.StateDir) {
101
+ cfg.Agent.StateDir = filepath.Join(configDir, cfg.Agent.StateDir)
102
+ }
103
+ if strings.TrimSpace(cfg.Agent.ControlAddr) == "" {
104
+ cfg.Agent.ControlAddr = DefaultControlAddr
105
+ }
106
+ if strings.TrimSpace(cfg.Agent.ServiceName) == "" {
107
+ cfg.Agent.ServiceName = DefaultServiceName
108
+ }
109
+ if strings.TrimSpace(cfg.Agent.RestartDelay) == "" {
110
+ cfg.Agent.RestartDelay = "5s"
111
+ }
112
+
113
+ for i := range cfg.Tunnels {
114
+ t := &cfg.Tunnels[i]
115
+ t.ID = strings.TrimSpace(t.ID)
116
+ t.Name = strings.TrimSpace(t.Name)
117
+ if t.ID == "" {
118
+ t.ID = t.Name
119
+ }
120
+ if t.ID == "" {
121
+ t.ID = fmt.Sprintf("tunnel-%d", i+1)
122
+ }
123
+ if t.IdentityPath == "" {
124
+ if len(cfg.Tunnels) <= 1 {
125
+ t.IdentityPath = filepath.Join(cfg.Agent.StateDir, defaultIdentityFilename)
126
+ } else {
127
+ t.IdentityPath = filepath.Join(cfg.Agent.StateDir, t.ID, defaultIdentityFilename)
128
+ }
129
+ } else if !filepath.IsAbs(t.IdentityPath) {
130
+ t.IdentityPath = filepath.Join(configDir, t.IdentityPath)
131
+ }
132
+ if t.MaxActiveRelays == 0 {
133
+ t.MaxActiveRelays = 3
134
+ }
135
+ if len(t.RelayURLs) > 0 {
136
+ relays, err := utils.NormalizeRelayURLs(t.RelayURLs...)
137
+ if err != nil {
138
+ return fmt.Errorf("tunnel %q relays: %w", t.ID, err)
139
+ }
140
+ t.RelayURLs = relays
141
+ }
142
+ for idx, relayURL := range t.MultiHop {
143
+ normalized, err := utils.NormalizeRelayURL(relayURL)
144
+ if err != nil {
145
+ return fmt.Errorf("tunnel %q multi_hop: %w", t.ID, err)
146
+ }
147
+ t.MultiHop[idx] = normalized
148
+ }
149
+ }
150
+ return nil
151
+}
152
+
153
+func (cfg Config) Validate() error {
154
+ if strings.TrimSpace(cfg.Agent.StateDir) == "" {
155
+ return errors.New("agent.state_dir is required")
156
+ }
157
+ if strings.TrimSpace(cfg.Agent.ControlAddr) == "" {
158
+ return errors.New("agent.control_addr is required")
159
+ }
160
+ if _, err := time.ParseDuration(cfg.Agent.RestartDelay); err != nil {
161
+ return fmt.Errorf("agent.restart_delay: %w", err)
162
+ }
163
+ if len(cfg.Tunnels) == 0 {
164
+ return errors.New("at least one tunnel is required")
165
+ }
166
+
167
+ seen := make(map[string]struct{}, len(cfg.Tunnels))
168
+ for _, tunnel := range cfg.Tunnels {
169
+ if err := tunnel.Validate(); err != nil {
170
+ return err
171
+ }
172
+ if _, ok := seen[tunnel.ID]; ok {
173
+ return fmt.Errorf("duplicate tunnel id %q", tunnel.ID)
174
+ }
175
+ seen[tunnel.ID] = struct{}{}
176
+ }
177
+ return nil
178
+}
179
+
180
+func (cfg TunnelConfig) Validate() error {
181
+ if strings.TrimSpace(cfg.ID) == "" {
182
+ return errors.New("tunnel id is required")
183
+ }
184
+ if strings.TrimSpace(cfg.TargetAddr) == "" && len(cfg.HTTPRoutes) == 0 {
185
+ return fmt.Errorf("tunnel %q requires target or http_routes", cfg.ID)
186
+ }
187
+ if strings.TrimSpace(cfg.TargetAddr) != "" && len(cfg.HTTPRoutes) > 0 {
188
+ return fmt.Errorf("tunnel %q cannot combine target and http_routes", cfg.ID)
189
+ }
190
+ if len(cfg.HTTPRoutes) > 0 && cfg.UDPEnabled {
191
+ return fmt.Errorf("tunnel %q cannot combine udp and http_routes", cfg.ID)
192
+ }
193
+ if cfg.MultiHopDepth < 0 {
194
+ return fmt.Errorf("tunnel %q multi_hop_depth cannot be negative", cfg.ID)
195
+ }
196
+ if len(cfg.MultiHop) == 1 {
197
+ return fmt.Errorf("tunnel %q multi_hop requires at least entry and exit relays", cfg.ID)
198
+ }
199
+ if len(cfg.MultiHop) > 0 && cfg.MultiHopDepth > 1 {
200
+ return fmt.Errorf("tunnel %q cannot combine multi_hop and multi_hop_depth", cfg.ID)
201
+ }
202
+ for _, route := range cfg.HTTPRoutes {
203
+ if strings.TrimSpace(route.Prefix) == "" || strings.TrimSpace(route.Upstream) == "" {
204
+ return fmt.Errorf("tunnel %q http_routes require prefix and upstream", cfg.ID)
205
+ }
206
+ }
207
+ return nil
208
+}
cmd/portal-tunnel/agent/control.go
new
+171
@@ -0,0 +1,171 @@
1
+package agent
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "net/url"
8
+ "path/filepath"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/gosuda/portal-tunnel/v2/types"
13
+ "github.com/gosuda/portal-tunnel/v2/utils"
14
+)
15
+
16
+const (
17
+ controlRequestBodyLimit = 8 << 10
18
+ endpointFilename = "agent-endpoint.json"
19
+)
20
+
21
+type endpoint struct {
22
+ ControlAddr string `json:"control_addr"`
23
+ Token string `json:"token"`
24
+ PID int `json:"pid"`
25
+ UpdatedAt time.Time `json:"updated_at"`
26
+}
27
+
28
+type controlHandler struct {
29
+ manager *manager
30
+ token string
31
+ shutdown func()
32
+ reload func() error
33
+}
34
+
35
+func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36
+ auth := strings.TrimSpace(r.Header.Get("Authorization"))
37
+ if !strings.HasPrefix(auth, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) != s.token {
38
+ utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
39
+ return
40
+ }
41
+
42
+ switch {
43
+ case r.URL.Path == types.PathAgentStatus:
44
+ if !utils.RequireMethod(w, r, http.MethodGet) {
45
+ return
46
+ }
47
+ utils.WriteAPIData(w, http.StatusOK, s.manager.Snapshot())
48
+ case r.URL.Path == types.PathAgentShutdown:
49
+ if !utils.RequireMethod(w, r, http.MethodPost) {
50
+ return
51
+ }
52
+ utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
53
+ if s.shutdown != nil {
54
+ go s.shutdown()
55
+ }
56
+ case r.URL.Path == types.PathAgentReload:
57
+ if !utils.RequireMethod(w, r, http.MethodPost) {
58
+ return
59
+ }
60
+ if s.reload == nil {
61
+ utils.WriteAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "reload is not configured")
62
+ return
63
+ }
64
+ if err := s.reload(); err != nil {
65
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
66
+ return
67
+ }
68
+ utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
69
+ case strings.HasPrefix(r.URL.Path, types.PathAgentTunnelsPrefix):
70
+ rest := strings.TrimPrefix(r.URL.Path, types.PathAgentTunnelsPrefix)
71
+ tunnelID, action, ok := strings.Cut(rest, "/")
72
+ tunnelID, err := url.PathUnescape(tunnelID)
73
+ if err != nil || strings.TrimSpace(tunnelID) == "" {
74
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid tunnel id")
75
+ return
76
+ }
77
+ if !ok {
78
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
79
+ return
80
+ }
81
+
82
+ switch action {
83
+ case strings.TrimPrefix(types.PathAgentRestartSegment, "/"):
84
+ if !utils.RequireMethod(w, r, http.MethodPost) {
85
+ return
86
+ }
87
+ if err := s.manager.RestartTunnel(tunnelID); err != nil {
88
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, err.Error())
89
+ return
90
+ }
91
+ utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
92
+ case strings.TrimPrefix(types.PathAgentRelaysSegment, "/"):
93
+ switch r.Method {
94
+ case http.MethodPost:
95
+ case http.MethodDelete:
96
+ default:
97
+ utils.MethodNotAllowedError().Write(w)
98
+ return
99
+ }
100
+
101
+ req, ok := utils.DecodeJSONRequest[types.AgentRelayRequest](w, r, controlRequestBodyLimit)
102
+ if !ok {
103
+ return
104
+ }
105
+ var err error
106
+ if r.Method == http.MethodPost {
107
+ err = s.manager.AddRelay(tunnelID, req.RelayURL)
108
+ } else {
109
+ err = s.manager.RemoveRelay(tunnelID, req.RelayURL)
110
+ }
111
+ if err != nil {
112
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
113
+ return
114
+ }
115
+ utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
116
+ default:
117
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
118
+ }
119
+ default:
120
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
121
+ }
122
+}
123
+
124
+func Status(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
125
+ var status types.AgentStatusResponse
126
+ err := controlRequest(ctx, stateDir, http.MethodGet, types.PathAgentStatus, nil, &status)
127
+ return status, err
128
+}
129
+
130
+func Shutdown(ctx context.Context, stateDir string) error {
131
+ return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentShutdown, nil, nil)
132
+}
133
+
134
+func Reload(ctx context.Context, stateDir string) error {
135
+ return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentReload, nil, nil)
136
+}
137
+
138
+func RestartTunnel(ctx context.Context, stateDir, tunnelID string) error {
139
+ path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + types.PathAgentRestartSegment
140
+ return controlRequest(ctx, stateDir, http.MethodPost, path, nil, nil)
141
+}
142
+
143
+func AddRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
144
+ path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + types.PathAgentRelaysSegment
145
+ return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
146
+}
147
+
148
+func RemoveRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
149
+ path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + types.PathAgentRelaysSegment
150
+ return controlRequest(ctx, stateDir, http.MethodDelete, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
151
+}
152
+
153
+func controlRequest(ctx context.Context, stateDir, method, path string, payload any, out any) error {
154
+ stateDir = strings.TrimSpace(stateDir)
155
+ if stateDir == "" {
156
+ return errors.New("state dir is required")
157
+ }
158
+ var endpoint endpoint
159
+ if err := utils.ReadJSONFile(filepath.Join(stateDir, endpointFilename), &endpoint); err != nil {
160
+ return err
161
+ }
162
+ if strings.TrimSpace(endpoint.ControlAddr) == "" || strings.TrimSpace(endpoint.Token) == "" {
163
+ return errors.New("agent endpoint state is incomplete")
164
+ }
165
+ baseURL, err := url.Parse("http://" + endpoint.ControlAddr)
166
+ if err != nil {
167
+ return err
168
+ }
169
+ headers := http.Header{"Authorization": []string{"Bearer " + endpoint.Token}}
170
+ return utils.HTTPDoAPIPath(ctx, &http.Client{Timeout: 5 * time.Second}, baseURL, method, path, payload, headers, out)
171
+}
cmd/portal-tunnel/agent/manager.go
new
+529
@@ -0,0 +1,529 @@
1
+package agent
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "reflect"
8
+ "slices"
9
+ "strings"
10
+ "sync"
11
+ "time"
12
+
13
+ "github.com/rs/zerolog/log"
14
+
15
+ "github.com/gosuda/portal-tunnel/v2/sdk"
16
+ "github.com/gosuda/portal-tunnel/v2/types"
17
+)
18
+
19
+const (
20
+ tunnelStateStarting = "starting"
21
+ tunnelStateRunning = "running"
22
+ tunnelStateRestarting = "restarting"
23
+ tunnelStateStopped = "stopped"
24
+ tunnelStateError = "error"
25
+)
26
+
27
+type manager struct {
28
+ startedAt time.Time
29
+ controlAddr string
30
+
31
+ mu sync.RWMutex
32
+ tunnels map[string]*managedTunnel
33
+ logs []types.AgentLogEntry
34
+ rootCtx context.Context
35
+}
36
+
37
+func newManager(cfg Config, controlAddr string) *manager {
38
+ restartDelay, _ := time.ParseDuration(cfg.Agent.RestartDelay)
39
+ if restartDelay <= 0 {
40
+ restartDelay = 5 * time.Second
41
+ }
42
+ manager := &manager{
43
+ startedAt: time.Now().UTC(),
44
+ controlAddr: controlAddr,
45
+ tunnels: make(map[string]*managedTunnel, len(cfg.Tunnels)),
46
+ }
47
+ for _, tunnelCfg := range cfg.Tunnels {
48
+ manager.tunnels[tunnelCfg.ID] = newTunnel(tunnelCfg, restartDelay, manager.appendLog)
49
+ }
50
+ return manager
51
+}
52
+
53
+func (m *manager) Start(ctx context.Context) {
54
+ m.mu.Lock()
55
+ m.rootCtx = ctx
56
+ m.mu.Unlock()
57
+
58
+ m.mu.RLock()
59
+ tunnels := make([]*managedTunnel, 0, len(m.tunnels))
60
+ for _, tunnel := range m.tunnels {
61
+ tunnels = append(tunnels, tunnel)
62
+ }
63
+ m.mu.RUnlock()
64
+
65
+ for _, tunnel := range tunnels {
66
+ tunnel.Start(ctx)
67
+ }
68
+}
69
+
70
+func (m *manager) Stop(ctx context.Context) error {
71
+ m.mu.RLock()
72
+ tunnels := make([]*managedTunnel, 0, len(m.tunnels))
73
+ for _, tunnel := range m.tunnels {
74
+ tunnels = append(tunnels, tunnel)
75
+ }
76
+ m.mu.RUnlock()
77
+
78
+ var wg sync.WaitGroup
79
+ wg.Add(len(tunnels))
80
+ for _, tunnel := range tunnels {
81
+ go func(t *managedTunnel) {
82
+ defer wg.Done()
83
+ if err := t.Stop(ctx); err != nil {
84
+ t.mu.RLock()
85
+ tunnelID := t.cfg.ID
86
+ t.mu.RUnlock()
87
+ log.Warn().Err(err).Str("tunnel_id", tunnelID).Msg("stop tunnel")
88
+ }
89
+ }(tunnel)
90
+ }
91
+
92
+ done := make(chan struct{})
93
+ go func() {
94
+ wg.Wait()
95
+ close(done)
96
+ }()
97
+
98
+ select {
99
+ case <-done:
100
+ return nil
101
+ case <-ctx.Done():
102
+ return ctx.Err()
103
+ }
104
+}
105
+
106
+func (m *manager) RestartTunnel(id string) error {
107
+ id = strings.TrimSpace(id)
108
+ m.mu.RLock()
109
+ tunnel := m.tunnels[id]
110
+ m.mu.RUnlock()
111
+ if tunnel == nil {
112
+ return fmt.Errorf("unknown tunnel %q", id)
113
+ }
114
+ tunnel.Restart()
115
+ return nil
116
+}
117
+
118
+func (m *manager) AddRelay(id, relayURL string) error {
119
+ id = strings.TrimSpace(id)
120
+ m.mu.RLock()
121
+ tunnel := m.tunnels[id]
122
+ m.mu.RUnlock()
123
+ if tunnel == nil {
124
+ return fmt.Errorf("unknown tunnel %q", id)
125
+ }
126
+ return tunnel.AddRelay(relayURL)
127
+}
128
+
129
+func (m *manager) RemoveRelay(id, relayURL string) error {
130
+ id = strings.TrimSpace(id)
131
+ m.mu.RLock()
132
+ tunnel := m.tunnels[id]
133
+ m.mu.RUnlock()
134
+ if tunnel == nil {
135
+ return fmt.Errorf("unknown tunnel %q", id)
136
+ }
137
+ return tunnel.RemoveRelay(relayURL)
138
+}
139
+
140
+func (m *manager) Reload(cfg Config) error {
141
+ m.mu.Lock()
142
+ rootCtx := m.rootCtx
143
+ restartDelay, _ := time.ParseDuration(cfg.Agent.RestartDelay)
144
+ if restartDelay <= 0 {
145
+ restartDelay = 5 * time.Second
146
+ }
147
+ next := make(map[string]TunnelConfig, len(cfg.Tunnels))
148
+ for _, tunnelCfg := range cfg.Tunnels {
149
+ next[tunnelCfg.ID] = tunnelCfg
150
+ }
151
+ toStop := make([]*managedTunnel, 0)
152
+ toStart := make([]*managedTunnel, 0)
153
+ toRestart := make([]*managedTunnel, 0)
154
+ for id, tunnel := range m.tunnels {
155
+ tunnelCfg, ok := next[id]
156
+ if !ok {
157
+ toStop = append(toStop, tunnel)
158
+ delete(m.tunnels, id)
159
+ continue
160
+ }
161
+ tunnel.mu.Lock()
162
+ tunnel.restartDelay = restartDelay
163
+ if !reflect.DeepEqual(tunnel.cfg, tunnelCfg) {
164
+ tunnel.cfg = tunnelCfg
165
+ tunnel.updatedAt = time.Now().UTC()
166
+ toRestart = append(toRestart, tunnel)
167
+ }
168
+ tunnel.mu.Unlock()
169
+ delete(next, id)
170
+ }
171
+ for _, tunnelCfg := range next {
172
+ tunnel := newTunnel(tunnelCfg, restartDelay, m.appendLog)
173
+ m.tunnels[tunnelCfg.ID] = tunnel
174
+ toStart = append(toStart, tunnel)
175
+ }
176
+ m.mu.Unlock()
177
+
178
+ for _, tunnel := range toStop {
179
+ _ = tunnel.Stop(context.Background())
180
+ }
181
+ if rootCtx == nil {
182
+ rootCtx = context.Background()
183
+ }
184
+ for _, tunnel := range toStart {
185
+ tunnel.Start(rootCtx)
186
+ }
187
+ for _, tunnel := range toRestart {
188
+ tunnel.Restart()
189
+ }
190
+ return nil
191
+}
192
+
193
+func (m *manager) Snapshot() types.AgentStatusResponse {
194
+ m.mu.RLock()
195
+ tunnels := make([]*managedTunnel, 0, len(m.tunnels))
196
+ for _, tunnel := range m.tunnels {
197
+ tunnels = append(tunnels, tunnel)
198
+ }
199
+ logs := append([]types.AgentLogEntry(nil), m.logs...)
200
+ m.mu.RUnlock()
201
+
202
+ statuses := make([]types.AgentTunnelStatus, 0, len(tunnels))
203
+ summary := types.AgentMetricsSummary{TunnelCount: len(tunnels)}
204
+ for _, tunnel := range tunnels {
205
+ status := tunnel.Snapshot()
206
+ switch status.State {
207
+ case tunnelStateRunning:
208
+ summary.RunningCount++
209
+ case tunnelStateError:
210
+ summary.ErrorCount++
211
+ }
212
+ statuses = append(statuses, status)
213
+ }
214
+ slices.SortFunc(statuses, func(a, b types.AgentTunnelStatus) int {
215
+ return strings.Compare(a.ID, b.ID)
216
+ })
217
+
218
+ return types.AgentStatusResponse{
219
+ ReleaseVersion: types.ReleaseVersion,
220
+ StartedAt: m.startedAt,
221
+ ControlAddr: m.controlAddr,
222
+ Tunnels: statuses,
223
+ Logs: logs,
224
+ Summary: summary,
225
+ }
226
+}
227
+
228
+func (m *manager) appendLog(entry types.AgentLogEntry) {
229
+ entry.Time = time.Now().UTC()
230
+ m.mu.Lock()
231
+ defer m.mu.Unlock()
232
+ m.logs = append(m.logs, entry)
233
+ if len(m.logs) > 200 {
234
+ copy(m.logs, m.logs[len(m.logs)-200:])
235
+ m.logs = m.logs[:200]
236
+ }
237
+}
238
+
239
+type managedTunnel struct {
240
+ mu sync.RWMutex
241
+ cfg TunnelConfig
242
+ restartDelay time.Duration
243
+ appendLog func(types.AgentLogEntry)
244
+
245
+ stopCancel context.CancelFunc
246
+ runCancel context.CancelFunc
247
+ done chan struct{}
248
+ exposure *sdk.Exposure
249
+
250
+ state string
251
+ lastError string
252
+ startedAt time.Time
253
+ updatedAt time.Time
254
+ restarts int
255
+}
256
+
257
+func newTunnel(cfg TunnelConfig, restartDelay time.Duration, appendLog func(types.AgentLogEntry)) *managedTunnel {
258
+ now := time.Now().UTC()
259
+ return &managedTunnel{
260
+ cfg: cfg,
261
+ restartDelay: restartDelay,
262
+ appendLog: appendLog,
263
+ state: tunnelStateStopped,
264
+ updatedAt: now,
265
+ }
266
+}
267
+
268
+func (t *managedTunnel) Start(parent context.Context) {
269
+ t.mu.Lock()
270
+ if t.done != nil {
271
+ t.mu.Unlock()
272
+ return
273
+ }
274
+ ctx, cancel := context.WithCancel(parent)
275
+ t.stopCancel = cancel
276
+ t.done = make(chan struct{})
277
+ done := t.done
278
+ t.mu.Unlock()
279
+
280
+ go func() {
281
+ defer close(done)
282
+ t.runLoop(ctx)
283
+ }()
284
+}
285
+
286
+func (t *managedTunnel) Stop(ctx context.Context) error {
287
+ t.mu.Lock()
288
+ stopCancel := t.stopCancel
289
+ runCancel := t.runCancel
290
+ done := t.done
291
+ t.stopCancel = nil
292
+ t.runCancel = nil
293
+ t.done = nil
294
+ if runCancel != nil {
295
+ runCancel()
296
+ }
297
+ if stopCancel != nil {
298
+ stopCancel()
299
+ }
300
+ t.mu.Unlock()
301
+
302
+ if done == nil {
303
+ return nil
304
+ }
305
+ select {
306
+ case <-done:
307
+ return nil
308
+ case <-ctx.Done():
309
+ return ctx.Err()
310
+ }
311
+}
312
+
313
+func (t *managedTunnel) Restart() {
314
+ t.mu.Lock()
315
+ if t.runCancel != nil {
316
+ t.state = tunnelStateRestarting
317
+ t.updatedAt = time.Now().UTC()
318
+ t.runCancel()
319
+ }
320
+ t.mu.Unlock()
321
+}
322
+
323
+func (t *managedTunnel) AddRelay(relayURL string) error {
324
+ t.mu.RLock()
325
+ id := t.cfg.ID
326
+ exposure := t.exposure
327
+ t.mu.RUnlock()
328
+ if exposure == nil {
329
+ return fmt.Errorf("tunnel %q is not running", id)
330
+ }
331
+ if err := exposure.AddRelay(relayURL); err != nil {
332
+ return err
333
+ }
334
+ t.appendLog(types.AgentLogEntry{TunnelID: id, Level: "info", Message: "relay added"})
335
+ return nil
336
+}
337
+
338
+func (t *managedTunnel) RemoveRelay(relayURL string) error {
339
+ t.mu.RLock()
340
+ id := t.cfg.ID
341
+ exposure := t.exposure
342
+ t.mu.RUnlock()
343
+ if exposure == nil {
344
+ return fmt.Errorf("tunnel %q is not running", id)
345
+ }
346
+ if err := exposure.RemoveRelay(relayURL); err != nil {
347
+ return err
348
+ }
349
+ t.appendLog(types.AgentLogEntry{TunnelID: id, Level: "info", Message: "relay removed"})
350
+ return nil
351
+}
352
+
353
+func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
354
+ t.mu.RLock()
355
+ cfg := t.cfg
356
+ state := t.state
357
+ lastError := t.lastError
358
+ startedAt := t.startedAt
359
+ updatedAt := t.updatedAt
360
+ restarts := t.restarts
361
+ exposure := t.exposure
362
+ t.mu.RUnlock()
363
+
364
+ status := types.AgentTunnelStatus{
365
+ ID: cfg.ID,
366
+ Name: cfg.Name,
367
+ State: state,
368
+ TargetAddr: cfg.TargetAddr,
369
+ UDPAddr: cfg.UDPAddr,
370
+ LastError: lastError,
371
+ StartedAt: startedAt,
372
+ UpdatedAt: updatedAt,
373
+ Restarts: restarts,
374
+ }
375
+ if exposure == nil {
376
+ return status
377
+ }
378
+ snapshot := exposure.Snapshot()
379
+ status.TargetAddr = snapshot.TargetAddr
380
+ status.UDPAddr = snapshot.UDPAddr
381
+ for _, relay := range snapshot.Relays {
382
+ status.Relays = append(status.Relays, types.AgentRelayStatus{
383
+ RelayURL: relay.RelayURL,
384
+ Hostname: relay.Hostname,
385
+ PublicURL: relay.PublicURL,
386
+ UDPAddr: relay.UDPAddr,
387
+ TCPAddr: relay.TCPAddr,
388
+ ExpiresAt: relay.ExpiresAt,
389
+ MultiHop: append([]string(nil), relay.MultiHop...),
390
+ Connected: relay.Connected,
391
+ })
392
+ if relay.PublicURL != "" {
393
+ status.PublicURLs = append(status.PublicURLs, relay.PublicURL)
394
+ }
395
+ }
396
+ return status
397
+}
398
+
399
+func (t *managedTunnel) runLoop(ctx context.Context) {
400
+ stop := func() {
401
+ t.mu.Lock()
402
+ t.state = tunnelStateStopped
403
+ t.updatedAt = time.Now().UTC()
404
+ t.exposure = nil
405
+ tunnelID := t.cfg.ID
406
+ t.mu.Unlock()
407
+ t.appendLog(types.AgentLogEntry{TunnelID: tunnelID, Level: "info", Message: "tunnel stopped"})
408
+ }
409
+
410
+ for {
411
+ if ctx.Err() != nil {
412
+ stop()
413
+ return
414
+ }
415
+ runCtx, runCancel := context.WithCancel(ctx)
416
+ t.mu.Lock()
417
+ t.runCancel = runCancel
418
+ t.mu.Unlock()
419
+ err := t.runOnce(runCtx)
420
+ t.mu.Lock()
421
+ t.runCancel = nil
422
+ t.exposure = nil
423
+ t.mu.Unlock()
424
+ if ctx.Err() != nil {
425
+ stop()
426
+ return
427
+ }
428
+
429
+ level := "error"
430
+ t.mu.Lock()
431
+ delay := t.restartDelay
432
+ message := fmt.Sprintf("tunnel stopped; restarting in %s", delay)
433
+ t.restarts++
434
+ t.state = tunnelStateError
435
+ t.lastError = ""
436
+ if errors.Is(err, context.Canceled) {
437
+ t.state = tunnelStateRestarting
438
+ delay = 100 * time.Millisecond
439
+ level = "info"
440
+ message = "tunnel restarting"
441
+ } else if err != nil {
442
+ t.lastError = err.Error()
443
+ }
444
+ t.updatedAt = time.Now().UTC()
445
+ tunnelID := t.cfg.ID
446
+ t.mu.Unlock()
447
+
448
+ t.appendLog(types.AgentLogEntry{TunnelID: tunnelID, Level: level, Message: message})
449
+ timer := time.NewTimer(delay)
450
+ select {
451
+ case <-ctx.Done():
452
+ timer.Stop()
453
+ stop()
454
+ return
455
+ case <-timer.C:
456
+ }
457
+ }
458
+}
459
+
460
+func (t *managedTunnel) runOnce(ctx context.Context) error {
461
+ t.mu.Lock()
462
+ cfg := t.cfg
463
+ t.state = tunnelStateStarting
464
+ t.lastError = ""
465
+ t.updatedAt = time.Now().UTC()
466
+ t.mu.Unlock()
467
+
468
+ discovery := true
469
+ if cfg.Discovery != nil {
470
+ discovery = *cfg.Discovery
471
+ }
472
+ banMITM := true
473
+ if cfg.BanMITM != nil {
474
+ banMITM = *cfg.BanMITM
475
+ }
476
+ exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
477
+ RelayURLs: append([]string(nil), cfg.RelayURLs...),
478
+ Discovery: discovery,
479
+ IdentityPath: cfg.IdentityPath,
480
+ IdentityJSON: cfg.IdentityJSON,
481
+ Name: cfg.Name,
482
+ TargetAddr: cfg.TargetAddr,
483
+ UDPAddr: cfg.UDPAddr,
484
+ UDPEnabled: cfg.UDPEnabled,
485
+ TCPEnabled: cfg.TCPEnabled,
486
+ MultiHop: append([]string(nil), cfg.MultiHop...),
487
+ MultiHopDepth: cfg.MultiHopDepth,
488
+ BanMITM: banMITM,
489
+ MaxActiveRelays: cfg.MaxActiveRelays,
490
+ Metadata: types.LeaseMetadata{
491
+ Description: cfg.Description,
492
+ Tags: append([]string(nil), cfg.Tags...),
493
+ Owner: cfg.Owner,
494
+ Thumbnail: cfg.Thumbnail,
495
+ Hide: cfg.Hide,
496
+ },
497
+ })
498
+ if err != nil {
499
+ return err
500
+ }
501
+ t.mu.Lock()
502
+ t.exposure = exposure
503
+ t.state = tunnelStateRunning
504
+ t.lastError = ""
505
+ t.startedAt = time.Now().UTC()
506
+ t.updatedAt = t.startedAt
507
+ tunnelID := t.cfg.ID
508
+ t.mu.Unlock()
509
+
510
+ t.appendLog(types.AgentLogEntry{TunnelID: tunnelID, Level: "info", Message: "tunnel started"})
511
+ defer exposure.Close()
512
+
513
+ if len(cfg.HTTPRoutes) > 0 {
514
+ routes := make([]sdk.HTTPRoute, 0, len(cfg.HTTPRoutes))
515
+ for _, route := range cfg.HTTPRoutes {
516
+ routes = append(routes, sdk.HTTPRoute{
517
+ Prefix: route.Prefix,
518
+ Upstream: route.Upstream,
519
+ })
520
+ }
521
+ err = exposure.RunHTTPRoutes(ctx, routes, "")
522
+ } else {
523
+ err = sdk.ProxyExposure(ctx, exposure)
524
+ }
525
+ if ctx.Err() != nil || errors.Is(err, context.Canceled) {
526
+ return ctx.Err()
527
+ }
528
+ return err
529
+}
cmd/portal-tunnel/agent/run.go
new
+131
@@ -0,0 +1,131 @@
1
+package agent
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "net/http"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/rs/zerolog/log"
15
+
16
+ "github.com/gosuda/portal-tunnel/v2/utils"
17
+)
18
+
19
+func Run(ctx context.Context, cfg Config) error {
20
+ endpointStateDir := strings.TrimSpace(cfg.Agent.StateDir)
21
+ if endpointStateDir == "" {
22
+ return errors.New("agent.state_dir is required")
23
+ }
24
+ if err := os.MkdirAll(endpointStateDir, 0o700); err != nil {
25
+ return err
26
+ }
27
+ token := utils.RandomID("agent_")
28
+
29
+ runtimeCtx, cancel := context.WithCancel(ctx)
30
+ defer cancel()
31
+
32
+ manager := newManager(cfg, "")
33
+ reload := func() error {
34
+ if cfg.sourcePath == "" {
35
+ return errors.New("config path is not available")
36
+ }
37
+ next, err := LoadConfig(cfg.sourcePath)
38
+ if err != nil {
39
+ return err
40
+ }
41
+ cfg = next
42
+ return manager.Reload(next)
43
+ }
44
+ controlAddr := strings.TrimSpace(cfg.Agent.ControlAddr)
45
+ if controlAddr == "" {
46
+ return errors.New("control address is required")
47
+ }
48
+ host, _, err := net.SplitHostPort(controlAddr)
49
+ if err != nil {
50
+ return fmt.Errorf("control address must be host:port: %w", err)
51
+ }
52
+ host = strings.Trim(host, "[]")
53
+ if host == "" {
54
+ return errors.New("control address must include a loopback host")
55
+ }
56
+ if !strings.EqualFold(host, "localhost") {
57
+ ip := net.ParseIP(host)
58
+ if ip == nil || !ip.IsLoopback() {
59
+ return fmt.Errorf("control address must bind to loopback, got %q", host)
60
+ }
61
+ }
62
+ var listenConfig net.ListenConfig
63
+ listener, err := listenConfig.Listen(runtimeCtx, "tcp", controlAddr)
64
+ if err != nil {
65
+ return err
66
+ }
67
+ control := &http.Server{
68
+ Handler: &controlHandler{
69
+ manager: manager,
70
+ token: token,
71
+ shutdown: cancel,
72
+ reload: reload,
73
+ },
74
+ ReadHeaderTimeout: 5 * time.Second,
75
+ }
76
+ listenAddr := listener.Addr().String()
77
+ manager.controlAddr = listenAddr
78
+
79
+ if err := utils.WriteJSONFile(filepath.Join(endpointStateDir, endpointFilename), endpoint{
80
+ ControlAddr: listenAddr,
81
+ Token: token,
82
+ PID: os.Getpid(),
83
+ UpdatedAt: time.Now().UTC(),
84
+ }, 0o600); err != nil {
85
+ _ = listener.Close()
86
+ _ = control.Shutdown(context.Background())
87
+ return err
88
+ }
89
+ defer func() {
90
+ _ = os.Remove(filepath.Join(endpointStateDir, endpointFilename))
91
+ }()
92
+
93
+ manager.Start(runtimeCtx)
94
+
95
+ errCh := make(chan error, 1)
96
+ go func() {
97
+ err := control.Serve(listener)
98
+ if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
99
+ err = nil
100
+ }
101
+ errCh <- err
102
+ }()
103
+
104
+ log.Info().
105
+ Str("control_addr", listenAddr).
106
+ Int("tunnel_count", len(cfg.Tunnels)).
107
+ Msg("portal agent started")
108
+
109
+ var serveErr error
110
+ select {
111
+ case <-runtimeCtx.Done():
112
+ case serveErr = <-errCh:
113
+ cancel()
114
+ }
115
+
116
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
117
+ defer shutdownCancel()
118
+ stopErr := manager.Stop(shutdownCtx)
119
+ closeErr := control.Shutdown(shutdownCtx)
120
+ if serveErr == nil {
121
+ select {
122
+ case serveErr = <-errCh:
123
+ default:
124
+ }
125
+ }
126
+ if errors.Is(serveErr, context.Canceled) {
127
+ serveErr = nil
128
+ }
129
+ log.Info().Msg("portal agent stopped")
130
+ return errors.Join(serveErr, stopErr, closeErr)
131
+}
cmd/portal-tunnel/agent/service/service.go
new
+49
@@ -0,0 +1,49 @@
1
+package service
2
+
3
+import (
4
+ "os"
5
+ "path/filepath"
6
+ "runtime"
7
+ "strings"
8
+)
9
+
10
+const defaultConfigFilename = "config.toml"
11
+
12
+type Definition struct {
13
+ Name string
14
+ DisplayName string
15
+ Description string
16
+ Executable string
17
+ Args []string
18
+ WorkingDir string
19
+}
20
+
21
+func DefaultConfigPath() string {
22
+ switch runtime.GOOS {
23
+ case "windows":
24
+ return filepath.Join(windowsProgramDataDir(), "Portal Tunnel", "Agent", defaultConfigFilename)
25
+ case "darwin":
26
+ return filepath.Join(string(filepath.Separator), "Library", "Application Support", "Portal Tunnel", "Agent", defaultConfigFilename)
27
+ default:
28
+ return filepath.Join(string(filepath.Separator), "etc", "portal-tunnel", "agent", defaultConfigFilename)
29
+ }
30
+}
31
+
32
+func DefaultDataDir() string {
33
+ switch runtime.GOOS {
34
+ case "windows":
35
+ return filepath.Join(windowsProgramDataDir(), "Portal Tunnel", "Agent")
36
+ case "darwin":
37
+ return filepath.Join(string(filepath.Separator), "Library", "Application Support", "Portal Tunnel", "Agent")
38
+ default:
39
+ return filepath.Join(string(filepath.Separator), "var", "lib", "portal-tunnel", "agent")
40
+ }
41
+}
42
+
43
+func windowsProgramDataDir() string {
44
+ programData := strings.TrimSpace(os.Getenv("ProgramData"))
45
+ if programData == "" {
46
+ return `C:\ProgramData`
47
+ }
48
+ return programData
49
+}
cmd/portal-tunnel/agent/service/service_darwin.go
new
+98
@@ -0,0 +1,98 @@
1
+//go:build darwin
2
+
3
+package service
4
+
5
+import (
6
+ "context"
7
+ "encoding/xml"
8
+ "fmt"
9
+ "os"
10
+ "os/exec"
11
+ "path/filepath"
12
+)
13
+
14
+func Install(ctx context.Context, def Definition) error {
15
+ plistPath, domain, err := launchdPlistPath(def.Name)
16
+ if err != nil {
17
+ return err
18
+ }
19
+ if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
20
+ return err
21
+ }
22
+ if err := os.WriteFile(plistPath, []byte(launchdPlist(def)), 0o644); err != nil {
23
+ return err
24
+ }
25
+ _ = exec.CommandContext(ctx, "launchctl", "bootout", domain, plistPath).Run()
26
+ return exec.CommandContext(ctx, "launchctl", "bootstrap", domain, plistPath).Run()
27
+}
28
+
29
+func Start(ctx context.Context, name string) error {
30
+ _, domain, err := launchdPlistPath(name)
31
+ if err != nil {
32
+ return err
33
+ }
34
+ return exec.CommandContext(ctx, "launchctl", "kickstart", "-k", domain+"/"+name).Run()
35
+}
36
+
37
+func StopDisable(ctx context.Context, name string) error {
38
+ plistPath, domain, err := launchdPlistPath(name)
39
+ if err != nil {
40
+ return err
41
+ }
42
+ _ = exec.CommandContext(ctx, "launchctl", "disable", domain+"/"+name).Run()
43
+ return exec.CommandContext(ctx, "launchctl", "bootout", domain, plistPath).Run()
44
+}
45
+
46
+func Run(ctx context.Context, name string, run func(context.Context) error) error {
47
+ return run(ctx)
48
+}
49
+
50
+func launchdPlistPath(name string) (string, string, error) {
51
+ if os.Geteuid() == 0 {
52
+ return filepath.Join("/Library/LaunchDaemons", name+".plist"), "system", nil
53
+ }
54
+ home, err := os.UserHomeDir()
55
+ if err != nil {
56
+ return "", "", err
57
+ }
58
+ return filepath.Join(home, "Library", "LaunchAgents", name+".plist"), fmt.Sprintf("gui/%d", os.Getuid()), nil
59
+}
60
+
61
+func launchdPlist(def Definition) string {
62
+ args := append([]string{def.Executable}, def.Args...)
63
+ argXML := ""
64
+ for _, arg := range args {
65
+ argXML += "\n <string>" + xmlEscape(arg) + "</string>"
66
+ }
67
+ return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
68
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
69
+<plist version="1.0">
70
+<dict>
71
+ <key>Label</key>
72
+ <string>%s</string>
73
+ <key>ProgramArguments</key>
74
+ <array>%s
75
+ </array>
76
+ <key>WorkingDirectory</key>
77
+ <string>%s</string>
78
+ <key>RunAtLoad</key>
79
+ <true/>
80
+ <key>KeepAlive</key>
81
+ <true/>
82
+</dict>
83
+</plist>
84
+`, xmlEscape(def.Name), argXML, xmlEscape(def.WorkingDir))
85
+}
86
+
87
+func xmlEscape(value string) string {
88
+ var out []byte
89
+ xml.EscapeText((*appendWriter)(&out), []byte(value))
90
+ return string(out)
91
+}
92
+
93
+type appendWriter []byte
94
+
95
+func (w *appendWriter) Write(p []byte) (int, error) {
96
+ *w = append(*w, p...)
97
+ return len(p), nil
98
+}
cmd/portal-tunnel/agent/service/service_linux.go
new
+97
@@ -0,0 +1,97 @@
1
+//go:build linux
2
+
3
+package service
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "strings"
12
+)
13
+
14
+func Install(ctx context.Context, def Definition) error {
15
+ unitPath, userMode, err := linuxUnitPath(def.Name)
16
+ if err != nil {
17
+ return err
18
+ }
19
+ if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil {
20
+ return err
21
+ }
22
+ if err := os.WriteFile(unitPath, []byte(systemdUnit(def)), 0o644); err != nil {
23
+ return err
24
+ }
25
+ args := systemctlArgs(userMode, "daemon-reload")
26
+ if err := exec.CommandContext(ctx, "systemctl", args...).Run(); err != nil {
27
+ return err
28
+ }
29
+ args = systemctlArgs(userMode, "enable", def.Name+".service")
30
+ return exec.CommandContext(ctx, "systemctl", args...).Run()
31
+}
32
+
33
+func Start(ctx context.Context, name string) error {
34
+ _, userMode, err := linuxUnitPath(name)
35
+ if err != nil {
36
+ return err
37
+ }
38
+ args := systemctlArgs(userMode, "start", name+".service")
39
+ return exec.CommandContext(ctx, "systemctl", args...).Run()
40
+}
41
+
42
+func StopDisable(ctx context.Context, name string) error {
43
+ _, userMode, err := linuxUnitPath(name)
44
+ if err != nil {
45
+ return err
46
+ }
47
+ args := systemctlArgs(userMode, "disable", "--now", name+".service")
48
+ return exec.CommandContext(ctx, "systemctl", args...).Run()
49
+}
50
+
51
+func Run(ctx context.Context, name string, run func(context.Context) error) error {
52
+ return run(ctx)
53
+}
54
+
55
+func linuxUnitPath(name string) (string, bool, error) {
56
+ if os.Geteuid() == 0 {
57
+ return filepath.Join("/etc/systemd/system", name+".service"), false, nil
58
+ }
59
+ home, err := os.UserHomeDir()
60
+ if err != nil {
61
+ return "", false, err
62
+ }
63
+ return filepath.Join(home, ".config", "systemd", "user", name+".service"), true, nil
64
+}
65
+
66
+func systemctlArgs(userMode bool, args ...string) []string {
67
+ if userMode {
68
+ return append([]string{"--user"}, args...)
69
+ }
70
+ return args
71
+}
72
+
73
+func systemdUnit(def Definition) string {
74
+ parts := append([]string{def.Executable}, def.Args...)
75
+ for i := range parts {
76
+ parts[i] = shellQuote(parts[i])
77
+ }
78
+ return fmt.Sprintf(`[Unit]
79
+Description=%s
80
+After=network-online.target
81
+Wants=network-online.target
82
+
83
+[Service]
84
+Type=simple
85
+WorkingDirectory=%s
86
+ExecStart=%s
87
+Restart=always
88
+RestartSec=5
89
+
90
+[Install]
91
+WantedBy=default.target
92
+`, def.Description, shellQuote(def.WorkingDir), strings.Join(parts, " "))
93
+}
94
+
95
+func shellQuote(value string) string {
96
+ return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
97
+}
cmd/portal-tunnel/agent/service/service_unsupported.go
new
+24
@@ -0,0 +1,24 @@
1
+//go:build !linux && !darwin && !windows
2
+
3
+package service
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+)
9
+
10
+func Install(context.Context, Definition) error {
11
+ return errors.New("portal agent service install is not supported on this OS")
12
+}
13
+
14
+func Start(context.Context, string) error {
15
+ return errors.New("portal agent service start is not supported on this OS")
16
+}
17
+
18
+func StopDisable(context.Context, string) error {
19
+ return errors.New("portal agent service stop is not supported on this OS")
20
+}
21
+
22
+func Run(ctx context.Context, name string, run func(context.Context) error) error {
23
+ return run(ctx)
24
+}
cmd/portal-tunnel/agent/service/service_windows.go
new
+188
@@ -0,0 +1,188 @@
1
+//go:build windows
2
+
3
+package service
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "syscall"
9
+ "time"
10
+
11
+ "golang.org/x/sys/windows"
12
+ "golang.org/x/sys/windows/svc"
13
+ "golang.org/x/sys/windows/svc/mgr"
14
+)
15
+
16
+func Install(ctx context.Context, def Definition) error {
17
+ m, err := mgr.Connect()
18
+ if err != nil {
19
+ return err
20
+ }
21
+ defer m.Disconnect()
22
+
23
+ cfg := mgr.Config{
24
+ StartType: mgr.StartAutomatic,
25
+ ErrorControl: mgr.ErrorNormal,
26
+ DisplayName: def.DisplayName,
27
+ Description: def.Description,
28
+ DelayedAutoStart: true,
29
+ }
30
+ s, err := m.OpenService(def.Name)
31
+ if err == nil {
32
+ defer s.Close()
33
+ existing, cfgErr := s.Config()
34
+ if cfgErr != nil {
35
+ return cfgErr
36
+ }
37
+ cfg = existing
38
+ cfg.StartType = mgr.StartAutomatic
39
+ cfg.ErrorControl = mgr.ErrorNormal
40
+ cfg.DisplayName = def.DisplayName
41
+ cfg.Description = def.Description
42
+ cfg.DelayedAutoStart = true
43
+ cfg.BinaryPathName = windowsCommandLine(def)
44
+ if err := s.UpdateConfig(cfg); err != nil {
45
+ return err
46
+ }
47
+ return configureWindowsRecovery(s)
48
+ }
49
+ if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
50
+ return err
51
+ }
52
+ s, err = m.CreateService(def.Name, def.Executable, cfg, def.Args...)
53
+ if err != nil {
54
+ return err
55
+ }
56
+ defer s.Close()
57
+ if err := configureWindowsRecovery(s); err != nil {
58
+ return err
59
+ }
60
+ return ctx.Err()
61
+}
62
+
63
+func Start(ctx context.Context, name string) error {
64
+ s, err := openService(name)
65
+ if err != nil {
66
+ return err
67
+ }
68
+ defer s.Close()
69
+ err = s.Start()
70
+ if err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
71
+ return err
72
+ }
73
+ return waitWindowsService(ctx, s, svc.Running)
74
+}
75
+
76
+func StopDisable(ctx context.Context, name string) error {
77
+ s, err := openService(name)
78
+ if err != nil {
79
+ if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
80
+ return nil
81
+ }
82
+ return err
83
+ }
84
+ defer s.Close()
85
+
86
+ cfg, err := s.Config()
87
+ if err == nil {
88
+ cfg.StartType = mgr.StartDisabled
89
+ _ = s.UpdateConfig(cfg)
90
+ }
91
+ status, err := s.Query()
92
+ if err == nil && status.State != svc.Stopped {
93
+ _, _ = s.Control(svc.Stop)
94
+ return waitWindowsService(ctx, s, svc.Stopped)
95
+ }
96
+ return ctx.Err()
97
+}
98
+
99
+func openService(name string) (*mgr.Service, error) {
100
+ m, err := mgr.Connect()
101
+ if err != nil {
102
+ return nil, err
103
+ }
104
+ s, err := m.OpenService(name)
105
+ _ = m.Disconnect()
106
+ return s, err
107
+}
108
+
109
+func waitWindowsService(ctx context.Context, s *mgr.Service, want svc.State) error {
110
+ ticker := time.NewTicker(300 * time.Millisecond)
111
+ defer ticker.Stop()
112
+ for {
113
+ status, err := s.Query()
114
+ if err != nil {
115
+ return err
116
+ }
117
+ if status.State == want {
118
+ return nil
119
+ }
120
+ select {
121
+ case <-ctx.Done():
122
+ return ctx.Err()
123
+ case <-ticker.C:
124
+ }
125
+ }
126
+}
127
+
128
+func windowsCommandLine(def Definition) string {
129
+ line := syscall.EscapeArg(def.Executable)
130
+ for _, arg := range def.Args {
131
+ line += " " + syscall.EscapeArg(arg)
132
+ }
133
+ return line
134
+}
135
+
136
+func configureWindowsRecovery(s *mgr.Service) error {
137
+ if err := s.SetRecoveryActions([]mgr.RecoveryAction{
138
+ {Type: mgr.ServiceRestart, Delay: 5 * time.Second},
139
+ {Type: mgr.ServiceRestart, Delay: 10 * time.Second},
140
+ {Type: mgr.ServiceRestart, Delay: 30 * time.Second},
141
+ }, 60); err != nil {
142
+ return err
143
+ }
144
+ return s.SetRecoveryActionsOnNonCrashFailures(false)
145
+}
146
+
147
+func Run(ctx context.Context, name string, run func(context.Context) error) error {
148
+ inService, err := svc.IsWindowsService()
149
+ if err != nil || !inService {
150
+ return run(ctx)
151
+ }
152
+ return svc.Run(name, windowsServiceHandler{ctx: ctx, run: run})
153
+}
154
+
155
+type windowsServiceHandler struct {
156
+ ctx context.Context
157
+ run func(context.Context) error
158
+}
159
+
160
+func (h windowsServiceHandler) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
161
+ ctx, cancel := context.WithCancel(h.ctx)
162
+ defer cancel()
163
+
164
+ errCh := make(chan error, 1)
165
+ status <- svc.Status{State: svc.StartPending}
166
+ go func() {
167
+ errCh <- h.run(ctx)
168
+ }()
169
+ status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
170
+
171
+ for {
172
+ select {
173
+ case req := <-requests:
174
+ switch req.Cmd {
175
+ case svc.Interrogate:
176
+ status <- req.CurrentStatus
177
+ case svc.Stop, svc.Shutdown:
178
+ status <- svc.Status{State: svc.StopPending}
179
+ cancel()
180
+ }
181
+ case err := <-errCh:
182
+ if err != nil && !errors.Is(err, context.Canceled) {
183
+ return false, 1
184
+ }
185
+ return false, 0
186
+ }
187
+ }
188
+}
cmd/portal-tunnel/main.go
+10
-2
@@ -25,6 +25,7 @@ func main() {
25
log.Logger = log.Output(zerolog.NewConsoleWriter())
26
if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{
27
"expose": runExposeCommand,
28
+ "agent": runAgentCommand,
29
"list": runListCommand,
30
"update": runUpdateCommand,
31
"version": func(args []string) error {
@@ -33,6 +34,7 @@ func main() {
34
},
35
"help": utils.MakeHelpCommand(printRootUsage, []utils.HelpTopic{
36
{Name: "expose", Usage: printExposeUsage},
37
+ {Name: "agent", Usage: printAgentUsage},
38
{Name: "list", Usage: printListUsage},
39
{Name: "update", Usage: printUpdateUsage},
40
}),
@@ -158,7 +160,7 @@ func runExposeCommand(args []string) error {
160
defer exposure.Close()
161
return exposure.RunHTTPRoutes(ctx, httpRoutes, "")
162
}
161
- return proxyExposure(ctx, exposure)
163
+ return sdk.ProxyExposure(ctx, exposure)
164
}
165
166
func runUpdateCommand(args []string) error {
@@ -251,6 +253,9 @@ func printRootUsage(w io.Writer) {
253
[]string{
254
"portal expose [flags] <target>",
255
"portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
256
+ "portal agent run [flags]",
257
+ "portal agent status [flags]",
258
+ "portal agent stop [flags]",
259
"portal list [flags]",
260
"portal update [flags]",
261
"portal version",
@@ -259,6 +264,9 @@ func printRootUsage(w io.Writer) {
264
"portal expose 3000",
265
"portal expose localhost:8080 --name my-app",
266
"portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
267
+ "portal agent run",
268
+ "portal agent status",
269
+ "portal agent stop",
270
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
271
"portal list",
272
"portal update",
@@ -306,7 +314,7 @@ func printUpdateUsage(w io.Writer) {
314
},
315
[]string{
316
"portal update",
309
- "portal update --version v2.1.7",
317
+ "portal update --version v2.1.9",
318
},
319
)
320
}
docs/src/routes/cli-reference/+page.md
+27
@@ -150,6 +150,33 @@ portal list [flags]
150
151
Unlike `portal expose`, `portal list` does not run the relay discovery expansion loop. It only resolves the registry seed list plus explicit `--relays` values.
152
153
+### `portal agent`
154
+
155
+Run a durable local agent that owns multiple tunnels from one config file.
156
+
157
+```bash
158
+portal agent run
159
+portal agent status
160
+portal agent stop
161
+```
162
+
163
+`portal agent run` reads the platform default `config.toml`, installs or updates the OS-managed service, and starts it. Use `--foreground` for local debugging without service registration.
164
+
165
+**Subcommands:**
166
+
167
+| Command | Description |
168
+|---------|-------------|
169
+| `portal agent run` | Install/update and start the managed agent service |
170
+| `portal agent run --config config.toml --foreground` | Run the agent in the current terminal |
171
+| `portal agent status [--json]` | Print tunnel, relay, public URL, and error state from the local control API |
172
+| `portal agent reload` | Reload `config.toml` and restart only changed tunnels |
173
+| `portal agent restart <tunnel-id>` | Restart one managed tunnel |
174
+| `portal agent relay-add <tunnel-id> <relay-url>` | Attach a relay to one running tunnel |
175
+| `portal agent relay-remove <tunnel-id> <relay-url>` | Detach a relay from one running tunnel |
176
+| `portal agent stop` | Gracefully stop the agent and disable/stop the OS service |
177
+
178
+The local control API binds only to loopback and uses a token in the agent state directory. See [Configuration Reference](/configuration#configtoml) for the `config.toml` format.
179
+
180
### `portal update`
181
182
Update the CLI binary to the latest release.
docs/src/routes/configuration/+page.md
+64
@@ -143,6 +143,70 @@ The `portal list` subcommand accepts the following flags:
143
144
## Configuration Files
145
146
+### `config.toml`
147
+
148
+`portal agent run` reads the platform default `config.toml` and starts one managed process for all declared tunnels. Relative paths are resolved from the config file directory.
149
+
150
+Default paths:
151
+
152
+| OS | Config | Default identity |
153
+|----|--------|------------------|
154
+| Linux | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
155
+| macOS | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
156
+| Windows | `%ProgramData%\Portal Tunnel\Agent\config.toml` | `%ProgramData%\Portal Tunnel\Agent\identity.json` |
157
+
158
+```toml
159
+[agent]
160
+control_addr = "127.0.0.1:4018"
161
+service_name = "portal-agent"
162
+restart_delay = "5s"
163
+
164
+[[tunnels]]
165
+id = "web"
166
+name = "myapp"
167
+target = "127.0.0.1:3000"
168
+relays = ["https://portal.example.com"]
169
+discovery = false
170
+description = "Managed web tunnel"
171
+tags = ["web"]
172
+
173
+[[tunnels]]
174
+id = "frontend-api"
175
+name = "myapp"
176
+
177
+[[tunnels.http_routes]]
178
+prefix = "/api"
179
+upstream = "http://127.0.0.1:3001"
180
+
181
+[[tunnels.http_routes]]
182
+prefix = "/"
183
+upstream = "http://127.0.0.1:5173"
184
+```
185
+
186
+Agent fields:
187
+
188
+| Field | Default | Description |
189
+|-------|---------|-------------|
190
+| `state_dir` | Platform default state directory | Stores the local control endpoint token and runtime state |
191
+| `control_addr` | `127.0.0.1:4018` | Loopback-only local control API address |
192
+| `service_name` | `portal-agent` | OS service name |
193
+| `restart_delay` | `5s` | Delay before restarting a failed tunnel |
194
+
195
+Tunnel fields mirror `portal expose` flags:
196
+
197
+| Field | Type | Description |
198
+|-------|------|-------------|
199
+| `id` | string | Stable tunnel ID used by `portal agent restart`, `relay-add`, and `relay-remove` |
200
+| `target` | string | Local TCP target, equivalent to the `portal expose <target>` argument |
201
+| `http_routes` | table array | HTTP route mappings; cannot be combined with `target` or `udp` |
202
+| `relays` | string array | Explicit relay API URLs |
203
+| `discovery` | bool | Include registry and relay discovery expansion |
204
+| `multi_hop` | string array | Ordered multi-hop relay path |
205
+| `multi_hop_depth` | int | Automatically select one multi-hop route with this depth |
206
+| `identity_path` | string | Tunnel identity JSON file path. When omitted, one tunnel uses the platform default `identity.json`; multiple tunnels use `<state-dir>/<tunnel-id>/identity.json` |
207
+| `udp`, `udp_addr`, `tcp` | bool/string | UDP and raw TCP relay options |
208
+| `description`, `tags`, `owner`, `thumbnail`, `hide` | mixed | Lease metadata shown by relays |
209
+
210
### `identity.json`
211
212
Stores the secp256k1 identity used to sign tunnel sessions and relay descriptors. `portal expose` treats `--identity-path` as a direct JSON file path. `relay-server` treats `IDENTITY_PATH` as a state directory and stores this file at `IDENTITY_PATH/identity.json`.
go.mod
+10
-1
@@ -15,6 +15,9 @@ require (
15
github.com/go-rod/rod v0.116.2
16
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
17
github.com/hashicorp/yamux v0.1.2
18
+ github.com/knadh/koanf/parsers/toml/v2 v2.2.0
19
+ github.com/knadh/koanf/providers/file v1.2.1
20
+ github.com/knadh/koanf/v2 v2.3.4
21
github.com/quic-go/quic-go v0.59.0
22
github.com/rs/zerolog v1.34.0
23
github.com/spruceid/siwe-go v0.2.1
@@ -22,6 +25,7 @@ require (
25
golang.org/x/net v0.53.0
26
golang.org/x/oauth2 v0.36.0
27
golang.org/x/sync v0.20.0
28
+ golang.org/x/sys v0.43.0
29
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
30
google.golang.org/api v0.275.0
31
)
@@ -46,17 +50,23 @@ require (
50
github.com/dchest/uniuri v1.2.0 // indirect
51
github.com/ethereum/go-ethereum v1.17.1 // indirect
52
github.com/felixge/httpsnoop v1.0.4 // indirect
53
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
54
github.com/go-logr/logr v1.4.3 // indirect
55
github.com/go-logr/stdr v1.2.2 // indirect
56
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
57
github.com/google/btree v1.1.2 // indirect
58
github.com/google/s2a-go v0.1.9 // indirect
59
github.com/google/uuid v1.6.0 // indirect
60
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
61
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
62
github.com/holiman/uint256 v1.3.2 // indirect
63
+ github.com/knadh/koanf/maps v0.1.2 // indirect
64
github.com/mattn/go-colorable v0.1.13 // indirect
65
github.com/mattn/go-isatty v0.0.21 // indirect
66
github.com/miekg/dns v1.1.72 // indirect
67
+ github.com/mitchellh/copystructure v1.2.0 // indirect
68
+ github.com/mitchellh/reflectwalk v1.0.2 // indirect
69
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
70
github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect
71
github.com/ysmood/fetchup v0.2.3 // indirect
72
github.com/ysmood/goob v0.4.0 // indirect
@@ -69,7 +79,6 @@ require (
79
go.opentelemetry.io/otel/metric v1.43.0 // indirect
80
go.opentelemetry.io/otel/trace v1.43.0 // indirect
81
golang.org/x/mod v0.35.0 // indirect
72
- golang.org/x/sys v0.43.0 // indirect
82
golang.org/x/text v0.36.0 // indirect
83
golang.org/x/time v0.15.0 // indirect
84
golang.org/x/tools v0.44.0 // indirect
go.sum
+18
@@ -55,6 +55,8 @@ github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wb
55
github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
56
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
57
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
58
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
59
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
60
github.com/go-acme/lego/v4 v4.34.0 h1:oRsIuPJ4ORX7ufviXvelUpBSez2XxeKGwo5pNG9BVeY=
61
github.com/go-acme/lego/v4 v4.34.0/go.mod h1:gsmdlx/ZS6OUeXbOj0U+VnCLLfEFj4WCYRkcGpZw+pc=
62
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@@ -66,6 +68,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
68
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
69
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
70
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
71
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
72
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
73
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
74
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
75
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -87,6 +91,14 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
91
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
92
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
93
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
94
+github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
95
+github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
96
+github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A=
97
+github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI=
98
+github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
99
+github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
100
+github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
101
+github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
102
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
103
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
104
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -95,6 +107,12 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG
107
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
108
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
109
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
110
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
111
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
112
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
113
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
114
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
115
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
116
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
117
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
118
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
portal/discovery/relayset.go
+47
-3
@@ -175,9 +175,7 @@ func (s *RelaySet) SetBootstrapRelayURLs(inputs []string) {
175
for key, state := range s.relays {
176
_, bootstrap := keep[key]
177
state.Bootstrap = bootstrap
178
- if !state.Bootstrap && !state.hasObservedDescriptor() && !state.Banned &&
179
- state.discoveryFailures == 0 && state.activeFailures == 0 &&
180
- state.nextDiscoveryRefreshAt.IsZero() && state.suppressActiveUntil.IsZero() {
178
+ if disposableRelayState(state) {
179
delete(s.relays, key)
180
continue
181
}
@@ -196,6 +194,40 @@ func (s *RelaySet) SetBootstrapRelayURLs(inputs []string) {
194
}
195
}
196
197
+func (s *RelaySet) AddBootstrapRelayURL(relayURL string) {
198
+ s.mu.Lock()
199
+ defer s.mu.Unlock()
200
+
201
+ state, ok := s.relays[relayURL]
202
+ if !ok {
203
+ state = newRelayState(relayURL)
204
+ }
205
+ state.Bootstrap = true
206
+ s.relays[relayURL] = state
207
+}
208
+
209
+func (s *RelaySet) RemoveBootstrapRelayURL(relayURL string) {
210
+ s.mu.Lock()
211
+ defer s.mu.Unlock()
212
+
213
+ state, ok := s.relays[relayURL]
214
+ if !ok {
215
+ return
216
+ }
217
+ state.Bootstrap = false
218
+ if disposableRelayState(state) {
219
+ delete(s.relays, relayURL)
220
+ return
221
+ }
222
+ s.relays[relayURL] = state
223
+}
224
+
225
+func disposableRelayState(state RelayState) bool {
226
+ return !state.Bootstrap && !state.hasObservedDescriptor() && !state.Banned &&
227
+ state.discoveryFailures == 0 && state.activeFailures == 0 &&
228
+ state.nextDiscoveryRefreshAt.IsZero() && state.suppressActiveUntil.IsZero()
229
+}
230
+
231
func (s *RelaySet) AggregateRelays() []RelayState {
232
s.mu.RLock()
233
states := make([]RelayState, 0, len(s.relays))
@@ -348,6 +380,18 @@ func (s *RelaySet) BanRelayURL(relayURL string) {
380
s.relays[relayURL] = state
381
}
382
383
+func (s *RelaySet) AllowRelayURL(relayURL string) {
384
+ s.mu.Lock()
385
+ defer s.mu.Unlock()
386
+
387
+ state, ok := s.relays[relayURL]
388
+ if !ok {
389
+ state = newRelayState(relayURL)
390
+ }
391
+ state.Banned = false
392
+ s.relays[relayURL] = state
393
+}
394
+
395
func (s *RelaySet) ConfirmRelayURL(relayURL string) {
396
s.mu.Lock()
397
defer s.mu.Unlock()
sdk/expose.go
+138
-7
@@ -193,6 +193,66 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
193
return exposure, nil
194
}
195
196
+// AddRelay attaches an explicit relay to the running exposure without
197
+// restarting the local tunnel.
198
+func (e *Exposure) AddRelay(relayURL string) error {
199
+ relayURL, err := utils.NormalizeRelayURL(relayURL)
200
+ if err != nil {
201
+ return err
202
+ }
203
+ if len(e.multiHop) > 0 || e.multiHopDepth > 1 {
204
+ return errors.New("runtime relay add/remove is not supported for multi-hop exposures")
205
+ }
206
+ if e.closed() {
207
+ return net.ErrClosed
208
+ }
209
+ if e.relaySet == nil {
210
+ return errors.New("exposure relay set is not initialized")
211
+ }
212
+
213
+ e.listenerMu.Lock()
214
+ if !slices.Contains(e.explicitRelays, relayURL) {
215
+ e.explicitRelays = append(append([]string(nil), e.explicitRelays...), relayURL)
216
+ }
217
+ e.listenerMu.Unlock()
218
+
219
+ e.relaySet.AllowRelayURL(relayURL)
220
+ e.relaySet.AddBootstrapRelayURL(relayURL)
221
+ return e.reconcileRelayListeners(true)
222
+}
223
+
224
+// RemoveRelay detaches a relay from the running exposure and suppresses
225
+// auto-selection for that relay until it is added again.
226
+func (e *Exposure) RemoveRelay(relayURL string) error {
227
+ relayURL, err := utils.NormalizeRelayURL(relayURL)
228
+ if err != nil {
229
+ return err
230
+ }
231
+ if len(e.multiHop) > 0 || e.multiHopDepth > 1 {
232
+ return errors.New("runtime relay add/remove is not supported for multi-hop exposures")
233
+ }
234
+ if e.closed() {
235
+ return net.ErrClosed
236
+ }
237
+ if e.relaySet == nil {
238
+ return errors.New("exposure relay set is not initialized")
239
+ }
240
+
241
+ e.listenerMu.Lock()
242
+ nextRelays := make([]string, 0, len(e.explicitRelays))
243
+ for _, existing := range e.explicitRelays {
244
+ if existing != relayURL {
245
+ nextRelays = append(nextRelays, existing)
246
+ }
247
+ }
248
+ e.explicitRelays = nextRelays
249
+ e.listenerMu.Unlock()
250
+
251
+ e.relaySet.BanRelayURL(relayURL)
252
+ e.relaySet.RemoveBootstrapRelayURL(relayURL)
253
+ return e.reconcileRelayListeners(false)
254
+}
255
+
256
func initialRouteCapacity(listenerRelayURLs []string, multiHopDepth int) int {
257
if multiHopDepth > 1 {
258
return 1
@@ -211,6 +271,15 @@ func (e *Exposure) ActiveRelayURLs() []string {
271
return relayURLs
272
}
273
274
+func (e *Exposure) closed() bool {
275
+ select {
276
+ case <-e.done:
277
+ return true
278
+ default:
279
+ return false
280
+ }
281
+}
282
+
283
func (e *Exposure) Addr() net.Addr {
284
if e.identity.Address == "" {
285
return exposureAddr("portal:exposure")
@@ -227,6 +296,66 @@ func (e *Exposure) Identity() types.Identity {
296
return e.identity
297
}
298
299
+type ExposureSnapshot struct {
300
+ Identity types.Identity `json:"identity"`
301
+ TargetAddr string `json:"target_addr,omitempty"`
302
+ UDPAddr string `json:"udp_addr,omitempty"`
303
+ Relays []ExposureRelaySnapshot `json:"relays,omitempty"`
304
+}
305
+
306
+type ExposureRelaySnapshot struct {
307
+ RelayURL string `json:"relay_url"`
308
+ Hostname string `json:"hostname,omitempty"`
309
+ PublicURL string `json:"public_url,omitempty"`
310
+ UDPAddr string `json:"udp_addr,omitempty"`
311
+ TCPAddr string `json:"tcp_addr,omitempty"`
312
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
313
+ MultiHop []string `json:"multi_hop,omitempty"`
314
+ Connected bool `json:"connected"`
315
+}
316
+
317
+func (e *Exposure) Snapshot() ExposureSnapshot {
318
+ e.listenerMu.RLock()
319
+ listeners := make([]*listener, 0, len(e.relayListeners))
320
+ for _, listener := range e.relayListeners {
321
+ if listener != nil {
322
+ listeners = append(listeners, listener)
323
+ }
324
+ }
325
+ e.listenerMu.RUnlock()
326
+
327
+ relays := make([]ExposureRelaySnapshot, 0, len(listeners))
328
+ for _, listener := range listeners {
329
+ relayURL := ""
330
+ if listener.relayURL != nil {
331
+ relayURL = listener.relayURL.String()
332
+ }
333
+ snap := ExposureRelaySnapshot{
334
+ RelayURL: relayURL,
335
+ MultiHop: append([]string(nil), listener.multiHop...),
336
+ }
337
+ if lease, ok := listener.leaseSnapshot(); ok {
338
+ snap.Hostname = lease.hostname
339
+ snap.PublicURL = listener.publicURLForLease(lease)
340
+ snap.UDPAddr = lease.udpAddr
341
+ snap.TCPAddr = lease.tcpAddr
342
+ snap.ExpiresAt = lease.expiresAt
343
+ snap.Connected = lease.hostname != ""
344
+ }
345
+ relays = append(relays, snap)
346
+ }
347
+ slices.SortFunc(relays, func(a, b ExposureRelaySnapshot) int {
348
+ return strings.Compare(a.RelayURL, b.RelayURL)
349
+ })
350
+
351
+ return ExposureSnapshot{
352
+ Identity: e.identity,
353
+ TargetAddr: e.TargetAddr,
354
+ UDPAddr: e.UDPAddr,
355
+ Relays: relays,
356
+ }
357
+}
358
+
359
func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
360
if !e.udpEnabled {
361
return types.DatagramFrame{}, net.ErrClosed
@@ -438,23 +567,26 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
567
}
568
569
func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
570
+ multiHop := e.multiHop
571
var listenerRelayURLs []string
442
- var multiHop []string
443
- if len(e.multiHop) > 0 {
444
- listenerRelayURLs = []string{e.multiHop[len(e.multiHop)-1]}
445
- multiHop = append([]string(nil), e.multiHop...)
572
+
573
+ e.listenerMu.Lock()
574
+ explicitRelays := e.explicitRelays
575
+ if len(multiHop) > 0 {
576
+ listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
577
} else if e.multiHopDepth > 1 {
578
multiHop = e.relaySet.PriorityMultiHop(discovery.ClientState{
579
MultiHopDepth: e.multiHopDepth,
580
LocalAddress: e.identity.Address,
581
})
582
if len(multiHop) < e.multiHopDepth {
583
+ e.listenerMu.Unlock()
584
return fmt.Errorf("multi-hop-depth %d requires %d overlay relay candidates, got %d", e.multiHopDepth, e.multiHopDepth, len(multiHop))
585
}
586
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
587
} else {
588
listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
457
- ExplicitRelayURLs: append([]string(nil), e.explicitRelays...),
589
+ ExplicitRelayURLs: explicitRelays,
590
MaxActiveRelays: e.maxActiveRelays,
591
RequireUDP: e.udpEnabled,
592
RequireTCP: e.tcpEnabled,
@@ -462,7 +594,6 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
594
})
595
}
596
465
- e.listenerMu.Lock()
597
staleRelayListeners := make(map[string]*listener)
598
removedRelayURLs := make([]string, 0)
599
for relayURL, listener := range e.relayListeners {
@@ -497,7 +628,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
628
}
629
for _, relayURL := range missingRelayURLs {
630
retryCount := 10
500
- if len(multiHop) > 0 || slices.Contains(e.explicitRelays, relayURL) {
631
+ if len(multiHop) > 0 || slices.Contains(explicitRelays, relayURL) {
632
retryCount = 0
633
}
634
listener, err := newListener(context.Background(), relayURL, listenerConfig{
sdk/expose_test.go
+40
@@ -125,3 +125,43 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
125
t.Fatal("active relay listener missing from exposure.listeners")
126
}
127
}
128
+
129
+func TestExposureRemoveRelayDetachesRunningListener(t *testing.T) {
130
+ const relayA = "https://relay-a.example"
131
+
132
+ relayAURL, err := url.Parse(relayA)
133
+ if err != nil {
134
+ t.Fatalf("url.Parse(relayA) error = %v", err)
135
+ }
136
+
137
+ relayAClosed := make(chan struct{})
138
+ exposure := &Exposure{
139
+ explicitRelays: []string{relayA},
140
+ relaySet: mustRelaySet(t, relayA),
141
+ relayListeners: make(map[string]*listener, 1),
142
+ }
143
+ exposure.relayListeners[relayA] = &listener{
144
+ relayURL: relayAURL,
145
+ cancel: func() { close(relayAClosed) },
146
+ doneCh: relayAClosed,
147
+ }
148
+
149
+ if err := exposure.RemoveRelay(relayA); err != nil {
150
+ t.Fatalf("RemoveRelay() error = %v", err)
151
+ }
152
+
153
+ select {
154
+ case <-relayAClosed:
155
+ default:
156
+ t.Fatal("removed relay listener was not closed")
157
+ }
158
+ if got := exposure.ActiveRelayURLs(); len(got) != 0 {
159
+ t.Fatalf("ActiveRelayURLs() = %v, want empty", got)
160
+ }
161
+ if len(exposure.explicitRelays) != 0 {
162
+ t.Fatalf("explicitRelays = %v, want empty", exposure.explicitRelays)
163
+ }
164
+ if got := exposure.relaySet.PriorityRelays(discovery.ClientState{ExplicitRelayURLs: []string{relayA}}); len(got) != 0 {
165
+ t.Fatalf("PriorityRelays() = %v, want empty", got)
166
+ }
167
+}
sdk/listener.go
+2
@@ -243,6 +243,7 @@ func (l *listener) Close() error {
243
type listenerLease struct {
244
hostname string
245
udpAddr string
246
+ tcpAddr string
247
accessToken string
248
expiresAt time.Time
249
sniPort int
@@ -751,6 +752,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
752
next := &listenerLease{
753
hostname: resp.Hostname,
754
udpAddr: resp.UDPAddr,
755
+ tcpAddr: resp.TCPAddr,
756
accessToken: resp.AccessToken,
757
expiresAt: resp.ExpiresAt,
758
publicURLBase: publicURLBase,
sdk/proxy.go
renamed
+7
-8
@@ -1,4 +1,4 @@
1
-package main
1
+package sdk
2
3
import (
4
"context"
@@ -12,11 +12,10 @@ import (
12
13
"github.com/rs/zerolog/log"
14
15
- "github.com/gosuda/portal-tunnel/v2/sdk"
15
"github.com/gosuda/portal-tunnel/v2/types"
16
)
17
19
-func proxyExposure(ctx context.Context, exposure *sdk.Exposure) error {
18
+func ProxyExposure(ctx context.Context, exposure *Exposure) error {
19
defer exposure.Close()
20
if len(exposure.ActiveRelayURLs()) == 0 {
21
return errors.New("no relay URLs provided")
@@ -103,7 +102,7 @@ func proxyExposure(ctx context.Context, exposure *sdk.Exposure) error {
102
return errors.Join(waitErr, udpErr, closeErr)
103
}
104
106
-func proxyRelayConnections(ctx context.Context, exposure *sdk.Exposure, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64) error {
105
+func proxyRelayConnections(ctx context.Context, exposure *Exposure, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64) error {
106
for {
107
relayConn, err := exposure.Accept()
108
if err != nil {
@@ -218,7 +217,7 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
217
218
// runUDPProxy waits for the exposure datagram plane and proxies it to the
219
// configured local UDP target.
221
-func runUDPProxy(ctx context.Context, exposure *sdk.Exposure, udpTarget string) error {
220
+func runUDPProxy(ctx context.Context, exposure *Exposure, udpTarget string) error {
221
udpAddrs, err := exposure.WaitDatagramReady(ctx)
222
if err != nil {
223
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
@@ -244,7 +243,7 @@ func runUDPProxy(ctx context.Context, exposure *sdk.Exposure, udpTarget string)
243
244
// proxyExposureDatagrams receives datagrams from the exposure datagram plane
245
// and forwards them to the local UDP service, relaying responses back.
247
-func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAddr string) error {
246
+func proxyExposureDatagrams(ctx context.Context, exposure *Exposure, localAddr string) error {
247
resolvedAddr, err := net.ResolveUDPAddr("udp", localAddr)
248
if err != nil {
249
return fmt.Errorf("resolve udp addr %q: %w", localAddr, err)
@@ -313,12 +312,12 @@ type udpFlowEntry struct {
312
313
type udpFlowManager struct {
314
target *net.UDPAddr
316
- exposure *sdk.Exposure
315
+ exposure *Exposure
316
mu sync.Mutex
317
flows map[udpFlowKey]*udpFlowEntry
318
}
319
321
-func newUDPFlowManager(target *net.UDPAddr, exposure *sdk.Exposure) *udpFlowManager {
320
+func newUDPFlowManager(target *net.UDPAddr, exposure *Exposure) *udpFlowManager {
321
return &udpFlowManager{
322
target: target,
323
exposure: exposure,
types/agent.go
new
+54
@@ -0,0 +1,54 @@
1
+package types
2
+
3
+import "time"
4
+
5
+type AgentStatusResponse struct {
6
+ ReleaseVersion string `json:"release_version"`
7
+ StartedAt time.Time `json:"started_at"`
8
+ ControlAddr string `json:"control_addr"`
9
+ Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
10
+ Logs []AgentLogEntry `json:"logs,omitempty"`
11
+ Summary AgentMetricsSummary `json:"summary"`
12
+}
13
+
14
+type AgentMetricsSummary struct {
15
+ TunnelCount int `json:"tunnel_count"`
16
+ RunningCount int `json:"running_count"`
17
+ ErrorCount int `json:"error_count"`
18
+}
19
+
20
+type AgentTunnelStatus struct {
21
+ ID string `json:"id"`
22
+ Name string `json:"name,omitempty"`
23
+ State string `json:"state"`
24
+ TargetAddr string `json:"target_addr,omitempty"`
25
+ UDPAddr string `json:"udp_addr,omitempty"`
26
+ LastError string `json:"last_error,omitempty"`
27
+ StartedAt time.Time `json:"started_at,omitempty"`
28
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
29
+ Restarts int `json:"restarts,omitempty"`
30
+ Relays []AgentRelayStatus `json:"relays,omitempty"`
31
+ PublicURLs []string `json:"public_urls,omitempty"`
32
+}
33
+
34
+type AgentRelayStatus struct {
35
+ RelayURL string `json:"relay_url"`
36
+ Hostname string `json:"hostname,omitempty"`
37
+ PublicURL string `json:"public_url,omitempty"`
38
+ UDPAddr string `json:"udp_addr,omitempty"`
39
+ TCPAddr string `json:"tcp_addr,omitempty"`
40
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
41
+ MultiHop []string `json:"multi_hop,omitempty"`
42
+ Connected bool `json:"connected"`
43
+}
44
+
45
+type AgentLogEntry struct {
46
+ Time time.Time `json:"time"`
47
+ TunnelID string `json:"tunnel_id,omitempty"`
48
+ Level string `json:"level"`
49
+ Message string `json:"message"`
50
+}
51
+
52
+type AgentRelayRequest struct {
53
+ RelayURL string `json:"relay_url"`
54
+}
types/error.go
+1
@@ -18,6 +18,7 @@ const (
18
APIErrorCodeLeaseNotFound = "lease_not_found"
19
APIErrorCodeLeaseRejected = "lease_rejected"
20
APIErrorCodeMethodNotAllowed = "method_not_allowed"
21
+ APIErrorCodeNotFound = "not_found"
22
APIErrorCodeRateLimited = "rate_limited"
23
APIErrorCodeSessionCreateFailed = "session_create_failed"
24
APIErrorCodeUnauthorized = "unauthorized"
types/paths.go
+7
@@ -24,6 +24,13 @@ const (
24
PathInstallPowerShell = "/install.ps1"
25
PathInstallBinPrefix = "/install/bin/"
26
27
+ PathAgentStatus = "/v1/status"
28
+ PathAgentShutdown = "/v1/shutdown"
29
+ PathAgentReload = "/v1/reload"
30
+ PathAgentTunnelsPrefix = "/v1/tunnels/"
31
+ PathAgentRelaysSegment = "/relays"
32
+ PathAgentRestartSegment = "/restart"
33
+
34
PathTunnelStatus = "/tunnel/status"
35
PathThumbnailPrefix = "/thumbnail/"
36