chore: tidy utils

Kim committed Mar 11, 2026 at 13:51 UTC 1c0e6bf78eac3b0120478672fe6549c2caff0a5d
11 files changed +181 -176
portal/api_server.go
+4 -4
@@ -112,7 +112,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
112 }
113
114 var req types.RegisterRequest
115 - if err := decodeJSONBody(w, r, &req); err != nil {
115 + if err := utils.DecodeJSONBody(w, r, &req, defaultControlBodyLimit); err != nil {
116 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
117 return
118 }
@@ -146,7 +146,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
146 }
147
148 var req types.RenewRequest
149 - if err := decodeJSONBody(w, r, &req); err != nil {
149 + if err := utils.DecodeJSONBody(w, r, &req, defaultControlBodyLimit); err != nil {
150 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
151 return
152 }
@@ -177,7 +177,7 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
177 }
178
179 var req types.UnregisterRequest
180 - if err := decodeJSONBody(w, r, &req); err != nil {
180 + if err := utils.DecodeJSONBody(w, r, &req, defaultControlBodyLimit); err != nil {
181 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
182 return
183 }
@@ -357,7 +357,7 @@ func (s *Server) authorizeLeaseToken(record *leaseRecord, token string) error {
357 if record == nil {
358 return errLeaseNotFound
359 }
360 - if !tokenMatches(record.ReverseToken, token) {
360 + if !utils.TokenMatches(record.ReverseToken, token) {
361 return errUnauthorized
362 }
363 return nil
portal/lease.go
+2 -2
@@ -145,7 +145,7 @@ func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, c
145 if !ok {
146 return nil, errLeaseNotFound
147 }
148 - if !tokenMatches(record.ReverseToken, reverseToken) {
148 + if !utils.TokenMatches(record.ReverseToken, reverseToken) {
149 return nil, errUnauthorized
150 }
151
@@ -167,7 +167,7 @@ func (r *leaseRegistry) Unregister(leaseID, reverseToken string) (*leaseRecord,
167 if !ok {
168 return nil, errLeaseNotFound
169 }
170 - if !tokenMatches(record.ReverseToken, reverseToken) {
170 + if !utils.TokenMatches(record.ReverseToken, reverseToken) {
171 return nil, errUnauthorized
172 }
173
portal/server.go
+39 -2
@@ -241,6 +241,16 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
241 return apiTLS, manager, nil
242 }
243
244 +func validateAPITLS(apiTLS keyless.TLSMaterialConfig) error {
245 + if len(apiTLS.CertPEM) == 0 {
246 + return errors.New("api tls certificate is required")
247 + }
248 + if len(apiTLS.KeyPEM) == 0 && apiTLS.Keyless == nil {
249 + return errors.New("api tls key or keyless signer is required")
250 + }
251 + return nil
252 +}
253 +
254 func (s *Server) runSNIListener(ctx context.Context) error {
255 for {
256 conn, err := s.sniListener.Accept()
@@ -292,7 +302,7 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
302 return
303 }
304
295 - bridgeConns(wrappedConn, session.Conn())
305 + BridgeConns(wrappedConn, session.Conn())
306 _ = session.Close()
307 }
308
@@ -307,7 +317,7 @@ func (s *Server) bridgeToAPI(ctx context.Context, conn net.Conn) {
317 _ = conn.Close()
318 return
319 }
310 - bridgeConns(conn, upstream)
320 + BridgeConns(conn, upstream)
321 }
322
323 func (s *Server) watchContext(ctx context.Context) error {
@@ -316,3 +326,30 @@ func (s *Server) watchContext(ctx context.Context) error {
326 defer cancel()
327 return s.Shutdown(shutdownCtx)
328 }
329 +
330 +func BridgeConns(left, right net.Conn) {
331 + defer left.Close()
332 + defer right.Close()
333 +
334 + var group errgroup.Group
335 + group.Go(func() error {
336 + _, err := io.Copy(right, left)
337 + closeWrite(right)
338 + return err
339 + })
340 + group.Go(func() error {
341 + _, err := io.Copy(left, right)
342 + closeWrite(left)
343 + return err
344 + })
345 + _ = group.Wait()
346 +}
347 +
348 +func closeWrite(conn net.Conn) {
349 + type closeWriter interface {
350 + CloseWrite() error
351 + }
352 + if cw, ok := conn.(closeWriter); ok {
353 + _ = cw.CloseWrite()
354 + }
355 +}
portal/utils.go deleted
-64
@@ -1,64 +0,0 @@
1 -package portal
2 -
3 -import (
4 - "crypto/subtle"
5 - "encoding/json"
6 - "errors"
7 - "io"
8 - "net"
9 - "net/http"
10 -
11 - "golang.org/x/sync/errgroup"
12 -
13 - "github.com/gosuda/portal/v2/portal/keyless"
14 -)
15 -
16 -func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
17 - r.Body = http.MaxBytesReader(w, r.Body, defaultControlBodyLimit)
18 - defer r.Body.Close()
19 - return json.NewDecoder(r.Body).Decode(dst)
20 -}
21 -
22 -func tokenMatches(expected, actual string) bool {
23 - if len(expected) == 0 || len(actual) == 0 {
24 - return false
25 - }
26 - return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
27 -}
28 -
29 -func validateAPITLS(apiTLS keyless.TLSMaterialConfig) error {
30 - if len(apiTLS.CertPEM) == 0 {
31 - return errors.New("api tls certificate is required")
32 - }
33 - if len(apiTLS.KeyPEM) == 0 && apiTLS.Keyless == nil {
34 - return errors.New("api tls key or keyless signer is required")
35 - }
36 - return nil
37 -}
38 -
39 -func bridgeConns(left, right net.Conn) {
40 - defer left.Close()
41 - defer right.Close()
42 -
43 - var group errgroup.Group
44 - group.Go(func() error {
45 - _, err := io.Copy(right, left)
46 - closeWrite(right)
47 - return err
48 - })
49 - group.Go(func() error {
50 - _, err := io.Copy(left, right)
51 - closeWrite(left)
52 - return err
53 - })
54 - _ = group.Wait()
55 -}
56 -
57 -func closeWrite(conn net.Conn) {
58 - type closeWriter interface {
59 - CloseWrite() error
60 - }
61 - if cw, ok := conn.(closeWriter); ok {
62 - _ = cw.CloseWrite()
63 - }
64 -}
sdk/api_client.go
+3 -26
@@ -5,9 +5,7 @@ import (
5 "bytes"
6 "context"
7 "crypto/tls"
8 - "crypto/x509"
8 "encoding/json"
10 - "errors"
9 "fmt"
10 "io"
11 "net"
@@ -79,7 +77,7 @@ func newApiClient(ctx context.Context, relayURL string, cfg ListenerConfig) (*ap
77 rootCAPEM = resolvedCAPEM
78 }
79
82 - rootCAs, err := buildRootCAs(rootCAPEM)
80 + rootCAs, err := utils.CertPoolFromPEM(rootCAPEM)
81 if err != nil {
82 return nil, err
83 }
@@ -106,7 +104,7 @@ func newApiClient(ctx context.Context, relayURL string, cfg ListenerConfig) (*ap
104 dialTimeout: dialTimeout,
105 name: name,
106 reverseToken: reverseToken,
109 - metadata: cloneMetadata(cfg.Metadata),
107 + metadata: cfg.Metadata.Copy(),
108 }
109
110 if err := api.ensureCompatible(ctx); err != nil {
@@ -129,7 +127,7 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration) (types
127 var resp types.RegisterResponse
128 if err := a.doJSON(ctx, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
129 Name: a.name,
132 - Metadata: cloneMetadata(a.metadata),
130 + Metadata: a.metadata.Copy(),
131 ReverseToken: a.reverseToken,
132 TTL: int(ttl / time.Second),
133 }, &resp); err != nil {
@@ -248,27 +246,6 @@ func (a *apiClient) doJSON(ctx context.Context, method, path string, payload any
246 return json.Unmarshal(envelope.Data, out)
247 }
248
251 -func buildRootCAs(rootCAPEM []byte) (*x509.CertPool, error) {
252 - if len(rootCAPEM) == 0 {
253 - return nil, nil
254 - }
255 - pool := x509.NewCertPool()
256 - if !pool.AppendCertsFromPEM(rootCAPEM) {
257 - return nil, errors.New("failed to parse relay root ca")
258 - }
259 - return pool, nil
260 -}
261 -
262 -func cloneMetadata(metadata types.LeaseMetadata) types.LeaseMetadata {
263 - return types.LeaseMetadata{
264 - Description: metadata.Description,
265 - Owner: metadata.Owner,
266 - Thumbnail: metadata.Thumbnail,
267 - Tags: append([]string(nil), metadata.Tags...),
268 - Hide: metadata.Hide,
269 - }
270 -}
271 -
249 type bufferedConn struct {
250 net.Conn
251 reader *bytes.Reader
sdk/expose.go
+5 -12
@@ -100,7 +100,7 @@ func (e *Exposure) Accept() (net.Conn, error) {
100 logger := log.With().Str("component", "sdk-exposure").Logger()
101 logger.Warn().
102 Err(err).
103 - Str("local_addr", exposureAddrString(e.listener.Addr())).
103 + Str("local_addr", utils.AddrString(e.listener.Addr())).
104 Msg("exposure accept failed")
105 }
106 return nil, err
@@ -110,15 +110,15 @@ func (e *Exposure) Accept() (net.Conn, error) {
110 logger := log.With().Str("component", "sdk-exposure").Logger()
111 logger.Info().
112 Uint64("conn_id", connID).
113 - Str("local_addr", exposureAddrString(conn.LocalAddr())).
114 - Str("remote_addr", exposureAddrString(conn.RemoteAddr())).
113 + Str("local_addr", utils.AddrString(conn.LocalAddr())).
114 + Str("remote_addr", utils.AddrString(conn.RemoteAddr())).
115 Msg("exposure connection accepted")
116
117 return &exposureConn{
118 Conn: conn,
119 id: connID,
120 - localAddr: exposureAddrString(conn.LocalAddr()),
121 - remoteAddr: exposureAddrString(conn.RemoteAddr()),
120 + localAddr: utils.AddrString(conn.LocalAddr()),
121 + remoteAddr: utils.AddrString(conn.RemoteAddr()),
122 }, nil
123 }
124
@@ -353,13 +353,6 @@ func (c *exposureConn) Close() error {
353 return closeErr
354 }
355
356 -func exposureAddrString(addr net.Addr) string {
357 - if addr == nil {
358 - return ""
359 - }
360 - return addr.String()
361 -}
362 -
356 // mergeListeners fans in multiple listeners into one net.Listener. It keeps
357 // serving accepts from remaining listeners when one listener stops, and returns
358 // a terminal error only after all source listeners have stopped.
sdk/listener.go
+5 -16
@@ -111,7 +111,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
111 l.mu.Lock()
112 l.leaseID = resp.LeaseID
113 l.hostname = resp.Hostname
114 - l.metadata = cloneMetadata(resp.Metadata)
114 + l.metadata = resp.Metadata.Copy()
115 l.tlsConfig = tlsConf
116 l.tlsCloser = tlsCloser
117 l.mu.Unlock()
@@ -190,7 +190,7 @@ func (l *Listener) Hostname() string {
190 func (l *Listener) Metadata() types.LeaseMetadata {
191 l.mu.Lock()
192 defer l.mu.Unlock()
193 - return cloneMetadata(l.metadata)
193 + return l.metadata.Copy()
194 }
195
196 func (l *Listener) PublicURL() string {
@@ -240,7 +240,7 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
240 }
241
242 for {
243 - if !sleepOrDone(ctx, interval) {
243 + if !utils.SleepOrDone(ctx, interval) {
244 return
245 }
246
@@ -376,7 +376,7 @@ func (l *Listener) reregister(ctx context.Context) error {
376 oldCloser := l.tlsCloser
377 l.leaseID = resp.LeaseID
378 l.hostname = resp.Hostname
379 - l.metadata = cloneMetadata(resp.Metadata)
379 + l.metadata = resp.Metadata.Copy()
380 l.tlsConfig = tlsConf
381 l.tlsCloser = tlsCloser
382 l.mu.Unlock()
@@ -418,18 +418,7 @@ func (l *Listener) retryOrClose(ctx context.Context, operation string, err error
418 Dur("retry_wait", l.retryWait).
419 Msg("operation failed; retrying")
420
421 - return sleepOrDone(ctx, l.retryWait)
422 -}
423 -
424 -func sleepOrDone(ctx context.Context, d time.Duration) bool {
425 - timer := time.NewTimer(d)
426 - defer timer.Stop()
427 - select {
428 - case <-ctx.Done():
429 - return false
430 - case <-timer.C:
431 - return true
432 - }
421 + return utils.SleepOrDone(ctx, l.retryWait)
422 }
423
424 type listenerAddr string
types/api.go
+10
@@ -68,6 +68,16 @@ type LeaseMetadata struct {
68 Hide bool `json:"hide,omitempty"`
69 }
70
71 +func (m LeaseMetadata) Copy() LeaseMetadata {
72 + return LeaseMetadata{
73 + Description: m.Description,
74 + Owner: m.Owner,
75 + Thumbnail: m.Thumbnail,
76 + Tags: append([]string(nil), m.Tags...),
77 + Hide: m.Hide,
78 + }
79 +}
80 +
81 type RegisterRequest struct {
82 Name string `json:"name"`
83 ReverseToken string `json:"reverse_token"`
utils/api.go
+6
@@ -77,3 +77,9 @@ func DecodeAPIRequestError(resp *http.Response) error {
77 Message: strings.TrimSpace(string(body)),
78 }
79 }
80 +
81 +func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst any, maxBytes int64) error {
82 + r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
83 + defer r.Body.Close()
84 + return json.NewDecoder(r.Body).Decode(dst)
85 +}
utils/utils.go
+94 -50
@@ -1,7 +1,10 @@
1 package utils
2
3 import (
4 + "context"
5 "crypto/rand"
6 + "crypto/subtle"
7 + "crypto/x509"
8 "encoding/hex"
9 "errors"
10 "fmt"
@@ -11,6 +14,7 @@ import (
14 "time"
15 )
16
17 +// Input parsing and normalization.
18 func SplitCSV(raw string) []string {
19 if strings.TrimSpace(raw) == "" {
20 return nil
@@ -27,30 +31,6 @@ func SplitCSV(raw string) []string {
31 return out
32 }
33
30 -func NormalizeRelayURLs(inputs []string) ([]string, error) {
31 - out := make([]string, 0, len(inputs))
32 - seen := make(map[string]struct{}, len(inputs))
33 -
34 - for _, input := range inputs {
35 - for _, part := range SplitCSV(input) {
36 - normalized, err := NormalizeRelayURL(part)
37 - if err != nil {
38 - return nil, err
39 - }
40 - if _, ok := seen[normalized]; ok {
41 - continue
42 - }
43 - seen[normalized] = struct{}{}
44 - out = append(out, normalized)
45 - }
46 - }
47 -
48 - if len(out) == 0 {
49 - return nil, nil
50 - }
51 - return out, nil
52 -}
53 -
34 func NormalizeDNSLabel(raw string) (string, error) {
35 label := NormalizeHostname(raw)
36 if label == "" {
@@ -74,18 +54,6 @@ func NormalizeDNSLabel(raw string) (string, error) {
54 return label, nil
55 }
56
77 -func LeaseHostname(name, rootHost string) (string, error) {
78 - label, err := NormalizeDNSLabel(name)
79 - if err != nil {
80 - return "", err
81 - }
82 - rootHost = NormalizeHostname(rootHost)
83 - if rootHost == "" {
84 - return "", errors.New("root host is required")
85 - }
86 - return label + "." + rootHost, nil
87 -}
88 -
57 func NormalizeRelayURL(raw string) (string, error) {
58 trimmed := strings.TrimSpace(raw)
59 if trimmed == "" {
@@ -121,6 +89,57 @@ func NormalizeRelayURL(raw string) (string, error) {
89 return parsed.String(), nil
90 }
91
92 +func PortalRootHost(portalURL string) string {
93 + u, err := url.Parse(strings.TrimSpace(portalURL))
94 + if err != nil || u.Host == "" {
95 + return ""
96 + }
97 + return NormalizeHostname(u.Hostname())
98 +}
99 +
100 +func NormalizeHostname(host string) string {
101 + host = strings.TrimSpace(strings.ToLower(host))
102 + host = strings.TrimSuffix(host, ".")
103 + return host
104 +}
105 +
106 +func NormalizeRelayURLs(inputs []string) ([]string, error) {
107 + out := make([]string, 0, len(inputs))
108 + seen := make(map[string]struct{}, len(inputs))
109 +
110 + for _, input := range inputs {
111 + for _, part := range SplitCSV(input) {
112 + normalized, err := NormalizeRelayURL(part)
113 + if err != nil {
114 + return nil, err
115 + }
116 + if _, ok := seen[normalized]; ok {
117 + continue
118 + }
119 + seen[normalized] = struct{}{}
120 + out = append(out, normalized)
121 + }
122 + }
123 +
124 + if len(out) == 0 {
125 + return nil, nil
126 + }
127 + return out, nil
128 +}
129 +
130 +func LeaseHostname(name, rootHost string) (string, error) {
131 + label, err := NormalizeDNSLabel(name)
132 + if err != nil {
133 + return "", err
134 + }
135 + rootHost = NormalizeHostname(rootHost)
136 + if rootHost == "" {
137 + return "", errors.New("root host is required")
138 + }
139 + return label + "." + rootHost, nil
140 +}
141 +
142 +// Network and transport helpers.
143 func NormalizeTargetAddr(raw string) (string, error) {
144 raw = strings.TrimSpace(raw)
145 if raw == "" {
@@ -162,20 +181,6 @@ func NormalizeTargetAddr(raw string) (string, error) {
181 return "", fmt.Errorf("invalid target address %q", raw)
182 }
183
165 -func PortalRootHost(portalURL string) string {
166 - u, err := url.Parse(strings.TrimSpace(portalURL))
167 - if err != nil || u.Host == "" {
168 - return ""
169 - }
170 - return NormalizeHostname(u.Hostname())
171 -}
172 -
173 -func NormalizeHostname(host string) string {
174 - host = strings.TrimSpace(strings.ToLower(host))
175 - host = strings.TrimSuffix(host, ".")
176 - return host
177 -}
178 -
184 func HostPortOrLoopback(addr string) string {
185 host, port, err := net.SplitHostPort(addr)
186 if err != nil {
@@ -206,6 +211,33 @@ func IsLocalRelayHost(host string) bool {
211 return strings.HasSuffix(host, ".localhost")
212 }
213
214 +func AddrString(addr net.Addr) string {
215 + if addr == nil {
216 + return ""
217 + }
218 + return addr.String()
219 +}
220 +
221 +// Security and TLS helpers.
222 +func TokenMatches(expected, actual string) bool {
223 + if len(expected) == 0 || len(actual) == 0 {
224 + return false
225 + }
226 + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
227 +}
228 +
229 +func CertPoolFromPEM(rootCAPEM []byte) (*x509.CertPool, error) {
230 + if len(rootCAPEM) == 0 {
231 + return nil, nil
232 + }
233 + pool := x509.NewCertPool()
234 + if !pool.AppendCertsFromPEM(rootCAPEM) {
235 + return nil, errors.New("failed to parse relay root ca")
236 + }
237 + return pool, nil
238 +}
239 +
240 +// Generic value helpers.
241 func DurationOrDefault(v, fallback time.Duration) time.Duration {
242 if v > 0 {
243 return v
@@ -213,6 +245,17 @@ func DurationOrDefault(v, fallback time.Duration) time.Duration {
245 return fallback
246 }
247
248 +func SleepOrDone(ctx context.Context, d time.Duration) bool {
249 + timer := time.NewTimer(d)
250 + defer timer.Stop()
251 + select {
252 + case <-ctx.Done():
253 + return false
254 + case <-timer.C:
255 + return true
256 + }
257 +}
258 +
259 func IntOrDefault(v, fallback int) int {
260 if v > 0 {
261 return v
@@ -220,6 +263,7 @@ func IntOrDefault(v, fallback int) int {
263 return fallback
264 }
265
266 +// Random value helpers.
267 func RandomID(prefix string) string {
268 buf := make([]byte, 8)
269 if _, err := rand.Read(buf); err != nil {
utils/utils_test.go
+13
@@ -1,9 +1,11 @@
1 package utils
2
3 import (
4 + "context"
5 "reflect"
6 "strings"
7 "testing"
8 + "time"
9 )
10
11 func TestNormalizeRelayURLs(t *testing.T) {
@@ -73,3 +75,14 @@ func TestRandomID(t *testing.T) {
75 t.Fatalf("RandomID() length = %d, want %d", len(got), len("tok_")+16)
76 }
77 }
78 +
79 +func TestSleepOrDoneCanceled(t *testing.T) {
80 + t.Parallel()
81 +
82 + ctx, cancel := context.WithCancel(context.Background())
83 + cancel()
84 +
85 + if SleepOrDone(ctx, time.Second) {
86 + t.Fatal("SleepOrDone() = true, want false for canceled context")
87 + }
88 +}