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