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