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(&registerReq); 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(&registerReq); 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(&registerReq); 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