tidy cmd and chore codes
rabbitprincess committed
Mar 21, 2026 at 20:12 UTC
4947851aef5abeaef10cc060452858f74337bb3d
23 files changed
+1272
-1878
AGENTS.md
+10
-9
@@ -13,16 +13,17 @@ Architecture, product behavior, and design rationale belong in `docs/architectur
13
14
## Project Principles
15
16
-- No wrapper functions or helpers without demonstrated value.
17
-- Prefer fewer concepts and direct code over extra layers, facades, and indirection.
18
-- Prefer flattening and merging nearby responsibilities over splitting files or packages by default.
19
-- Remove dead fields, dead methods, dead config, and stale state while touching nearby code.
20
-- Avoid duplicate normalization, copying, and caching unless aliasing or trust boundaries require it.
21
-- Keep stable shared contracts, shared constants, and public paths in `types/`, not runtime state, package-local logic, or generic helpers.
16
+- When caller and callee are both local and no real boundary exists, change both directly; do not preserve local call shapes.
17
+- If a field, method, wrapper, or abstraction has no clear, current use and does not protect a real boundary, remove it immediately.
18
+- No wrapper functions or helpers unless they remove real coupling or protect a real boundary.
19
+- Prefer direct code over layers, facades, and indirection.
20
+- Prefer flattening and merging nearby responsibilities over splitting by default.
21
+- Remove dead fields, methods, config, and stale state while touching nearby code.
22
+- Do not duplicate normalization, validation, or defaulting logic; keep it in a single real owner.
23
- Keep shared stateless transforms in `utils/`; keep stateful and domain-shaped logic with the real owner.
23
-- Resolve complexity in the lowest coherent owner and expose only the minimum necessary surface upward.
24
-- Shared runtime logic should live in one real owner and be reused, not mirrored by parallel helpers.
25
-- Prefer not preserving backward compatibility by default, but ask when breaking it may cause real downstream problems.
24
+- Keep stable shared contracts, constants, and public paths in `types/`, not in runtime or helpers.
25
+- Resolve complexity in the lowest coherent owner and expose only the minimum surface upward.
26
+- Shared runtime logic must live in one real owner and be reused, not mirrored.
27
28
## Verification
29
cmd/demo-app/handler.go
+15
@@ -8,6 +8,8 @@ import (
8
"time"
9
10
"golang.org/x/net/websocket"
11
+
12
+ "github.com/gosuda/portal/v2/sdk"
13
)
14
15
//go:embed static
@@ -61,3 +63,16 @@ func handleCookies(w http.ResponseWriter, r *http.Request) {
63
"message": "4 cookies set: session_id, auth_token, csrf_token, user_pref",
64
})
65
}
66
+
67
+func newUDPInfoHandler(exposure *sdk.Exposure) http.Handler {
68
+ mux := http.NewServeMux()
69
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
70
+ w.Header().Set("Content-Type", "application/json")
71
+ udpAddrs, _ := exposure.WaitDatagramReady(r.Context())
72
+ _ = json.NewEncoder(w).Encode(map[string]any{
73
+ "message": "demo-udp is running",
74
+ "udp_addrs": udpAddrs,
75
+ })
76
+ })
77
+ return mux
78
+}
cmd/demo-app/main.go
+233
-51
@@ -5,10 +5,9 @@ import (
5
"errors"
6
"flag"
7
"fmt"
8
+ "io"
9
+ "net"
10
"os"
9
- "os/signal"
10
- "syscall"
11
- "time"
11
12
"github.com/rs/zerolog"
13
"github.com/rs/zerolog/log"
@@ -18,71 +17,187 @@ import (
17
"github.com/gosuda/portal/v2/utils"
18
)
19
21
-var (
22
- flagRelayURLs string
23
- flagDefaultRelays bool
24
- flagAddr string
25
- flagName string
26
- flagDesc string
27
- flagTags string
28
- flagOwner string
29
- flagHide bool
30
- flagThumbnail string
31
-)
32
-
20
func main() {
34
- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
35
- logger := log.With().Str("component", "demo-app").Logger()
36
-
37
- flag.StringVar(&flagRelayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set) [env: RELAYS]")
38
- flag.BoolVar(&flagDefaultRelays, "default-relays", utils.ParseBoolEnv("DEFAULT_RELAYS", true), "include public registry relays [env: DEFAULT_RELAYS]")
39
- flag.StringVar(&flagAddr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
40
- flag.StringVar(&flagName, "name", "demo-app", "public hostname prefix (single DNS label)")
41
- flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
42
- flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
43
- flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
44
- flag.StringVar(&flagThumbnail, "thumbnail", "https://picsum.photos/640/360", "lease thumbnail")
45
- flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
46
-
47
- flag.Parse()
48
-
49
- if err := runDemo(); err != nil {
50
- logger.Error().Err(err).Msg("demo command failed")
21
+ log.Logger = log.Output(zerolog.NewConsoleWriter())
22
+ if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{
23
+ "": runTCPCommand,
24
+ "tcp": runTCPCommand,
25
+ "udp": runUDPCommand,
26
+ "help": runHelpCommand,
27
+ }); err != nil {
28
+ log.Error().Err(err).Msg("demo command failed")
29
os.Exit(1)
30
}
31
}
32
55
-func runDemo() error {
56
- logger := log.With().Str("component", "demo-app").Logger()
57
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
33
+type demoConfig struct {
34
+ relayURLs string
35
+ defaultRelays bool
36
+ addr string
37
+ name string
38
+ desc string
39
+ tags string
40
+ owner string
41
+ hide bool
42
+ thumbnail string
43
+}
44
+
45
+func runTCPCommand(args []string) error {
46
+ cfg := demoConfig{}
47
+
48
+ fs := utils.NewFlagSet("demo-app", printTCPUsage)
49
+ utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set)", "RELAYS")
50
+ utils.BoolFlagEnv(fs, &cfg.defaultRelays, "default-relays", true, "include public registry relays", "DEFAULT_RELAYS")
51
+ utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
52
+ utils.StringFlag(fs, &cfg.name, "name", "demo-app", "public hostname prefix (single DNS label)")
53
+ utils.StringFlag(fs, &cfg.desc, "description", "Portal demo connectivity app", "lease description")
54
+ utils.StringFlag(fs, &cfg.tags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
55
+ utils.StringFlag(fs, &cfg.owner, "owner", "PortalApp Developer", "lease owner")
56
+ utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", "https://picsum.photos/640/360", "lease thumbnail")
57
+ utils.BoolFlag(fs, &cfg.hide, "hide", false, "hide this lease from listings")
58
+
59
+ if err := utils.ParseFlagSet(fs, args, printTCPUsage); err != nil {
60
+ if errors.Is(err, flag.ErrHelp) {
61
+ return nil
62
+ }
63
+ return err
64
+ }
65
+ if err := utils.RequireNoArgs(fs.Args(), "demo-app"); err != nil {
66
+ printTCPUsage(os.Stderr)
67
+ return err
68
+ }
69
+
70
+ ctx, stop := utils.SignalContext()
71
+ defer stop()
72
+
73
+ return runTCPDemo(ctx, cfg)
74
+}
75
+
76
+func runUDPCommand(args []string) error {
77
+ cfg := demoConfig{}
78
+ fs := utils.NewFlagSet("demo-app-udp", printUDPUsage)
79
+
80
+ utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set)", "RELAYS")
81
+ utils.BoolFlagEnv(fs, &cfg.defaultRelays, "default-relays", false, "include public registry relays", "DEFAULT_RELAYS")
82
+ utils.StringFlag(fs, &cfg.name, "name", "demo-udp", "public hostname prefix (single DNS label)")
83
+ utils.StringFlag(fs, &cfg.desc, "description", "Portal demo UDP echo service", "lease description")
84
+ utils.StringFlag(fs, &cfg.tags, "tags", "demo,udp,echo", "comma-separated lease tags")
85
+ utils.StringFlag(fs, &cfg.owner, "owner", "PortalApp Developer", "lease owner")
86
+ utils.StringFlag(fs, &cfg.thumbnail, "thumbnail", "", "lease thumbnail")
87
+ utils.BoolFlag(fs, &cfg.hide, "hide", true, "hide this lease from listings")
88
+
89
+ if err := utils.ParseFlagSet(fs, args, printUDPUsage); err != nil {
90
+ if errors.Is(err, flag.ErrHelp) {
91
+ return nil
92
+ }
93
+ return err
94
+ }
95
+ if err := utils.RequireNoArgs(fs.Args(), "udp"); err != nil {
96
+ printUDPUsage(os.Stderr)
97
+ return err
98
+ }
99
+
100
+ ctx, stop := utils.SignalContext()
101
defer stop()
102
60
- relayURLs := utils.SplitCSV(flagRelayURLs)
103
+ return runUDPDemo(ctx, cfg)
104
+}
105
+
106
+func runHelpCommand(args []string) error {
107
+ if len(args) == 0 {
108
+ printRootUsage(os.Stdout)
109
+ return nil
110
+ }
111
+ if len(args) > 1 {
112
+ printRootUsage(os.Stderr)
113
+ return errors.New("only one help topic is supported")
114
+ }
115
+
116
+ switch args[0] {
117
+ case "", "help", "-h", "--help":
118
+ printRootUsage(os.Stdout)
119
+ return nil
120
+ case "tcp":
121
+ printTCPUsage(os.Stdout)
122
+ return nil
123
+ case "udp":
124
+ printUDPUsage(os.Stdout)
125
+ return nil
126
+ default:
127
+ printRootUsage(os.Stderr)
128
+ return fmt.Errorf("unknown help topic %q", args[0])
129
+ }
130
+}
131
+
132
+func runTCPDemo(ctx context.Context, cfg demoConfig) error {
133
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
62
- RelayURLs: relayURLs,
63
- DefaultRelayEnabled: flagDefaultRelays,
64
- Name: flagName,
134
+ RelayURLs: utils.SplitCSV(cfg.relayURLs),
135
+ DefaultRelayEnabled: cfg.defaultRelays,
136
+ Name: cfg.name,
137
Metadata: types.LeaseMetadata{
66
- Description: flagDesc,
67
- Tags: utils.SplitCSV(flagTags),
68
- Owner: flagOwner,
69
- Thumbnail: flagThumbnail,
70
- Hide: flagHide,
138
+ Description: cfg.desc,
139
+ Tags: utils.SplitCSV(cfg.tags),
140
+ Owner: cfg.owner,
141
+ Thumbnail: cfg.thumbnail,
142
+ Hide: cfg.hide,
143
},
144
})
145
if err != nil {
146
return fmt.Errorf("exposure listen error: %w", err)
147
}
148
+
149
+ rawAddr := cfg.addr
150
+ cfg.addr, err = utils.NormalizeTargetAddr(cfg.addr)
151
+ if err != nil {
152
+ return fmt.Errorf("invalid --addr value %q: %w", rawAddr, err)
153
+ }
154
+ httpHandler := newHandler()
155
defer exposure.Close()
77
- if exposure == nil {
78
- logger.Info().Msg("demo app running without relay")
156
+ err = exposure.RunHTTP(ctx, httpHandler, cfg.addr)
157
+ if err != nil {
158
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
159
+ err = nil
160
+ }
161
+ return err
162
+ }
163
+
164
+ if ctx.Err() != nil {
165
+ log.Info().Msg("demo app shutting down")
166
}
167
+ log.Info().Msg("demo app shutdown complete")
168
+ return nil
169
+}
170
81
- flagAddr, err := utils.NormalizeTargetAddr(flagAddr)
171
+func runUDPDemo(ctx context.Context, cfg demoConfig) error {
172
+ exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
173
+ RelayURLs: utils.SplitCSV(cfg.relayURLs),
174
+ DefaultRelayEnabled: cfg.defaultRelays,
175
+ Name: cfg.name,
176
+ UDPEnabled: true,
177
+ Metadata: types.LeaseMetadata{
178
+ Description: cfg.desc,
179
+ Tags: utils.SplitCSV(cfg.tags),
180
+ Owner: cfg.owner,
181
+ Thumbnail: cfg.thumbnail,
182
+ Hide: cfg.hide,
183
+ },
184
+ })
185
if err != nil {
83
- return fmt.Errorf("invalid --addr value %q: %w", flagAddr, err)
186
+ return fmt.Errorf("exposure listen error: %w", err)
187
}
85
- if err := exposure.RunHTTP(ctx, newHandler(), flagAddr); err != nil {
188
+ defer exposure.Close()
189
+
190
+ udpAddrs, err := exposure.WaitDatagramReady(ctx)
191
+ if err != nil {
192
+ return fmt.Errorf("wait for udp readiness: %w", err)
193
+ }
194
+ for _, udpAddr := range udpAddrs {
195
+ log.Info().Str("udp_addr", udpAddr).Msg("demo udp relay ready")
196
+ }
197
+
198
+ go runUDPEchoLoop(ctx, exposure)
199
+
200
+ if err := exposure.RunHTTP(ctx, newUDPInfoHandler(exposure), ""); err != nil {
201
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
202
err = nil
203
}
@@ -90,8 +205,75 @@ func runDemo() error {
205
}
206
207
if ctx.Err() != nil {
93
- logger.Info().Msg("demo app shutting down")
208
+ log.Info().Msg("demo udp shutting down")
209
}
95
- logger.Info().Msg("demo app shutdown complete")
210
+ log.Info().Msg("demo udp shutdown complete")
211
return nil
212
}
213
+
214
+func runUDPEchoLoop(ctx context.Context, exposure *sdk.Exposure) {
215
+ for {
216
+ frame, err := exposure.AcceptDatagram()
217
+ if err != nil {
218
+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
219
+ return
220
+ }
221
+ log.Warn().Err(err).Msg("demo udp accept failed")
222
+ return
223
+ }
224
+
225
+ payload := append([]byte(nil), frame.Payload...)
226
+ if len(payload) == 0 {
227
+ payload = []byte("pong")
228
+ }
229
+ frame.Payload = payload
230
+ if err := exposure.SendDatagram(frame); err != nil && ctx.Err() == nil && !errors.Is(err, net.ErrClosed) {
231
+ log.Warn().Err(err).Uint32("flow_id", frame.FlowID).Msg("demo udp reply failed")
232
+ return
233
+ }
234
+ }
235
+}
236
+
237
+func printRootUsage(w io.Writer) {
238
+ utils.WriteCommandUsage(w,
239
+ []string{
240
+ "demo-app [flags]",
241
+ "demo-app tcp [flags]",
242
+ "demo-app udp [flags]",
243
+ "demo-app help",
244
+ },
245
+ []string{
246
+ "demo-app",
247
+ "demo-app --name my-app",
248
+ "demo-app tcp --addr 127.0.0.1:9000",
249
+ "demo-app udp",
250
+ },
251
+ )
252
+}
253
+
254
+func printTCPUsage(w io.Writer) {
255
+ utils.WriteCommandUsage(w,
256
+ []string{
257
+ "demo-app [flags]",
258
+ "demo-app tcp [flags]",
259
+ },
260
+ []string{
261
+ "demo-app",
262
+ "demo-app --name my-app",
263
+ "demo-app tcp --addr 127.0.0.1:9000",
264
+ },
265
+ )
266
+}
267
+
268
+func printUDPUsage(w io.Writer) {
269
+ utils.WriteCommandUsage(w,
270
+ []string{
271
+ "demo-app udp [flags]",
272
+ },
273
+ []string{
274
+ "demo-app udp",
275
+ "demo-app udp --name my-udp-demo",
276
+ "demo-app udp --default-relays=true",
277
+ },
278
+ )
279
+}
cmd/demo-udp/main.go
deleted
-140
@@ -1,140 +0,0 @@
1
-package main
2
-
3
-import (
4
- "context"
5
- "encoding/json"
6
- "errors"
7
- "flag"
8
- "fmt"
9
- "net"
10
- "net/http"
11
- "os"
12
- "os/signal"
13
- "syscall"
14
- "time"
15
-
16
- "github.com/rs/zerolog"
17
- "github.com/rs/zerolog/log"
18
-
19
- "github.com/gosuda/portal/v2/sdk"
20
- "github.com/gosuda/portal/v2/types"
21
- "github.com/gosuda/portal/v2/utils"
22
-)
23
-
24
-var (
25
- flagRelayURLs string
26
- flagDefaultRelays bool
27
- flagName string
28
- flagDesc string
29
- flagTags string
30
- flagOwner string
31
- flagHide bool
32
- flagThumbnail string
33
-)
34
-
35
-func main() {
36
- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
37
- logger := log.With().Str("component", "demo-udp").Logger()
38
-
39
- flag.StringVar(&flagRelayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set) [env: RELAYS]")
40
- flag.BoolVar(&flagDefaultRelays, "default-relays", utils.ParseBoolEnv("DEFAULT_RELAYS", false), "include public registry relays [env: DEFAULT_RELAYS]")
41
- flag.StringVar(&flagName, "name", "demo-udp", "public hostname prefix (single DNS label)")
42
- flag.StringVar(&flagDesc, "description", "Portal demo UDP echo service", "lease description")
43
- flag.StringVar(&flagTags, "tags", "demo,udp,echo", "comma-separated lease tags")
44
- flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
45
- flag.StringVar(&flagThumbnail, "thumbnail", "", "lease thumbnail")
46
- flag.BoolVar(&flagHide, "hide", true, "hide this lease from listings")
47
-
48
- flag.Parse()
49
-
50
- if err := runDemoUDP(); err != nil {
51
- logger.Error().Err(err).Msg("demo udp command failed")
52
- os.Exit(1)
53
- }
54
-}
55
-
56
-func runDemoUDP() error {
57
- logger := log.With().Str("component", "demo-udp").Logger()
58
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
59
- defer stop()
60
-
61
- relayURLs := utils.SplitCSV(flagRelayURLs)
62
- exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
63
- RelayURLs: relayURLs,
64
- DefaultRelayEnabled: flagDefaultRelays,
65
- Name: flagName,
66
- UDPEnabled: true,
67
- Metadata: types.LeaseMetadata{
68
- Description: flagDesc,
69
- Tags: utils.SplitCSV(flagTags),
70
- Owner: flagOwner,
71
- Thumbnail: flagThumbnail,
72
- Hide: flagHide,
73
- },
74
- })
75
- if err != nil {
76
- return fmt.Errorf("exposure listen error: %w", err)
77
- }
78
- if exposure == nil {
79
- return errors.New("demo udp requires at least one relay")
80
- }
81
- defer exposure.Close()
82
-
83
- udpAddrs, err := exposure.WaitDatagramReady(ctx)
84
- if err != nil {
85
- return fmt.Errorf("wait for udp readiness: %w", err)
86
- }
87
- for _, udpAddr := range udpAddrs {
88
- logger.Info().Str("udp_addr", udpAddr).Msg("demo udp relay ready")
89
- }
90
-
91
- go runUDPEchoLoop(ctx, exposure, logger)
92
-
93
- if err := exposure.RunHTTP(ctx, newInfoHandler(exposure), ""); err != nil {
94
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
95
- err = nil
96
- }
97
- return err
98
- }
99
-
100
- if ctx.Err() != nil {
101
- logger.Info().Msg("demo udp shutting down")
102
- }
103
- logger.Info().Msg("demo udp shutdown complete")
104
- return nil
105
-}
106
-
107
-func runUDPEchoLoop(ctx context.Context, exposure *sdk.Exposure, logger zerolog.Logger) {
108
- for {
109
- frame, err := exposure.AcceptDatagram()
110
- if err != nil {
111
- if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
112
- return
113
- }
114
- logger.Warn().Err(err).Msg("demo udp accept failed")
115
- return
116
- }
117
-
118
- payload := append([]byte(nil), frame.Payload...)
119
- if len(payload) == 0 {
120
- payload = []byte("pong")
121
- }
122
- frame.Payload = payload
123
- if err := exposure.SendDatagram(frame); err != nil && ctx.Err() == nil && !errors.Is(err, net.ErrClosed) {
124
- logger.Warn().Err(err).Uint32("flow_id", frame.FlowID).Msg("demo udp reply failed")
125
- return
126
- }
127
- }
128
-}
129
-
130
-func newInfoHandler(exposure *sdk.Exposure) http.Handler {
131
- mux := http.NewServeMux()
132
- mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
133
- w.Header().Set("Content-Type", "application/json")
134
- _ = json.NewEncoder(w).Encode(map[string]any{
135
- "message": "demo-udp is running",
136
- "udp_addrs": exposure.UDPAddrs(),
137
- })
138
- })
139
- return mux
140
-}
cmd/portal-tunnel/README.md
+7
-8
@@ -34,8 +34,8 @@ portal expose --name myapp \
34
35
- `<target>` accepts a bare port like `3000`, a `host:port`, or an `http(s)://host:port` URL.
36
- Bare ports resolve to `127.0.0.1:<port>`.
37
-- `--name` is optional. When omitted, the CLI generates a name derived from its local seed and target port.
38
-- `--relays` overrides installed default relays for that run.
37
+- `--name` is optional. When omitted, the CLI generates a name for that run.
38
+- `--relays` sets the relay API URLs for that run.
39
- `--default-relays=false` disables the public registry list for that run.
40
41
Flags:
@@ -53,7 +53,7 @@ Flags:
53
54
### `portal list [flags]`
55
56
-- Prints the relay URLs that the CLI will use with the current installed config plus any runtime overrides.
56
+- Prints the relay URLs that the CLI will use for the current invocation.
57
- `--relays` and `--default-relays=false` follow the same semantics as `portal expose`.
58
59
Legacy execution compatibility has been removed:
@@ -66,10 +66,9 @@ Legacy execution compatibility has been removed:
66
67
- `install.sh` installs the downloaded binary as `portal`.
68
- `install.ps1` installs `portal.exe` for the current Windows user and updates the user `PATH`.
69
-- The installer writes relay defaults to the user config file:
70
- - Linux/macOS: `${XDG_CONFIG_HOME:-$HOME/.config}/portal/config.json`
71
- - Windows: `%APPDATA%\portal\config.json`
72
-- Installed defaults currently include the relay that served the installer plus the public registry list.
69
+- The installer does not write a config file.
70
+- `portal expose 3000` still works after install because default relays are enabled.
71
+- Use `--relays https://portal.example.com` only when you want to target a specific relay explicitly.
72
73
## Notes
74
@@ -78,7 +77,7 @@ Legacy execution compatibility has been removed:
77
- The tunnel consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
78
- Relay startup and reconnect failures are retried independently in the background. A relay that is down does not stop healthy relays from continuing to serve traffic.
79
- The tunnel starts once relay URLs pass local validation. Remote compatibility checks, lease registration, and reconnects continue in the background until each relay becomes ready.
81
-- The configured relay list is either `public registry + installed/configured relay URLs` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
80
+- The configured relay list is either `public registry + --relays values` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
81
- SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
82
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
83
- When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/portal-tunnel/config.go
deleted
-86
@@ -1,86 +0,0 @@
1
-package main
2
-
3
-import (
4
- "encoding/json"
5
- "errors"
6
- "os"
7
- "path/filepath"
8
- "strings"
9
-
10
- "github.com/gosuda/portal/v2/utils"
11
-)
12
-
13
-const (
14
- cliConfigDirName = "portal"
15
- cliConfigFileName = "config.json"
16
-)
17
-
18
-type cliConfig struct {
19
- ClientID string `json:"client_id,omitempty"`
20
- OwnerPrivateKey string `json:"owner_private_key,omitempty"`
21
- Relays []string `json:"relays,omitempty"`
22
-}
23
-
24
-func loadCLIConfig() (cliConfig, string, error) {
25
- path, err := cliConfigPath()
26
- if err != nil {
27
- return cliConfig{}, "", err
28
- }
29
-
30
- cfg := cliConfig{}
31
- data, err := os.ReadFile(path)
32
- if err != nil {
33
- if errors.Is(err, os.ErrNotExist) {
34
- return cfg, path, nil
35
- }
36
- return cfg, path, err
37
- }
38
- if len(data) == 0 {
39
- return cfg, path, nil
40
- }
41
- if err := json.Unmarshal(data, &cfg); err != nil {
42
- return cliConfig{}, path, err
43
- }
44
- if len(cfg.Relays) > 0 {
45
- cfg.Relays, err = utils.NormalizeRelayURLs(cfg.Relays)
46
- if err != nil {
47
- return cliConfig{}, path, err
48
- }
49
- }
50
- return cfg, path, nil
51
-}
52
-
53
-func saveCLIConfig(path string, cfg cliConfig) error {
54
- if strings.TrimSpace(path) == "" {
55
- var err error
56
- path, err = cliConfigPath()
57
- if err != nil {
58
- return err
59
- }
60
- }
61
-
62
- if len(cfg.Relays) > 0 {
63
- normalizedRelays, err := utils.NormalizeRelayURLs(cfg.Relays)
64
- if err != nil {
65
- return err
66
- }
67
- cfg.Relays = normalizedRelays
68
- }
69
- data, err := json.MarshalIndent(cfg, "", " ")
70
- if err != nil {
71
- return err
72
- }
73
-
74
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
75
- return err
76
- }
77
- return os.WriteFile(path, append(data, '\n'), 0o600)
78
-}
79
-
80
-func cliConfigPath() (string, error) {
81
- baseDir, err := os.UserConfigDir()
82
- if err != nil {
83
- return "", err
84
- }
85
- return filepath.Join(baseDir, cliConfigDirName, cliConfigFileName), nil
86
-}
cmd/portal-tunnel/main.go
+133
-301
@@ -7,14 +7,8 @@ import (
7
"flag"
8
"fmt"
9
"io"
10
- "net"
10
"os"
12
- "os/signal"
13
- "strconv"
11
"strings"
15
- "sync"
16
- "sync/atomic"
17
- "syscall"
12
"time"
13
14
"github.com/rs/zerolog"
@@ -26,235 +20,129 @@ import (
20
)
21
22
func main() {
29
- zerolog.TimeFieldFormat = time.RFC3339
30
- zerolog.SetGlobalLevel(zerolog.InfoLevel)
31
- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
32
-
33
- if err := run(os.Args[1:]); err != nil {
23
+ log.Logger = log.Output(zerolog.NewConsoleWriter())
24
+ if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{
25
+ "expose": runExposeCommand,
26
+ "list": runListCommand,
27
+ "help": runHelpCommand,
28
+ }); err != nil {
29
log.Error().Err(err).Msg("portal tunnel exited with error")
30
os.Exit(1)
31
}
32
}
33
39
-func run(args []string) error {
40
- if len(args) == 0 {
41
- printRootUsage(os.Stdout)
42
- return nil
43
- }
44
-
45
- command := strings.TrimSpace(args[0])
46
- switch command {
47
- case "help", "-h", "--help":
48
- printRootUsage(os.Stdout)
49
- return nil
50
- case "expose":
51
- return runExposeCommand(args[1:])
52
- case "list":
53
- return runListCommand(args[1:])
54
- default:
55
- printRootUsage(os.Stderr)
56
- return fmt.Errorf("unknown command %q", command)
57
- }
34
+type exposeFlags struct {
35
+ relayCSV string
36
+ defaultRelays bool
37
+ discoveryEnabled bool
38
+ privateKey string
39
+ name string
40
+ desc string
41
+ tags string
42
+ owner string
43
+ thumbnail string
44
+ hide bool
45
+ targetAddr string
46
+ udp bool
47
+ udpAddr string
48
}
49
50
func runExposeCommand(args []string) error {
61
- cfg, cfgPath, err := loadCLIConfig()
62
- if err != nil {
63
- return fmt.Errorf("load portal config: %w", err)
64
- }
65
-
66
- defaultRelays := true
67
-
68
- fs := flag.NewFlagSet("expose", flag.ContinueOnError)
69
- fs.SetOutput(io.Discard)
70
-
71
- var (
72
- relayCSV string
73
- target string
74
- udpAddr string
75
- name string
76
- desc string
77
- tags string
78
- thumbnail string
79
- owner string
80
- privateKey string
81
- discoveryEnabled bool
82
- hide bool
83
- udp bool
84
- )
85
- fs.StringVar(&relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
86
- fs.BoolVar(&defaultRelays, "default-relays", defaultRelays, "Include public registry relays")
87
- fs.StringVar(&name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
88
- fs.StringVar(&desc, "description", "", "Service description metadata")
89
- fs.StringVar(&tags, "tags", "", "Service tags metadata (comma-separated)")
90
- fs.StringVar(&thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
91
- fs.StringVar(&owner, "owner", "", "Service owner metadata")
92
- fs.StringVar(&privateKey, "private-key", "", "Owner private key used to derive a discovery address")
93
- fs.BoolVar(&discoveryEnabled, "discovery", false, "Advertise known relay URLs and discover additional relay bootstraps")
94
- fs.BoolVar(&hide, "hide", false, "Hide service from discovery")
95
-
96
- fs.BoolVar(&udp, "udp", utils.ParseBoolEnv("UDP_ENABLED", false), "Enable public UDP relay in addition to the default TCP relay")
97
- fs.StringVar(&udpAddr, "udp-addr", strings.TrimSpace(os.Getenv("UDP_ADDR")), "Local UDP target address for relayed datagrams (host:port or port only) when --udp is enabled")
98
- fs.Usage = func() {
99
- printExposeUsage(fs.Output())
51
+ flags := exposeFlags{}
52
+ fs := utils.NewFlagSet("expose", printExposeUsage)
53
+
54
+ utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
55
+ utils.BoolFlag(fs, &flags.defaultRelays, "default-relays", true, "Include public registry relays")
56
+ utils.BoolFlag(fs, &flags.discoveryEnabled, "discovery", false, "Advertise known relay URLs and discover additional relay bootstraps")
57
+ utils.StringFlag(fs, &flags.privateKey, "private-key", "", "Owner private key used to derive a discovery address")
58
+ utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
59
+ utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata")
60
+ utils.StringFlag(fs, &flags.tags, "tags", "", "Service tags metadata (comma-separated)")
61
+ utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata")
62
+ utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
63
+ utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from discovery")
64
+ utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
65
+ utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
66
+
67
+ if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil {
68
+ if errors.Is(err, flag.ErrHelp) {
69
+ return nil
70
+ }
71
+ return err
72
}
73
102
- if err := fs.Parse(args); err != nil {
74
+ var err error
75
+ flags.targetAddr, err = utils.OptionalSingleArg(fs.Args(), "target")
76
+ if err != nil {
77
printExposeUsage(os.Stderr)
78
return err
79
}
106
-
107
- if positionals := fs.Args(); len(positionals) > 0 {
108
- if len(positionals) > 1 {
109
- return errors.New("only one target is supported")
110
- }
111
- target = positionals[0]
112
- }
113
-
114
- target = strings.TrimSpace(target)
115
- if target == "" {
80
+ if flags.targetAddr == "" {
81
printExposeUsage(os.Stderr)
82
return errors.New("target is required")
83
}
119
- if _, err := strconv.Atoi(target); err == nil {
120
- target = net.JoinHostPort("127.0.0.1", target)
121
- } else {
122
- targetAddr, err := utils.NormalizeTargetAddr(target)
123
- if err != nil {
124
- printExposeUsage(os.Stderr)
125
- return fmt.Errorf("invalid target %q: %w", target, err)
126
- }
127
- target = targetAddr
128
- }
129
- udpAddr = strings.TrimSpace(udpAddr)
130
- switch {
131
- case udp && udpAddr == "":
132
- printExposeUsage(os.Stderr)
133
- return errors.New("--udp-addr is required when --udp is enabled")
134
- case !udp && udpAddr != "":
135
- printExposeUsage(os.Stderr)
136
- return errors.New("--udp-addr requires --udp")
137
- case udpAddr != "":
138
- if _, err := strconv.Atoi(udpAddr); err == nil {
139
- udpAddr = net.JoinHostPort("127.0.0.1", udpAddr)
140
- } else {
141
- targetAddr, err := utils.NormalizeTargetAddr(udpAddr)
142
- if err != nil {
143
- printExposeUsage(os.Stderr)
144
- return fmt.Errorf("invalid udp target %q: %w", udpAddr, err)
145
- }
146
- udpAddr = targetAddr
147
- }
148
- }
149
-
150
- if strings.TrimSpace(name) == "" {
151
- if strings.TrimSpace(cfg.ClientID) == "" {
152
- cfg.ClientID = utils.RandomID("cli_")
153
- }
154
- name, err = defaultExposeName(target, cfg.ClientID)
84
+ if flags.name == "" {
85
+ flags.name, err = defaultExposeName(flags.targetAddr, utils.RandomID("cli_"))
86
if err != nil {
87
return fmt.Errorf("derive service name: %w", err)
88
}
158
- if saveErr := saveCLIConfig(cfgPath, cfg); saveErr != nil {
159
- return fmt.Errorf("persist portal config: %w", saveErr)
160
- }
89
}
90
163
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
91
+ ctx, stop := utils.SignalContext()
92
defer stop()
93
166
- relayInputs := append([]string(nil), cfg.Relays...)
167
- if explicitRelays := strings.TrimSpace(relayCSV); explicitRelays != "" {
168
- relayInputs = []string{explicitRelays}
169
- }
170
-
171
- previousOwnerPrivateKey := cfg.OwnerPrivateKey
172
- if strings.TrimSpace(privateKey) != "" {
173
- cfg.OwnerPrivateKey = privateKey
174
- }
175
- var ownerPrivateKey *string
176
- if discoveryEnabled || strings.TrimSpace(cfg.OwnerPrivateKey) != "" {
177
- ownerPrivateKey = &cfg.OwnerPrivateKey
178
- }
179
-
94
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
181
- RelayURLs: relayInputs,
182
- DefaultRelayEnabled: defaultRelays,
183
- Name: name,
184
- UDPEnabled: udp,
185
- Discovery: discoveryEnabled,
95
+ RelayURLs: utils.SplitCSV(flags.relayCSV),
96
+ DefaultRelayEnabled: flags.defaultRelays,
97
+ Name: flags.name,
98
+ TargetAddr: flags.targetAddr,
99
+ UDPAddr: flags.udpAddr,
100
+ UDPEnabled: flags.udp,
101
+ Discovery: flags.discoveryEnabled,
102
Metadata: types.LeaseMetadata{
187
- Description: desc,
188
- Tags: utils.SplitCSV(tags),
189
- Owner: owner,
190
- Thumbnail: thumbnail,
191
- Hide: hide,
103
+ Description: flags.desc,
104
+ Tags: utils.SplitCSV(flags.tags),
105
+ Owner: flags.owner,
106
+ Thumbnail: flags.thumbnail,
107
+ Hide: flags.hide,
108
},
193
- OwnerPrivateKey: ownerPrivateKey,
109
+ OwnerPrivateKey: flags.privateKey,
110
})
111
if err != nil {
196
- return fmt.Errorf("service %s: failed to start relays: %w", name, err)
197
- }
198
- if exposure == nil {
199
- return errors.New("no relay URLs provided")
200
- }
201
- if identity := exposure.OwnerIdentity(); identity.PrivateKey != "" {
202
- cfg.OwnerPrivateKey = identity.PrivateKey
203
- }
204
- if cfg.OwnerPrivateKey != previousOwnerPrivateKey {
205
- if saveErr := saveCLIConfig(cfgPath, cfg); saveErr != nil {
206
- _ = exposure.Close()
207
- return fmt.Errorf("persist portal config: %w", saveErr)
208
- }
112
+ return fmt.Errorf("service %s: failed to start relays: %w", flags.name, err)
113
}
114
+ return proxyExposure(ctx, exposure, flags.name)
115
+}
116
211
- return runTunnel(
212
- ctx,
213
- stop,
214
- exposure,
215
- target,
216
- udpAddr,
217
- name,
218
- udp,
219
- )
117
+type listFlags struct {
118
+ relayCSV string
119
+ defaultRelays bool
120
}
121
122
func runListCommand(args []string) error {
223
- cfg, _, err := loadCLIConfig()
224
- if err != nil {
225
- return fmt.Errorf("load portal config: %w", err)
226
- }
123
+ flags := listFlags{}
124
+ fs := utils.NewFlagSet("list", printListUsage)
125
228
- defaultRelays := true
229
-
230
- fs := flag.NewFlagSet("list", flag.ContinueOnError)
231
- fs.SetOutput(io.Discard)
232
-
233
- var relayCSV string
234
- fs.StringVar(&relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
235
- fs.BoolVar(&defaultRelays, "default-relays", defaultRelays, "Include public registry relays")
236
- fs.Usage = func() {
237
- printListUsage(fs.Output())
238
- }
126
+ utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
127
+ utils.BoolFlag(fs, &flags.defaultRelays, "default-relays", true, "Include public registry relays")
128
240
- if err := fs.Parse(args); err != nil {
241
- printListUsage(os.Stderr)
129
+ if err := utils.ParseFlagSet(fs, args, printListUsage); err != nil {
130
+ if errors.Is(err, flag.ErrHelp) {
131
+ return nil
132
+ }
133
return err
134
}
244
- if len(fs.Args()) > 0 {
135
+ if err := utils.RequireNoArgs(fs.Args(), "list"); err != nil {
136
printListUsage(os.Stderr)
246
- return errors.New("list does not accept positional arguments")
137
+ return err
138
}
139
140
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
141
defer cancel()
142
252
- relayInputs := append([]string(nil), cfg.Relays...)
253
- if explicitRelays := strings.TrimSpace(relayCSV); explicitRelays != "" {
254
- relayInputs = []string{explicitRelays}
255
- }
143
+ relayInputs := utils.SplitCSV(flags.relayCSV)
144
257
- relayURLs, err := sdk.ResolveRelayURLs(ctx, relayInputs, defaultRelays)
145
+ relayURLs, err := sdk.ResolveRelayURLs(ctx, relayInputs, flags.defaultRelays)
146
if err != nil {
147
return fmt.Errorf("resolve relay urls: %w", err)
148
}
@@ -268,95 +156,30 @@ func runListCommand(args []string) error {
156
return nil
157
}
158
271
-func runTunnel(
272
- ctx context.Context,
273
- stop func(),
274
- exposure *sdk.Exposure,
275
- tcpTarget string,
276
- udpTarget string,
277
- name string,
278
- udpEnabled bool,
279
-) error {
280
- logger := log.With().Str("component", "portal").Logger()
281
- if exposure == nil {
282
- return errors.New("no relay URLs provided")
283
- }
284
- defer exposure.Close()
285
-
286
- var udpErrCh chan error
287
- if udpEnabled {
288
- udpErrCh = make(chan error, 1)
289
- go func() {
290
- if err := runUDPProxy(ctx, exposure, udpTarget); err != nil && ctx.Err() == nil {
291
- udpErrCh <- err
292
- if stop != nil {
293
- stop()
294
- }
295
- }
296
- }()
297
- }
298
-
299
- logger.Info().
300
- Str("release_version", types.ReleaseVersion).
301
- Str("tcp_target", tcpTarget).
302
- Str("service_name", name).
303
- Strs("relays", exposure.RelayURLs()).
304
- Msg("starting portal tunnel")
305
- if udpEnabled {
306
- logger.Info().
307
- Str("udp_target", udpTarget).
308
- Str("service_name", name).
309
- Msg("udp relay enabled")
310
- }
311
-
312
- var connWG sync.WaitGroup
313
- var connCount atomic.Int64
314
-
315
- go func() {
316
- <-ctx.Done()
317
- _ = exposure.Close()
318
- }()
319
-
320
- waitErr := proxyRelayConnections(ctx, exposure, tcpTarget, &connWG, &connCount)
321
- if waitErr != nil && stop != nil {
322
- stop()
323
- }
324
- var udpErr error
325
- if udpErrCh != nil {
326
- select {
327
- case udpErr = <-udpErrCh:
328
- default:
329
- }
330
- }
331
- closeErr := exposure.Close()
332
- if waitErr != nil {
333
- logger.Error().Err(waitErr).Msg("relay supervisor exited with error")
334
- }
335
- if udpErr != nil {
336
- logger.Error().Err(udpErr).Msg("udp proxy exited with error")
337
- }
338
- if closeErr != nil {
339
- logger.Error().Err(closeErr).Msg("relay shutdown failed")
159
+func runHelpCommand(args []string) error {
160
+ if len(args) == 0 {
161
+ printRootUsage(os.Stdout)
162
+ return nil
163
}
341
-
342
- if ctx.Err() != nil {
343
- logger.Info().Msg("tunnel shutting down")
164
+ if len(args) > 1 {
165
+ printRootUsage(os.Stderr)
166
+ return errors.New("only one help topic is supported")
167
}
168
346
- done := make(chan struct{})
347
- go func() {
348
- connWG.Wait()
349
- close(done)
350
- }()
351
-
352
- select {
353
- case <-done:
354
- case <-time.After(5 * time.Second):
355
- logger.Warn().Msg("tunnel shutdown timeout; connections still active")
169
+ switch strings.TrimSpace(args[0]) {
170
+ case "", "help", "-h", "--help":
171
+ printRootUsage(os.Stdout)
172
+ return nil
173
+ case "expose":
174
+ printExposeUsage(os.Stdout)
175
+ return nil
176
+ case "list":
177
+ printListUsage(os.Stdout)
178
+ return nil
179
+ default:
180
+ printRootUsage(os.Stderr)
181
+ return fmt.Errorf("unknown help topic %q", strings.TrimSpace(args[0]))
182
}
357
-
358
- logger.Info().Msg("tunnel shutdown complete")
359
- return errors.Join(waitErr, udpErr, closeErr)
183
}
184
185
var exposeNameOpeners = []string{
@@ -389,8 +212,8 @@ var exposeNameClosers = []string{
212
"whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
213
}
214
392
-func defaultExposeName(target, clientID string) (string, error) {
393
- seed := strings.TrimSpace(clientID)
215
+func defaultExposeName(target, rawSeed string) (string, error) {
216
+ seed := strings.TrimSpace(rawSeed)
217
if cut, ok := strings.CutPrefix(seed, "cli_"); ok {
218
seed = cut
219
}
@@ -409,33 +232,42 @@ func defaultExposeName(target, clientID string) (string, error) {
232
}
233
234
func printRootUsage(w io.Writer) {
412
- fmt.Fprintln(w, "Usage:")
413
- fmt.Fprintln(w, " portal expose [flags] <target>")
414
- fmt.Fprintln(w, " portal list [flags]")
415
- fmt.Fprintln(w)
416
- fmt.Fprintln(w, "Examples:")
417
- fmt.Fprintln(w, " portal expose 3000")
418
- fmt.Fprintln(w, " portal expose --name my-app localhost:8080")
419
- fmt.Fprintln(w, " portal expose --udp --udp-addr 127.0.0.1:5353 3000")
420
- fmt.Fprintln(w, " portal list")
235
+ utils.WriteCommandUsage(w,
236
+ []string{
237
+ "portal expose [flags] <target>",
238
+ "portal list [flags]",
239
+ },
240
+ []string{
241
+ "portal expose 3000",
242
+ "portal expose --name my-app localhost:8080",
243
+ "portal expose --udp --udp-addr 127.0.0.1:5353 3000",
244
+ "portal list",
245
+ },
246
+ )
247
}
248
249
func printExposeUsage(w io.Writer) {
424
- fmt.Fprintln(w, "Usage:")
425
- fmt.Fprintln(w, " portal expose [flags] <target>")
426
- fmt.Fprintln(w)
427
- fmt.Fprintln(w, "Examples:")
428
- fmt.Fprintln(w, " portal expose 3000")
429
- fmt.Fprintln(w, " portal expose --name my-app localhost:8080")
430
- fmt.Fprintln(w, " portal expose --udp --udp-addr 127.0.0.1:5353 3000")
431
- fmt.Fprintln(w, " portal expose --relays https://portal.example.com --default-relays=false 3000")
250
+ utils.WriteCommandUsage(w,
251
+ []string{
252
+ "portal expose [flags] <target>",
253
+ },
254
+ []string{
255
+ "portal expose 3000",
256
+ "portal expose --name my-app localhost:8080",
257
+ "portal expose --udp --udp-addr 127.0.0.1:5353 3000",
258
+ "portal expose --relays https://portal.example.com --default-relays=false 3000",
259
+ },
260
+ )
261
}
262
263
func printListUsage(w io.Writer) {
435
- fmt.Fprintln(w, "Usage:")
436
- fmt.Fprintln(w, " portal list [flags]")
437
- fmt.Fprintln(w)
438
- fmt.Fprintln(w, "Examples:")
439
- fmt.Fprintln(w, " portal list")
440
- fmt.Fprintln(w, " portal list --relays https://portal.example.com --default-relays=false")
264
+ utils.WriteCommandUsage(w,
265
+ []string{
266
+ "portal list [flags]",
267
+ },
268
+ []string{
269
+ "portal list",
270
+ "portal list --relays https://portal.example.com --default-relays=false",
271
+ },
272
+ )
273
}
cmd/portal-tunnel/relays.go
+101
-32
@@ -14,14 +14,97 @@ import (
14
15
"github.com/gosuda/portal/v2/sdk"
16
"github.com/gosuda/portal/v2/types"
17
- "github.com/gosuda/portal/v2/utils"
17
)
18
20
-func proxyRelayConnections(ctx context.Context, relayListener net.Listener, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64) error {
21
- logger := log.With().Str("component", "portal-tunnel").Logger()
19
+func proxyExposure(ctx context.Context, exposure *sdk.Exposure, serviceName string) error {
20
+ defer exposure.Close()
21
+ if len(exposure.ActiveRelayURLs()) == 0 {
22
+ return errors.New("no relay URLs provided")
23
+ }
24
+
25
+ tcpTarget := exposure.TargetAddr
26
+ udpTarget := exposure.UDPAddr
27
+ udpEnabled := udpTarget != ""
28
+
29
+ log.Info().
30
+ Str("release_version", types.ReleaseVersion).
31
+ Str("tcp_target", tcpTarget).
32
+ Str("service_name", serviceName).
33
+ Strs("relays", exposure.ActiveRelayURLs()).
34
+ Msg("starting portal tunnel")
35
+ if udpEnabled {
36
+ log.Info().
37
+ Str("udp_target", udpTarget).
38
+ Str("service_name", serviceName).
39
+ Msg("udp relay enabled")
40
+ }
41
+
42
+ var connWG sync.WaitGroup
43
+ var connCount atomic.Int64
44
+ var udpErrCh chan error
45
+
46
+ if udpEnabled {
47
+ udpErrCh = make(chan error, 1)
48
+ go func() {
49
+ if err := runUDPProxy(ctx, exposure, udpTarget); err != nil && ctx.Err() == nil {
50
+ udpErrCh <- err
51
+ _ = exposure.Close()
52
+ }
53
+ }()
54
+ }
55
+
56
+ go func() {
57
+ <-ctx.Done()
58
+ _ = exposure.Close()
59
+ }()
60
+
61
+ waitErr := proxyRelayConnections(ctx, exposure, tcpTarget, &connWG, &connCount)
62
+ if waitErr != nil {
63
+ _ = exposure.Close()
64
+ }
65
+
66
+ var udpErr error
67
+ if udpErrCh != nil {
68
+ select {
69
+ case udpErr = <-udpErrCh:
70
+ default:
71
+ }
72
+ }
73
74
+ closeErr := exposure.Close()
75
+ if waitErr != nil {
76
+ log.Error().Err(waitErr).Msg("relay supervisor exited with error")
77
+ }
78
+ if udpErr != nil {
79
+ log.Error().Err(udpErr).Msg("udp proxy exited with error")
80
+ }
81
+ if closeErr != nil {
82
+ log.Error().Err(closeErr).Msg("relay shutdown failed")
83
+ }
84
+
85
+ if ctx.Err() != nil {
86
+ log.Info().Msg("tunnel shutting down")
87
+ }
88
+
89
+ done := make(chan struct{})
90
+ go func() {
91
+ connWG.Wait()
92
+ close(done)
93
+ }()
94
+
95
+ select {
96
+ case <-done:
97
+ case <-time.After(5 * time.Second):
98
+ log.Warn().Msg("tunnel shutdown timeout; connections still active")
99
+ }
100
+
101
+ log.Info().Msg("tunnel shutdown complete")
102
+ return errors.Join(waitErr, udpErr, closeErr)
103
+}
104
+
105
+func proxyRelayConnections(ctx context.Context, exposure *sdk.Exposure, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64) error {
106
for {
24
- relayConn, err := relayListener.Accept()
107
+ relayConn, err := exposure.Accept()
108
if err != nil {
109
switch {
110
case ctx.Err() != nil || errors.Is(err, context.Canceled):
@@ -34,7 +117,7 @@ func proxyRelayConnections(ctx context.Context, relayListener net.Listener, loca
117
}
118
119
connID := connCount.Add(1)
37
- logger.Info().
120
+ log.Info().
121
Int64("conn_id", connID).
122
Str("remote_addr", relayConn.RemoteAddr().String()).
123
Msg("accepted relay connection")
@@ -43,9 +126,9 @@ func proxyRelayConnections(ctx context.Context, relayListener net.Listener, loca
126
go func(connID int64, relayConn net.Conn) {
127
defer connWG.Done()
128
if err := proxyConnection(ctx, localAddr, relayConn); err != nil {
46
- logger.Error().Err(err).Int64("conn_id", connID).Msg("proxy connection failed")
129
+ log.Error().Err(err).Int64("conn_id", connID).Msg("proxy connection failed")
130
}
48
- logger.Info().Int64("conn_id", connID).Msg("proxy connection closed")
131
+ log.Info().Int64("conn_id", connID).Msg("proxy connection closed")
132
}(connID, relayConn)
133
}
134
}
@@ -60,13 +143,8 @@ var bufferPool = sync.Pool{
143
func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
144
defer relayConn.Close()
145
63
- targetAddr, err := utils.NormalizeTargetAddr(localAddr)
64
- if err != nil {
65
- return fmt.Errorf("invalid target %q: %w", localAddr, err)
66
- }
67
-
146
dialer := &net.Dialer{Timeout: 5 * time.Second}
69
- localConn, err := dialer.DialContext(ctx, "tcp", targetAddr)
147
+ localConn, err := dialer.DialContext(ctx, "tcp", localAddr)
148
if err != nil {
149
return writeEmptyHTTPResponse(relayConn)
150
}
@@ -138,8 +216,6 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
216
// runUDPProxy waits for the exposure datagram plane and proxies it to the
217
// configured local UDP target.
218
func runUDPProxy(ctx context.Context, exposure *sdk.Exposure, udpTarget string) error {
141
- logger := log.With().Str("component", "portal-tunnel-udp").Logger()
142
-
219
udpAddrs, err := exposure.WaitDatagramReady(ctx)
220
if err != nil {
221
if ctx.Err() != nil || errors.Is(err, context.Canceled) {
@@ -155,7 +231,7 @@ func runUDPProxy(ctx context.Context, exposure *sdk.Exposure, udpTarget string)
231
}
232
233
for _, udpAddr := range udpAddrs {
158
- logger.Info().
234
+ log.Info().
235
Str("udp_addr", udpAddr).
236
Msg("UDP tunnel ready")
237
}
@@ -166,16 +242,9 @@ func runUDPProxy(ctx context.Context, exposure *sdk.Exposure, udpTarget string)
242
// proxyExposureDatagrams receives datagrams from the exposure datagram plane
243
// and forwards them to the local UDP service, relaying responses back.
244
func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAddr string) error {
169
- logger := log.With().Str("component", "portal-tunnel-udp").Logger()
170
-
171
- targetAddr, err := utils.NormalizeTargetAddr(localAddr)
172
- if err != nil {
173
- return fmt.Errorf("invalid --udp-addr value %q: %w", localAddr, err)
174
- }
175
-
176
- resolvedAddr, err := net.ResolveUDPAddr("udp", targetAddr)
245
+ resolvedAddr, err := net.ResolveUDPAddr("udp", localAddr)
246
if err != nil {
178
- return fmt.Errorf("resolve udp addr %q: %w", targetAddr, err)
247
+ return fmt.Errorf("resolve udp addr %q: %w", localAddr, err)
248
}
249
250
type flowKey struct {
@@ -260,7 +329,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
329
if ctx.Err() != nil {
330
return
331
}
263
- logger.Debug().
332
+ log.Debug().
333
Err(err).
334
Uint32("flow_id", key.flowID).
335
Str("lease_id", key.leaseID).
@@ -282,7 +351,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
351
mu.Unlock()
352
353
if sendErr := exposure.SendDatagram(replyFrame); sendErr != nil {
285
- logger.Debug().
354
+ log.Debug().
355
Err(sendErr).
356
Uint32("flow_id", key.flowID).
357
Str("lease_id", key.leaseID).
@@ -296,7 +365,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
365
return localConn, nil
366
}
367
299
- logger.Info().Str("target", targetAddr).Msg("udp proxy loop started, waiting for datagrams")
368
+ log.Info().Str("target", localAddr).Msg("udp proxy loop started, waiting for datagrams")
369
for {
370
frame, err := exposure.AcceptDatagram()
371
if err != nil {
@@ -309,18 +378,18 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
378
return fmt.Errorf("accept datagram: %w", err)
379
}
380
312
- logger.Debug().
381
+ log.Debug().
382
Uint32("flow_id", frame.FlowID).
383
Int("bytes", len(frame.Payload)).
384
Str("lease_id", frame.LeaseID).
385
Str("relay_url", frame.RelayURL).
386
Str("udp_addr", frame.UDPAddr).
318
- Str("target", targetAddr).
387
+ Str("target", localAddr).
388
Msg("datagram received from relay, forwarding to local")
389
390
localConn, err := getOrCreateFlow(frame)
391
if err != nil {
323
- logger.Warn().
392
+ log.Warn().
393
Err(err).
394
Uint32("flow_id", frame.FlowID).
395
Str("lease_id", frame.LeaseID).
@@ -330,7 +399,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
399
}
400
401
if _, err := localConn.Write(frame.Payload); err != nil {
333
- logger.Warn().
402
+ log.Warn().
403
Err(err).
404
Uint32("flow_id", frame.FlowID).
405
Str("lease_id", frame.LeaseID).
cmd/relay-server/admin.go
+28
-15
@@ -21,8 +21,6 @@ import (
21
22
const cookieName = "portal_admin"
23
24
-var adminSettingsPath = "admin_settings.json"
25
-
24
type adminAuth struct {
25
sessions map[string]time.Time
26
secretKey string
@@ -38,7 +36,6 @@ func newAdminAuth(secretKey string) *adminAuth {
36
}
37
secretKey = generated
38
log.Warn().
41
- Str("component", "relay-server-admin").
39
Str("admin_secret_key", secretKey).
40
Msg("generated random admin secret key because ADMIN_SECRET_KEY was empty")
41
}
@@ -100,8 +97,8 @@ func (a *adminAuth) cleanupExpiredSessionsLocked() {
97
}
98
}
99
103
-func loadAdminState(runtime *policy.Runtime) error {
104
- root, name, err := openSettingsRoot(adminSettingsPath)
100
+func loadAdminState(path string, runtime *policy.Runtime) error {
101
+ root, name, err := openSettingsRoot(path)
102
if err != nil {
103
return err
104
}
@@ -188,7 +185,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
185
utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
186
}
187
writeOK := func() {
191
- saveAdminState(runtime)
188
+ f.saveAdminState(runtime)
189
utils.WriteAPIOK(w, http.StatusOK)
190
}
191
@@ -221,7 +218,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
218
return
219
}
220
runtime.SetUDPPolicy(req.Enabled, req.MaxLeases)
224
- saveAdminState(runtime)
221
+ f.saveAdminState(runtime)
222
utils.WriteAPIData(w, http.StatusOK, types.AdminUDPSettingsResponse{
223
Enabled: runtime.IsUDPEnabled(),
224
MaxLeases: runtime.UDPMaxLeases(),
@@ -240,7 +237,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
237
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
238
return
239
}
243
- saveAdminState(runtime)
240
+ f.saveAdminState(runtime)
241
utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
242
ApprovalMode: string(runtime.Approver().Mode()),
243
})
@@ -398,14 +395,21 @@ func (f *Frontend) isAuthenticated(r *http.Request) bool {
395
return f.auth.ValidateSession(cookie.Value)
396
}
397
401
-func saveAdminState(runtime *policy.Runtime) {
398
+func (f *Frontend) saveAdminState(runtime *policy.Runtime) {
399
+ if f == nil {
400
+ return
401
+ }
402
+ saveAdminState(f.adminSettingsPath, runtime)
403
+}
404
+
405
+func saveAdminState(path string, runtime *policy.Runtime) {
406
payload := persistedStateFromRuntime(runtime)
407
data, err := json.MarshalIndent(payload, "", " ")
408
if err != nil {
409
return
410
}
411
408
- root, name, err := openSettingsRoot(adminSettingsPath)
412
+ root, name, err := openSettingsRoot(path)
413
if err != nil {
414
return
415
}
@@ -420,12 +424,14 @@ type persistedAdminState struct {
424
BannedLeases []string `json:"banned_leases,omitempty"`
425
BannedIPs []string `json:"banned_ips,omitempty"`
426
LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
423
- UDPEnabled bool `json:"udp_enabled"`
424
- UDPMaxLeases int `json:"udp_max_leases"`
427
+ UDPEnabled *bool `json:"udp_enabled,omitempty"`
428
+ UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
429
}
430
431
func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
432
approver := runtime.Approver()
433
+ udpEnabled := runtime.IsUDPEnabled()
434
+ udpMaxLeases := runtime.UDPMaxLeases()
435
return persistedAdminState{
436
ApprovalMode: string(approver.Mode()),
437
ApprovedLeases: approver.ApprovedLeases(),
@@ -433,8 +439,8 @@ func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
439
BannedLeases: runtime.BannedLeases(),
440
BannedIPs: runtime.IPFilter().BannedIPs(),
441
LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
436
- UDPEnabled: runtime.IsUDPEnabled(),
437
- UDPMaxLeases: runtime.UDPMaxLeases(),
442
+ UDPEnabled: &udpEnabled,
443
+ UDPMaxLeases: &udpMaxLeases,
444
}
445
}
446
@@ -451,7 +457,14 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
457
runtime.SetBannedLeases(s.BannedLeases)
458
runtime.IPFilter().SetBannedIPs(s.BannedIPs)
459
runtime.BPSManager().SetLeaseBPSLimits(s.LeaseBPS)
454
- runtime.SetUDPPolicy(s.UDPEnabled, s.UDPMaxLeases)
460
+ switch {
461
+ case s.UDPEnabled != nil && s.UDPMaxLeases != nil:
462
+ runtime.SetUDPPolicy(*s.UDPEnabled, *s.UDPMaxLeases)
463
+ case s.UDPEnabled != nil:
464
+ runtime.SetUDPPolicy(*s.UDPEnabled, runtime.UDPMaxLeases())
465
+ case s.UDPMaxLeases != nil:
466
+ runtime.SetUDPPolicy(runtime.IsUDPEnabled(), *s.UDPMaxLeases)
467
+ }
468
return nil
469
}
470
cmd/relay-server/frontend.go
+13
-18
@@ -7,7 +7,6 @@ import (
7
"html"
8
"io/fs"
9
"mime"
10
- "net"
10
"net/http"
11
"path"
12
"strings"
@@ -27,18 +26,16 @@ type readDirFileFS interface {
26
var embeddedDistFS embed.FS
27
28
type Frontend struct {
30
- distFS readDirFileFS
31
- portalURL string
32
- server *portal.Server
33
- auth *adminAuth
34
- trustProxy bool
35
- trustedCIDRs []*net.IPNet
29
+ distFS readDirFileFS
30
+ server *portal.Server
31
+ auth *adminAuth
32
+ adminSettingsPath string
33
34
cachedPortalHTML []byte
35
cachedPortalHTMLOnce sync.Once
36
}
37
41
-func NewFrontend(portalURL string, server *portal.Server, adminSecret string, trustedProxyCIDRs []*net.IPNet, trustProxy bool) (*Frontend, error) {
38
+func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string) (*Frontend, error) {
39
if server == nil {
40
return nil, errors.New("frontend requires portal server")
41
}
@@ -46,17 +43,15 @@ func NewFrontend(portalURL string, server *portal.Server, adminSecret string, tr
43
if runtime == nil {
44
return nil, errors.New("frontend requires policy runtime")
45
}
49
- if err := loadAdminState(runtime); err != nil {
46
+ if err := loadAdminState(adminSettingsPath, runtime); err != nil {
47
return nil, err
48
}
49
50
return &Frontend{
54
- distFS: embeddedDistFS,
55
- portalURL: strings.TrimSpace(portalURL),
56
- server: server,
57
- auth: newAdminAuth(adminSecret),
58
- trustProxy: trustProxy,
59
- trustedCIDRs: trustedProxyCIDRs,
51
+ distFS: embeddedDistFS,
52
+ server: server,
53
+ auth: newAdminAuth(adminSecret),
54
+ adminSettingsPath: strings.TrimSpace(adminSettingsPath),
55
}, nil
56
}
57
@@ -84,10 +79,10 @@ func (f *Frontend) Handler() *http.ServeMux {
79
mux.HandleFunc(types.PathAdmin, f.serveAdmin)
80
mux.HandleFunc(types.PathAdminPrefix, f.serveAdmin)
81
mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
87
- serveInstallScript(w, r, f.portalURL, false)
82
+ serveInstallScript(w, r, f.server.PortalURL(), false)
83
})
84
mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
90
- serveInstallScript(w, r, f.portalURL, true)
85
+ serveInstallScript(w, r, f.server.PortalURL(), true)
86
})
87
mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
88
@@ -205,7 +200,7 @@ func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL st
200
description = "Transform your local services into web-accessible endpoints. Instant access from anywhere."
201
}
202
if imageURL == "" {
208
- base := strings.TrimSuffix(f.portalURL, "/")
203
+ base := strings.TrimSuffix(f.server.PortalURL(), "/")
204
if !strings.HasPrefix(base, "http") {
205
base = "https://" + base
206
}
cmd/relay-server/main.go
+89
-163
@@ -1,16 +1,12 @@
1
package main
2
3
import (
4
- "context"
4
"errors"
5
"flag"
6
"fmt"
7
+ "io"
8
"os"
9
- "os/signal"
10
- "path/filepath"
9
"strings"
12
- "syscall"
13
- "time"
10
11
"github.com/rs/zerolog"
12
"github.com/rs/zerolog/log"
@@ -21,26 +17,31 @@ import (
17
"github.com/gosuda/portal/v2/utils"
18
)
19
24
-const (
25
- defaultAPIPort = 4017
26
- defaultSNIPort = 443
27
- defaultUDPPortCount = 0
28
- defaultPortalURL = "https://localhost:4017"
29
- defaultKeylessDir = "./.portal-certs"
30
-)
20
+func main() {
21
+ log.Logger = log.Output(zerolog.NewConsoleWriter())
22
+ if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{
23
+ "": runServeCommand,
24
+ "serve": runServeCommand,
25
+ "help": runHelpCommand,
26
+ }); err != nil {
27
+ log.Error().Err(err).Msg("execute root command")
28
+ os.Exit(1)
29
+ }
30
+}
31
32
type relayServerConfig struct {
33
PortalURL string
34
- OwnerPrivateKey string
35
- Bootstraps string
34
APIPort int
35
SNIPort int
36
UDPPortCount int
39
- AdminSecretKey string
37
+ Bootstraps string
38
DiscoveryEnabled bool
39
+ OwnerPrivateKey string
40
+ AdminSecretKey string
41
TrustProxyHeaders bool
42
TrustedProxyCIDRs string
43
KeylessDir string
44
+ AdminSettingsPath string
45
ACMEDNSProvider string
46
CloudflareToken string
47
AWSAccessKeyID string
@@ -50,106 +51,57 @@ type relayServerConfig struct {
51
AWSHostedZoneID string
52
}
53
53
-func main() {
54
- log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
55
- logger := log.With().Str("component", "relay-server").Logger()
56
-
54
+func runServeCommand(args []string) error {
55
cfg := relayServerConfig{}
58
-
59
- portalURL := strings.TrimSuffix(trimmedEnv("PORTAL_URL"), "/")
60
- if portalURL == "" {
61
- portalURL = defaultPortalURL
62
- }
63
- apiPort := parsePortNumber(os.Getenv("API_PORT"), defaultAPIPort)
64
- sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
65
- udpPortCount := parseNonNegativeInt(os.Getenv("UDP_PORT_COUNT"), defaultUDPPortCount)
66
- ownerPrivateKey := trimmedEnv("OWNER_PRIVATE_KEY")
67
- bootstraps := trimmedEnv("BOOTSTRAPS")
68
- adminSecretKey := trimmedEnv("ADMIN_SECRET_KEY")
69
- discoveryEnabled := utils.ParseBoolEnv("DISCOVERY_ENABLED", false)
70
- trustProxyHeaders := utils.ParseBoolEnv("TRUST_PROXY_HEADERS", false)
71
- trustedProxyCIDRs := trimmedEnv("TRUSTED_PROXY_CIDRS")
72
- keylessDir := trimmedEnv("KEYLESS_DIR")
73
- if keylessDir == "" {
74
- keylessDir = defaultKeylessDir
75
- }
76
- adminSettingsPath = filepath.Join(keylessDir, "admin_settings.json")
77
- acmeDNSProvider := trimmedEnv("ACME_DNS_PROVIDER")
78
- if acmeDNSProvider == "" {
79
- acmeDNSProvider = "cloudflare"
56
+ fs := utils.NewFlagSet("relay-server", printRootUsage)
57
+
58
+ utils.StringFlagEnv(fs, &cfg.PortalURL, "portal-url", "https://localhost:4017", "portal base URL", "PORTAL_URL")
59
+ utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT")
60
+ utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT")
61
+ utils.IntFlagEnv(fs, &cfg.UDPPortCount, "udp-port-count", 0, utils.ParseNonNegativeInt, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled)", "UDP_PORT_COUNT")
62
+ utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
63
+ utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY_ENABLED")
64
+ utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
65
+ utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
66
+ utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
67
+ utils.StringFlagEnv(fs, &cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", "", "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled", "TRUSTED_PROXY_CIDRS")
68
+
69
+ utils.StringFlagEnv(fs, &cfg.KeylessDir, "keyless-dir", "./.portal-certs", "directory path for relay keyless materials", "KEYLESS_DIR")
70
+ utils.StringFlagEnv(fs, &cfg.AdminSettingsPath, "admin-settings-path", "admin_settings.json", "admin settings file path", "ADMIN_SETTINGS_PATH")
71
+ utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "cloudflare", "ACME DNS provider for DNS-01 and A-record sync (cloudflare|route53)", "ACME_DNS_PROVIDER")
72
+ utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
73
+ utils.StringFlagEnv(fs, &cfg.AWSAccessKeyID, "aws-access-key-id", "", "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted", "AWS_ACCESS_KEY_ID")
74
+ utils.StringFlagEnv(fs, &cfg.AWSSecretAccessKey, "aws-secret-access-key", "", "AWS secret access key for Route53 static credentials", "AWS_SECRET_ACCESS_KEY")
75
+ utils.StringFlagEnv(fs, &cfg.AWSSessionToken, "aws-session-token", "", "AWS session token for Route53 temporary credentials", "AWS_SESSION_TOKEN")
76
+ utils.StringFlagEnv(fs, &cfg.AWSRegion, "aws-region", "", "AWS region for Route53 and Route53-backed DNS-01; defaults to us-east-1 when unset", "AWS_REGION", "AWS_DEFAULT_REGION")
77
+ utils.StringFlagEnv(fs, &cfg.AWSHostedZoneID, "aws-hosted-zone-id", "", "explicit Route53 hosted zone ID override", "AWS_HOSTED_ZONE_ID")
78
+
79
+ if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil {
80
+ if errors.Is(err, flag.ErrHelp) {
81
+ return nil
82
+ }
83
+ return err
84
}
81
- cloudflareToken := trimmedEnv("CLOUDFLARE_TOKEN")
82
- awsAccessKeyID := trimmedEnv("AWS_ACCESS_KEY_ID")
83
- awsSecretAccessKey := trimmedEnv("AWS_SECRET_ACCESS_KEY")
84
- awsSessionToken := trimmedEnv("AWS_SESSION_TOKEN")
85
- awsRegion := trimmedEnv("AWS_REGION")
86
- if awsRegion == "" {
87
- awsRegion = trimmedEnv("AWS_DEFAULT_REGION")
85
+ if err := utils.RequireNoArgs(fs.Args(), "relay-server"); err != nil {
86
+ printRootUsage(os.Stderr)
87
+ return err
88
}
89
- awsHostedZoneID := trimmedEnv("AWS_HOSTED_ZONE_ID")
90
-
91
- flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
92
- flag.IntVar(&cfg.APIPort, "api-port", apiPort, "Admin/API server port (env: API_PORT)")
93
- flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "TCP SNI router port number (env: SNI_PORT)")
94
- flag.IntVar(&cfg.UDPPortCount, "udp-port-count", udpPortCount, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled) (env: UDP_PORT_COUNT)")
95
-
96
- flag.StringVar(&cfg.OwnerPrivateKey, "owner-private-key", ownerPrivateKey, "relay owner private key used to derive a discovery address (env: OWNER_PRIVATE_KEY)")
97
- flag.StringVar(&cfg.Bootstraps, "bootstraps", bootstraps, "additional bootstrap relay API URLs used for discovery expansion (env: BOOTSTRAPS)")
98
- flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", adminSecretKey, "admin auth secret (env: ADMIN_SECRET_KEY)")
99
- flag.BoolVar(&cfg.DiscoveryEnabled, "discovery", discoveryEnabled, "serve relay discovery endpoints and poll discovery peers (env: DISCOVERY_ENABLED)")
100
- flag.BoolVar(&cfg.TrustProxyHeaders, "trust-proxy-headers", trustProxyHeaders, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies (env: TRUST_PROXY_HEADERS)")
101
- flag.StringVar(&cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", trustedProxyCIDRs, "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled (env: TRUSTED_PROXY_CIDRS)")
102
-
103
- flag.StringVar(&cfg.KeylessDir, "keyless-dir", keylessDir, "directory path for relay keyless materials (env: KEYLESS_DIR)")
104
- flag.StringVar(&cfg.ACMEDNSProvider, "acme-dns-provider", acmeDNSProvider, "ACME DNS provider for DNS-01 and A-record sync (cloudflare|route53) (env: ACME_DNS_PROVIDER)")
105
- flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (required when acme-dns-provider=cloudflare) (env: CLOUDFLARE_TOKEN)")
106
- flag.StringVar(&cfg.AWSAccessKeyID, "aws-access-key-id", awsAccessKeyID, "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted (env: AWS_ACCESS_KEY_ID)")
107
- flag.StringVar(&cfg.AWSSecretAccessKey, "aws-secret-access-key", awsSecretAccessKey, "AWS secret access key for Route53 static credentials (env: AWS_SECRET_ACCESS_KEY)")
108
- flag.StringVar(&cfg.AWSSessionToken, "aws-session-token", awsSessionToken, "AWS session token for Route53 temporary credentials (env: AWS_SESSION_TOKEN)")
109
- flag.StringVar(&cfg.AWSRegion, "aws-region", awsRegion, "AWS region for Route53 and Route53-backed DNS-01; defaults to us-east-1 when unset (env: AWS_REGION or AWS_DEFAULT_REGION)")
110
- flag.StringVar(&cfg.AWSHostedZoneID, "aws-hosted-zone-id", awsHostedZoneID, "explicit Route53 hosted zone ID override (env: AWS_HOSTED_ZONE_ID)")
111
- flag.Parse()
89
113
- logger.Info().
90
+ log.Info().
91
Str("release_version", types.ReleaseVersion).
92
Str("portal_url", cfg.PortalURL).
93
+ Str("admin_settings_path", cfg.AdminSettingsPath).
94
Bool("discovery_enabled", cfg.DiscoveryEnabled).
95
Bool("udp_enabled", cfg.UDPPortCount > 0).
96
Msg("configured relay server")
97
120
- if err := runServer(cfg); err != nil {
121
- logger.Fatal().Err(err).Msg("execute root command")
122
- }
123
-}
124
-
125
-func runServer(cfg relayServerConfig) error {
126
- logger := log.With().Str("component", "relay-server").Logger()
127
-
128
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
98
+ ctx, stop := utils.SignalContext()
99
defer stop()
100
131
- rootHost := utils.PortalRootHost(cfg.PortalURL)
132
- apiListenAddr := fmt.Sprintf(":%d", cfg.APIPort)
133
- sniListenAddr := fmt.Sprintf(":%d", cfg.SNIPort)
134
- trustedProxyCIDRs, err := utils.ParseCIDRs(cfg.TrustedProxyCIDRs)
135
- if err != nil {
136
- return fmt.Errorf("parse trusted proxy cidrs: %w", err)
137
- }
138
- bootstraps, err := utils.NormalizeRelayURLs(utils.SplitCSV(cfg.Bootstraps))
139
- if err != nil {
140
- return fmt.Errorf("normalize bootstraps: %w", err)
141
- }
142
- if strings.TrimSpace(cfg.OwnerPrivateKey) == "" {
143
- cfg.OwnerPrivateKey, err = loadOwnerPrivateKey(cfg.KeylessDir)
144
- if err != nil {
145
- return fmt.Errorf("load relay owner private key: %w", err)
146
- }
147
- }
148
- previousOwnerPrivateKey := cfg.OwnerPrivateKey
101
server, err := portal.NewServer(portal.ServerConfig{
102
PortalURL: cfg.PortalURL,
103
OwnerPrivateKey: cfg.OwnerPrivateKey,
152
- Bootstraps: bootstraps,
104
+ Bootstraps: []string{cfg.Bootstraps},
105
ACME: acme.Config{
106
KeyDir: cfg.KeylessDir,
107
DNSProvider: cfg.ACMEDNSProvider,
@@ -160,9 +112,9 @@ func runServer(cfg relayServerConfig) error {
112
AWSRegion: cfg.AWSRegion,
113
AWSHostedZoneID: cfg.AWSHostedZoneID,
114
},
163
- APIListenAddr: apiListenAddr,
164
- SNIListenAddr: sniListenAddr,
165
- TrustedProxyCIDRs: trustedProxyCIDRs,
115
+ APIPort: cfg.APIPort,
116
+ SNIPort: cfg.SNIPort,
117
+ TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
118
TrustProxyHeaders: cfg.TrustProxyHeaders,
119
DiscoveryEnabled: cfg.DiscoveryEnabled,
120
UDPPortCount: cfg.UDPPortCount,
@@ -170,16 +122,8 @@ func runServer(cfg relayServerConfig) error {
122
if err != nil {
123
return fmt.Errorf("create relay server: %w", err)
124
}
173
- if identity := server.OwnerIdentity(); identity.PrivateKey != "" {
174
- cfg.OwnerPrivateKey = identity.PrivateKey
175
- }
176
- if cfg.OwnerPrivateKey != previousOwnerPrivateKey {
177
- if err := saveOwnerPrivateKey(cfg.KeylessDir, cfg.OwnerPrivateKey); err != nil {
178
- return fmt.Errorf("persist relay owner private key: %w", err)
179
- }
180
- }
125
182
- frontend, err := NewFrontend(cfg.PortalURL, server, cfg.AdminSecretKey, trustedProxyCIDRs, cfg.TrustProxyHeaders)
126
+ frontend, err := NewFrontend(server, cfg.AdminSecretKey, cfg.AdminSettingsPath)
127
if err != nil {
128
return fmt.Errorf("create frontend: %w", err)
129
}
@@ -188,7 +132,8 @@ func runServer(cfg relayServerConfig) error {
132
return fmt.Errorf("start relay server: %w", err)
133
}
134
191
- logEvent := logger.Info().
135
+ rootHost := server.RootHost()
136
+ logEvent := log.Info().
137
Str("api_addr", utils.HostPortOrLoopback(server.APIAddr())).
138
Str("sni_addr", server.SNIAddr()).
139
Str("root_host", rootHost).
@@ -204,58 +149,39 @@ func runServer(cfg relayServerConfig) error {
149
return server.Wait()
150
}
151
207
-func trimmedEnv(name string) string {
208
- return strings.TrimSpace(os.Getenv(name))
209
-}
210
-
211
-func parsePortNumber(raw string, fallback int) int {
212
- raw = strings.TrimSpace(raw)
213
- if raw == "" {
214
- return fallback
215
- }
216
- var port int
217
- if _, err := fmt.Sscanf(raw, "%d", &port); err != nil || port < 1 || port > 65535 {
218
- return fallback
219
- }
220
- return port
221
-}
222
-
223
-func parseNonNegativeInt(raw string, fallback int) int {
224
- raw = strings.TrimSpace(raw)
225
- if raw == "" {
226
- return fallback
227
- }
228
- var v int
229
- if _, err := fmt.Sscanf(raw, "%d", &v); err != nil || v < 0 {
230
- return fallback
231
- }
232
- return v
233
-}
234
-
235
-func ownerPrivateKeyPath(keylessDir string) string {
236
- return filepath.Join(strings.TrimSpace(keylessDir), "owner_private_key.hex")
237
-}
238
-
239
-func loadOwnerPrivateKey(keylessDir string) (string, error) {
240
- keyPath := ownerPrivateKeyPath(keylessDir)
241
- data, err := os.ReadFile(keyPath)
242
- if err != nil {
243
- if errors.Is(err, os.ErrNotExist) {
244
- return "", nil
152
+func runHelpCommand(args []string) error {
153
+ switch len(args) {
154
+ case 0:
155
+ printRootUsage(os.Stdout)
156
+ return nil
157
+ case 1:
158
+ switch strings.TrimSpace(args[0]) {
159
+ case "", "help", "-h", "--help", "serve":
160
+ printRootUsage(os.Stdout)
161
+ return nil
162
+ default:
163
+ printRootUsage(os.Stderr)
164
+ return fmt.Errorf("unknown help topic %q", strings.TrimSpace(args[0]))
165
}
246
- return "", err
166
+ default:
167
+ printRootUsage(os.Stderr)
168
+ return errors.New("only one help topic is supported")
169
}
248
- return strings.TrimSpace(string(data)), nil
170
}
171
251
-func saveOwnerPrivateKey(keylessDir, privateKey string) error {
252
- privateKey = strings.TrimSpace(privateKey)
253
- if privateKey == "" {
254
- return nil
255
- }
256
- keyPath := ownerPrivateKeyPath(keylessDir)
257
- if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
258
- return err
259
- }
260
- return os.WriteFile(keyPath, []byte(privateKey+"\n"), 0o600)
172
+func printRootUsage(w io.Writer) {
173
+ utils.WriteCommandUsage(w,
174
+ []string{
175
+ "relay-server [flags]",
176
+ "relay-server serve [flags]",
177
+ "relay-server help",
178
+ },
179
+ []string{
180
+ "relay-server",
181
+ "relay-server serve",
182
+ "relay-server --portal-url https://portal.example.com",
183
+ "relay-server --discovery --udp-port-count 100",
184
+ "relay-server help",
185
+ },
186
+ )
187
}
cmd/relay-server/tunnel.go
+12
-72
@@ -3,7 +3,6 @@ package main
3
import (
4
"crypto/sha256"
5
"encoding/hex"
6
- "encoding/json"
6
"fmt"
7
"net/http"
8
"strings"
@@ -11,7 +10,7 @@ import (
10
"github.com/gosuda/portal/v2/types"
11
)
12
14
-const installShellScriptTemplatePrefix = `#!/usr/bin/env sh
13
+const installShellScriptTemplate = `#!/usr/bin/env sh
14
set -eu
15
16
OS="$(uname -s)"
@@ -108,19 +107,6 @@ pick_install_path() {
107
return 1
108
}
109
111
-write_config() {
112
- CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
113
- CONFIG_DIR="$CONFIG_HOME/portal"
114
- CONFIG_PATH="$CONFIG_DIR/config.json"
115
- mkdir -p "$CONFIG_DIR"
116
- cat > "$CONFIG_PATH" <<'EOF'
117
-`
118
-
119
-const installShellScriptTemplateSuffix = `
120
-EOF
121
- printf '%%s\n' "$CONFIG_PATH"
122
-}
123
-
110
INSTALL_PATH="$(pick_install_path)" || {
111
echo "No writable install directory found. Ensure an existing portal install is writable or create \$HOME/.local/bin or \$HOME/bin." >&2
112
exit 1
@@ -128,10 +114,8 @@ INSTALL_PATH="$(pick_install_path)" || {
114
115
cp "$BIN_PATH" "$INSTALL_PATH"
116
chmod +x "$INSTALL_PATH"
131
-CONFIG_PATH="$(write_config)"
117
118
echo "Installed portal to $INSTALL_PATH" >&2
134
-echo "Saved default relay config to $CONFIG_PATH" >&2
119
120
INSTALL_DIR="$(dirname "$INSTALL_PATH")"
121
case ":$PATH:" in
@@ -142,10 +126,10 @@ case ":$PATH:" in
126
esac
127
128
echo "Next step:" >&2
145
-echo " portal expose 3000" >&2
129
+echo " portal expose --relays $BASE_URL 3000" >&2
130
`
131
148
-const installPowerShellTemplatePrefix = `$ErrorActionPreference = "Stop"
132
+const installPowerShellTemplate = `$ErrorActionPreference = "Stop"
133
$BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { %s }
134
$OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
135
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
@@ -186,23 +170,6 @@ try {
170
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
171
$InstallPath = Join-Path $InstallDir "portal.exe"
172
Copy-Item -Force $BinPath $InstallPath
189
-
190
- $ConfigRoot = [Environment]::GetFolderPath("ApplicationData")
191
- if ([string]::IsNullOrWhiteSpace($ConfigRoot)) {
192
- throw "Failed to resolve ApplicationData directory."
193
- }
194
-
195
- $ConfigDir = Join-Path $ConfigRoot "portal"
196
- New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
197
- $ConfigPath = Join-Path $ConfigDir "config.json"
198
- $ConfigPayload = @'
199
-`
200
-
201
-const installPowerShellTemplateSuffix = `
202
-'@
203
- $Utf8NoBom = New-Object System.Text.UTF8Encoding $false
204
- [System.IO.File]::WriteAllText($ConfigPath, $ConfigPayload, $Utf8NoBom)
205
-
173
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
174
$UserEntries = @()
175
if (-not [string]::IsNullOrWhiteSpace($UserPath)) {
@@ -223,9 +190,8 @@ const installPowerShellTemplateSuffix = `
190
}
191
192
Write-Host "Installed portal to $InstallPath"
226
- Write-Host "Saved default relay config to $ConfigPath"
193
Write-Host "Next step:"
228
- Write-Host " portal expose 3000"
194
+ Write-Host " portal expose --relays $BaseUrl 3000"
195
} finally {
196
[System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol
197
if ($WorkDir -and (Test-Path $WorkDir)) {
@@ -289,22 +255,10 @@ func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string
255
return
256
}
257
292
- type installerConfig struct {
293
- Relays []string `json:"relays"`
294
- }
295
- configPayload, err := json.Marshal(installerConfig{
296
- Relays: []string{strings.TrimSpace(portalURL)},
297
- })
298
- if err != nil {
299
- http.Error(w, "failed to build installer config", http.StatusInternalServerError)
300
- return
301
- }
302
-
303
- script := buildInstallShellScript(portalURL, configPayload)
258
+ script := buildInstallScript(portalURL, isWindows)
259
contentType := "text/x-shellscript"
260
filename := "install.sh"
261
if isWindows {
307
- script = buildInstallPowerShellScript(portalURL, configPayload)
262
contentType = "text/plain; charset=utf-8"
263
filename = "install.ps1"
264
}
@@ -316,26 +270,12 @@ func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string
270
}
271
}
272
319
-func buildInstallShellScript(portalURL string, configPayload []byte) string {
320
- var script strings.Builder
321
- fmt.Fprintf(&script, installShellScriptTemplatePrefix, shellSingleQuoted(portalURL))
322
- script.Write(configPayload)
323
- script.WriteString(installShellScriptTemplateSuffix)
324
- return script.String()
325
-}
326
-
327
-func buildInstallPowerShellScript(portalURL string, configPayload []byte) string {
328
- var script strings.Builder
329
- fmt.Fprintf(&script, installPowerShellTemplatePrefix, powerShellSingleQuoted(portalURL))
330
- script.Write(configPayload)
331
- script.WriteString(installPowerShellTemplateSuffix)
332
- return script.String()
333
-}
334
-
335
-func shellSingleQuoted(value string) string {
336
- return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
337
-}
273
+func buildInstallScript(portalURL string, isWindows bool) string {
274
+ if !isWindows {
275
+ quotedPortalURL := "'" + strings.ReplaceAll(portalURL, "'", `'"'"'`) + "'"
276
+ return fmt.Sprintf(installShellScriptTemplate, quotedPortalURL)
277
+ }
278
339
-func powerShellSingleQuoted(value string) string {
340
- return "'" + strings.ReplaceAll(value, "'", "''") + "'"
279
+ quotedPortalURL := "'" + strings.ReplaceAll(portalURL, "'", "''") + "'"
280
+ return fmt.Sprintf(installPowerShellTemplate, quotedPortalURL)
281
}
portal/api_server.go
+17
-14
@@ -172,7 +172,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
172
return
173
}
174
175
- clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.cfg.TrustedProxyCIDRs)
175
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.trustedProxyCIDRs)
176
if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
177
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
178
return
@@ -218,7 +218,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
218
return
219
}
220
221
- clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.cfg.TrustedProxyCIDRs)
221
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.trustedProxyCIDRs)
222
if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
223
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
224
return
@@ -288,7 +288,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
288
289
leaseID := strings.TrimSpace(r.URL.Query().Get("lease_id"))
290
token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
291
- clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.cfg.TrustedProxyCIDRs)
291
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.trustedProxyCIDRs)
292
if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
293
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
294
return
@@ -347,7 +347,6 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
347
if err := stream.OfferConn(conn); err != nil {
348
log.Warn().
349
Err(err).
350
- Str("component", "relay-server").
350
Str("lease_id", lease.ID).
351
Str("lease_name", lease.Name).
352
Str("remote_addr", remoteAddr).
@@ -357,7 +356,6 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
356
357
s.registry.Touch(lease.ID, clientIP, time.Now())
358
log.Info().
360
- Str("component", "relay-server").
359
Str("lease_id", lease.ID).
360
Str("lease_name", lease.Name).
361
Str("remote_addr", remoteAddr).
@@ -451,7 +449,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
449
if req.TTL > 0 {
450
ttl = time.Duration(req.TTL) * time.Second
451
}
454
- bootstraps, err := utils.NormalizeRelayURLs(req.Bootstraps)
452
+ bootstraps, err := utils.NormalizeRelayURLs(req.Bootstraps...)
453
if err != nil {
454
return types.RegisterResponse{}, fmt.Errorf("normalize bootstraps: %w", err)
455
}
@@ -525,16 +523,21 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
523
}
524
}
525
528
- responseBootstraps := append([]string(nil), s.cfg.Bootstraps...)
526
+ responseBootstraps, err := utils.NormalizeRelayURLs(s.cfg.PortalURL)
527
+ if err != nil {
528
+ record.Close()
529
+ _, _ = s.registry.Unregister(record.ID, record.ReverseToken)
530
+ return types.RegisterResponse{}, err
531
+ }
532
if s.DiscoveryEnabled() {
530
- responseBootstraps = s.discoveryBootstrapsSnapshot()
533
+ responseBootstraps, err = utils.NormalizeRelayURLs(append(responseBootstraps, s.discoveryBootstrapsSnapshot()...)...)
534
} else {
532
- responseBootstraps, err = utils.NormalizeRelayURLs(append(responseBootstraps, record.Bootstraps...))
533
- if err != nil {
534
- record.Close()
535
- _, _ = s.registry.Unregister(record.ID, record.ReverseToken)
536
- return types.RegisterResponse{}, err
537
- }
535
+ responseBootstraps, err = utils.NormalizeRelayURLs(append(responseBootstraps, append(s.cfg.Bootstraps, record.Bootstraps...)...)...)
536
+ }
537
+ if err != nil {
538
+ record.Close()
539
+ _, _ = s.registry.Unregister(record.ID, record.ReverseToken)
540
+ return types.RegisterResponse{}, err
541
}
542
543
resp := types.RegisterResponse{
portal/discovery/discovery.go
+3
-7
@@ -20,11 +20,7 @@ type Resolver func(context.Context, types.DiscoverRequest) (types.DiscoverRespon
20
const defaultRequestTimeout = 15 * time.Second
21
22
func DiscoverBootstraps(ctx context.Context, peers []string, req types.DiscoverRequest, rootCAPEM []byte) ([]string, error) {
23
- if ctx == nil {
24
- ctx = context.Background()
25
- }
26
-
27
- peers, err := utils.NormalizeRelayURLs(peers)
23
+ peers, err := utils.NormalizeRelayURLs(peers...)
24
if err != nil {
25
return nil, err
26
}
@@ -197,11 +193,11 @@ func buildResponseBootstraps(selfURLs, bootstraps, extra []string) ([]string, er
193
return merged, nil
194
}
195
200
- normalizedSelf, err := utils.NormalizeRelayURLs(selfURLs)
196
+ normalizedSelf, err := utils.NormalizeRelayURLs(selfURLs...)
197
if err != nil {
198
return nil, fmt.Errorf("normalize self urls: %w", err)
199
}
204
- resolvedBootstraps, err := utils.NormalizeRelayURLs(append(normalizedSelf, merged...))
200
+ resolvedBootstraps, err := utils.NormalizeRelayURLs(append(normalizedSelf, merged...)...)
201
if err != nil {
202
return nil, fmt.Errorf("normalize bootstraps: %w", err)
203
}
portal/server.go
+49
-33
@@ -42,10 +42,12 @@ type ServerConfig struct {
42
OwnerPrivateKey string
43
Bootstraps []string
44
ACME acme.Config
45
+ APIPort int
46
+ SNIPort int
47
APIListenAddr string
48
SNIListenAddr string
49
QUICListenAddr string
48
- TrustedProxyCIDRs []*net.IPNet
50
+ TrustedProxyCIDRs string
51
LeaseTTL time.Duration
52
ClaimTimeout time.Duration
53
IdleKeepaliveInterval time.Duration
@@ -70,18 +72,18 @@ type Server struct {
72
ownerIdentity discovery.Identity
73
cfg ServerConfig
74
rootHost string
75
+ trustedProxyCIDRs []*net.IPNet
76
discoveryMu sync.RWMutex
77
discoveryBootstraps []string
78
shutdownOnce sync.Once
79
}
80
81
func NewServer(cfg ServerConfig) (*Server, error) {
79
- if cfg.APIListenAddr == "" {
80
- cfg.APIListenAddr = ":4017"
81
- }
82
- if cfg.SNIListenAddr == "" {
83
- cfg.SNIListenAddr = ":443"
84
- }
82
+ cfg.PortalURL = strings.TrimSuffix(strings.TrimSpace(cfg.PortalURL), "/")
83
+ cfg.APIPort = utils.IntOrDefault(cfg.APIPort, 4017)
84
+ cfg.SNIPort = utils.IntOrDefault(cfg.SNIPort, 443)
85
+ cfg.APIListenAddr = utils.StringOrDefault(cfg.APIListenAddr, fmt.Sprintf(":%d", cfg.APIPort))
86
+ cfg.SNIListenAddr = utils.StringOrDefault(cfg.SNIListenAddr, fmt.Sprintf(":%d", cfg.SNIPort))
87
cfg.LeaseTTL = utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
88
cfg.ClaimTimeout = utils.DurationOrDefault(cfg.ClaimTimeout, defaultClaimTimeout)
89
cfg.IdleKeepaliveInterval = utils.DurationOrDefault(cfg.IdleKeepaliveInterval, defaultIdleKeepalive)
@@ -91,16 +93,16 @@ func NewServer(cfg ServerConfig) (*Server, error) {
93
if rootHost == "" {
94
return nil, errors.New("root host is required")
95
}
94
- if cfg.QUICListenAddr == "" {
95
- cfg.QUICListenAddr = cfg.SNIListenAddr
96
+ cfg.QUICListenAddr = utils.StringOrDefault(cfg.QUICListenAddr, cfg.SNIListenAddr)
97
+ trustedProxyCIDRs, err := utils.ParseCIDRs(cfg.TrustedProxyCIDRs)
98
+ if err != nil {
99
+ return nil, fmt.Errorf("parse trusted proxy cidrs: %w", err)
100
}
97
- if len(cfg.Bootstraps) > 0 {
98
- bootstraps, err := utils.NormalizeRelayURLs(cfg.Bootstraps)
99
- if err != nil {
100
- return nil, err
101
- }
102
- cfg.Bootstraps = bootstraps
101
+ bootstraps, err := utils.NormalizeRelayURLs(cfg.Bootstraps...)
102
+ if err != nil {
103
+ return nil, fmt.Errorf("normalize bootstraps: %w", err)
104
}
105
+ cfg.Bootstraps = bootstraps
106
107
portMin, portMax := 0, 0
108
if cfg.UDPPortCount > 0 {
@@ -108,25 +110,34 @@ func NewServer(cfg ServerConfig) (*Server, error) {
110
portMax = defaultUDPPortBase + cfg.UDPPortCount - 1
111
}
112
111
- ownerIdentity := discovery.Identity{}
112
- if cfg.DiscoveryEnabled || strings.TrimSpace(cfg.OwnerPrivateKey) != "" {
113
- identity, err := discovery.ResolveIdentity(cfg.OwnerPrivateKey)
114
- if err != nil {
115
- return nil, fmt.Errorf("resolve owner identity: %w", err)
113
+ ownerPrivateKey := strings.TrimSpace(cfg.OwnerPrivateKey)
114
+ ownerIdentity, err := discovery.ResolveIdentity(ownerPrivateKey)
115
+ if err != nil {
116
+ if ownerPrivateKey == "" {
117
+ return nil, fmt.Errorf("generate relay owner private key: %w", err)
118
}
117
- ownerIdentity = identity
118
- cfg.OwnerPrivateKey = identity.PrivateKey
119
+ return nil, fmt.Errorf("resolve owner identity: %w", err)
120
+ }
121
+ if ownerPrivateKey == "" {
122
+ log.Warn().
123
+ Str("owner_address", ownerIdentity.Address).
124
+ Str("owner_private_key", ownerIdentity.PrivateKey).
125
+ Msg("generated relay owner private key; set OWNER_PRIVATE_KEY unique identity")
126
}
127
+ cfg.OwnerPrivateKey = ""
128
121
- registry := newLeaseRegistry(policy.NewRuntime())
129
+ runtime := policy.NewRuntime()
130
+ runtime.SetUDPPolicy(cfg.UDPPortCount > 0, 0)
131
+ registry := newLeaseRegistry(runtime)
132
ports := transport.NewPortAllocator(portMin, portMax, 5*time.Minute)
133
134
s := &Server{
125
- cfg: cfg,
126
- rootHost: rootHost,
127
- registry: registry,
128
- ports: ports,
129
- ownerIdentity: ownerIdentity,
135
+ cfg: cfg,
136
+ rootHost: rootHost,
137
+ registry: registry,
138
+ ports: ports,
139
+ ownerIdentity: ownerIdentity,
140
+ trustedProxyCIDRs: trustedProxyCIDRs,
141
}
142
143
// Tear down all lease resources when leases expire via TTL janitor.
@@ -280,11 +291,18 @@ func (s *Server) DiscoveryEnabled() bool {
291
return s != nil && s.cfg.DiscoveryEnabled
292
}
293
283
-func (s *Server) OwnerIdentity() discovery.Identity {
294
+func (s *Server) PortalURL() string {
295
+ if s == nil {
296
+ return ""
297
+ }
298
+ return s.cfg.PortalURL
299
+}
300
+
301
+func (s *Server) RootHost() string {
302
if s == nil {
285
- return discovery.Identity{}
303
+ return ""
304
}
287
- return s.ownerIdentity
305
+ return s.rootHost
306
}
307
308
func (s *Server) LeaseSnapshots() []types.Lease {
@@ -501,7 +519,6 @@ func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error
519
s.group.Go(func() error { return s.runQUICTunnelListener(listener) })
520
521
log.Info().
504
- Str("component", "relay-server").
522
Str("internal_quic_tunnel_addr", listener.Addr().String()).
523
Msg("internal quic tunnel listener started")
524
return nil
@@ -592,7 +609,6 @@ func (s *Server) runDiscoveryLoop(ctx context.Context) error {
609
Msg("merge discovered bootstraps failed")
610
} else if len(added) > 0 {
611
log.Info().
595
- Str("component", "relay-server").
612
Int("peer_count", len(peers)).
613
Int("added_count", len(added)).
614
Int("total_bootstrap_count", len(s.discoveryBootstrapsSnapshot())).
portal/transport/stream_relay.go
-1
@@ -133,7 +133,6 @@ func (b *RelayStream) watchSession(session *relaySession) {
133
}
134
readyCount = len(b.ready)
135
log.Info().
136
- Str("component", "relay-server").
136
Str("lease_id", b.leaseID).
137
Str("remote_addr", session.remoteAddrString()).
138
Int("ready", readyCount).
sdk/api_client.go
+1
-9
@@ -17,7 +17,6 @@ import (
17
18
"github.com/quic-go/quic-go"
19
20
- "github.com/gosuda/portal/v2/portal/discovery"
20
"github.com/gosuda/portal/v2/portal/keyless"
21
"github.com/gosuda/portal/v2/types"
22
"github.com/gosuda/portal/v2/utils"
@@ -72,13 +71,6 @@ func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
71
72
dialTimeout := utils.DurationOrDefault(cfg.DialTimeout, defaultDialTimeout)
73
requestTimeout := utils.DurationOrDefault(cfg.RequestTimeout, defaultRequestTimeout)
75
- ownerAddress := strings.TrimSpace(cfg.OwnerAddress)
76
- if ownerAddress != "" {
77
- ownerAddress, err = discovery.NormalizeEVMAddress(ownerAddress)
78
- if err != nil {
79
- return nil, fmt.Errorf("normalize owner address: %w", err)
80
- }
81
- }
74
75
return &apiClient{
76
baseURL: baseURL,
@@ -88,7 +80,7 @@ func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
80
name: name,
81
reverseToken: reverseToken,
82
metadata: cfg.Metadata.Copy(),
91
- ownerAddress: ownerAddress,
83
+ ownerAddress: cfg.ownerAddress,
84
}, nil
85
}
86
sdk/expose.go
+152
-246
@@ -26,9 +26,10 @@ type Exposure struct {
26
done <-chan struct{}
27
28
name string
29
+ TargetAddr string
30
+ UDPAddr string
31
reverseToken string
32
udpEnabled bool
31
- identity discovery.Identity
33
metadata types.LeaseMetadata
34
ownerAddress string
35
rootCAPEM []byte
@@ -51,38 +52,38 @@ type ExposeConfig struct {
52
RelayURLs []string
53
DefaultRelayEnabled bool
54
Name string
55
+ TargetAddr string
56
+ UDPAddr string
57
ReverseToken string
58
UDPEnabled bool
59
Discovery bool
60
Metadata types.LeaseMetadata
58
- OwnerAddress string
59
- OwnerPrivateKey *string
61
+ OwnerPrivateKey string
62
RootCAPEM []byte
63
}
64
65
// Expose creates relay listeners for each normalized relay URL and exposes a
66
// dynamic listener hub for accepting traffic from all of them.
67
func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
66
- if ctx == nil {
67
- ctx = context.Background()
68
- }
69
-
68
relayURLs, err := ResolveRelayURLs(ctx, cfg.RelayURLs, cfg.DefaultRelayEnabled)
69
if err != nil {
70
return nil, err
71
}
74
- if len(relayURLs) == 0 {
75
- return nil, nil
76
- }
72
78
- ownerAddress := strings.TrimSpace(cfg.OwnerAddress)
79
- identity := discovery.Identity{}
80
- if cfg.OwnerPrivateKey != nil {
81
- identity, err = discovery.ResolveIdentity(*cfg.OwnerPrivateKey)
73
+ identity, err := discovery.ResolveIdentity(cfg.OwnerPrivateKey)
74
+ if err != nil {
75
+ return nil, fmt.Errorf("resolve owner identity: %w", err)
76
+ }
77
+ targetAddr, err := utils.NormalizeLoopbackTarget(cfg.TargetAddr)
78
+ if err != nil {
79
+ return nil, fmt.Errorf("invalid target value %q: %w", cfg.TargetAddr, err)
80
+ }
81
+ udpAddr := cfg.UDPAddr
82
+ if cfg.UDPEnabled {
83
+ udpAddr, err = utils.NormalizeLoopbackTarget(utils.StringOrDefault(udpAddr, targetAddr))
84
if err != nil {
83
- return nil, fmt.Errorf("resolve owner identity: %w", err)
85
+ return nil, fmt.Errorf("invalid --udp-addr value %q: %w", cfg.UDPAddr, err)
86
}
85
- ownerAddress = identity.Address
87
}
88
89
exposureCtx, cancel := context.WithCancel(ctx)
@@ -90,11 +91,12 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
91
cancel: cancel,
92
done: exposureCtx.Done(),
93
name: cfg.Name,
94
+ TargetAddr: targetAddr,
95
+ UDPAddr: udpAddr,
96
reverseToken: cfg.ReverseToken,
97
udpEnabled: cfg.UDPEnabled,
95
- identity: identity,
98
metadata: cfg.Metadata.Copy(),
97
- ownerAddress: ownerAddress,
99
+ ownerAddress: identity.Address,
100
rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
101
discoveryEnabled: cfg.Discovery,
102
accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
@@ -103,12 +105,16 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
105
starting: make(map[string]struct{}, len(relayURLs)),
106
}
107
106
- if _, err := exposure.applyRelayURLs(relayURLs, true); err != nil {
107
- _ = exposure.Close()
108
- return nil, err
108
+ if len(relayURLs) > 0 {
109
+ if _, err := exposure.applyRelayURLs(relayURLs, true); err != nil {
110
+ _ = exposure.Close()
111
+ return nil, err
112
+ }
113
}
114
111
- go exposure.monitorStartupCounts()
115
+ if len(relayURLs) > 0 {
116
+ go exposure.monitorStartupCounts()
117
+ }
118
if exposure.discoveryEnabled {
119
go exposure.runDiscoveryLoop(exposureCtx)
120
}
@@ -117,11 +123,13 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
123
_ = exposure.Close()
124
}()
125
120
- log.Info().
121
- Str("release_version", types.ReleaseVersion).
122
- Int("relay_count", len(exposure.ActiveRelayURLs())).
123
- Strs("relays", exposure.ActiveRelayURLs()).
124
- Msg("exposure relay started")
126
+ if len(relayURLs) > 0 {
127
+ log.Info().
128
+ Str("release_version", types.ReleaseVersion).
129
+ Int("relay_count", len(exposure.ActiveRelayURLs())).
130
+ Strs("relays", exposure.ActiveRelayURLs()).
131
+ Msg("exposure relay started")
132
+ }
133
134
return exposure, nil
135
}
@@ -129,7 +137,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
137
const defaultDiscoveryInterval = 30 * time.Second
138
139
func ResolveRelayURLs(ctx context.Context, explicit []string, includeDefaults bool) ([]string, error) {
132
- explicit, err := utils.NormalizeRelayURLs(explicit)
140
+ explicit, err := utils.NormalizeRelayURLs(explicit...)
141
if err != nil {
142
return nil, err
143
}
@@ -160,7 +168,7 @@ func ResolveRelayURLs(ctx context.Context, explicit []string, includeDefaults bo
168
return explicit, nil
169
}
170
163
- defaults, err := utils.NormalizeRelayURLs(registry.Relays)
171
+ defaults, err := utils.NormalizeRelayURLs(registry.Relays...)
172
if err != nil {
173
return explicit, nil
174
}
@@ -170,15 +178,7 @@ func ResolveRelayURLs(ctx context.Context, explicit []string, includeDefaults bo
178
return utils.MergeRelayURLs(defaults, nil, explicit)
179
}
180
173
-func (e *Exposure) RelayURLs() []string {
174
- return e.ActiveRelayURLs()
175
-}
176
-
181
func (e *Exposure) KnownRelayURLs() []string {
178
- if e == nil {
179
- return nil
180
- }
181
-
182
e.mu.RLock()
183
defer e.mu.RUnlock()
184
@@ -190,10 +190,6 @@ func (e *Exposure) KnownRelayURLs() []string {
190
}
191
192
func (e *Exposure) ActiveRelayURLs() []string {
193
- if e == nil {
194
- return nil
195
- }
196
-
193
e.mu.RLock()
194
defer e.mu.RUnlock()
195
@@ -204,18 +200,7 @@ func (e *Exposure) ActiveRelayURLs() []string {
200
return append([]string(nil), e.activeRelayURLs...)
201
}
202
207
-func (e *Exposure) OwnerIdentity() discovery.Identity {
208
- if e == nil {
209
- return discovery.Identity{}
210
- }
211
- return e.identity
212
-}
213
-
203
func (e *Exposure) Accept() (net.Conn, error) {
215
- if e == nil {
216
- return nil, net.ErrClosed
217
- }
218
-
204
select {
205
case <-e.done:
206
return nil, net.ErrClosed
@@ -274,7 +259,7 @@ func (e *Exposure) PublicURLs() []string {
259
260
func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr string) error {
261
var relayListener net.Listener
277
- if e != nil {
262
+ if len(e.ActiveRelayURLs()) > 0 {
263
relayListener = e
264
}
265
return RunHTTP(ctx, relayListener, handler, localAddr)
@@ -382,10 +367,6 @@ func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handl
367
}
368
369
func (e *Exposure) Close() error {
385
- if e == nil {
386
- return nil
387
- }
388
-
370
var closeErr error
371
e.closeOnce.Do(func() {
372
if e.cancel != nil {
@@ -414,7 +395,7 @@ func (e *Exposure) Close() error {
395
}
396
397
func (e *Exposure) applyRelayURLs(relayURLs []string, failOnError bool) ([]string, error) {
417
- if e == nil || len(relayURLs) == 0 {
398
+ if len(relayURLs) == 0 {
399
return nil, nil
400
}
401
@@ -448,11 +429,20 @@ func (e *Exposure) applyRelayURLs(relayURLs []string, failOnError bool) ([]strin
429
}
430
431
func (e *Exposure) syncListeners(failOnError bool) error {
451
- if e == nil {
452
- return nil
432
+ e.mu.Lock()
433
+ missing := make([]string, 0)
434
+ for _, relayURL := range e.activeRelayURLs {
435
+ if _, ok := e.listeners[relayURL]; ok {
436
+ continue
437
+ }
438
+ if _, ok := e.starting[relayURL]; ok {
439
+ continue
440
+ }
441
+ e.starting[relayURL] = struct{}{}
442
+ missing = append(missing, relayURL)
443
}
444
+ e.mu.Unlock()
445
455
- missing := e.reserveMissingRelayURLs()
446
for _, relayURL := range missing {
447
listener, err := e.newListener(relayURL)
448
if err != nil {
@@ -470,24 +460,6 @@ func (e *Exposure) syncListeners(failOnError bool) error {
460
return nil
461
}
462
473
-func (e *Exposure) reserveMissingRelayURLs() []string {
474
- e.mu.Lock()
475
- defer e.mu.Unlock()
476
-
477
- missing := make([]string, 0)
478
- for _, relayURL := range e.activeRelayURLs {
479
- if _, ok := e.listeners[relayURL]; ok {
480
- continue
481
- }
482
- if _, ok := e.starting[relayURL]; ok {
483
- continue
484
- }
485
- e.starting[relayURL] = struct{}{}
486
- missing = append(missing, relayURL)
487
- }
488
- return missing
489
-}
490
-
463
func (e *Exposure) newListener(relayURL string) (*Listener, error) {
464
bootstraps := []string(nil)
465
if e.discoveryEnabled {
@@ -498,16 +470,16 @@ func (e *Exposure) newListener(relayURL string) (*Listener, error) {
470
Name: e.name,
471
ReverseToken: e.reverseToken,
472
UDPEnabled: e.udpEnabled,
501
- OwnerAddress: e.ownerAddress,
473
RegisterBootstraps: bootstraps,
474
Metadata: e.metadata.Copy(),
475
RootCAPEM: append([]byte(nil), e.rootCAPEM...),
476
+ ownerAddress: e.ownerAddress,
477
}
478
return NewListener(context.Background(), relayURL, cfg)
479
}
480
481
func (e *Exposure) installListener(relayURL string, listener *Listener) {
510
- if e == nil || listener == nil {
482
+ if listener == nil {
483
return
484
}
485
@@ -531,15 +503,73 @@ func (e *Exposure) installListener(relayURL string, listener *Listener) {
503
log.Info().Str("relay_url", relayURL).Msg("relay added to exposure")
504
go e.runListenerAcceptLoop(listener)
505
if e.udpEnabled {
534
- go e.attachDatagramPlane(context.Background(), listener)
506
+ go func() {
507
+ relayURL := listener.api.baseURL.String()
508
+ if err := listener.WaitRegistered(context.Background()); err != nil {
509
+ switch {
510
+ case e.closed():
511
+ return
512
+ case errors.Is(err, net.ErrClosed), errors.Is(err, context.Canceled):
513
+ return
514
+ default:
515
+ log.Warn().
516
+ Err(err).
517
+ Str("relay_url", relayURL).
518
+ Msg("attach datagram plane failed")
519
+ return
520
+ }
521
+ }
522
+ if listener.UDPAddr() == "" {
523
+ if !e.closed() && !listener.closed() {
524
+ log.Warn().
525
+ Str("relay_url", relayURL).
526
+ Msg("attach datagram plane failed")
527
+ }
528
+ return
529
+ }
530
+
531
+ ticker := time.NewTicker(50 * time.Millisecond)
532
+ defer ticker.Stop()
533
+ for listener.datagram == nil || !listener.datagram.Connected() {
534
+ select {
535
+ case <-e.done:
536
+ return
537
+ case <-listener.doneCh:
538
+ return
539
+ case <-ticker.C:
540
+ }
541
+ }
542
+
543
+ for {
544
+ frame, err := listener.datagram.Accept(listener.doneCh)
545
+ if err != nil {
546
+ if e.closed() || errors.Is(err, net.ErrClosed) {
547
+ return
548
+ }
549
+ log.Warn().
550
+ Err(err).
551
+ Str("relay_url", relayURL).
552
+ Str("lease_id", listener.LeaseID()).
553
+ Msg("datagram accept failed")
554
+ return
555
+ }
556
+
557
+ frame.Payload = append([]byte(nil), frame.Payload...)
558
+ frame.LeaseID = listener.LeaseID()
559
+ frame.RelayURL = relayURL
560
+ frame.UDPAddr = listener.UDPAddr()
561
+
562
+ select {
563
+ case <-e.done:
564
+ return
565
+ case e.datagrams <- frame:
566
+ }
567
+ }
568
+ }()
569
}
570
}
571
572
func (e *Exposure) listenersOrdered() []*Listener {
539
- if e == nil {
540
- return nil
541
- }
542
-
573
e.mu.RLock()
574
defer e.mu.RUnlock()
575
@@ -553,7 +583,7 @@ func (e *Exposure) listenersOrdered() []*Listener {
583
}
584
585
func (e *Exposure) runListenerAcceptLoop(listener *Listener) {
556
- if e == nil || listener == nil {
586
+ if listener == nil {
587
return
588
}
589
@@ -617,16 +647,8 @@ func (c *exposureConn) Close() error {
647
return closeErr
648
}
649
620
-func (e *Exposure) SupportsDatagram() bool {
621
- return e != nil && e.udpEnabled
622
-}
623
-
624
-func (e *Exposure) UDPEnabled() bool {
625
- return e != nil && e.udpEnabled
626
-}
627
-
650
func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
629
- if e == nil || !e.SupportsDatagram() {
651
+ if !e.udpEnabled {
652
return types.DatagramFrame{}, net.ErrClosed
653
}
654
@@ -639,7 +661,7 @@ func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
661
}
662
663
func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
642
- if e == nil || !e.SupportsDatagram() {
664
+ if !e.udpEnabled {
665
return net.ErrClosed
666
}
667
@@ -651,45 +673,17 @@ func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
673
e.mu.RLock()
674
listener := e.listeners[relayURL]
675
e.mu.RUnlock()
654
- if listener == nil {
676
+ if listener == nil || listener.datagram == nil {
677
return net.ErrClosed
678
}
679
if leaseID := strings.TrimSpace(frame.LeaseID); leaseID != "" && leaseID != listener.LeaseID() {
680
return errors.New("datagram frame targets stale lease")
681
}
660
- return listener.SendDatagram(frame.FlowID, frame.Payload)
661
-}
662
-
663
-func (e *Exposure) UDPAddrs() []string {
664
- listeners := e.listenersOrdered()
665
- if len(listeners) == 0 || !e.SupportsDatagram() {
666
- return nil
667
- }
668
-
669
- out := make([]string, 0, len(listeners))
670
- seen := make(map[string]struct{})
671
- for _, listener := range listeners {
672
- if listener == nil {
673
- continue
674
- }
675
- udpAddr := listener.UDPAddr()
676
- if udpAddr == "" {
677
- continue
678
- }
679
- if _, ok := seen[udpAddr]; ok {
680
- continue
681
- }
682
- seen[udpAddr] = struct{}{}
683
- out = append(out, udpAddr)
684
- }
685
- if len(out) == 0 {
686
- return nil
687
- }
688
- return out
682
+ return listener.datagram.Send(frame.FlowID, frame.Payload)
683
}
684
685
func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
692
- if e == nil || !e.SupportsDatagram() {
686
+ if !e.udpEnabled {
687
return nil, errors.New("exposure does not have udp enabled")
688
}
689
@@ -697,10 +691,38 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
691
defer ticker.Stop()
692
693
for {
700
- if addrs := e.readyUDPAddrs(); len(addrs) > 0 {
694
+ listeners := e.listenersOrdered()
695
+ addrs := make([]string, 0, len(listeners))
696
+ seen := make(map[string]struct{})
697
+ resolvedWithoutDatagram := true
698
+ for _, listener := range listeners {
699
+ if listener == nil {
700
+ continue
701
+ }
702
+
703
+ udpAddr := listener.UDPAddr()
704
+ if listener.datagram != nil && listener.datagram.Connected() && udpAddr != "" {
705
+ if _, ok := seen[udpAddr]; !ok {
706
+ seen[udpAddr] = struct{}{}
707
+ addrs = append(addrs, udpAddr)
708
+ }
709
+ }
710
+
711
+ select {
712
+ case <-listener.registered:
713
+ if udpAddr != "" {
714
+ resolvedWithoutDatagram = false
715
+ }
716
+ default:
717
+ if !listener.closed() {
718
+ resolvedWithoutDatagram = false
719
+ }
720
+ }
721
+ }
722
+ if len(addrs) > 0 {
723
return addrs, nil
724
}
703
- if e.allDatagramNegotiationsResolvedWithoutDatagram() {
725
+ if resolvedWithoutDatagram {
726
return nil, errors.New("relay did not expose udp")
727
}
728
@@ -714,119 +736,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
736
}
737
}
738
717
-func (e *Exposure) readyUDPAddrs() []string {
718
- listeners := e.listenersOrdered()
719
- if len(listeners) == 0 || !e.SupportsDatagram() {
720
- return nil
721
- }
722
-
723
- out := make([]string, 0, len(listeners))
724
- seen := make(map[string]struct{})
725
- for _, listener := range listeners {
726
- if listener == nil || !listener.datagramConnected() {
727
- continue
728
- }
729
-
730
- udpAddr := listener.UDPAddr()
731
- if udpAddr == "" {
732
- continue
733
- }
734
- if _, ok := seen[udpAddr]; ok {
735
- continue
736
- }
737
- seen[udpAddr] = struct{}{}
738
- out = append(out, udpAddr)
739
- }
740
- if len(out) == 0 {
741
- return nil
742
- }
743
- return out
744
-}
745
-
746
-func (e *Exposure) allDatagramNegotiationsResolvedWithoutDatagram() bool {
747
- listeners := e.listenersOrdered()
748
- if len(listeners) == 0 {
749
- return true
750
- }
751
-
752
- resolved := 0
753
- for _, listener := range listeners {
754
- if listener == nil {
755
- resolved++
756
- continue
757
- }
758
-
759
- registered, enabled := listener.datagramNegotiationState()
760
- if !registered {
761
- if listener.closed() {
762
- resolved++
763
- }
764
- continue
765
- }
766
- if enabled {
767
- return false
768
- }
769
- resolved++
770
- }
771
-
772
- return resolved == len(listeners)
773
-}
774
-
775
-func (e *Exposure) attachDatagramPlane(ctx context.Context, listener *Listener) {
776
- relayURL := listener.api.baseURL.String()
777
- err := listener.WaitDatagramReady(ctx)
778
- if err != nil {
779
- switch {
780
- case e.closed():
781
- return
782
- case ctx != nil && ctx.Err() != nil:
783
- return
784
- case errors.Is(err, net.ErrClosed), errors.Is(err, context.Canceled):
785
- return
786
- default:
787
- log.Warn().
788
- Err(err).
789
- Str("relay_url", relayURL).
790
- Msg("attach datagram plane failed")
791
- return
792
- }
793
- }
794
-
795
- e.forwardDatagrams(relayURL, listener)
796
-}
797
-
798
-func (e *Exposure) forwardDatagrams(relayURL string, listener *Listener) {
799
- for {
800
- frame, err := listener.AcceptDatagram()
801
- if err != nil {
802
- if e.closed() || errors.Is(err, net.ErrClosed) {
803
- return
804
- }
805
- log.Warn().
806
- Err(err).
807
- Str("relay_url", relayURL).
808
- Str("lease_id", listener.LeaseID()).
809
- Msg("datagram accept failed")
810
- return
811
- }
812
-
813
- frame.Payload = append([]byte(nil), frame.Payload...)
814
- frame.LeaseID = listener.LeaseID()
815
- frame.RelayURL = relayURL
816
- frame.UDPAddr = listener.UDPAddr()
817
-
818
- select {
819
- case <-e.done:
820
- return
821
- case e.datagrams <- frame:
822
- }
823
- }
824
-}
825
-
739
func (e *Exposure) closed() bool {
827
- if e == nil || e.done == nil {
828
- return true
829
- }
740
select {
741
case <-e.done:
742
return true
@@ -836,10 +746,6 @@ func (e *Exposure) closed() bool {
746
}
747
748
func (e *Exposure) monitorStartupCounts() {
839
- if e == nil {
840
- return
841
- }
842
-
749
ticker := time.NewTicker(time.Second)
750
defer ticker.Stop()
751
prevStatuses := make(map[string]listenerStatus)
sdk/listener.go
+4
-98
@@ -25,7 +25,6 @@ type ListenerConfig struct {
25
Name string
26
ReverseToken string
27
UDPEnabled bool
28
- OwnerAddress string
28
Metadata types.LeaseMetadata
29
RootCAPEM []byte
30
DialTimeout time.Duration
@@ -38,6 +37,7 @@ type ListenerConfig struct {
37
RetryWait time.Duration
38
39
RegisterBootstraps []string
40
+ ownerAddress string
41
}
42
43
type listenerStatus string
@@ -78,10 +78,6 @@ type Listener struct {
78
// NewListener creates one relay listener and its dedicated relay transport for one relay URL.
79
// Only local config validation fails immediately; relay startup runs in the background until ready.
80
func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Listener, error) {
81
- if ctx == nil {
82
- ctx = context.Background()
83
- }
84
-
81
listenerCtx, cancel := context.WithCancel(ctx)
82
readyTarget := utils.IntOrDefault(cfg.ReadyTarget, defaultReadyTarget)
83
leaseTTL := utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
@@ -95,7 +91,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
91
return nil, err
92
}
93
98
- initialBootstraps, err := utils.NormalizeRelayURLs(cfg.RegisterBootstraps)
94
+ initialBootstraps, err := utils.NormalizeRelayURLs(cfg.RegisterBootstraps...)
95
if err != nil {
96
cancel()
97
return nil, fmt.Errorf("normalize bootstraps: %w", err)
@@ -299,27 +295,6 @@ func (l *Listener) PublicURL() string {
295
}).String()
296
}
297
302
-func (l *Listener) ActiveSessions() int {
303
- if l == nil || l.stream == nil {
304
- return 0
305
- }
306
- return l.stream.ActiveSessions()
307
-}
308
-
309
-func (l *Listener) AcceptDatagram() (types.DatagramFrame, error) {
310
- if l == nil || !l.activeSupportsDatagram() || l.datagram == nil {
311
- return types.DatagramFrame{}, net.ErrClosed
312
- }
313
- return l.datagram.Accept(l.doneCh)
314
-}
315
-
316
-func (l *Listener) SendDatagram(flowID uint32, payload []byte) error {
317
- if l == nil || !l.activeSupportsDatagram() || l.datagram == nil {
318
- return net.ErrClosed
319
- }
320
- return l.datagram.Send(flowID, payload)
321
-}
322
-
298
func (l *Listener) UDPAddr() string {
299
l.mu.Lock()
300
defer l.mu.Unlock()
@@ -327,14 +302,14 @@ func (l *Listener) UDPAddr() string {
302
}
303
304
func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool) {
330
- if l == nil || !l.activeSupportsDatagram() {
305
+ if l.datagram == nil {
306
return transport.ClientDatagramState{}, false
307
}
308
309
l.mu.Lock()
310
defer l.mu.Unlock()
311
337
- if l.api == nil || l.leaseID == "" {
312
+ if l.api == nil || l.leaseID == "" || l.udpAddr == "" {
313
return transport.ClientDatagramState{}, false
314
}
315
@@ -344,63 +319,6 @@ func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool)
319
}, true
320
}
321
347
-func (l *Listener) WaitDatagramReady(ctx context.Context) error {
348
- if l == nil || l.datagram == nil {
349
- return errors.New("lease does not have udp enabled")
350
- }
351
- if err := l.WaitRegistered(ctx); err != nil {
352
- return err
353
- }
354
- if !l.activeSupportsDatagram() {
355
- return errors.New("relay did not enable udp")
356
- }
357
- if l.UDPAddr() == "" {
358
- return errors.New("lease registration did not expose udp address")
359
- }
360
-
361
- ticker := time.NewTicker(50 * time.Millisecond)
362
- defer ticker.Stop()
363
-
364
- for {
365
- if l.datagramConnected() {
366
- return nil
367
- }
368
-
369
- select {
370
- case <-l.doneCh:
371
- return net.ErrClosed
372
- case <-ctx.Done():
373
- return ctx.Err()
374
- case <-ticker.C:
375
- }
376
- }
377
-}
378
-
379
-func (l *Listener) activeSupportsDatagram() bool {
380
- if l == nil || l.datagram == nil {
381
- return false
382
- }
383
- l.mu.Lock()
384
- defer l.mu.Unlock()
385
- return l.udpAddr != ""
386
-}
387
-
388
-func (l *Listener) datagramConnected() bool {
389
- return l != nil && l.datagram != nil && l.datagram.Connected()
390
-}
391
-
392
-func (l *Listener) datagramNegotiationState() (registered bool, enabled bool) {
393
- if l == nil {
394
- return true, false
395
- }
396
- select {
397
- case <-l.registered:
398
- return true, l.activeSupportsDatagram()
399
- default:
400
- return false, false
401
- }
402
-}
403
-
322
func (l *Listener) runRenewLoop(ctx context.Context) {
323
interval := l.leaseTTL / 2
324
if interval <= 0 {
@@ -519,18 +437,6 @@ func (l *Listener) registerAndConfigure(ctx context.Context, registerBootstraps
437
return nil
438
}
439
522
-func (l *Listener) SupportsDatagram() bool {
523
- return l != nil && l.datagram != nil
524
-}
525
-
526
-func (l *Listener) SupportsStream() bool {
527
- return l != nil
528
-}
529
-
530
-func (l *Listener) UDPEnabled() bool {
531
- return l != nil && l.datagram != nil
532
-}
533
-
440
// WaitRegistered blocks until the first successful lease registration or context cancellation.
441
func (l *Listener) WaitRegistered(ctx context.Context) error {
442
select {
sdk/sdk_test.go
+33
-508
@@ -3,11 +3,9 @@ package sdk
3
import (
4
"context"
5
"encoding/json"
6
- "errors"
6
"net/http"
7
"net/http/httptest"
8
"strings"
10
- "sync/atomic"
9
"testing"
10
"time"
11
@@ -15,66 +13,6 @@ import (
13
"github.com/gosuda/portal/v2/types"
14
)
15
18
-func TestNewListenerRetriesInitialStartupUntilReady(t *testing.T) {
19
- var domainCount atomic.Int32
20
- var registerCount atomic.Int32
21
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
- switch r.URL.Path {
23
- case types.PathSDKDomain:
24
- if domainCount.Add(1) == 1 {
25
- http.Error(w, "temporarily unavailable", http.StatusBadGateway)
26
- return
27
- }
28
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
29
- OK: true,
30
- Data: types.DomainResponse{
31
- Version: types.SDKProtocolVersion,
32
- },
33
- })
34
- case types.PathSDKRegister:
35
- if registerCount.Add(1) == 1 {
36
- http.Error(w, "temporarily unavailable", http.StatusBadGateway)
37
- return
38
- }
39
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
40
- OK: true,
41
- Data: types.RegisterResponse{
42
- LeaseID: "lease-1",
43
- Hostname: "127.0.0.1",
44
- },
45
- })
46
- case types.PathSDKConnect:
47
- writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
48
- OK: false,
49
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
50
- })
51
- case types.PathSDKRenew:
52
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
53
- OK: true,
54
- Data: types.RenewResponse{LeaseID: "lease-1"},
55
- })
56
- case types.PathSDKUnregister:
57
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
58
- default:
59
- http.NotFound(w, r)
60
- }
61
- }))
62
- defer server.Close()
63
-
64
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
65
- Name: "demo",
66
- RetryWait: 10 * time.Millisecond,
67
- })
68
- if err != nil {
69
- t.Fatalf("NewListener() error = %v", err)
70
- }
71
- defer listener.Close()
72
-
73
- waitForSDKTest(t, func() bool {
74
- return domainCount.Load() >= 2 && registerCount.Load() >= 2 && listener.LeaseID() == "lease-1"
75
- })
76
-}
77
-
16
func TestNewListenerRejectsInvalidName(t *testing.T) {
17
listener, err := NewListener(context.Background(), "https://relay.example.com", ListenerConfig{Name: "demo app"})
18
if err == nil {
@@ -177,371 +115,18 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
115
}
116
}
117
180
-func TestNewListenerReregistersOnLeaseNotFound(t *testing.T) {
181
- var registerCount atomic.Int32
182
- registerReqCh := make(chan types.RegisterRequest, 2)
183
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184
- switch r.URL.Path {
185
- case types.PathSDKDomain:
186
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
187
- OK: true,
188
- Data: types.DomainResponse{
189
- Version: types.SDKProtocolVersion,
190
- },
191
- })
192
- case types.PathSDKRegister:
193
- var registerReq types.RegisterRequest
194
- if err := json.NewDecoder(r.Body).Decode(®isterReq); err != nil {
195
- t.Fatalf("decode register request: %v", err)
196
- }
197
- select {
198
- case registerReqCh <- registerReq:
199
- default:
200
- }
201
- count := registerCount.Add(1)
202
- leaseID := "lease-1"
203
- if count > 1 {
204
- leaseID = "lease-2"
205
- }
206
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
207
- OK: true,
208
- Data: types.RegisterResponse{
209
- LeaseID: leaseID,
210
- Hostname: "127.0.0.1",
211
- },
212
- })
213
- case types.PathSDKRenew:
214
- var req types.RenewRequest
215
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
216
- t.Fatalf("decode renew request: %v", err)
217
- }
218
- if req.LeaseID == "lease-1" {
219
- writeSDKTestEnvelope(w, http.StatusNotFound, types.APIEnvelope[any]{
220
- OK: false,
221
- Error: &types.APIError{Code: types.APIErrorCodeLeaseNotFound, Message: "lease not found"},
222
- })
223
- return
224
- }
225
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
226
- OK: true,
227
- Data: types.RenewResponse{LeaseID: req.LeaseID},
228
- })
229
- case types.PathSDKConnect:
230
- writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
231
- OK: false,
232
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
233
- })
234
- case types.PathSDKUnregister:
235
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
236
- default:
237
- http.NotFound(w, r)
238
- }
239
- }))
240
- defer server.Close()
241
-
242
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
243
- Name: "demo",
244
- LeaseTTL: 80 * time.Millisecond,
245
- RenewBefore: 40 * time.Millisecond,
246
- RegisterBootstraps: []string{"https://relay-a.example.com", "https://relay-b.example.com"},
247
- })
248
- if err != nil {
249
- t.Fatalf("NewListener() error = %v", err)
250
- }
251
- defer listener.Close()
252
-
253
- waitForSDKTest(t, func() bool {
254
- return listener.LeaseID() == "lease-2"
255
- })
256
-
257
- var requests []types.RegisterRequest
258
- waitForSDKTest(t, func() bool {
259
- for len(requests) < 2 {
260
- select {
261
- case req := <-registerReqCh:
262
- requests = append(requests, req)
263
- default:
264
- return false
265
- }
266
- }
267
- return true
268
- })
269
-
270
- for _, req := range requests {
271
- if len(req.Bootstraps) != 2 || req.Bootstraps[0] != "https://relay-a.example.com" || req.Bootstraps[1] != "https://relay-b.example.com" {
272
- t.Fatalf("register request Bootstraps = %v, want [%q %q]", req.Bootstraps, "https://relay-a.example.com", "https://relay-b.example.com")
273
- }
274
- }
275
-}
276
-
277
-func TestNewListenerClosesAfterReverseSessionRetryBudgetExhausted(t *testing.T) {
278
- var connectCount atomic.Int32
279
- var unregisterCount atomic.Int32
280
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
281
- switch r.URL.Path {
282
- case types.PathSDKDomain:
283
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
284
- OK: true,
285
- Data: types.DomainResponse{
286
- Version: types.SDKProtocolVersion,
287
- },
288
- })
289
- case types.PathSDKRegister:
290
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
291
- OK: true,
292
- Data: types.RegisterResponse{
293
- LeaseID: "lease-1",
294
- Hostname: "127.0.0.1",
295
- },
296
- })
297
- case types.PathSDKConnect:
298
- connectCount.Add(1)
299
- writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
300
- OK: false,
301
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "reverse session denied"},
302
- })
303
- case types.PathSDKUnregister:
304
- unregisterCount.Add(1)
305
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
306
- default:
307
- http.NotFound(w, r)
308
- }
309
- }))
310
- defer server.Close()
311
-
312
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
313
- Name: "demo",
314
- RetryCount: 1,
315
- RetryWait: 10 * time.Millisecond,
316
- })
317
- if err != nil {
318
- t.Fatalf("NewListener() error = %v", err)
319
- }
320
- defer listener.Close()
321
-
322
- waitForSDKTest(t, func() bool {
323
- return listener.closed()
324
- })
325
- if connectCount.Load() < 2 {
326
- t.Fatalf("connect count = %d, want at least 2", connectCount.Load())
327
- }
328
- if unregisterCount.Load() == 0 {
329
- t.Fatal("expected listener to unregister lease after retry budget exhaustion")
330
- }
331
-}
332
-
333
-func TestNewListenerRetriesForeverWhenRetryCountIsNegative(t *testing.T) {
334
- var connectCount atomic.Int32
335
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336
- switch r.URL.Path {
337
- case types.PathSDKDomain:
338
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
339
- OK: true,
340
- Data: types.DomainResponse{
341
- Version: types.SDKProtocolVersion,
342
- },
343
- })
344
- case types.PathSDKRegister:
345
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
346
- OK: true,
347
- Data: types.RegisterResponse{
348
- LeaseID: "lease-1",
349
- Hostname: "127.0.0.1",
350
- },
351
- })
352
- case types.PathSDKConnect:
353
- connectCount.Add(1)
354
- writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
355
- OK: false,
356
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "reverse session denied"},
357
- })
358
- case types.PathSDKUnregister:
359
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
360
- default:
361
- http.NotFound(w, r)
362
- }
363
- }))
364
- defer server.Close()
365
-
366
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
367
- Name: "demo",
368
- RetryCount: -1,
369
- RetryWait: 10 * time.Millisecond,
370
- })
371
- if err != nil {
372
- t.Fatalf("NewListener() error = %v", err)
373
- }
374
- defer listener.Close()
375
-
376
- waitForSDKTest(t, func() bool {
377
- return connectCount.Load() >= 3
378
- })
379
- if listener.closed() {
380
- t.Fatal("listener closed unexpectedly with negative RetryCount")
381
- }
382
-}
383
-
118
func TestExposeNoRelayInputs(t *testing.T) {
119
exposure, err := Expose(context.Background(), ExposeConfig{Name: "demo"})
120
if err != nil {
121
t.Fatalf("Expose() error = %v", err)
122
}
389
- if exposure != nil {
390
- t.Fatalf("Expose() exposure = %#v, want nil", exposure)
391
- }
392
-}
393
-
394
-func TestExposeRegistersKnownRelayURLs(t *testing.T) {
395
- registerReqCh := make(chan types.RegisterRequest, 2)
396
- newRelayServer := func() *httptest.Server {
397
- return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398
- switch r.URL.Path {
399
- case types.PathSDKDomain:
400
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
401
- OK: true,
402
- Data: types.DomainResponse{
403
- Version: types.SDKProtocolVersion,
404
- },
405
- })
406
- case types.PathSDKRegister:
407
- var registerReq types.RegisterRequest
408
- if err := json.NewDecoder(r.Body).Decode(®isterReq); err != nil {
409
- t.Fatalf("decode register request: %v", err)
410
- }
411
- select {
412
- case registerReqCh <- registerReq:
413
- default:
414
- }
415
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
416
- OK: true,
417
- Data: types.RegisterResponse{
418
- LeaseID: "lease-1",
419
- Hostname: "127.0.0.1",
420
- },
421
- })
422
- case types.PathSDKConnect:
423
- writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
424
- OK: false,
425
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
426
- })
427
- case types.PathSDKRenew:
428
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
429
- OK: true,
430
- Data: types.RenewResponse{LeaseID: "lease-1"},
431
- })
432
- case types.PathSDKUnregister:
433
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
434
- default:
435
- http.NotFound(w, r)
436
- }
437
- }))
438
- }
439
-
440
- relayA := newRelayServer()
441
- defer relayA.Close()
442
- relayB := newRelayServer()
443
- defer relayB.Close()
444
-
445
- exposure, err := Expose(context.Background(), ExposeConfig{
446
- RelayURLs: []string{relayA.URL, relayB.URL},
447
- Discovery: true,
448
- Name: "demo",
449
- OwnerAddress: "0x52908400098527886E0F7030069857D2E4169EE7",
450
- })
451
- if err != nil {
452
- t.Fatalf("Expose() error = %v", err)
453
- }
454
- defer exposure.Close()
455
-
456
- var requests []types.RegisterRequest
457
- waitForSDKTest(t, func() bool {
458
- for len(requests) < 2 {
459
- select {
460
- case req := <-registerReqCh:
461
- requests = append(requests, req)
462
- default:
463
- return false
464
- }
465
- }
466
- return true
467
- })
468
-
469
- for _, req := range requests {
470
- if req.OwnerAddress != "0x52908400098527886E0F7030069857D2E4169EE7" {
471
- t.Fatalf("register request OwnerAddress = %q, want configured owner address", req.OwnerAddress)
472
- }
473
- if len(req.Bootstraps) != 2 || req.Bootstraps[0] != relayA.URL || req.Bootstraps[1] != relayB.URL {
474
- t.Fatalf("register request Bootstraps = %v, want [%q %q]", req.Bootstraps, relayA.URL, relayB.URL)
475
- }
476
- }
477
-}
478
-
479
-func TestExposeRemovesClosedListenersSoRelaysCanRestart(t *testing.T) {
480
- var registerCount atomic.Int32
481
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
482
- switch r.URL.Path {
483
- case types.PathSDKDomain:
484
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
485
- OK: true,
486
- Data: types.DomainResponse{
487
- Version: types.SDKProtocolVersion,
488
- },
489
- })
490
- case types.PathSDKRegister:
491
- if registerCount.Add(1) == 1 {
492
- writeSDKTestEnvelope(w, http.StatusServiceUnavailable, types.APIEnvelope[any]{
493
- OK: false,
494
- Error: &types.APIError{Code: types.APIErrorCodeFeatureUnavailable, Message: "relay unavailable"},
495
- })
496
- return
497
- }
498
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
499
- OK: true,
500
- Data: types.RegisterResponse{
501
- LeaseID: "lease-1",
502
- Hostname: "127.0.0.1",
503
- },
504
- })
505
- case types.PathSDKConnect:
506
- writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
507
- OK: false,
508
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
509
- })
510
- case types.PathSDKRenew:
511
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
512
- OK: true,
513
- Data: types.RenewResponse{LeaseID: "lease-1"},
514
- })
515
- case types.PathSDKUnregister:
516
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
517
- default:
518
- http.NotFound(w, r)
519
- }
520
- }))
521
- defer server.Close()
522
-
523
- exposure, err := Expose(context.Background(), ExposeConfig{
524
- RelayURLs: []string{server.URL},
525
- Name: "demo",
526
- })
527
- if err != nil {
528
- t.Fatalf("Expose() error = %v", err)
123
+ if exposure == nil {
124
+ t.Fatal("Expose() exposure = nil, want non-nil")
125
}
126
defer exposure.Close()
531
-
532
- waitForSDKTest(t, func() bool {
533
- exposure.mu.RLock()
534
- defer exposure.mu.RUnlock()
535
- return registerCount.Load() >= 1 && len(exposure.listeners) == 0
536
- })
537
-
538
- if _, err := exposure.applyRelayURLs(exposure.RelayURLs(), false); err != nil {
539
- t.Fatalf("applyRelayURLs() error = %v", err)
127
+ if got := exposure.ActiveRelayURLs(); len(got) != 0 {
128
+ t.Fatalf("Expose() relay urls = %v, want empty", got)
129
}
541
-
542
- waitForSDKTest(t, func() bool {
543
- return registerCount.Load() >= 2 && len(exposure.PublicURLs()) == 1
544
- })
130
}
131
132
func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
@@ -598,7 +183,7 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
183
exposure, err := Expose(context.Background(), ExposeConfig{
184
RelayURLs: []string{server.URL},
185
Name: "demo",
601
- OwnerPrivateKey: &ownerPrivateKey,
186
+ OwnerPrivateKey: ownerPrivateKey,
187
})
188
if err != nil {
189
t.Fatalf("Expose() error = %v", err)
@@ -618,17 +203,10 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
203
if registerReq.OwnerAddress != identity.Address {
204
t.Fatalf("register request OwnerAddress = %q, want %q", registerReq.OwnerAddress, identity.Address)
205
}
621
- resolvedIdentity := exposure.OwnerIdentity()
622
- if resolvedIdentity.Address != identity.Address {
623
- t.Fatalf("OwnerIdentity().Address = %q, want %q", resolvedIdentity.Address, identity.Address)
624
- }
625
- if resolvedIdentity.PrivateKey != ownerPrivateKey {
626
- t.Fatalf("OwnerIdentity().PrivateKey = %q, want configured private key", resolvedIdentity.PrivateKey)
627
- }
206
}
207
630
-func TestNewListenerUDPEnabledKeepsStreamAndDatagram(t *testing.T) {
631
- var connectCount atomic.Int32
208
+func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
209
+ registerReqCh := make(chan types.RegisterRequest, 1)
210
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211
switch r.URL.Path {
212
case types.PathSDKDomain:
@@ -639,25 +217,30 @@ func TestNewListenerUDPEnabledKeepsStreamAndDatagram(t *testing.T) {
217
},
218
})
219
case types.PathSDKRegister:
220
+ var registerReq types.RegisterRequest
221
+ if err := json.NewDecoder(r.Body).Decode(®isterReq); err != nil {
222
+ t.Fatalf("decode register request: %v", err)
223
+ }
224
+ select {
225
+ case registerReqCh <- registerReq:
226
+ default:
227
+ }
228
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
229
OK: true,
230
Data: types.RegisterResponse{
645
- LeaseID: "lease-udp",
646
- Hostname: "demo.example.com",
647
- UDPAddr: "demo.example.com:29900",
648
- UDPEnabled: true,
231
+ LeaseID: "lease-1",
232
+ Hostname: "127.0.0.1",
233
},
234
})
235
case types.PathSDKConnect:
652
- connectCount.Add(1)
236
writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
237
OK: false,
655
- Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "reverse session denied"},
238
+ Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
239
})
240
case types.PathSDKRenew:
241
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
242
OK: true,
660
- Data: types.RenewResponse{LeaseID: "lease-udp"},
243
+ Data: types.RenewResponse{LeaseID: "lease-1"},
244
})
245
case types.PathSDKUnregister:
246
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -667,88 +250,30 @@ func TestNewListenerUDPEnabledKeepsStreamAndDatagram(t *testing.T) {
250
}))
251
defer server.Close()
252
670
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
671
- Name: "demo",
672
- UDPEnabled: true,
673
- LeaseTTL: 100 * time.Millisecond,
253
+ exposure, err := Expose(context.Background(), ExposeConfig{
254
+ RelayURLs: []string{server.URL},
255
+ Name: "demo",
256
})
257
if err != nil {
676
- t.Fatalf("NewListener() error = %v", err)
258
+ t.Fatalf("Expose() error = %v", err)
259
}
678
- defer listener.Close()
260
+ defer exposure.Close()
261
262
+ var registerReq types.RegisterRequest
263
waitForSDKTest(t, func() bool {
681
- return listener.LeaseID() == "lease-udp"
682
- })
683
- time.Sleep(150 * time.Millisecond)
684
-
685
- if connectCount.Load() == 0 {
686
- t.Fatal("connect count = 0, want reverse session attempts")
687
- }
688
- if got := listener.PublicURL(); got == "" {
689
- t.Fatal("PublicURL() = empty, want public url")
690
- }
691
- if !listener.SupportsDatagram() {
692
- t.Fatal("SupportsDatagram() = false, want true")
693
- }
694
- if !listener.SupportsStream() {
695
- t.Fatal("SupportsStream() = false, want true")
696
- }
697
-}
698
-
699
-func TestListenerPublishesUDPAddressAfterRegistration(t *testing.T) {
700
- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
701
- switch r.URL.Path {
702
- case types.PathSDKDomain:
703
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
704
- OK: true,
705
- Data: types.DomainResponse{
706
- Version: types.SDKProtocolVersion,
707
- },
708
- })
709
- case types.PathSDKRegister:
710
- writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
711
- OK: true,
712
- Data: types.RegisterResponse{
713
- LeaseID: "lease-udp",
714
- Hostname: "demo.example.com",
715
- UDPAddr: "demo.example.com:29900",
716
- UDPEnabled: true,
717
- },
718
- })
719
- case types.PathSDKRenew:
720
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
721
- OK: true,
722
- Data: types.RenewResponse{LeaseID: "lease-udp"},
723
- })
724
- case types.PathSDKUnregister:
725
- writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
264
+ select {
265
+ case registerReq = <-registerReqCh:
266
+ return true
267
default:
727
- http.NotFound(w, r)
268
+ return false
269
}
729
- }))
730
- defer server.Close()
731
-
732
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
733
- Name: "demo",
734
- UDPEnabled: true,
270
})
736
- if err != nil {
737
- t.Fatalf("NewListener() error = %v", err)
738
- }
739
- defer listener.Close()
271
741
- if err := listener.WaitRegistered(context.Background()); err != nil {
742
- t.Fatalf("WaitRegistered() error = %v", err)
272
+ if registerReq.OwnerAddress == "" {
273
+ t.Fatal("register request OwnerAddress = empty, want generated address")
274
}
744
- if got := listener.UDPAddr(); got != "demo.example.com:29900" {
745
- t.Fatalf("UDPAddr() = %q, want %q", got, "demo.example.com:29900")
746
- }
747
-
748
- ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
749
- defer cancel()
750
- if err := listener.WaitDatagramReady(ctx); !errors.Is(err, context.DeadlineExceeded) {
751
- t.Fatalf("WaitDatagramReady() error = %v, want %v", err, context.DeadlineExceeded)
275
+ if _, err := discovery.NormalizeEVMAddress(registerReq.OwnerAddress); err != nil {
276
+ t.Fatalf("register request OwnerAddress = %q, want valid EVM address: %v", registerReq.OwnerAddress, err)
277
}
278
}
279
utils/cmd.go
new
+367
@@ -0,0 +1,367 @@
1
+package utils
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "flag"
7
+ "fmt"
8
+ "io"
9
+ "net"
10
+ "os"
11
+ "os/signal"
12
+ "strconv"
13
+ "strings"
14
+ "syscall"
15
+ "time"
16
+)
17
+
18
+type CommandFunc func([]string) error
19
+type IntEnvParser func(string, int) int
20
+type boolFlagValue interface{ IsBoolFlag() bool }
21
+
22
+func TrimmedEnv(name string) string {
23
+ return strings.TrimSpace(os.Getenv(name))
24
+}
25
+
26
+func ResolveStringEnv(fallback string, envNames ...string) string {
27
+ value := fallback
28
+ for _, envName := range envNames {
29
+ if envValue := TrimmedEnv(envName); envValue != "" {
30
+ value = envValue
31
+ break
32
+ }
33
+ }
34
+ return value
35
+}
36
+
37
+func ResolveBoolEnv(fallback bool, envNames ...string) bool {
38
+ for _, envName := range envNames {
39
+ raw := TrimmedEnv(envName)
40
+ if raw == "" {
41
+ continue
42
+ }
43
+ parsed, err := strconv.ParseBool(raw)
44
+ if err != nil {
45
+ return fallback
46
+ }
47
+ return parsed
48
+ }
49
+ return fallback
50
+}
51
+
52
+func ResolveIntEnv(fallback int, parse IntEnvParser, envNames ...string) int {
53
+ if parse == nil {
54
+ parse = func(raw string, fallback int) int {
55
+ v, err := strconv.Atoi(strings.TrimSpace(raw))
56
+ if err != nil {
57
+ return fallback
58
+ }
59
+ return v
60
+ }
61
+ }
62
+ for _, envName := range envNames {
63
+ raw := TrimmedEnv(envName)
64
+ if raw == "" {
65
+ continue
66
+ }
67
+ return parse(raw, fallback)
68
+ }
69
+ return fallback
70
+}
71
+
72
+func ParsePortNumber(raw string, fallback int) int {
73
+ raw = strings.TrimSpace(raw)
74
+ if raw == "" {
75
+ return fallback
76
+ }
77
+ port, err := strconv.Atoi(raw)
78
+ if err != nil || port < 1 || port > 65535 {
79
+ return fallback
80
+ }
81
+ return port
82
+}
83
+
84
+func ParseNonNegativeInt(raw string, fallback int) int {
85
+ raw = strings.TrimSpace(raw)
86
+ if raw == "" {
87
+ return fallback
88
+ }
89
+ v, err := strconv.Atoi(raw)
90
+ if err != nil || v < 0 {
91
+ return fallback
92
+ }
93
+ return v
94
+}
95
+
96
+func DurationOrDefault(v, fallback time.Duration) time.Duration {
97
+ if v > 0 {
98
+ return v
99
+ }
100
+ return fallback
101
+}
102
+
103
+func IntOrDefault(v, fallback int) int {
104
+ if v > 0 {
105
+ return v
106
+ }
107
+ return fallback
108
+}
109
+
110
+func StringOrDefault(v, fallback string) string {
111
+ if v != "" {
112
+ return v
113
+ }
114
+ return fallback
115
+}
116
+
117
+func StringFlag(fs *flag.FlagSet, target *string, name, fallback, usage string) {
118
+ ensureFlagSet(fs).StringVar(target, name, fallback, usage)
119
+}
120
+
121
+func StringFlagEnv(fs *flag.FlagSet, target *string, name, fallback, usage string, envNames ...string) {
122
+ ensureFlagSet(fs).StringVar(target, name, ResolveStringEnv(fallback, envNames...), flagUsage(usage, envNames...))
123
+}
124
+
125
+func BoolFlag(fs *flag.FlagSet, target *bool, name string, fallback bool, usage string) {
126
+ ensureFlagSet(fs).BoolVar(target, name, fallback, usage)
127
+}
128
+
129
+func BoolFlagEnv(fs *flag.FlagSet, target *bool, name string, fallback bool, usage string, envNames ...string) {
130
+ ensureFlagSet(fs).BoolVar(target, name, ResolveBoolEnv(fallback, envNames...), flagUsage(usage, envNames...))
131
+}
132
+
133
+func IntFlag(fs *flag.FlagSet, target *int, name string, fallback int, usage string) {
134
+ ensureFlagSet(fs).IntVar(target, name, fallback, usage)
135
+}
136
+
137
+func IntFlagEnv(fs *flag.FlagSet, target *int, name string, fallback int, parse IntEnvParser, usage string, envNames ...string) {
138
+ ensureFlagSet(fs).IntVar(target, name, ResolveIntEnv(fallback, parse, envNames...), flagUsage(usage, envNames...))
139
+}
140
+
141
+func ensureFlagSet(fs *flag.FlagSet) *flag.FlagSet {
142
+ if fs != nil {
143
+ return fs
144
+ }
145
+ return flag.CommandLine
146
+}
147
+
148
+func flagUsage(usage string, envNames ...string) string {
149
+ names := make([]string, 0, len(envNames))
150
+ for _, envName := range envNames {
151
+ envName = strings.TrimSpace(envName)
152
+ if envName != "" {
153
+ names = append(names, envName)
154
+ }
155
+ }
156
+ if len(names) == 0 {
157
+ return usage
158
+ }
159
+ var envUsage string
160
+ switch len(names) {
161
+ case 1:
162
+ envUsage = names[0]
163
+ case 2:
164
+ envUsage = names[0] + " or " + names[1]
165
+ default:
166
+ envUsage = strings.Join(names[:len(names)-1], ", ") + ", or " + names[len(names)-1]
167
+ }
168
+ if strings.TrimSpace(usage) == "" {
169
+ return "(env: " + envUsage + ")"
170
+ }
171
+ return usage + " (env: " + envUsage + ")"
172
+}
173
+
174
+func SignalContext() (context.Context, context.CancelFunc) {
175
+ return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
176
+}
177
+
178
+func RunCommands(
179
+ args []string,
180
+ stdout io.Writer,
181
+ stderr io.Writer,
182
+ usage func(io.Writer),
183
+ commands map[string]CommandFunc,
184
+) error {
185
+ defaultCommand, hasDefaultCommand := commands[""]
186
+ if len(args) == 0 {
187
+ if hasDefaultCommand {
188
+ return defaultCommand(nil)
189
+ }
190
+ if usage != nil {
191
+ usage(stdout)
192
+ }
193
+ return nil
194
+ }
195
+
196
+ command := strings.TrimSpace(args[0])
197
+ switch {
198
+ case command == "help":
199
+ if runCommand, ok := commands[command]; ok {
200
+ return runCommand(args[1:])
201
+ }
202
+ if usage != nil {
203
+ usage(stdout)
204
+ }
205
+ return nil
206
+ case command == "-h" || command == "--help":
207
+ if usage != nil {
208
+ usage(stdout)
209
+ }
210
+ return nil
211
+ case command == "" || strings.HasPrefix(command, "-"):
212
+ if hasDefaultCommand {
213
+ return defaultCommand(args)
214
+ }
215
+ }
216
+
217
+ runCommand, ok := commands[command]
218
+ if !ok {
219
+ if usage != nil {
220
+ usage(stderr)
221
+ }
222
+ return fmt.Errorf("unknown command %q", command)
223
+ }
224
+ return runCommand(args[1:])
225
+}
226
+
227
+func NewFlagSet(name string, usage func(io.Writer)) *flag.FlagSet {
228
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
229
+ fs.SetOutput(io.Discard)
230
+ if usage != nil {
231
+ fs.Usage = func() {
232
+ usage(fs.Output())
233
+ }
234
+ }
235
+ return fs
236
+}
237
+
238
+func ParseFlagSet(fs *flag.FlagSet, args []string, usage func(io.Writer)) error {
239
+ args = normalizeFlagArgs(fs, args)
240
+ if len(args) == 1 && (args[0] == "help" || args[0] == "-h" || args[0] == "--help") {
241
+ if usage != nil {
242
+ usage(os.Stdout)
243
+ }
244
+ return flag.ErrHelp
245
+ }
246
+ if err := fs.Parse(args); err != nil {
247
+ if errors.Is(err, flag.ErrHelp) {
248
+ if usage != nil {
249
+ usage(os.Stdout)
250
+ }
251
+ return flag.ErrHelp
252
+ }
253
+ if usage != nil {
254
+ usage(os.Stderr)
255
+ }
256
+ return err
257
+ }
258
+ return nil
259
+}
260
+
261
+func normalizeFlagArgs(fs *flag.FlagSet, args []string) []string {
262
+ if fs == nil || len(args) < 2 {
263
+ return args
264
+ }
265
+
266
+ flags := make([]string, 0, len(args))
267
+ positionals := make([]string, 0, len(args))
268
+
269
+ for i := 0; i < len(args); i++ {
270
+ arg := strings.TrimSpace(args[i])
271
+ if arg == "" || arg == "-" || !strings.HasPrefix(arg, "-") {
272
+ positionals = append(positionals, args[i])
273
+ continue
274
+ }
275
+ if arg == "--" {
276
+ positionals = append(positionals, args[i+1:]...)
277
+ break
278
+ }
279
+
280
+ name := strings.TrimLeft(strings.TrimSpace(arg), "-")
281
+ if name == "" {
282
+ positionals = append(positionals, args[i])
283
+ continue
284
+ }
285
+ hasInlineValue := false
286
+ if cut, _, ok := strings.Cut(name, "="); ok {
287
+ name = strings.TrimSpace(cut)
288
+ hasInlineValue = true
289
+ }
290
+ if name == "" {
291
+ positionals = append(positionals, args[i])
292
+ continue
293
+ }
294
+
295
+ flags = append(flags, args[i])
296
+
297
+ flagDef := fs.Lookup(name)
298
+ if hasInlineValue || flagDef == nil {
299
+ continue
300
+ }
301
+ boolValue, ok := flagDef.Value.(boolFlagValue)
302
+ if ok && boolValue.IsBoolFlag() {
303
+ continue
304
+ }
305
+ if i+1 >= len(args) {
306
+ continue
307
+ }
308
+ i++
309
+ flags = append(flags, args[i])
310
+ }
311
+
312
+ return append(flags, positionals...)
313
+}
314
+
315
+func OptionalSingleArg(args []string, name string) (string, error) {
316
+ switch len(args) {
317
+ case 0:
318
+ return "", nil
319
+ case 1:
320
+ return strings.TrimSpace(args[0]), nil
321
+ default:
322
+ return "", fmt.Errorf("only one %s is supported", strings.TrimSpace(name))
323
+ }
324
+}
325
+
326
+func RequireNoArgs(args []string, command string) error {
327
+ if len(args) == 0 {
328
+ return nil
329
+ }
330
+ return fmt.Errorf("%s does not accept positional arguments", strings.TrimSpace(command))
331
+}
332
+
333
+func NormalizeLoopbackTarget(raw string) (string, error) {
334
+ raw = strings.TrimSpace(raw)
335
+ if raw == "" {
336
+ return "", nil
337
+ }
338
+ if port, ok := strings.CutPrefix(raw, ":"); ok {
339
+ if _, err := strconv.Atoi(port); err == nil {
340
+ return net.JoinHostPort("127.0.0.1", port), nil
341
+ }
342
+ }
343
+ if _, err := strconv.Atoi(raw); err == nil {
344
+ return net.JoinHostPort("127.0.0.1", raw), nil
345
+ }
346
+ return NormalizeTargetAddr(raw)
347
+}
348
+
349
+func WriteCommandUsage(w io.Writer, usage []string, examples []string) {
350
+ if w == nil {
351
+ return
352
+ }
353
+ if len(usage) > 0 {
354
+ fmt.Fprintln(w, "Usage:")
355
+ for _, line := range usage {
356
+ fmt.Fprintln(w, " "+strings.TrimSpace(line))
357
+ }
358
+ }
359
+ if len(examples) == 0 {
360
+ return
361
+ }
362
+ fmt.Fprintln(w)
363
+ fmt.Fprintln(w, "Examples:")
364
+ for _, line := range examples {
365
+ fmt.Fprintln(w, " "+strings.TrimSpace(line))
366
+ }
367
+}
utils/utils.go
+3
-48
@@ -12,8 +12,6 @@ import (
12
"io"
13
"net"
14
"net/url"
15
- "os"
16
- "strconv"
15
"strings"
16
"time"
17
)
@@ -130,7 +128,7 @@ func NormalizeHostname(host string) string {
128
return host
129
}
130
133
-func NormalizeRelayURLs(inputs []string) ([]string, error) {
131
+func NormalizeRelayURLs(inputs ...string) ([]string, error) {
132
out := make([]string, 0, len(inputs))
133
134
for _, input := range inputs {
@@ -147,7 +145,7 @@ func NormalizeRelayURLs(inputs []string) ([]string, error) {
145
}
146
147
func MergeRelayURLs(current, excluded, inputs []string) ([]string, error) {
150
- merged, err := NormalizeRelayURLs(append(append([]string(nil), current...), inputs...))
148
+ merged, err := NormalizeRelayURLs(append(append([]string(nil), current...), inputs...)...)
149
if err != nil {
150
return nil, err
151
}
@@ -155,7 +153,7 @@ func MergeRelayURLs(current, excluded, inputs []string) ([]string, error) {
153
return merged, nil
154
}
155
158
- excluded, err = NormalizeRelayURLs(excluded)
156
+ excluded, err = NormalizeRelayURLs(excluded...)
157
if err != nil {
158
return nil, err
159
}
@@ -247,21 +245,6 @@ func FormatLastSeen(d time.Duration) string {
245
return fmt.Sprintf("%ds", int(d/time.Second))
246
}
247
250
-func FormatISOTime(ts time.Time) string {
251
- if ts.IsZero() {
252
- return ""
253
- }
254
- return ts.UTC().Format(time.RFC3339)
255
-}
256
-
257
-func LeaseLink(host string) string {
258
- host = strings.TrimSpace(host)
259
- if host == "" {
260
- return ""
261
- }
262
- return "https://" + host + "/"
263
-}
264
-
248
func DecodeBase64URLString(encoded string) (string, error) {
249
decoded, err := base64.URLEncoding.DecodeString(encoded)
250
if err == nil {
@@ -381,14 +364,6 @@ func CertPoolFromPEM(rootCAPEM []byte) (*x509.CertPool, error) {
364
return pool, nil
365
}
366
384
-// Generic value helpers.
385
-func DurationOrDefault(v, fallback time.Duration) time.Duration {
386
- if v > 0 {
387
- return v
388
- }
389
- return fallback
390
-}
391
-
367
func SleepOrDone(ctx context.Context, d time.Duration) bool {
368
timer := time.NewTimer(d)
369
defer timer.Stop()
@@ -400,26 +375,6 @@ func SleepOrDone(ctx context.Context, d time.Duration) bool {
375
}
376
}
377
403
-func IntOrDefault(v, fallback int) int {
404
- if v > 0 {
405
- return v
406
- }
407
- return fallback
408
-}
409
-
410
-// ParseBoolEnv reads a boolean environment variable and falls back when unset or invalid.
411
-func ParseBoolEnv(name string, fallback bool) bool {
412
- raw := strings.TrimSpace(os.Getenv(name))
413
- if raw == "" {
414
- return fallback
415
- }
416
- parsed, err := strconv.ParseBool(raw)
417
- if err != nil {
418
- return fallback
419
- }
420
- return parsed
421
-}
422
-
378
// Random value helpers.
379
func RandomID(prefix string) string {
380
buf := make([]byte, 8)
utils/utils_test.go
+2
-19
@@ -11,10 +11,10 @@ import (
11
func TestNormalizeRelayURLs(t *testing.T) {
12
t.Parallel()
13
14
- got, err := NormalizeRelayURLs([]string{
14
+ got, err := NormalizeRelayURLs(
15
" localhost:4017 , https://relay.example.com/base/relay?x=1#frag ",
16
"https://relay.example.com/base",
17
- })
17
+ )
18
if err != nil {
19
t.Fatalf("NormalizeRelayURLs() error = %v", err)
20
}
@@ -100,23 +100,6 @@ func TestFormatLastSeen(t *testing.T) {
100
}
101
}
102
103
-func TestFormatISOTime(t *testing.T) {
104
- t.Parallel()
105
-
106
- ts := time.Date(2026, time.March, 17, 9, 10, 11, 0, time.FixedZone("KST", 9*60*60))
107
- if got := FormatISOTime(ts); got != "2026-03-17T00:10:11Z" {
108
- t.Fatalf("FormatISOTime() = %q, want %q", got, "2026-03-17T00:10:11Z")
109
- }
110
-}
111
-
112
-func TestLeaseLink(t *testing.T) {
113
- t.Parallel()
114
-
115
- if got := LeaseLink("demo.example.com"); got != "https://demo.example.com/" {
116
- t.Fatalf("LeaseLink() = %q, want %q", got, "https://demo.example.com/")
117
- }
118
-}
119
-
103
func TestDecodeBase64URLString(t *testing.T) {
104
t.Parallel()
105