feat: add command-line flags for relay configuration and update README

YoonHyunWoo committed Nov 15, 2025 at 14:26 UTC 48fffd0394e0a8ab8b7e6803c61e36f4885c5090
4 files changed +196 -39
cmd/portal-tunnel/README.md new
+98
@@ -0,0 +1,98 @@
1 +# portal-tunnel User Guide
2 +
3 +`portal-tunnel` is a tunneling tool that registers locally running services to a Portal relay server, allowing external access through the `/peer/<service-name>` path. A single process can manage multiple relays and multiple services simultaneously.
4 +
5 +## Requirements
6 +
7 +* Go 1.25 or later
8 +* Portal relay server URL (e.g., `wss://portal.gosuda.org/relay`)
9 +* Local TCP-based services to expose through the relay (e.g., HTTP, gRPC)
10 +
11 +## Running the Tool
12 +
13 +### Build the binary
14 +
15 +```bash
16 +go build -o bin/portal-tunnel ./cmd/portal-tunnel
17 +bin/portal-tunnel expose --help
18 +```
19 +
20 +### Run with `go run`
21 +
22 +```bash
23 +go run ./cmd/portal-tunnel expose \
24 + --relay ws://localhost:4017/relay \
25 + --host localhost \
26 + --port 4018
27 +```
28 +
29 +## Using a Configuration File
30 +
31 +1. Copy the example configuration:
32 +
33 + ```bash
34 + cp cmd/portal-tunnel/config.yaml.example portal-tunnel.yaml
35 + ```
36 +
37 +2. Update relay and service definitions in `portal-tunnel.yaml`.
38 +
39 +3. Start the tunnel:
40 +
41 + ```bash
42 + bin/portal-tunnel expose --config portal-tunnel.yaml
43 + ```
44 +
45 +4. To run only a specific service, add the `--service <name>` flag.
46 +
47 +### config.yaml Fields
48 +
49 +```yaml
50 +relays:
51 + - name: gosuda
52 + urls:
53 + - wss://portal.gosuda.org.kr/relay
54 +
55 +services:
56 + - name: my-api
57 + relayPreference: # Relays are attempted in the listed order.
58 + - gosuda
59 + target: localhost:8080 # Local proxy target (host:port)
60 + protocols: # Optional; defaults to ["http/1.1"]
61 + - http/1.1
62 + - h2
63 +```
64 +
65 +* `relays`: List of relay servers. Each entry must include `name` and one or more `urls`.
66 +* `services`: List of local services to expose.
67 +
68 + * `name`: Service name to register with the relay. If omitted, a name is generated as `tunnel-<lease-id>`.
69 + * `relayPreference`: Ordered list of relay names. Unknown names are ignored; at least one valid URL must remain.
70 + * `target`: Local proxy target (`host:port`).
71 + * `protocols`: ALPN protocol list. Defaults to `http/1.1` if omitted.
72 +
73 +The process gracefully shuts down all tunnels when it receives `SIGINT` or `SIGTERM`.
74 +
75 +## Running a Single Service with Flags
76 +
77 +You can expose a single temporary service without a configuration file.
78 +
79 +```bash
80 +bin/portal-tunnel expose \
81 + --relay wss://portal.gosuda.org.kr/relay \
82 + --host localhost \
83 + --port 8080 \
84 + --name dev-api
85 +```
86 +
87 +* `--relay`: Required. WebSocket URL of the relay server.
88 +* `--host`, `--port`: Local service address to proxy. Defaults to `localhost:4018`.
89 +* `--name`: Public service name. Auto-generated if omitted.
90 +
91 +## Verifying Access
92 +
93 +When the tunnel is established, the log prints the accessible URL.
94 +
95 +* Access via `/peer/<service-name>` or `/peer/<lease-id>`.
96 +* Example: `http://portal.gosuda.org.kr/peer/dev-api`
97 +
98 +Relay logs show connection events (`->`) and disconnect events (`<-`), allowing real-time monitoring.
cmd/portal-tunnel/main.go
+96 -29
@@ -20,6 +20,10 @@ import (
20 var (
21 flagConfigPath string
22 flagService string
23 + flagRelayURL string
24 + flagHost string
25 + flagPort string
26 + flagName string
27 )
28
29 type serviceContext struct {
@@ -39,6 +43,10 @@ func main() {
43 fs := flag.NewFlagSet("expose", flag.ExitOnError)
44 fs.StringVar(&flagConfigPath, "config", "", "Path to portal-tunnel config file")
45 fs.StringVar(&flagService, "service", "", "Specific service name to expose (defaults to first entry)")
46 + fs.StringVar(&flagRelayURL, "relay", "ws://localhost:4017/relay", "Portal relay server URL when config is not provided")
47 + fs.StringVar(&flagHost, "host", "localhost", "Local host to proxy to when config is not provided")
48 + fs.StringVar(&flagPort, "port", "4018", "Local port to proxy to when config is not provided")
49 + fs.StringVar(&flagName, "name", "", "Service name when config is not provided (auto-generated if empty)")
50 _ = fs.Parse(os.Args[2:])
51
52 if err := runExpose(); err != nil {
@@ -58,13 +66,17 @@ func printTunnelUsage() {
66 fmt.Println()
67 fmt.Println("Usage:")
68 fmt.Println(" portal-tunnel expose --config <file> [--service <name>]")
69 + fmt.Println(" portal-tunnel expose [--relay URL] [--host HOST] [--port PORT] [--name NAME]")
70 }
71
72 func runExpose() error {
73 if flagConfigPath == "" {
65 - return fmt.Errorf("--config is required")
74 + return runExposeWithFlags()
75 }
76 + return runExposeWithConfig()
77 +}
78
79 +func runExposeWithConfig() error {
80 cfg, err := LoadConfig(flagConfigPath)
81 if err != nil {
82 return fmt.Errorf("load config: %w", err)
@@ -96,7 +108,7 @@ func runExpose() error {
108 wg.Add(1)
109 go func() {
110 defer wg.Done()
99 - if err := runServiceTunnel(ctx, relayDir, service); err != nil {
111 + if err := runServiceTunnel(ctx, relayDir, service, fmt.Sprintf("config=%s", flagConfigPath)); err != nil {
112 errCh <- err
113 }
114 }()
@@ -122,6 +134,56 @@ func runExpose() error {
134 }
135 }
136
137 +func runExposeWithFlags() error {
138 + relayURL := strings.TrimSpace(flagRelayURL)
139 + if relayURL == "" {
140 + return fmt.Errorf("--relay is required when --config is not provided")
141 + }
142 +
143 + host := strings.TrimSpace(flagHost)
144 + if host == "" {
145 + host = "localhost"
146 + }
147 + port := strings.TrimSpace(flagPort)
148 + if port == "" {
149 + return fmt.Errorf("--port is required when --config is not provided")
150 + }
151 +
152 + target := net.JoinHostPort(host, port)
153 + service := &ServiceConfig{
154 + Name: strings.TrimSpace(flagName),
155 + Target: target,
156 + Protocols: []string{"http/1.1", "h2"},
157 + RelayPreference: []string{"flags"},
158 + }
159 +
160 + relayDir := NewRelayDirectory([]RelayConfig{
161 + {
162 + Name: "flags",
163 + URLs: []string{relayURL},
164 + },
165 + })
166 +
167 + ctx, cancel := context.WithCancel(context.Background())
168 + defer cancel()
169 +
170 + sigCh := make(chan os.Signal, 1)
171 + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
172 + go func() {
173 + <-sigCh
174 + log.Info().Msg("")
175 + log.Info().Msg("Shutting down tunnel...")
176 + cancel()
177 + }()
178 +
179 + if err := runServiceTunnel(ctx, relayDir, service, "flags"); err != nil {
180 + return err
181 + }
182 +
183 + log.Info().Msg("Tunnel stopped")
184 + return nil
185 +}
186 +
187 func proxyConnection(ctx context.Context, svcCtx *serviceContext, relayConn net.Conn, connNum int) error {
188 defer relayConn.Close()
189
@@ -170,44 +232,49 @@ func proxyConnection(ctx context.Context, svcCtx *serviceContext, relayConn net.
232 return err
233 }
234
173 -func runServiceTunnel(ctx context.Context, relayDir *RelayDirectory, service *ServiceConfig) error {
235 +func runServiceTunnel(ctx context.Context, relayDir *RelayDirectory, service *ServiceConfig, origin string) error {
236 localAddr := service.Target
237 + serviceName := strings.TrimSpace(service.Name)
238 bootstrapServers, err := relayDir.BootstrapServers(service.RelayPreference)
239 if err != nil {
177 - return fmt.Errorf("service %s: resolve relay servers: %w", service.Name, err)
240 + return fmt.Errorf("service %s: resolve relay servers: %w", serviceName, err)
241 }
242
243 + cred := sdk.NewCredential()
244 + leaseID := cred.ID()
245 + if serviceName == "" {
246 + serviceName = fmt.Sprintf("tunnel-%s", leaseID[:8])
247 + log.Info().Str("service", serviceName).Msg("No service name provided; generated automatically")
248 + }
249 svcCtx := &serviceContext{
181 - Name: service.Name,
250 + Name: serviceName,
251 LocalAddr: localAddr,
252 RelayServers: bootstrapServers,
253 }
254
186 - log.Info().Str("service", service.Name).Msgf("Waiting for local service at %s (interval=%v)...", localAddr, time.Second)
255 + log.Info().Str("service", serviceName).Msgf("Waiting for local service at %s (interval=%v)...", localAddr, time.Second)
256 if err := waitForLocalService(localAddr, 0, time.Second); err != nil {
188 - return fmt.Errorf("service %s: %w", service.Name, err)
257 + return fmt.Errorf("service %s: %w", serviceName, err)
258 }
190 - log.Info().Str("service", service.Name).Msgf("✓ Local service is reachable at %s", localAddr)
259 + log.Info().Str("service", serviceName).Msgf("✓ Local service is reachable at %s", localAddr)
260
192 - cred := sdk.NewCredential()
193 - leaseID := cred.ID()
194 -
195 - log.Info().Str("service", service.Name).Msgf("Starting Portal Tunnel (config=%s)...", flagConfigPath)
196 - log.Info().Str("service", service.Name).Msgf(" Local: %s", localAddr)
197 - log.Info().Str("service", service.Name).Msgf(" Relays: %s", strings.Join(bootstrapServers, ", "))
198 - log.Info().Str("service", service.Name).Msgf(" Lease ID: %s", leaseID)
261 + log.Info().Str("service", serviceName).Msgf("Starting Portal Tunnel (%s)...", origin)
262 + log.Info().Str("service", serviceName).Msgf(" Local: %s", localAddr)
263 + log.Info().Str("service", serviceName).Msgf(" Relays: %s", strings.Join(bootstrapServers, ", "))
264 + log.Info().Str("service", serviceName).Msgf(" Lease ID: %s", leaseID)
265
266 client, err := sdk.NewClient(func(c *sdk.RDClientConfig) {
267 c.BootstrapServers = bootstrapServers
268 +
269 })
270 if err != nil {
204 - return fmt.Errorf("service %s: failed to connect to relay: %w", service.Name, err)
271 + return fmt.Errorf("service %s: failed to connect to relay: %w", serviceName, err)
272 }
273 defer client.Close()
274
208 - listener, err := client.Listen(cred, service.Name, service.Protocols)
275 + listener, err := client.Listen(cred, serviceName, service.Protocols)
276 if err != nil {
210 - return fmt.Errorf("service %s: failed to register service: %w", service.Name, err)
277 + return fmt.Errorf("service %s: failed to register service: %w", serviceName, err)
278 }
279 defer listener.Close()
280
@@ -216,14 +283,14 @@ func runServiceTunnel(ctx context.Context, relayDir *RelayDirectory, service *Se
283 _ = listener.Close()
284 }()
285
219 - log.Info().Str("service", service.Name).Msg("")
220 - log.Info().Str("service", service.Name).Msg("=== Service is now publicly accessible ===")
221 - log.Info().Str("service", service.Name).Msg("Access via:")
222 - log.Info().Str("service", service.Name).Msgf("- Name: /peer/%s", service.Name)
223 - log.Info().Str("service", service.Name).Msgf("- Lease ID: /peer/%s", leaseID)
286 + log.Info().Str("service", serviceName).Msg("")
287 + log.Info().Str("service", serviceName).Msg("=== Service is now publicly accessible ===")
288 + log.Info().Str("service", serviceName).Msg("Access via:")
289 + log.Info().Str("service", serviceName).Msgf("- Name: /peer/%s", serviceName)
290 + log.Info().Str("service", serviceName).Msgf("- Lease ID: /peer/%s", leaseID)
291 relayHost := extractHost(bootstrapServers[0])
225 - log.Info().Str("service", service.Name).Msgf("- Example: http://%s/peer/%s", relayHost, service.Name)
226 - log.Info().Str("service", service.Name).Msg("")
292 + log.Info().Str("service", serviceName).Msgf("- Example: http://%s/peer/%s", relayHost, serviceName)
293 + log.Info().Str("service", serviceName).Msg("")
294
295 connCount := 0
296 var connWG sync.WaitGroup
@@ -241,22 +308,22 @@ func runServiceTunnel(ctx context.Context, relayDir *RelayDirectory, service *Se
308 case <-ctx.Done():
309 return nil
310 default:
244 - log.Error().Str("service", service.Name).Err(err).Msg("Failed to accept connection")
311 + log.Error().Str("service", serviceName).Err(err).Msg("Failed to accept connection")
312 continue
313 }
314 }
315
316 connCount++
317 currentConnCount := connCount
251 - log.Info().Str("service", service.Name).Msgf("→ [#%d] New connection from %s", currentConnCount, relayConn.RemoteAddr())
318 + log.Info().Str("service", serviceName).Msgf("→ [#%d] New connection from %s", currentConnCount, relayConn.RemoteAddr())
319
320 connWG.Add(1)
321 go func(relayConn net.Conn, connNum int) {
322 defer connWG.Done()
323 if err := proxyConnection(ctx, svcCtx, relayConn, connNum); err != nil {
257 - log.Error().Str("service", service.Name).Err(err).Int("conn", connNum).Msg("Proxy error")
324 + log.Error().Str("service", serviceName).Err(err).Int("conn", connNum).Msg("Proxy error")
325 }
259 - log.Info().Str("service", service.Name).Msgf("← [#%d] Connection closed", connNum)
326 + log.Info().Str("service", serviceName).Msgf("← [#%d] Connection closed", connNum)
327 }(relayConn, currentConnCount)
328 }
329 }
go.mod
+1 -1
@@ -12,6 +12,7 @@ require (
12 golang.org/x/crypto v0.43.0
13 golang.org/x/net v0.46.0
14 google.golang.org/protobuf v1.36.10
15 + gopkg.in/yaml.v3 v3.0.1
16 )
17
18 require (
@@ -19,5 +20,4 @@ require (
20 github.com/mattn/go-isatty v0.0.20 // indirect
21 golang.org/x/sys v0.37.0 // indirect
22 golang.org/x/text v0.30.0 // indirect
22 - gopkg.in/yaml.v3 v3.0.1 // indirect
23 )
go.sum
+1 -9
@@ -1,7 +1,6 @@
1 github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
2 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
3 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4 -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
5 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -9,8 +8,6 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
8 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
9 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
10 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
12 -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
13 -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
11 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
12 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
13 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -24,12 +21,6 @@ github.com/planetscale/vtprotobuf v0.6.0/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6
21 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
22 github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
23 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
27 -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
28 -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
29 -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
30 -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
31 -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
32 -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
24 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
25 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
26 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -47,6 +38,7 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
38 golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
39 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
40 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
41 +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
42 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
43 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
44 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=