feat: refactor command-line flags for portal tunnel and remove broccoli dependency
gosunuts committed
Feb 26, 2026 at 11:34 UTC
70fde217091437c7540a9caa2e22524ea15f6c98
3 files changed
+70
-88
cmd/portal-tunnel/main.go
+70
-85
@@ -2,6 +2,7 @@ package main
2
3
import (
4
"context"
5
+ "flag"
6
"fmt"
7
"io"
8
"net"
@@ -12,59 +13,62 @@ import (
13
"syscall"
14
"time"
15
16
+ "github.com/rs/zerolog"
17
"github.com/rs/zerolog/log"
16
- "gopkg.eu.org/broccoli"
18
+
19
"gosuda.org/portal/sdk"
20
)
21
20
-type Config struct {
21
- _ struct{} `version:"0.0.1" command:"portal-tunnel" about:"Expose local services through Portal relay"`
22
+var (
23
+ flagRelayURLs string
24
+ flagHost string
25
+ flagName string
26
+ flagTLSEnable bool
27
+ flagProtocols string
28
+ flagDescription string
29
+ flagTags string
30
+ flagThumbnail string
31
+ flagOwner string
32
+ flagHide bool
33
+)
34
23
- RelayURLs string `flag:"relay" env:"RELAYS" default:"http://localhost:4017" about:"Portal relay server API URLs (comma-separated, http/https)"`
24
- Host string `flag:"host" env:"APP_HOST" about:"Target host to proxy to (host:port or URL)"`
25
- Name string `flag:"name" env:"APP_NAME" about:"Service name"`
35
+func main() {
36
+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
37
27
- // TLS Mode
28
- TLSEnable bool `flag:"tls" env:"TLS_ENABLE" default:"true" about:"Enable TLS termination on tunnel client (uses relay ACME DNS-01)"`
38
+ defaultRelayURLs := os.Getenv("RELAYS")
39
+ if defaultRelayURLs == "" {
40
+ defaultRelayURLs = "http://localhost:4017"
41
+ }
42
30
- // Metadata
31
- Protocols string `flag:"protocols" env:"APP_PROTOCOLS" default:"http/1.1,h2" about:"ALPN protocols (comma-separated)"`
32
- Description string `flag:"description" env:"APP_DESCRIPTION" about:"Service description metadata"`
33
- Tags string `flag:"tags" env:"APP_TAGS" about:"Service tags metadata (comma-separated)"`
34
- Thumbnail string `flag:"thumbnail" env:"APP_THUMBNAIL" about:"Service thumbnail URL metadata"`
35
- Owner string `flag:"owner" env:"APP_OWNER" about:"Service owner metadata"`
36
- Hide bool `flag:"hide" env:"APP_HIDE" about:"Hide service from discovery (metadata)"`
37
-}
43
+ flag.StringVar(&flagRelayURLs, "relay", defaultRelayURLs, "Portal relay server API URLs (comma-separated, http/https) [env: RELAYS]")
44
+ flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
45
+ flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
46
39
-func main() {
40
- var cfg Config
41
- app, err := broccoli.NewApp(&cfg)
42
- if err != nil {
43
- log.Error().Err(err).Msg("Failed to create app")
44
- os.Exit(1)
45
- }
47
+ defaultTLS := os.Getenv("TLS_ENABLE") != "false"
48
+ flag.BoolVar(&flagTLSEnable, "tls", defaultTLS, "Enable TLS termination on tunnel client (uses relay ACME DNS-01) [env: TLS_ENABLE]")
49
47
- if _, _, err = app.Bind(&cfg, os.Args[1:]); err != nil {
48
- if err == broccoli.ErrHelp {
49
- fmt.Println(app.Help())
50
- os.Exit(0)
51
- }
50
+ flag.StringVar(&flagProtocols, "protocols", os.Getenv("APP_PROTOCOLS"), "ALPN protocols (comma-separated) [env: APP_PROTOCOLS]")
51
+ flag.StringVar(&flagDescription, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
52
+ flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
53
+ flag.StringVar(&flagThumbnail, "thumbnail", os.Getenv("APP_THUMBNAIL"), "Service thumbnail URL metadata [env: APP_THUMBNAIL]")
54
+ flag.StringVar(&flagOwner, "owner", os.Getenv("APP_OWNER"), "Service owner metadata [env: APP_OWNER]")
55
53
- fmt.Println(app.Help())
54
- log.Error().Err(err).Msg("Failed to bind CLI arguments")
55
- os.Exit(1)
56
- }
56
+ defaultHide := os.Getenv("APP_HIDE") == "true"
57
+ flag.BoolVar(&flagHide, "hide", defaultHide, "Hide service from discovery (metadata) [env: APP_HIDE]")
58
+
59
+ flag.Parse()
60
58
- if cfg.Host == "" || cfg.Name == "" {
59
- fmt.Println(app.Help())
61
+ if flagHost == "" || flagName == "" {
62
+ flag.Usage()
63
os.Exit(1)
64
}
65
63
- relayURLs := parseURLs(cfg.RelayURLs)
66
+ relayURLs := parseURLs(flagRelayURLs)
67
if len(relayURLs) == 0 {
68
log.Error().Msg("--relay must include at least one non-empty URL")
69
os.Exit(1)
70
}
71
+
72
ctx, cancel := context.WithCancel(context.Background())
73
defer cancel()
74
@@ -78,7 +82,7 @@ func main() {
82
cancel()
83
}()
84
81
- if err := runServiceTunnel(ctx, relayURLs, cfg, "flags"); err != nil {
85
+ if err := runServiceTunnel(ctx, relayURLs); err != nil {
86
log.Error().Err(err).Msg("Exited with error")
87
os.Exit(1)
88
}
@@ -86,46 +90,42 @@ func main() {
90
log.Info().Msg("Tunnel stopped")
91
}
92
89
-func runServiceTunnel(ctx context.Context, relayURLs []string, cfg Config, origin string) error {
93
+func runServiceTunnel(ctx context.Context, relayURLs []string) error {
94
if len(relayURLs) == 0 {
95
return fmt.Errorf("no relay URLs provided")
96
}
97
94
- log.Info().Str("service", cfg.Name).Msgf("Local service is reachable at %s", cfg.Host)
95
- log.Info().Str("service", cfg.Name).Msgf("Starting Portal Tunnel (%s)...", origin)
96
- log.Info().Str("service", cfg.Name).Msgf(" Local: %s", cfg.Host)
97
- log.Info().Str("service", cfg.Name).Msgf(" Relays: %s", strings.Join(relayURLs, ", "))
98
- log.Info().Str("service", cfg.Name).Msgf(" TLS Mode: %v", cfg.TLSEnable)
98
+ log.Info().Str("service", flagName).Msgf("Local service is reachable at %s", flagHost)
99
+ log.Info().Str("service", flagName).Msg("Starting Portal Tunnel...")
100
+ log.Info().Str("service", flagName).Msgf(" Local: %s", flagHost)
101
+ log.Info().Str("service", flagName).Msgf(" Relays: %s", strings.Join(relayURLs, ", "))
102
+ log.Info().Str("service", flagName).Msgf(" TLS Mode: %v", flagTLSEnable)
103
100
- // Build SDK client options
104
var clientOpts []sdk.ClientOption
105
clientOpts = append(clientOpts, sdk.WithBootstrapServers(relayURLs))
106
104
- // Configure TLS if enabled
105
- if cfg.TLSEnable {
107
+ if flagTLSEnable {
108
clientOpts = append(clientOpts, sdk.WithTLS())
107
- log.Info().Str("service", cfg.Name).Msg("TLS: Using relay ACME DNS-01 (E2EE)")
109
+ log.Info().Str("service", flagName).Msg("TLS: Using relay ACME DNS-01 (E2EE)")
110
}
111
112
client, err := sdk.NewClient(clientOpts...)
113
if err != nil {
112
- return fmt.Errorf("service %s: failed to create client: %w", cfg.Name, err)
114
+ return fmt.Errorf("service %s: failed to create client: %w", flagName, err)
115
}
116
defer client.Close()
117
116
- // Create metadata options
118
metadataOptions := []sdk.MetadataOption{
118
- sdk.WithDescription(cfg.Description),
119
- sdk.WithTags(splitCSV(cfg.Tags)),
120
- sdk.WithOwner(cfg.Owner),
121
- sdk.WithThumbnail(cfg.Thumbnail),
122
- sdk.WithHide(cfg.Hide),
119
+ sdk.WithDescription(flagDescription),
120
+ sdk.WithTags(splitCSV(flagTags)),
121
+ sdk.WithOwner(flagOwner),
122
+ sdk.WithThumbnail(flagThumbnail),
123
+ sdk.WithHide(flagHide),
124
}
125
125
- // Create listener (with or without TLS based on config)
126
- listener, err := client.Listen(cfg.Name, metadataOptions...)
126
+ listener, err := client.Listen(flagName, metadataOptions...)
127
if err != nil {
128
- return fmt.Errorf("service %s: failed to register service: %w", cfg.Name, err)
128
+ return fmt.Errorf("service %s: failed to register service: %w", flagName, err)
129
}
130
defer listener.Close()
131
@@ -134,17 +134,17 @@ func runServiceTunnel(ctx context.Context, relayURLs []string, cfg Config, origi
134
_ = listener.Close()
135
}()
136
137
- log.Info().Str("service", cfg.Name).Msg("")
138
- log.Info().Str("service", cfg.Name).Msg("Access via:")
139
- log.Info().Str("service", cfg.Name).Msgf("- Relay: %s", relayURLs[0])
137
+ log.Info().Str("service", flagName).Msg("")
138
+ log.Info().Str("service", flagName).Msg("Access via:")
139
+ log.Info().Str("service", flagName).Msgf("- Relay: %s", relayURLs[0])
140
if leaseAware, ok := listener.(interface{ LeaseID() string }); ok {
141
- log.Info().Str("service", cfg.Name).Msgf("- Lease ID: %s", leaseAware.LeaseID())
141
+ log.Info().Str("service", flagName).Msgf("- Lease ID: %s", leaseAware.LeaseID())
142
}
143
- if cfg.TLSEnable {
144
- log.Info().Str("service", cfg.Name).Msg("- TLS: Enabled")
143
+ if flagTLSEnable {
144
+ log.Info().Str("service", flagName).Msg("- TLS: Enabled")
145
}
146
147
- log.Info().Str("service", cfg.Name).Msg("")
147
+ log.Info().Str("service", flagName).Msg("")
148
149
connCount := 0
150
var connWG sync.WaitGroup
@@ -162,30 +162,29 @@ func runServiceTunnel(ctx context.Context, relayURLs []string, cfg Config, origi
162
case <-ctx.Done():
163
return nil
164
default:
165
- log.Error().Str("service", cfg.Name).Err(err).Msg("Failed to accept connection")
165
+ log.Error().Str("service", flagName).Err(err).Msg("Failed to accept connection")
166
continue
167
}
168
}
169
170
connCount++
171
- log.Info().Str("service", cfg.Name).Msgf("→ [#%d] New connection from %s", connCount, relayConn.RemoteAddr())
171
+ log.Info().Str("service", flagName).Msgf("→ [#%d] New connection from %s", connCount, relayConn.RemoteAddr())
172
173
connWG.Add(1)
174
go func(relayConn net.Conn) {
175
defer connWG.Done()
176
proxyType := "TCP"
177
- if cfg.TLSEnable {
177
+ if flagTLSEnable {
178
proxyType = "TLS→TCP"
179
}
180
- if err := proxyConnection(ctx, cfg.Host, relayConn, cfg.TLSEnable); err != nil {
181
- log.Error().Str("service", cfg.Name).Str("proxy", proxyType).Err(err).Msg("Proxy error")
180
+ if err := proxyConnection(ctx, flagHost, relayConn, flagTLSEnable); err != nil {
181
+ log.Error().Str("service", flagName).Str("proxy", proxyType).Err(err).Msg("Proxy error")
182
}
183
- log.Info().Str("service", cfg.Name).Str("proxy", proxyType).Msg("Connection closed")
183
+ log.Info().Str("service", flagName).Str("proxy", proxyType).Msg("Connection closed")
184
}(relayConn)
185
}
186
}
187
188
-// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
188
func parseURLs(raw string) []string {
189
raw = strings.TrimSpace(raw)
190
if raw == "" {
@@ -214,9 +213,6 @@ func splitCSV(raw string) []string {
213
return out
214
}
215
217
-// bufferPool provides reusable 64KB buffers for io.CopyBuffer to eliminate
218
-// per-copy allocations and reduce GC pressure under high concurrency.
219
-// Using *[]byte to avoid interface boxing allocation in sync.Pool.
216
var bufferPool = sync.Pool{
217
New: func() any {
218
b := make([]byte, 64*1024)
@@ -224,12 +220,9 @@ var bufferPool = sync.Pool{
220
},
221
}
222
227
-// proxyConnection proxies data between relay and local service using raw TCP.
228
-// It ensures complete data transfer before closing connections.
223
func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn, tlsEnabled bool) error {
224
defer relayConn.Close()
225
232
- // Try to connect to local service (no retry)
226
dialer := &net.Dialer{Timeout: 5 * time.Second}
227
localConn, err := dialer.DialContext(ctx, "tcp", localAddr)
228
if err != nil {
@@ -246,11 +239,9 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
239
240
log.Info().Str("local_addr", localAddr).Msg("Connected to local service")
241
249
- // Use bidirectional copy with proper error handling
242
errCh := make(chan error, 2)
243
stopCh := make(chan struct{})
244
253
- // Context cancellation handler
245
go func() {
246
select {
247
case <-ctx.Done():
@@ -260,7 +251,6 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
251
}
252
}()
253
263
- // Relay -> Local
254
go func() {
255
bufPtr := bufferPool.Get().(*[]byte)
256
defer bufferPool.Put(bufPtr)
@@ -268,14 +258,12 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
258
if err != nil {
259
log.Debug().Err(err).Msg("relay->local copy ended")
260
}
271
- // Shut down localConn write side to signal EOF to local service
261
if tcpConn, ok := localConn.(*net.TCPConn); ok {
262
tcpConn.CloseWrite()
263
}
264
errCh <- err
265
}()
266
278
- // Local -> Relay
267
go func() {
268
bufPtr := bufferPool.Get().(*[]byte)
269
defer bufferPool.Put(bufPtr)
@@ -286,9 +274,8 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
274
errCh <- err
275
}()
276
289
- // Wait for both directions to finish
277
var firstErr error
291
- for i := 0; i < 2; i++ {
278
+ for range 2 {
279
if err := <-errCh; err != nil && firstErr == nil {
280
firstErr = err
281
}
@@ -298,8 +285,6 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn,
285
return firstErr
286
}
287
301
-// writeEmptyHTTPResponse writes an HTML response indicating the service is unavailable.
302
-// Used when the local service is unavailable to avoid showing browser error pages.
288
func writeEmptyHTTPResponse(conn net.Conn) error {
289
htmlBody := `<!DOCTYPE html>
290
<html>
go.mod
-1
@@ -6,7 +6,6 @@ require (
6
github.com/go-acme/lego/v4 v4.32.0
7
github.com/rs/zerolog v1.34.0
8
golang.org/x/net v0.50.0
9
- gopkg.eu.org/broccoli v1.2.4
9
)
10
11
require (
go.sum
-2
@@ -74,7 +74,5 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
74
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
75
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
76
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
77
-gopkg.eu.org/broccoli v1.2.4 h1:9RvAPhBI6QCakVCDBw0QwojVmqK3qmcj0ib66CxK+kY=
78
-gopkg.eu.org/broccoli v1.2.4/go.mod h1:eM8HnmLyfiQHAwqh2afErWYnAkkOvi+RXgoXBRhKMCQ=
77
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
78
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=