refact: consolidate util method
Kim committed
Mar 11, 2026 at 12:08 UTC
649a47cd9882158aa77e01995b01becb5bb1bf62
20 files changed
+577
-535
cmd/demo-app/main.go
+4
-3
@@ -15,6 +15,7 @@ import (
15
16
"github.com/gosuda/portal/v2/sdk"
17
"github.com/gosuda/portal/v2/types"
18
+ "github.com/gosuda/portal/v2/utils"
19
)
20
21
var (
@@ -54,9 +55,9 @@ func runDemo() error {
55
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
56
defer stop()
57
57
- exposure, err := sdk.Expose(ctx, sdk.SplitCSV(flagServerURLs), flagName, types.LeaseMetadata{
58
+ exposure, err := sdk.Expose(ctx, utils.SplitCSV(flagServerURLs), flagName, types.LeaseMetadata{
59
Description: flagDesc,
59
- Tags: sdk.SplitCSV(flagTags),
60
+ Tags: utils.SplitCSV(flagTags),
61
Owner: flagOwner,
62
Thumbnail: flagThumbnail,
63
Hide: flagHide,
@@ -69,7 +70,7 @@ func runDemo() error {
70
logger.Info().Msg("demo app running without relay")
71
}
72
72
- flagAddr, err := sdk.NormalizeTargetAddr(flagAddr)
73
+ flagAddr, err := utils.NormalizeTargetAddr(flagAddr)
74
if err != nil {
75
return fmt.Errorf("invalid --addr value %q: %w", flagAddr, err)
76
}
cmd/portal-tunnel/main.go
+3
-2
@@ -17,6 +17,7 @@ import (
17
18
"github.com/gosuda/portal/v2/sdk"
19
"github.com/gosuda/portal/v2/types"
20
+ "github.com/gosuda/portal/v2/utils"
21
)
22
23
var (
@@ -62,9 +63,9 @@ func runTunnel() error {
63
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
64
defer stop()
65
65
- exposure, err := sdk.Expose(ctx, sdk.SplitCSV(flagRelayURLs), flagName, types.LeaseMetadata{
66
+ exposure, err := sdk.Expose(ctx, utils.SplitCSV(flagRelayURLs), flagName, types.LeaseMetadata{
67
Description: flagDesc,
67
- Tags: sdk.SplitCSV(flagTags),
68
+ Tags: utils.SplitCSV(flagTags),
69
Owner: flagOwner,
70
Thumbnail: flagThumbnail,
71
Hide: flagHide,
cmd/portal-tunnel/relays.go
+2
-2
@@ -12,7 +12,7 @@ import (
12
13
"github.com/rs/zerolog/log"
14
15
- "github.com/gosuda/portal/v2/sdk"
15
+ "github.com/gosuda/portal/v2/utils"
16
)
17
18
func proxyRelayConnections(ctx context.Context, relayListener net.Listener, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64) error {
@@ -58,7 +58,7 @@ var bufferPool = sync.Pool{
58
func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
59
defer relayConn.Close()
60
61
- targetAddr, err := sdk.NormalizeTargetAddr(localAddr)
61
+ targetAddr, err := utils.NormalizeTargetAddr(localAddr)
62
if err != nil {
63
return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
64
}
cmd/relay-server/main.go
+3
-1
@@ -9,6 +9,8 @@ import (
9
10
"github.com/rs/zerolog"
11
"github.com/rs/zerolog/log"
12
+
13
+ "github.com/gosuda/portal/v2/utils"
14
)
15
16
const (
@@ -92,7 +94,7 @@ func main() {
94
flag.StringVar(&cfg.AWSHostedZoneID, "aws-hosted-zone-id", awsHostedZoneID, "explicit Route53 hosted zone ID override (env: AWS_HOSTED_ZONE_ID)")
95
flag.Parse()
96
95
- cfg.Bootstraps = parseURLs(bootstrapsCSV)
97
+ cfg.Bootstraps = utils.SplitCSV(bootstrapsCSV)
98
if len(cfg.Bootstraps) == 0 {
99
cfg.Bootstraps = []string{cfg.PortalURL}
100
}
cmd/relay-server/serve.go
+3
-17
@@ -15,6 +15,7 @@ import (
15
"github.com/gosuda/portal/v2/portal/acme"
16
"github.com/gosuda/portal/v2/portal/admin"
17
"github.com/gosuda/portal/v2/types"
18
+ "github.com/gosuda/portal/v2/utils"
19
)
20
21
func runServer(cfg relayServerConfig) error {
@@ -26,7 +27,7 @@ func runServer(cfg relayServerConfig) error {
27
if len(cfg.Bootstraps) > 0 && cfg.PortalURL == "" {
28
cfg.PortalURL = cfg.Bootstraps[0]
29
}
29
- rootHost := portal.PortalRootHost(cfg.PortalURL)
30
+ rootHost := utils.PortalRootHost(cfg.PortalURL)
31
apiListenAddr := fmt.Sprintf(":%d", cfg.APIPort)
32
sniListenAddr := fmt.Sprintf(":%d", cfg.SNIPort)
33
server, err := portal.NewServer(portal.ServerConfig{
@@ -65,7 +66,7 @@ func runServer(cfg relayServerConfig) error {
66
}
67
68
logger.Info().
68
- Str("api_addr", portal.HostPortOrLoopback(server.APIAddr())).
69
+ Str("api_addr", utils.HostPortOrLoopback(server.APIAddr())).
70
Str("sni_addr", server.SNIAddr()).
71
Str("root_host", rootHost).
72
Str("acme_dns_provider", cfg.ACMEDNSProvider).
@@ -117,18 +118,3 @@ func frontendRootAssetPaths() []string {
118
"/portal.jpg",
119
}
120
}
120
-
121
-func parseURLs(raw string) []string {
122
- if strings.TrimSpace(raw) == "" {
123
- return nil
124
- }
125
- parts := strings.Split(raw, ",")
126
- out := make([]string, 0, len(parts))
127
- for _, part := range parts {
128
- part = strings.TrimSpace(part)
129
- if part != "" {
130
- out = append(out, part)
131
- }
132
- }
133
- return out
134
-}
portal/admin/handler.go
+40
-68
@@ -10,6 +10,7 @@ import (
10
"github.com/gosuda/portal/v2/portal"
11
"github.com/gosuda/portal/v2/portal/policy"
12
"github.com/gosuda/portal/v2/types"
13
+ "github.com/gosuda/portal/v2/utils"
14
)
15
16
const cookieName = "portal_admin"
@@ -78,11 +79,11 @@ func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
79
}
80
81
if !h.isAuthenticated(r) {
81
- writeAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
82
+ utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
83
return
84
}
85
if h.server == nil {
85
- writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeFeatureUnavailable, "admin handler is not bound to a server")
86
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeFeatureUnavailable, "admin handler is not bound to a server")
87
return
88
}
89
@@ -109,17 +110,17 @@ func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
110
111
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
112
if r.Method != http.MethodPost {
112
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
113
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
114
return
115
}
116
if !h.auth.AuthEnabled() {
116
- writeAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeAuthDisabled, "admin authentication is not configured")
117
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeAuthDisabled, "admin authentication is not configured")
118
return
119
}
120
121
clientIP := policy.ExtractClientIP(r, h.trustProxy)
122
if h.auth.IsIPLocked(clientIP) {
122
- writeAPIErrorWithData(w, http.StatusTooManyRequests, types.APIErrorCodeAuthLocked, "Too many failed attempts. Please try again later.", types.AdminLoginResponse{
123
+ utils.WriteAPIErrorWithData(w, http.StatusTooManyRequests, types.APIErrorCodeAuthLocked, "Too many failed attempts. Please try again later.", types.AdminLoginResponse{
124
Locked: true,
125
RemainingSeconds: h.auth.LockRemainingSeconds(clientIP),
126
})
@@ -128,7 +129,7 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
129
130
var req types.AdminLoginRequest
131
if err := decodeJSON(w, r, &req); err != nil {
131
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
132
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
133
return
134
}
135
if !h.auth.ValidateKey(req.Key) {
@@ -137,14 +138,14 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
138
if locked {
139
resp.RemainingSeconds = h.auth.LockRemainingSeconds(clientIP)
140
}
140
- writeAPIErrorWithData(w, http.StatusUnauthorized, types.APIErrorCodeInvalidKey, "Invalid key", resp)
141
+ utils.WriteAPIErrorWithData(w, http.StatusUnauthorized, types.APIErrorCodeInvalidKey, "Invalid key", resp)
142
return
143
}
144
145
h.auth.ResetFailedLogin(clientIP)
146
token, err := h.auth.CreateSession()
147
if err != nil {
147
- writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeSessionCreateFailed, "failed to create admin session")
148
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeSessionCreateFailed, "failed to create admin session")
149
return
150
}
151
@@ -157,12 +158,12 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
158
SameSite: http.SameSiteStrictMode,
159
MaxAge: 86400,
160
})
160
- writeAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
161
+ utils.WriteAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
162
}
163
164
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
165
if r.Method != http.MethodPost {
165
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
166
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
167
return
168
}
169
@@ -178,15 +179,15 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
179
SameSite: http.SameSiteStrictMode,
180
MaxAge: -1,
181
})
181
- writeAPIOK(w, http.StatusOK)
182
+ utils.WriteAPIOK(w, http.StatusOK)
183
}
184
185
func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
186
if r.Method != http.MethodGet {
186
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
187
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
188
return
189
}
189
- writeAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
190
+ utils.WriteAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
191
Authenticated: h.isAuthenticated(r),
192
AuthEnabled: h.auth.AuthEnabled(),
193
})
@@ -194,27 +195,27 @@ func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
195
196
func (h *Handler) handleLeases(w http.ResponseWriter, r *http.Request) {
197
if r.Method != http.MethodGet {
197
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
198
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
199
return
200
}
200
- writeAPIData(w, http.StatusOK, h.buildLeaseRows(h.server, true))
201
+ utils.WriteAPIData(w, http.StatusOK, h.buildLeaseRows(h.server, true))
202
}
203
204
func (h *Handler) handleBannedLeases(w http.ResponseWriter, r *http.Request) {
205
if r.Method != http.MethodGet {
205
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
206
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
207
return
208
}
208
- writeAPIData(w, http.StatusOK, h.policyRuntime().BannedLeases())
209
+ utils.WriteAPIData(w, http.StatusOK, h.policyRuntime().BannedLeases())
210
}
211
212
func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
213
if r.Method != http.MethodGet {
213
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
214
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
215
return
216
}
217
approver := h.policyRuntime().Approver()
217
- writeAPIData(w, http.StatusOK, types.AdminSettingsResponse{
218
+ utils.WriteAPIData(w, http.StatusOK, types.AdminSettingsResponse{
219
ApprovalMode: string(approver.Mode()),
220
ApprovedLeases: approver.ApprovedLeases(),
221
DeniedLeases: approver.DeniedLeases(),
@@ -226,21 +227,21 @@ func (h *Handler) handleApprovalMode(w http.ResponseWriter, r *http.Request) {
227
approver := runtime.Approver()
228
switch r.Method {
229
case http.MethodGet:
229
- writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
230
+ utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
231
case http.MethodPost:
232
var req types.AdminApprovalModeRequest
233
if err := decodeJSON(w, r, &req); err != nil {
233
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
234
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
235
return
236
}
237
if err := approver.SetMode(policy.Mode(req.Mode)); err != nil {
237
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
238
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
239
return
240
}
241
_ = h.settings.Save(runtime)
241
- writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
242
+ utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
243
default:
243
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
244
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
245
}
246
}
247
@@ -254,7 +255,7 @@ func (h *Handler) handleLeaseAction(w http.ResponseWriter, r *http.Request, path
255
256
leaseID, ok := decodeLeaseID(parts[0])
257
if !ok {
257
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidLeaseID, "invalid lease ID")
258
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidLeaseID, "invalid lease ID")
259
return
260
}
261
@@ -262,7 +263,7 @@ func (h *Handler) handleLeaseAction(w http.ResponseWriter, r *http.Request, path
263
case "ban":
264
h.handleLeaseBan(w, r, leaseID)
265
case "bps":
265
- writeAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "bps control is not enabled in this build")
266
+ utils.WriteAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "bps control is not enabled in this build")
267
case "approve":
268
h.handleLeaseApproval(w, r, leaseID)
269
case "deny":
@@ -278,13 +279,13 @@ func (h *Handler) handleLeaseBan(w http.ResponseWriter, r *http.Request, leaseID
279
case http.MethodPost:
280
runtime.BanLease(leaseID)
281
_ = h.settings.Save(runtime)
281
- writeAPIOK(w, http.StatusOK)
282
+ utils.WriteAPIOK(w, http.StatusOK)
283
case http.MethodDelete:
284
runtime.UnbanLease(leaseID)
285
_ = h.settings.Save(runtime)
285
- writeAPIOK(w, http.StatusOK)
286
+ utils.WriteAPIOK(w, http.StatusOK)
287
default:
287
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
288
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
289
}
290
}
291
@@ -296,13 +297,13 @@ func (h *Handler) handleLeaseApproval(w http.ResponseWriter, r *http.Request, le
297
approver.Approve(leaseID)
298
approver.Undeny(leaseID)
299
_ = h.settings.Save(runtime)
299
- writeAPIOK(w, http.StatusOK)
300
+ utils.WriteAPIOK(w, http.StatusOK)
301
case http.MethodDelete:
302
approver.Revoke(leaseID)
303
_ = h.settings.Save(runtime)
303
- writeAPIOK(w, http.StatusOK)
304
+ utils.WriteAPIOK(w, http.StatusOK)
305
default:
305
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
306
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
307
}
308
}
309
@@ -313,13 +314,13 @@ func (h *Handler) handleLeaseDenial(w http.ResponseWriter, r *http.Request, leas
314
case http.MethodPost:
315
approver.Deny(leaseID)
316
_ = h.settings.Save(runtime)
316
- writeAPIOK(w, http.StatusOK)
317
+ utils.WriteAPIOK(w, http.StatusOK)
318
case http.MethodDelete:
319
approver.Undeny(leaseID)
320
_ = h.settings.Save(runtime)
320
- writeAPIOK(w, http.StatusOK)
321
+ utils.WriteAPIOK(w, http.StatusOK)
322
default:
322
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
323
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
324
}
325
}
326
@@ -331,7 +332,7 @@ func (h *Handler) handleIPBan(w http.ResponseWriter, r *http.Request, path strin
332
rawIP := strings.TrimSuffix(strings.TrimPrefix(path, types.PathAdminIPsPrefix), "/ban")
333
rawIP = strings.Trim(rawIP, "/")
334
if net.ParseIP(rawIP) == nil {
334
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
335
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
336
return
337
}
338
@@ -341,13 +342,13 @@ func (h *Handler) handleIPBan(w http.ResponseWriter, r *http.Request, path strin
342
case http.MethodPost:
343
ipFilter.BanIP(rawIP)
344
_ = h.settings.Save(runtime)
344
- writeAPIOK(w, http.StatusOK)
345
+ utils.WriteAPIOK(w, http.StatusOK)
346
case http.MethodDelete:
347
ipFilter.UnbanIP(rawIP)
348
_ = h.settings.Save(runtime)
348
- writeAPIOK(w, http.StatusOK)
349
+ utils.WriteAPIOK(w, http.StatusOK)
350
default:
350
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
351
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
352
}
353
}
354
@@ -379,35 +380,6 @@ func decodeLeaseID(encoded string) (string, bool) {
380
return string(idBytes), true
381
}
382
382
-func writeAPIData(w http.ResponseWriter, status int, data any) {
383
- w.Header().Set("Content-Type", "application/json")
384
- w.WriteHeader(status)
385
- _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{OK: true, Data: data})
386
-}
387
-
388
-func writeAPIOK(w http.ResponseWriter, status int) {
389
- writeAPIData(w, status, map[string]any{})
390
-}
391
-
392
-func writeAPIError(w http.ResponseWriter, status int, code, message string) {
393
- w.Header().Set("Content-Type", "application/json")
394
- w.WriteHeader(status)
395
- _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
396
- OK: false,
397
- Error: &types.APIError{Code: code, Message: message},
398
- })
399
-}
400
-
401
-func writeAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
402
- w.Header().Set("Content-Type", "application/json")
403
- w.WriteHeader(status)
404
- _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
405
- OK: false,
406
- Data: data,
407
- Error: &types.APIError{Code: code, Message: message},
408
- })
409
-}
410
-
383
func (h *Handler) policyRuntime() *policy.Runtime {
384
if h.server == nil {
385
return nil
portal/api_server.go
+28
-88
@@ -1,9 +1,7 @@
1
package portal
2
3
import (
4
- "crypto/subtle"
4
"crypto/tls"
6
- "encoding/json"
5
"errors"
6
"fmt"
7
"io"
@@ -17,6 +15,7 @@ import (
15
"github.com/gosuda/portal/v2/portal/keyless"
16
"github.com/gosuda/portal/v2/portal/policy"
17
"github.com/gosuda/portal/v2/types"
18
+ "github.com/gosuda/portal/v2/utils"
19
)
20
21
var (
@@ -79,24 +78,24 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
78
}
79
80
func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
82
- writeAPIData(w, http.StatusOK, map[string]any{
81
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{
82
"service": "portal-relay",
83
"root": s.rootHost,
84
})
85
}
86
87
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
89
- writeAPIData(w, http.StatusOK, map[string]any{"status": "ok"})
88
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{"status": "ok"})
89
}
90
91
func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
92
if r.Method != http.MethodGet {
94
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
93
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
94
return
95
}
96
97
name := r.URL.Query().Get("name")
99
- writeAPIData(w, http.StatusOK, types.DomainResponse{
98
+ utils.WriteAPIData(w, http.StatusOK, types.DomainResponse{
99
RootHost: s.rootHost,
100
SuggestedHostname: suggestHostname(name, s.rootHost),
101
Version: types.SDKProtocolVersion,
@@ -105,19 +104,19 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
104
105
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
106
if r.Method != http.MethodPost {
108
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
107
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
108
return
109
}
110
111
clientIP := s.clientIPFromRequest(r)
112
if s.isClientIPBanned(clientIP) {
114
- writeAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
113
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
114
return
115
}
116
117
var req types.RegisterRequest
118
if err := decodeJSONBody(w, r, &req); err != nil {
120
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
119
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
120
return
121
}
122
@@ -130,28 +129,28 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
129
if errors.Is(err, errIPBanned) {
130
status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
131
}
133
- writeAPIError(w, status, code, err.Error())
132
+ utils.WriteAPIError(w, status, code, err.Error())
133
return
134
}
135
137
- writeAPIData(w, http.StatusCreated, resp)
136
+ utils.WriteAPIData(w, http.StatusCreated, resp)
137
}
138
139
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
140
if r.Method != http.MethodPost {
142
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
141
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
142
return
143
}
144
145
clientIP := s.clientIPFromRequest(r)
146
if s.isClientIPBanned(clientIP) {
148
- writeAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
147
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
148
return
149
}
150
151
var req types.RenewRequest
152
if err := decodeJSONBody(w, r, &req); err != nil {
154
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
153
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
154
return
155
}
156
@@ -167,22 +166,22 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
166
if errors.Is(err, errIPBanned) {
167
status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
168
}
170
- writeAPIError(w, status, code, err.Error())
169
+ utils.WriteAPIError(w, status, code, err.Error())
170
return
171
}
172
174
- writeAPIData(w, http.StatusOK, resp)
173
+ utils.WriteAPIData(w, http.StatusOK, resp)
174
}
175
176
func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
177
if r.Method != http.MethodPost {
179
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
178
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
179
return
180
}
181
182
var req types.UnregisterRequest
183
if err := decodeJSONBody(w, r, &req); err != nil {
185
- writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
184
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
185
return
186
}
187
@@ -194,20 +193,20 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
193
if errors.Is(err, errUnauthorized) {
194
status, code = http.StatusForbidden, types.APIErrorCodeUnauthorized
195
}
197
- writeAPIError(w, status, code, err.Error())
196
+ utils.WriteAPIError(w, status, code, err.Error())
197
return
198
}
199
201
- writeAPIOK(w, http.StatusOK)
200
+ utils.WriteAPIOK(w, http.StatusOK)
201
}
202
203
func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
204
if r.Method != http.MethodGet {
206
- writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
205
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
206
return
207
}
208
if r.ProtoMajor != 1 {
210
- writeAPIError(w, http.StatusHTTPVersionNotSupported, types.APIErrorCodeHTTP11Only, "reverse connect requires HTTP/1.1")
209
+ utils.WriteAPIError(w, http.StatusHTTPVersionNotSupported, types.APIErrorCodeHTTP11Only, "reverse connect requires HTTP/1.1")
210
return
211
}
212
@@ -215,33 +214,33 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
214
token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
215
clientIP := s.clientIPFromRequest(r)
216
if s.isClientIPBanned(clientIP) {
218
- writeAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
217
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
218
return
219
}
220
221
lease, err := s.findLeaseByID(leaseID)
222
if err != nil {
224
- writeAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
223
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
224
return
225
}
226
if !s.registry.IsRoutable(lease) {
228
- writeAPIError(w, http.StatusForbidden, types.APIErrorCodeLeaseRejected, "lease is not approved for routing")
227
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeLeaseRejected, "lease is not approved for routing")
228
return
229
}
230
if authErr := s.authorizeLeaseToken(lease, token); authErr != nil {
232
- writeAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, authErr.Error())
231
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, authErr.Error())
232
return
233
}
234
235
hijacker, ok := w.(http.Hijacker)
236
if !ok {
238
- writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeHijackUnsupported, "hijacking is not supported")
237
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeHijackUnsupported, "hijacking is not supported")
238
return
239
}
240
241
conn, rw, err := hijacker.Hijack()
242
if err != nil {
244
- writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeHijackFailed, err.Error())
243
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeHijackFailed, err.Error())
244
return
245
}
246
@@ -298,7 +297,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
297
ttl = time.Duration(req.TTL) * time.Second
298
}
299
301
- leaseID := randomID("lease_")
300
+ leaseID := utils.RandomID("lease_")
301
now := time.Now()
302
expiresAt := now.Add(ttl)
303
record := &leaseRecord{
@@ -380,55 +379,6 @@ func (s *Server) connectURL() string {
379
return base + types.PathSDKConnect
380
}
381
383
-func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
384
- r.Body = http.MaxBytesReader(w, r.Body, defaultControlBodyLimit)
385
- defer r.Body.Close()
386
- return json.NewDecoder(r.Body).Decode(dst)
387
-}
388
-
389
-func normalizeHostnames(hosts []string) []string {
390
- seen := make(map[string]struct{}, len(hosts))
391
- out := make([]string, 0, len(hosts))
392
- for _, host := range hosts {
393
- host = normalizeHostname(host)
394
- if host == "" {
395
- continue
396
- }
397
- if _, ok := seen[host]; ok {
398
- continue
399
- }
400
- seen[host] = struct{}{}
401
- out = append(out, host)
402
- }
403
- return out
404
-}
405
-
406
-func tokenMatches(expected, actual string) bool {
407
- if len(expected) == 0 || len(actual) == 0 {
408
- return false
409
- }
410
- return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
411
-}
412
-
413
-func writeAPIData(w http.ResponseWriter, status int, data any) {
414
- w.Header().Set("Content-Type", "application/json")
415
- w.WriteHeader(status)
416
- _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{OK: true, Data: data})
417
-}
418
-
419
-func writeAPIOK(w http.ResponseWriter, status int) {
420
- writeAPIData(w, status, map[string]any{})
421
-}
422
-
423
-func writeAPIError(w http.ResponseWriter, status int, code, message string) {
424
- w.Header().Set("Content-Type", "application/json")
425
- w.WriteHeader(status)
426
- _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
427
- OK: false,
428
- Error: &types.APIError{Code: code, Message: message},
429
- })
430
-}
431
-
382
func (s *Server) clientIPFromRequest(r *http.Request) string {
383
if r == nil {
384
return ""
@@ -440,16 +390,6 @@ func (s *Server) isClientIPBanned(clientIP string) bool {
390
return s.registry.IsClientIPBanned(clientIP)
391
}
392
443
-func validateAPITLS(apiTLS keyless.TLSMaterialConfig) error {
444
- if len(apiTLS.CertPEM) == 0 {
445
- return errors.New("api tls certificate is required")
446
- }
447
- if len(apiTLS.KeyPEM) == 0 && apiTLS.Keyless == nil {
448
- return errors.New("api tls key or keyless signer is required")
449
- }
450
- return nil
451
-}
452
-
393
func newKeylessSignerHandler(apiTLS keyless.TLSMaterialConfig) (http.Handler, error) {
394
if len(apiTLS.KeyPEM) == 0 {
395
return nil, nil
portal/keyless/client.go
+10
-16
@@ -13,10 +13,16 @@ import (
13
"time"
14
15
keylesstls "github.com/gosuda/keyless_tls/keyless"
16
+ "github.com/gosuda/portal/v2/utils"
17
)
18
19
func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioCloser, error) {
19
- parsed, err := url.Parse(strings.TrimSpace(relayURL))
20
+ normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
21
+ if err != nil {
22
+ return nil, nil, err
23
+ }
24
+
25
+ parsed, err := url.Parse(normalizedRelayURL)
26
if err != nil {
27
return nil, nil, fmt.Errorf("parse relay url: %w", err)
28
}
@@ -25,7 +31,7 @@ func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioClo
31
return nil, nil, errors.New("relay hostname is required")
32
}
33
28
- certPEM, rootCAPEM, err := ResolveMaterials(context.Background(), relayURL, serverName)
34
+ certPEM, rootCAPEM, err := ResolveMaterials(context.Background(), normalizedRelayURL, serverName)
35
if err != nil {
36
return nil, nil, fmt.Errorf("prepare keyless materials: %w", err)
37
}
@@ -41,7 +47,7 @@ func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioClo
47
}
48
49
remoteSigner, err := keylesstls.NewRemoteSigner(keylesstls.RemoteSignerConfig{
44
- Endpoint: relayURL,
50
+ Endpoint: normalizedRelayURL,
51
ServerName: serverName,
52
KeyID: RelayKeyID,
53
RootCAPEM: rootCAPEM,
@@ -152,7 +158,7 @@ func FetchEndpointCertificateChain(ctx context.Context, endpoint, serverName str
158
tlsConn := tls.Client(rawConn, &tls.Config{
159
MinVersion: tls.VersionTLS12,
160
ServerName: serverName,
155
- InsecureSkipVerify: isLocalhost(host),
161
+ InsecureSkipVerify: utils.IsLocalRelayHost(host),
162
NextProtos: []string{"http/1.1"},
163
})
164
defer tlsConn.Close()
@@ -174,15 +180,3 @@ func FetchEndpointCertificateChain(ctx context.Context, endpoint, serverName str
180
}
181
return chainPEM, nil
182
}
177
-
178
-func isLocalhost(host string) bool {
179
- host = strings.TrimSpace(strings.ToLower(host))
180
- switch host {
181
- case "", "localhost":
182
- return true
183
- }
184
- if ip := net.ParseIP(host); ip != nil {
185
- return ip.IsLoopback()
186
- }
187
- return strings.HasSuffix(host, ".localhost")
188
-}
portal/lease.go
+8
-10
@@ -10,6 +10,7 @@ import (
10
11
"github.com/gosuda/portal/v2/portal/policy"
12
"github.com/gosuda/portal/v2/types"
13
+ "github.com/gosuda/portal/v2/utils"
14
)
15
16
type leaseRegistry struct {
@@ -89,7 +90,7 @@ func (r *leaseRegistry) Get(leaseID string) (*leaseRecord, bool) {
90
}
91
92
func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
92
- host = normalizeHostname(host)
93
+ host = utils.NormalizeHostname(host)
94
if host == "" {
95
return nil, false
96
}
@@ -301,7 +302,7 @@ func newRouteTable() *routeTable {
302
}
303
304
func (t *routeTable) Set(host, leaseID string) {
304
- host = normalizeHostname(host)
305
+ host = utils.NormalizeHostname(host)
306
if host == "" {
307
return
308
}
@@ -310,12 +311,12 @@ func (t *routeTable) Set(host, leaseID string) {
311
312
func (t *routeTable) DeleteLease(hosts []string) {
313
for _, host := range hosts {
313
- delete(t.exact, normalizeHostname(host))
314
+ delete(t.exact, utils.NormalizeHostname(host))
315
}
316
}
317
318
func (t *routeTable) LookupExact(host string) (string, bool) {
318
- host = normalizeHostname(host)
319
+ host = utils.NormalizeHostname(host)
320
if host == "" {
321
return "", false
322
}
@@ -324,7 +325,7 @@ func (t *routeTable) LookupExact(host string) (string, bool) {
325
}
326
327
func (t *routeTable) Lookup(host string) (string, bool) {
327
- host = normalizeHostname(host)
328
+ host = utils.NormalizeHostname(host)
329
if host == "" {
330
return "", false
331
}
@@ -333,14 +334,11 @@ func (t *routeTable) Lookup(host string) (string, bool) {
334
return leaseID, true
335
}
336
336
- parts := stringsSplit(host, ".")
337
+ parts := strings.Split(host, ".")
338
if len(parts) < 3 {
339
return "", false
340
}
340
- wildcard := "*." + stringsJoin(parts[1:], ".")
341
+ wildcard := "*." + strings.Join(parts[1:], ".")
342
leaseID, ok := t.exact[wildcard]
343
return leaseID, ok
344
}
344
-
345
-func stringsSplit(s, sep string) []string { return strings.Split(s, sep) }
346
-func stringsJoin(parts []string, sep string) string { return strings.Join(parts, sep) }
portal/server.go
+10
-36
@@ -16,6 +16,7 @@ import (
16
"github.com/gosuda/portal/v2/portal/acme"
17
"github.com/gosuda/portal/v2/portal/keyless"
18
"github.com/gosuda/portal/v2/portal/policy"
19
+ "github.com/gosuda/portal/v2/utils"
20
)
21
22
const (
@@ -63,17 +64,17 @@ func NewServer(cfg ServerConfig) (*Server, error) {
64
if cfg.SNIListenAddr == "" {
65
cfg.SNIListenAddr = ":443"
66
}
66
- cfg.LeaseTTL = durationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
67
- cfg.ClaimTimeout = durationOrDefault(cfg.ClaimTimeout, defaultClaimTimeout)
68
- cfg.IdleKeepaliveInterval = durationOrDefault(cfg.IdleKeepaliveInterval, defaultIdleKeepalive)
69
- cfg.ReadyQueueLimit = intOrDefault(cfg.ReadyQueueLimit, defaultReadyQueueLimit)
70
- cfg.ClientHelloTimeout = durationOrDefault(cfg.ClientHelloTimeout, defaultClientHelloWait)
67
+ cfg.LeaseTTL = utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
68
+ cfg.ClaimTimeout = utils.DurationOrDefault(cfg.ClaimTimeout, defaultClaimTimeout)
69
+ cfg.IdleKeepaliveInterval = utils.DurationOrDefault(cfg.IdleKeepaliveInterval, defaultIdleKeepalive)
70
+ cfg.ReadyQueueLimit = utils.IntOrDefault(cfg.ReadyQueueLimit, defaultReadyQueueLimit)
71
+ cfg.ClientHelloTimeout = utils.DurationOrDefault(cfg.ClientHelloTimeout, defaultClientHelloWait)
72
trustedProxyCIDRs, err := policy.ParseTrustedProxyCIDRs(cfg.TrustedProxyCIDRs)
73
if err != nil {
74
return nil, fmt.Errorf("parse trusted proxy cidrs: %w", err)
75
}
76
policy.SetTrustedProxyCIDRs(trustedProxyCIDRs)
76
- rootHost := PortalRootHost(cfg.PortalURL)
77
+ rootHost := utils.PortalRootHost(cfg.PortalURL)
78
if rootHost == "" {
79
return nil, errors.New("root host is required")
80
}
@@ -212,7 +213,7 @@ func (s *Server) ListLeases() []LeaseSnapshot {
213
214
func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig, *acme.Manager, error) {
215
acmeCfg := s.cfg.ACME
215
- if baseDomain := normalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.rootHost {
216
+ if baseDomain := utils.NormalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.rootHost {
217
return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("acme base domain %q does not match portal root host %q", acmeCfg.BaseDomain, s.rootHost)
218
}
219
acmeCfg.BaseDomain = s.rootHost
@@ -261,7 +262,7 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
262
return
263
}
264
264
- serverName := normalizeHostname(clientHello.ServerName)
265
+ serverName := utils.NormalizeHostname(clientHello.ServerName)
266
if serverName == "" {
267
_ = wrappedConn.Close()
268
return
@@ -301,7 +302,7 @@ func (s *Server) bridgeToAPI(ctx context.Context, conn net.Conn) {
302
return
303
}
304
dialer := &net.Dialer{Timeout: 5 * time.Second}
304
- upstream, err := dialer.DialContext(ctx, "tcp", HostPortOrLoopback(s.apiListener.Addr().String()))
305
+ upstream, err := dialer.DialContext(ctx, "tcp", utils.HostPortOrLoopback(s.apiListener.Addr().String()))
306
if err != nil {
307
_ = conn.Close()
308
return
@@ -315,30 +316,3 @@ func (s *Server) watchContext(ctx context.Context) error {
316
defer cancel()
317
return s.Shutdown(shutdownCtx)
318
}
318
-
319
-func bridgeConns(left, right net.Conn) {
320
- defer left.Close()
321
- defer right.Close()
322
-
323
- var group errgroup.Group
324
- group.Go(func() error {
325
- _, err := io.Copy(right, left)
326
- closeWrite(right)
327
- return err
328
- })
329
- group.Go(func() error {
330
- _, err := io.Copy(left, right)
331
- closeWrite(left)
332
- return err
333
- })
334
- _ = group.Wait()
335
-}
336
-
337
-func closeWrite(conn net.Conn) {
338
- type closeWriter interface {
339
- CloseWrite() error
340
- }
341
- if cw, ok := conn.(closeWriter); ok {
342
- _ = cw.CloseWrite()
343
- }
344
-}
portal/server_test.go
+3
-2
@@ -10,6 +10,7 @@ import (
10
11
"github.com/gosuda/portal/v2/portal/acme"
12
"github.com/gosuda/portal/v2/types"
13
+ "github.com/gosuda/portal/v2/utils"
14
)
15
16
func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
@@ -45,7 +46,7 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
46
}
47
})
48
48
- healthResp, err := client.Get("https://" + HostPortOrLoopback(server.APIAddr()) + types.PathHealthz)
49
+ healthResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.APIAddr()) + types.PathHealthz)
50
if err != nil {
51
t.Fatalf("GET /healthz error = %v", err)
52
}
@@ -63,7 +64,7 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
64
t.Fatalf("GET /healthz response = %+v, want ok status", healthEnvelope)
65
}
66
66
- signResp, err := client.Get("https://" + HostPortOrLoopback(server.APIAddr()) + types.PathV1Sign)
67
+ signResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.APIAddr()) + types.PathV1Sign)
68
if err != nil {
69
t.Fatalf("GET /v1/sign error = %v", err)
70
}
portal/utils.go
+65
-39
@@ -1,26 +1,58 @@
1
package portal
2
3
import (
4
- "crypto/rand"
5
- "encoding/hex"
4
+ "crypto/subtle"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
"net"
7
- "net/url"
9
+ "net/http"
10
"strings"
9
- "time"
11
+
12
+ "golang.org/x/sync/errgroup"
13
+
14
+ "github.com/gosuda/portal/v2/portal/keyless"
15
+ "github.com/gosuda/portal/v2/utils"
16
)
17
12
-func PortalRootHost(portalURL string) string {
13
- u, err := url.Parse(strings.TrimSpace(portalURL))
14
- if err != nil || u.Host == "" {
15
- return ""
18
+func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
19
+ r.Body = http.MaxBytesReader(w, r.Body, defaultControlBodyLimit)
20
+ defer r.Body.Close()
21
+ return json.NewDecoder(r.Body).Decode(dst)
22
+}
23
+
24
+func normalizeHostnames(hosts []string) []string {
25
+ seen := make(map[string]struct{}, len(hosts))
26
+ out := make([]string, 0, len(hosts))
27
+ for _, host := range hosts {
28
+ host = utils.NormalizeHostname(host)
29
+ if host == "" {
30
+ continue
31
+ }
32
+ if _, ok := seen[host]; ok {
33
+ continue
34
+ }
35
+ seen[host] = struct{}{}
36
+ out = append(out, host)
37
}
17
- return normalizeHostname(u.Hostname())
38
+ return out
39
}
40
20
-func normalizeHostname(host string) string {
21
- host = strings.TrimSpace(strings.ToLower(host))
22
- host = strings.TrimSuffix(host, ".")
23
- return host
41
+func tokenMatches(expected, actual string) bool {
42
+ if len(expected) == 0 || len(actual) == 0 {
43
+ return false
44
+ }
45
+ return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
46
+}
47
+
48
+func validateAPITLS(apiTLS keyless.TLSMaterialConfig) error {
49
+ if len(apiTLS.CertPEM) == 0 {
50
+ return errors.New("api tls certificate is required")
51
+ }
52
+ if len(apiTLS.KeyPEM) == 0 && apiTLS.Keyless == nil {
53
+ return errors.New("api tls key or keyless signer is required")
54
+ }
55
+ return nil
56
}
57
58
func sanitizeLabel(name string) string {
@@ -58,35 +90,29 @@ func suggestHostname(name, rootHost string) string {
90
return label + "." + rootHost
91
}
92
61
-func randomID(prefix string) string {
62
- buf := make([]byte, 8)
63
- if _, err := rand.Read(buf); err != nil {
64
- panic(err)
65
- }
66
- return prefix + hex.EncodeToString(buf)
67
-}
68
-
69
-func durationOrDefault(v, fallback time.Duration) time.Duration {
70
- if v > 0 {
71
- return v
72
- }
73
- return fallback
74
-}
93
+func bridgeConns(left, right net.Conn) {
94
+ defer left.Close()
95
+ defer right.Close()
96
76
-func intOrDefault(v, fallback int) int {
77
- if v > 0 {
78
- return v
79
- }
80
- return fallback
97
+ var group errgroup.Group
98
+ group.Go(func() error {
99
+ _, err := io.Copy(right, left)
100
+ closeWrite(right)
101
+ return err
102
+ })
103
+ group.Go(func() error {
104
+ _, err := io.Copy(left, right)
105
+ closeWrite(left)
106
+ return err
107
+ })
108
+ _ = group.Wait()
109
}
110
83
-func HostPortOrLoopback(addr string) string {
84
- host, port, err := net.SplitHostPort(addr)
85
- if err != nil {
86
- return addr
111
+func closeWrite(conn net.Conn) {
112
+ type closeWriter interface {
113
+ CloseWrite() error
114
}
88
- if host == "" || host == "::" || host == "0.0.0.0" {
89
- host = "127.0.0.1"
115
+ if cw, ok := conn.(closeWriter); ok {
116
+ _ = cw.CloseWrite()
117
}
91
- return net.JoinHostPort(host, port)
118
}
sdk/api_client.go
+16
-85
@@ -4,10 +4,8 @@ import (
4
"bufio"
5
"bytes"
6
"context"
7
- "crypto/rand"
7
"crypto/tls"
8
"crypto/x509"
10
- "encoding/hex"
9
"encoding/json"
10
"errors"
11
"fmt"
@@ -20,6 +18,7 @@ import (
18
19
"github.com/gosuda/portal/v2/portal/keyless"
20
"github.com/gosuda/portal/v2/types"
21
+ "github.com/gosuda/portal/v2/utils"
22
)
23
24
const (
@@ -52,25 +51,21 @@ func newApiClient(ctx context.Context, relayURL string, cfg ListenerConfig) (*ap
51
52
reverseToken := strings.TrimSpace(cfg.ReverseToken)
53
if reverseToken == "" {
55
- reverseToken = randomToken()
54
+ reverseToken = utils.RandomID("tok_")
55
}
56
58
- baseURL, err := url.Parse(strings.TrimSpace(relayURL))
57
+ normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
58
if err != nil {
60
- return nil, fmt.Errorf("parse relay url: %w", err)
61
- }
62
- if !strings.EqualFold(baseURL.Scheme, "https") {
63
- return nil, fmt.Errorf("relay url must use https: %q", relayURL)
59
+ return nil, err
60
}
65
- if baseURL.Host == "" {
66
- return nil, fmt.Errorf("relay url host is empty: %q", relayURL)
61
+
62
+ baseURL, err := url.Parse(normalizedRelayURL)
63
+ if err != nil {
64
+ return nil, fmt.Errorf("parse relay url: %w", err)
65
}
68
- baseURL.Path = strings.TrimRight(baseURL.Path, "/")
69
- baseURL.RawQuery = ""
70
- baseURL.Fragment = ""
66
67
rootCAPEM := append([]byte(nil), cfg.RootCAPEM...)
73
- if len(rootCAPEM) == 0 && isLocalRelayHost(baseURL.Hostname()) {
68
+ if len(rootCAPEM) == 0 && utils.IsLocalRelayHost(baseURL.Hostname()) {
69
bootstrapParent := ctx
70
if bootstrapParent == nil {
71
bootstrapParent = context.Background()
@@ -90,14 +85,8 @@ func newApiClient(ctx context.Context, relayURL string, cfg ListenerConfig) (*ap
85
return nil, err
86
}
87
93
- dialTimeout := cfg.DialTimeout
94
- if dialTimeout <= 0 {
95
- dialTimeout = defaultDialTimeout
96
- }
97
- requestTimeout := cfg.RequestTimeout
98
- if requestTimeout <= 0 {
99
- requestTimeout = defaultRequestTimeout
100
- }
88
+ dialTimeout := utils.DurationOrDefault(cfg.DialTimeout, defaultDialTimeout)
89
+ requestTimeout := utils.DurationOrDefault(cfg.RequestTimeout, defaultRequestTimeout)
90
91
baseTLS := &tls.Config{
92
MinVersion: tls.VersionTLS12,
@@ -188,7 +177,7 @@ func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net
177
Config: a.rawTLSConfig.Clone(),
178
}
179
191
- conn, err := dialer.DialContext(ctx, "tcp", ensurePort(a.baseURL.Host))
180
+ conn, err := dialer.DialContext(ctx, "tcp", utils.EnsurePort(a.baseURL.Host))
181
if err != nil {
182
return nil, err
183
}
@@ -222,7 +211,7 @@ func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net
211
defer resp.Body.Close()
212
213
if resp.StatusCode != http.StatusOK {
225
- apiErr := decodeAPIResponseError(resp)
214
+ apiErr := utils.DecodeAPIRequestError(resp)
215
_ = conn.Close()
216
return nil, apiErr
217
}
@@ -253,22 +242,12 @@ func (a *apiClient) doJSON(ctx context.Context, method, path string, payload any
242
}
243
defer resp.Body.Close()
244
256
- var envelope types.APIEnvelope[json.RawMessage]
257
- if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
245
+ envelope, err := utils.DecodeAPIEnvelope[json.RawMessage](resp.Body)
246
+ if err != nil {
247
return fmt.Errorf("decode response: %w", err)
248
}
249
if !envelope.OK {
261
- if envelope.Error == nil {
262
- return &types.APIRequestError{
263
- StatusCode: resp.StatusCode,
264
- Message: fmt.Sprintf("api request failed with status %d", resp.StatusCode),
265
- }
266
- }
267
- return &types.APIRequestError{
268
- StatusCode: resp.StatusCode,
269
- Code: envelope.Error.Code,
270
- Message: envelope.Error.Message,
271
- }
250
+ return utils.NewAPIRequestError(resp.StatusCode, envelope.Error)
251
}
252
if out == nil {
253
return nil
@@ -287,54 +266,6 @@ func buildRootCAs(rootCAPEM []byte) (*x509.CertPool, error) {
266
return pool, nil
267
}
268
290
-func decodeAPIResponseError(resp *http.Response) error {
291
- if resp == nil {
292
- return &types.APIRequestError{Message: "empty api response"}
293
- }
294
-
295
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
296
- var envelope types.APIEnvelope[json.RawMessage]
297
- if err := json.Unmarshal(body, &envelope); err == nil && envelope.Error != nil {
298
- return &types.APIRequestError{
299
- StatusCode: resp.StatusCode,
300
- Code: envelope.Error.Code,
301
- Message: envelope.Error.Message,
302
- }
303
- }
304
-
305
- return &types.APIRequestError{
306
- StatusCode: resp.StatusCode,
307
- Message: strings.TrimSpace(string(body)),
308
- }
309
-}
310
-
311
-func randomToken() string {
312
- buf := make([]byte, 8)
313
- if _, err := rand.Read(buf); err != nil {
314
- panic(err)
315
- }
316
- return "tok_" + hex.EncodeToString(buf)
317
-}
318
-
319
-func ensurePort(host string) string {
320
- if _, _, err := net.SplitHostPort(host); err == nil {
321
- return host
322
- }
323
- return net.JoinHostPort(host, "443")
324
-}
325
-
326
-func isLocalRelayHost(host string) bool {
327
- host = strings.TrimSpace(strings.ToLower(host))
328
- switch host {
329
- case "", "localhost":
330
- return true
331
- }
332
- if ip := net.ParseIP(host); ip != nil {
333
- return ip.IsLoopback()
334
- }
335
- return strings.HasSuffix(host, ".localhost")
336
-}
337
-
269
func cloneMetadata(metadata types.LeaseMetadata) types.LeaseMetadata {
270
return types.LeaseMetadata{
271
Description: metadata.Description,
sdk/expose.go
+2
-126
@@ -6,7 +6,6 @@ import (
6
"fmt"
7
"net"
8
"net/http"
9
- "net/url"
9
"strings"
10
"sync"
11
"sync/atomic"
@@ -14,6 +13,7 @@ import (
13
"github.com/rs/zerolog/log"
14
15
"github.com/gosuda/portal/v2/types"
16
+ "github.com/gosuda/portal/v2/utils"
17
)
18
19
// Exposure owns the lifecycle of one or more relay listeners and accepts
@@ -30,7 +30,7 @@ type Exposure struct {
30
// merged listener for accepting traffic from all of them. Empty relay input
31
// returns nil, nil so callers can fall back to local-only serving.
32
func Expose(ctx context.Context, relayUrls []string, name string, metadata types.LeaseMetadata) (*Exposure, error) {
33
- relayURLs, err := NormalizeRelayURLs(relayUrls)
33
+ relayURLs, err := utils.NormalizeRelayURLs(relayUrls)
34
if err != nil {
35
return nil, err
36
}
@@ -508,127 +508,3 @@ func (l *mergedListener) terminalErrorOr(fallback error) error {
508
}
509
return l.terminalErr
510
}
511
-
512
-// SplitCSV splits a comma-separated string, trimming whitespace and dropping
513
-// empty entries.
514
-func SplitCSV(raw string) []string {
515
- if strings.TrimSpace(raw) == "" {
516
- return nil
517
- }
518
-
519
- parts := strings.Split(raw, ",")
520
- out := make([]string, 0, len(parts))
521
- for _, part := range parts {
522
- part = strings.TrimSpace(part)
523
- if part != "" {
524
- out = append(out, part)
525
- }
526
- }
527
- return out
528
-}
529
-
530
-// NormalizeRelayURLs splits, normalizes, and de-duplicates relay URLs while
531
-// preserving input order. Empty inputs return nil, nil.
532
-func NormalizeRelayURLs(inputs []string) ([]string, error) {
533
- out := make([]string, 0, len(inputs))
534
- seen := make(map[string]struct{}, len(inputs))
535
-
536
- for _, input := range inputs {
537
- for _, part := range SplitCSV(input) {
538
- normalized, err := NormalizeRelayURL(part)
539
- if err != nil {
540
- return nil, err
541
- }
542
- if _, ok := seen[normalized]; ok {
543
- continue
544
- }
545
- seen[normalized] = struct{}{}
546
- out = append(out, normalized)
547
- }
548
- }
549
-
550
- if len(out) == 0 {
551
- return nil, nil
552
- }
553
- return out, nil
554
-}
555
-
556
-// NormalizeRelayURL accepts host[:port] or https URLs and returns the canonical
557
-// relay base URL used by the SDK.
558
-func NormalizeRelayURL(raw string) (string, error) {
559
- trimmed := strings.TrimSpace(raw)
560
- if trimmed == "" {
561
- return "", errors.New("relay url is empty")
562
- }
563
- if !strings.Contains(trimmed, "://") {
564
- trimmed = "https://" + strings.TrimPrefix(trimmed, "//")
565
- }
566
-
567
- parsed, err := url.Parse(trimmed)
568
- if err != nil {
569
- return "", fmt.Errorf("parse relay url %q: %w", raw, err)
570
- }
571
- if parsed.Host == "" && parsed.Path != "" && !strings.Contains(parsed.Path, "/") {
572
- parsed, err = url.Parse("https://" + strings.TrimSpace(parsed.Path))
573
- if err != nil {
574
- return "", fmt.Errorf("parse relay url %q: %w", raw, err)
575
- }
576
- }
577
- if parsed.Host == "" {
578
- return "", fmt.Errorf("relay url host is empty: %q", raw)
579
- }
580
- if !strings.EqualFold(parsed.Scheme, "https") {
581
- return "", fmt.Errorf("relay url must use https: %q", raw)
582
- }
583
-
584
- parsed.RawQuery = ""
585
- parsed.Fragment = ""
586
- parsed.Path = strings.TrimRight(parsed.Path, "/")
587
- if strings.HasSuffix(strings.ToLower(parsed.Path), "/relay") {
588
- parsed.Path = strings.TrimSuffix(parsed.Path, "/relay")
589
- }
590
- return parsed.String(), nil
591
-}
592
-
593
-// NormalizeTargetAddr accepts host[:port] or http/https URLs and returns a
594
-// canonical host:port target address for local dialing.
595
-func NormalizeTargetAddr(raw string) (string, error) {
596
- raw = strings.TrimSpace(raw)
597
- if raw == "" {
598
- return "", errors.New("target address is required")
599
- }
600
-
601
- if strings.Contains(raw, "://") {
602
- targetURL, err := url.Parse(raw)
603
- if err != nil {
604
- return "", fmt.Errorf("parse target url: %w", err)
605
- }
606
- if !strings.EqualFold(targetURL.Scheme, "http") && !strings.EqualFold(targetURL.Scheme, "https") {
607
- return "", fmt.Errorf("unsupported target url scheme %q", targetURL.Scheme)
608
- }
609
- if targetURL.Host == "" {
610
- return "", errors.New("target url host is empty")
611
- }
612
- if targetURL.Path != "" && targetURL.Path != "/" {
613
- return "", errors.New("target url path is not supported")
614
- }
615
- if targetURL.RawQuery != "" {
616
- return "", errors.New("target url query is not supported")
617
- }
618
- if targetURL.Fragment != "" {
619
- return "", errors.New("target url fragment is not supported")
620
- }
621
- raw = targetURL.Host
622
- }
623
-
624
- if _, _, err := net.SplitHostPort(raw); err == nil {
625
- return raw, nil
626
- }
627
- if strings.Count(raw, ":") == 0 {
628
- return net.JoinHostPort(raw, "80"), nil
629
- }
630
- if ip := net.ParseIP(raw); ip != nil {
631
- return net.JoinHostPort(raw, "80"), nil
632
- }
633
- return "", fmt.Errorf("invalid target address %q", raw)
634
-}
sdk/listener.go
+6
-20
@@ -14,6 +14,7 @@ import (
14
15
"github.com/gosuda/portal/v2/portal/keyless"
16
"github.com/gosuda/portal/v2/types"
17
+ "github.com/gosuda/portal/v2/utils"
18
)
19
20
type ListenerConfig struct {
@@ -60,26 +61,11 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
61
}
62
63
listenerCtx, cancel := context.WithCancel(ctx)
63
- readyTarget := cfg.ReadyTarget
64
- if readyTarget <= 0 {
65
- readyTarget = defaultReadyTarget
66
- }
67
- leaseTTL := cfg.LeaseTTL
68
- if leaseTTL <= 0 {
69
- leaseTTL = defaultLeaseTTL
70
- }
71
- handshakeTimeout := cfg.HandshakeTimeout
72
- if handshakeTimeout <= 0 {
73
- handshakeTimeout = defaultHandshakeTimeout
74
- }
75
- renewBefore := cfg.RenewBefore
76
- if renewBefore <= 0 {
77
- renewBefore = defaultRenewBefore
78
- }
79
- retryWait := cfg.RetryWait
80
- if retryWait <= 0 {
81
- retryWait = defaultRetryWait
82
- }
64
+ readyTarget := utils.IntOrDefault(cfg.ReadyTarget, defaultReadyTarget)
65
+ leaseTTL := utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
66
+ handshakeTimeout := utils.DurationOrDefault(cfg.HandshakeTimeout, defaultHandshakeTimeout)
67
+ renewBefore := utils.DurationOrDefault(cfg.RenewBefore, defaultRenewBefore)
68
+ retryWait := utils.DurationOrDefault(cfg.RetryWait, defaultRetryWait)
69
70
api, err := newApiClient(listenerCtx, relayURL, cfg)
71
if err != nil {
sdk/sdk_test.go
-20
@@ -336,26 +336,6 @@ func TestExposeNoRelayInputs(t *testing.T) {
336
}
337
}
338
339
-func TestNormalizeRelayURLs(t *testing.T) {
340
- t.Parallel()
341
-
342
- got, err := NormalizeRelayURLs([]string{
343
- " localhost:4017 , https://relay.example.com/base/relay?x=1#frag ",
344
- "https://relay.example.com/base",
345
- })
346
- if err != nil {
347
- t.Fatalf("NormalizeRelayURLs() error = %v", err)
348
- }
349
-
350
- want := []string{
351
- "https://localhost:4017",
352
- "https://relay.example.com/base",
353
- }
354
- if !reflect.DeepEqual(got, want) {
355
- t.Fatalf("NormalizeRelayURLs() = %v, want %v", got, want)
356
- }
357
-}
358
-
339
func writeSDKTestEnvelope[T any](w http.ResponseWriter, status int, envelope types.APIEnvelope[T]) {
340
w.Header().Set("Content-Type", "application/json")
341
w.WriteHeader(status)
utils/api.go
new
+79
@@ -0,0 +1,79 @@
1
+package utils
2
+
3
+import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "strings"
9
+
10
+ "github.com/gosuda/portal/v2/types"
11
+)
12
+
13
+func WriteAPIEnvelope[T any](w http.ResponseWriter, status int, envelope types.APIEnvelope[T]) {
14
+ w.Header().Set("Content-Type", "application/json")
15
+ w.WriteHeader(status)
16
+ _ = json.NewEncoder(w).Encode(envelope)
17
+}
18
+
19
+func WriteAPIData(w http.ResponseWriter, status int, data any) {
20
+ WriteAPIEnvelope(w, status, types.APIEnvelope[any]{OK: true, Data: data})
21
+}
22
+
23
+func WriteAPIOK(w http.ResponseWriter, status int) {
24
+ WriteAPIData(w, status, map[string]any{})
25
+}
26
+
27
+func WriteAPIError(w http.ResponseWriter, status int, code, message string) {
28
+ WriteAPIEnvelope(w, status, types.APIEnvelope[any]{
29
+ OK: false,
30
+ Error: &types.APIError{Code: code, Message: message},
31
+ })
32
+}
33
+
34
+func WriteAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
35
+ WriteAPIEnvelope(w, status, types.APIEnvelope[any]{
36
+ OK: false,
37
+ Data: data,
38
+ Error: &types.APIError{Code: code, Message: message},
39
+ })
40
+}
41
+
42
+func DecodeAPIEnvelope[T any](r io.Reader) (types.APIEnvelope[T], error) {
43
+ var envelope types.APIEnvelope[T]
44
+ if err := json.NewDecoder(r).Decode(&envelope); err != nil {
45
+ return types.APIEnvelope[T]{}, err
46
+ }
47
+ return envelope, nil
48
+}
49
+
50
+func NewAPIRequestError(statusCode int, apiErr *types.APIError) *types.APIRequestError {
51
+ if apiErr == nil {
52
+ return &types.APIRequestError{
53
+ StatusCode: statusCode,
54
+ Message: fmt.Sprintf("api request failed with status %d", statusCode),
55
+ }
56
+ }
57
+ return &types.APIRequestError{
58
+ StatusCode: statusCode,
59
+ Code: apiErr.Code,
60
+ Message: apiErr.Message,
61
+ }
62
+}
63
+
64
+func DecodeAPIRequestError(resp *http.Response) error {
65
+ if resp == nil {
66
+ return &types.APIRequestError{Message: "empty api response"}
67
+ }
68
+
69
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
70
+ var envelope types.APIEnvelope[json.RawMessage]
71
+ if err := json.Unmarshal(body, &envelope); err == nil && !envelope.OK {
72
+ return NewAPIRequestError(resp.StatusCode, envelope.Error)
73
+ }
74
+
75
+ return &types.APIRequestError{
76
+ StatusCode: resp.StatusCode,
77
+ Message: strings.TrimSpace(string(body)),
78
+ }
79
+}
utils/api_test.go
new
+50
@@ -0,0 +1,50 @@
1
+package utils
2
+
3
+import (
4
+ "bytes"
5
+ "errors"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/gosuda/portal/v2/types"
13
+)
14
+
15
+func TestWriteAPIDataAndDecodeEnvelope(t *testing.T) {
16
+ t.Parallel()
17
+
18
+ rec := httptest.NewRecorder()
19
+ WriteAPIData(rec, http.StatusCreated, map[string]string{"status": "ok"})
20
+
21
+ if rec.Code != http.StatusCreated {
22
+ t.Fatalf("WriteAPIData() status = %d, want %d", rec.Code, http.StatusCreated)
23
+ }
24
+
25
+ envelope, err := DecodeAPIEnvelope[map[string]string](bytes.NewReader(rec.Body.Bytes()))
26
+ if err != nil {
27
+ t.Fatalf("DecodeAPIEnvelope() error = %v", err)
28
+ }
29
+ if !envelope.OK || envelope.Data["status"] != "ok" {
30
+ t.Fatalf("DecodeAPIEnvelope() = %+v, want ok envelope", envelope)
31
+ }
32
+}
33
+
34
+func TestDecodeAPIRequestError(t *testing.T) {
35
+ t.Parallel()
36
+
37
+ resp := &http.Response{
38
+ StatusCode: http.StatusForbidden,
39
+ Body: io.NopCloser(strings.NewReader(`{"ok":false,"error":{"code":"unauthorized","message":"denied"}}`)),
40
+ }
41
+
42
+ err := DecodeAPIRequestError(resp)
43
+ var apiErr *types.APIRequestError
44
+ if !errors.As(err, &apiErr) {
45
+ t.Fatalf("DecodeAPIRequestError() error = %T, want *types.APIRequestError", err)
46
+ }
47
+ if apiErr.StatusCode != http.StatusForbidden || apiErr.Code != "unauthorized" || apiErr.Message != "denied" {
48
+ t.Fatalf("DecodeAPIRequestError() = %+v, want status/code/message populated", apiErr)
49
+ }
50
+}
utils/utils.go
new
+194
@@ -0,0 +1,194 @@
1
+package utils
2
+
3
+import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "errors"
7
+ "fmt"
8
+ "net"
9
+ "net/url"
10
+ "strings"
11
+ "time"
12
+)
13
+
14
+func SplitCSV(raw string) []string {
15
+ if strings.TrimSpace(raw) == "" {
16
+ return nil
17
+ }
18
+
19
+ parts := strings.Split(raw, ",")
20
+ out := make([]string, 0, len(parts))
21
+ for _, part := range parts {
22
+ part = strings.TrimSpace(part)
23
+ if part != "" {
24
+ out = append(out, part)
25
+ }
26
+ }
27
+ return out
28
+}
29
+
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
+
54
+func NormalizeRelayURL(raw string) (string, error) {
55
+ trimmed := strings.TrimSpace(raw)
56
+ if trimmed == "" {
57
+ return "", errors.New("relay url is empty")
58
+ }
59
+ if !strings.Contains(trimmed, "://") {
60
+ trimmed = "https://" + strings.TrimPrefix(trimmed, "//")
61
+ }
62
+
63
+ parsed, err := url.Parse(trimmed)
64
+ if err != nil {
65
+ return "", fmt.Errorf("parse relay url %q: %w", raw, err)
66
+ }
67
+ if parsed.Host == "" && parsed.Path != "" && !strings.Contains(parsed.Path, "/") {
68
+ parsed, err = url.Parse("https://" + strings.TrimSpace(parsed.Path))
69
+ if err != nil {
70
+ return "", fmt.Errorf("parse relay url %q: %w", raw, err)
71
+ }
72
+ }
73
+ if parsed.Host == "" {
74
+ return "", fmt.Errorf("relay url host is empty: %q", raw)
75
+ }
76
+ if !strings.EqualFold(parsed.Scheme, "https") {
77
+ return "", fmt.Errorf("relay url must use https: %q", raw)
78
+ }
79
+
80
+ parsed.RawQuery = ""
81
+ parsed.Fragment = ""
82
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
83
+ if strings.HasSuffix(strings.ToLower(parsed.Path), "/relay") {
84
+ parsed.Path = strings.TrimSuffix(parsed.Path, "/relay")
85
+ }
86
+ return parsed.String(), nil
87
+}
88
+
89
+func NormalizeTargetAddr(raw string) (string, error) {
90
+ raw = strings.TrimSpace(raw)
91
+ if raw == "" {
92
+ return "", errors.New("target address is required")
93
+ }
94
+
95
+ if strings.Contains(raw, "://") {
96
+ targetURL, err := url.Parse(raw)
97
+ if err != nil {
98
+ return "", fmt.Errorf("parse target url: %w", err)
99
+ }
100
+ if !strings.EqualFold(targetURL.Scheme, "http") && !strings.EqualFold(targetURL.Scheme, "https") {
101
+ return "", fmt.Errorf("unsupported target url scheme %q", targetURL.Scheme)
102
+ }
103
+ if targetURL.Host == "" {
104
+ return "", errors.New("target url host is empty")
105
+ }
106
+ if targetURL.Path != "" && targetURL.Path != "/" {
107
+ return "", errors.New("target url path is not supported")
108
+ }
109
+ if targetURL.RawQuery != "" {
110
+ return "", errors.New("target url query is not supported")
111
+ }
112
+ if targetURL.Fragment != "" {
113
+ return "", errors.New("target url fragment is not supported")
114
+ }
115
+ raw = targetURL.Host
116
+ }
117
+
118
+ if _, _, err := net.SplitHostPort(raw); err == nil {
119
+ return raw, nil
120
+ }
121
+ if strings.Count(raw, ":") == 0 {
122
+ return net.JoinHostPort(raw, "80"), nil
123
+ }
124
+ if ip := net.ParseIP(raw); ip != nil {
125
+ return net.JoinHostPort(raw, "80"), nil
126
+ }
127
+ return "", fmt.Errorf("invalid target address %q", raw)
128
+}
129
+
130
+func PortalRootHost(portalURL string) string {
131
+ u, err := url.Parse(strings.TrimSpace(portalURL))
132
+ if err != nil || u.Host == "" {
133
+ return ""
134
+ }
135
+ return NormalizeHostname(u.Hostname())
136
+}
137
+
138
+func NormalizeHostname(host string) string {
139
+ host = strings.TrimSpace(strings.ToLower(host))
140
+ host = strings.TrimSuffix(host, ".")
141
+ return host
142
+}
143
+
144
+func HostPortOrLoopback(addr string) string {
145
+ host, port, err := net.SplitHostPort(addr)
146
+ if err != nil {
147
+ return addr
148
+ }
149
+ if host == "" || host == "::" || host == "0.0.0.0" {
150
+ host = "127.0.0.1"
151
+ }
152
+ return net.JoinHostPort(host, port)
153
+}
154
+
155
+func EnsurePort(host string) string {
156
+ if _, _, err := net.SplitHostPort(host); err == nil {
157
+ return host
158
+ }
159
+ return net.JoinHostPort(host, "443")
160
+}
161
+
162
+func IsLocalRelayHost(host string) bool {
163
+ host = NormalizeHostname(host)
164
+ switch host {
165
+ case "", "localhost":
166
+ return true
167
+ }
168
+ if ip := net.ParseIP(host); ip != nil {
169
+ return ip.IsLoopback()
170
+ }
171
+ return strings.HasSuffix(host, ".localhost")
172
+}
173
+
174
+func DurationOrDefault(v, fallback time.Duration) time.Duration {
175
+ if v > 0 {
176
+ return v
177
+ }
178
+ return fallback
179
+}
180
+
181
+func IntOrDefault(v, fallback int) int {
182
+ if v > 0 {
183
+ return v
184
+ }
185
+ return fallback
186
+}
187
+
188
+func RandomID(prefix string) string {
189
+ buf := make([]byte, 8)
190
+ if _, err := rand.Read(buf); err != nil {
191
+ panic(err)
192
+ }
193
+ return prefix + hex.EncodeToString(buf)
194
+}
utils/utils_test.go
new
+51
@@ -0,0 +1,51 @@
1
+package utils
2
+
3
+import (
4
+ "reflect"
5
+ "strings"
6
+ "testing"
7
+)
8
+
9
+func TestNormalizeRelayURLs(t *testing.T) {
10
+ t.Parallel()
11
+
12
+ got, err := NormalizeRelayURLs([]string{
13
+ " localhost:4017 , https://relay.example.com/base/relay?x=1#frag ",
14
+ "https://relay.example.com/base",
15
+ })
16
+ if err != nil {
17
+ t.Fatalf("NormalizeRelayURLs() error = %v", err)
18
+ }
19
+
20
+ want := []string{
21
+ "https://localhost:4017",
22
+ "https://relay.example.com/base",
23
+ }
24
+ if !reflect.DeepEqual(got, want) {
25
+ t.Fatalf("NormalizeRelayURLs() = %v, want %v", got, want)
26
+ }
27
+}
28
+
29
+func TestNormalizeTargetAddr(t *testing.T) {
30
+ t.Parallel()
31
+
32
+ got, err := NormalizeTargetAddr("http://127.0.0.1")
33
+ if err != nil {
34
+ t.Fatalf("NormalizeTargetAddr() error = %v", err)
35
+ }
36
+ if got != "127.0.0.1:80" {
37
+ t.Fatalf("NormalizeTargetAddr() = %q, want %q", got, "127.0.0.1:80")
38
+ }
39
+}
40
+
41
+func TestRandomID(t *testing.T) {
42
+ t.Parallel()
43
+
44
+ got := RandomID("tok_")
45
+ if !strings.HasPrefix(got, "tok_") {
46
+ t.Fatalf("RandomID() = %q, want tok_ prefix", got)
47
+ }
48
+ if len(got) != len("tok_")+16 {
49
+ t.Fatalf("RandomID() length = %d, want %d", len(got), len("tok_")+16)
50
+ }
51
+}