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
+}