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=