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=