refactor: consolidate domain and network utilities into types package

Kim committed Mar 3, 2026 at 18:51 UTC b0e50760fa587ce1ea37dc4c841d2b2cee2728ec
16 files changed +811 -634
cmd/demo-app/main.go
+9 -8
@@ -18,6 +18,7 @@ import (
18 "golang.org/x/net/websocket"
19
20 "gosuda.org/portal/sdk"
21 + "gosuda.org/portal/types"
22 )
23
24 //go:embed static
@@ -61,23 +62,23 @@ func runDemo() error {
62 if flagTLS {
63 opts = append(opts, sdk.WithTLS())
64 }
64 - client, err := sdk.NewClient(opts...)
65 + sdkClient, err := sdk.NewClient(opts...)
66 if err != nil {
67 return fmt.Errorf("new client: %w", err)
68 }
68 - defer client.Close()
69 + defer sdkClient.Close()
70
71 // 2) Register lease
72 // Create base64 data URI from embedded thumbnail
73 thumbnailDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(thumbnailPNG)
74
74 - listener, err := client.Listen(
75 + listener, err := sdkClient.Listen(
76 flagName,
76 - sdk.WithDescription(flagDesc),
77 - sdk.WithTags(strings.Split(flagTags, ",")),
78 - sdk.WithOwner(flagOwner),
79 - sdk.WithThumbnail(thumbnailDataURI),
80 - sdk.WithHide(flagHide),
77 + types.WithDescription(flagDesc),
78 + types.WithTags(strings.Split(flagTags, ",")),
79 + types.WithOwner(flagOwner),
80 + types.WithThumbnail(thumbnailDataURI),
81 + types.WithHide(flagHide),
82 )
83 if err != nil {
84 return fmt.Errorf("listen: %w", err)
cmd/portal-tunnel/main.go
+11 -40
@@ -6,7 +6,6 @@ import (
6 "fmt"
7 "io"
8 "net"
9 - "net/url"
9 "os"
10 "os/signal"
11 "strings"
@@ -18,6 +17,7 @@ import (
17 "github.com/rs/zerolog/log"
18
19 "gosuda.org/portal/sdk"
20 + "gosuda.org/portal/types"
21 )
22
23 var (
@@ -66,7 +66,7 @@ func runTunnel() error {
66 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
67 defer stop()
68
69 - relayURLs := splitCSV(flagRelayURLs)
69 + relayURLs := types.ParseURLs(flagRelayURLs)
70 if len(relayURLs) == 0 {
71 return fmt.Errorf("no relay URLs provided")
72 }
@@ -81,19 +81,19 @@ func runTunnel() error {
81 if flagTLS {
82 opts = append(opts, sdk.WithTLS())
83 }
84 - client, err := sdk.NewClient(opts...)
84 + sdkClient, err := sdk.NewClient(opts...)
85 if err != nil {
86 return fmt.Errorf("service %s: failed to create client: %w", flagName, err)
87 }
88 - defer client.Close()
88 + defer sdkClient.Close()
89
90 - listener, err := client.Listen(
90 + listener, err := sdkClient.Listen(
91 flagName,
92 - sdk.WithDescription(flagDesc),
93 - sdk.WithTags(splitCSV(flagTags)),
94 - sdk.WithOwner(flagOwner),
95 - sdk.WithThumbnail(flagThumbnail),
96 - sdk.WithHide(flagHide),
92 + types.WithDescription(flagDesc),
93 + types.WithTags(types.ParseURLs(flagTags)),
94 + types.WithOwner(flagOwner),
95 + types.WithThumbnail(flagThumbnail),
96 + types.WithHide(flagHide),
97 )
98 if err != nil {
99 return fmt.Errorf("service %s: failed to register service: %w", flagName, err)
@@ -184,7 +184,7 @@ var bufferPool = sync.Pool{
184 func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn, tlsEnabled bool) error {
185 defer relayConn.Close()
186
187 - targetAddr, err := normalizeTargetAddr(localAddr)
187 + targetAddr, err := types.NormalizeTargetAddr(localAddr)
188 if err != nil {
189 return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
190 }
@@ -275,32 +275,3 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
275 _, err := conn.Write([]byte(response))
276 return err
277 }
278 -
279 -func splitCSV(raw string) []string {
280 - parts := strings.Split(raw, ",")
281 - out := make([]string, 0, len(parts))
282 - for _, part := range parts {
283 - part = strings.TrimSpace(part)
284 - if part != "" {
285 - out = append(out, part)
286 - }
287 - }
288 - return out
289 -}
290 -
291 -func normalizeTargetAddr(raw string) (string, error) {
292 - raw = strings.TrimSpace(raw)
293 - if raw == "" {
294 - return "", fmt.Errorf("empty host")
295 - }
296 -
297 - u, err := url.Parse(raw)
298 - if err == nil && u.Scheme != "" {
299 - if strings.TrimSpace(u.Host) == "" {
300 - return "", fmt.Errorf("missing host in URL")
301 - }
302 - return u.Host, nil
303 - }
304 -
305 - return raw, nil
306 -}
cmd/relay-server/admin.go
+29 -34
@@ -12,6 +12,7 @@ import (
12
13 "gosuda.org/portal/cmd/relay-server/manager"
14 "gosuda.org/portal/portal"
15 + "gosuda.org/portal/types"
16 )
17
18 const adminCookieName = "portal_admin"
@@ -273,18 +274,16 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
274 if a.authManager.IsIPLocked(clientIP) {
275 remaining := a.authManager.GetLockRemainingSeconds(clientIP)
276 w.WriteHeader(http.StatusTooManyRequests)
276 - writeJSON(w, map[string]any{
277 - "success": false,
278 - "error": "Too many failed attempts. Please try again later.",
279 - "locked": true,
280 - "remaining_seconds": remaining,
277 + writeJSON(w, types.AdminLoginResponse{
278 + Success: false,
279 + Error: "Too many failed attempts. Please try again later.",
280 + Locked: true,
281 + RemainingSeconds: remaining,
282 })
283 return
284 }
285
285 - var req struct {
286 - Key string `json:"key"`
287 - }
286 + var req types.AdminLoginRequest
287 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
288 http.Error(w, "Invalid request body", http.StatusBadRequest)
289 return
@@ -295,13 +294,13 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
294 nowLocked := a.authManager.RecordFailedLogin(clientIP)
295 log.Warn().Str("ip", clientIP).Bool("now_locked", nowLocked).Msg("[Admin] Failed login attempt")
296
298 - response := map[string]any{
299 - "success": false,
300 - "error": "Invalid key",
301 - "locked": nowLocked,
297 + response := types.AdminLoginResponse{
298 + Success: false,
299 + Error: "Invalid key",
300 + Locked: nowLocked,
301 }
302 if nowLocked {
304 - response["remaining_seconds"] = 60
303 + response.RemainingSeconds = 60
304 }
305 w.WriteHeader(http.StatusUnauthorized)
306 writeJSON(w, response)
@@ -324,8 +323,8 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
323 })
324
325 log.Info().Str("ip", clientIP).Msg("[Admin] Successful login")
327 - writeJSON(w, map[string]any{
328 - "success": true,
326 + writeJSON(w, types.AdminLoginResponse{
327 + Success: true,
328 })
329 }
330
@@ -348,8 +347,8 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
347 MaxAge: -1, // Delete cookie
348 })
349
351 - writeJSON(w, map[string]any{
352 - "success": true,
350 + writeJSON(w, types.AdminLoginResponse{
351 + Success: true,
352 })
353 }
354
@@ -360,9 +359,9 @@ func (a *Admin) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
359 // Check if secret key is configured
360 authEnabled := a.authManager != nil && a.authManager.HasSecretKey()
361
363 - writeJSON(w, map[string]any{
364 - "authenticated": authenticated,
365 - "auth_enabled": authEnabled,
362 + writeJSON(w, types.AdminAuthStatusResponse{
363 + Authenticated: authenticated,
364 + AuthEnabled: authEnabled,
365 })
366 }
367
@@ -394,23 +393,21 @@ func (a *Admin) handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, se
393 }
394
395 func (a *Admin) handleGetSettings(w http.ResponseWriter) {
397 - writeJSON(w, map[string]any{
398 - "approval_mode": a.approveManager.GetApprovalMode(),
399 - "approved_leases": a.approveManager.GetApprovedLeases(),
400 - "denied_leases": a.approveManager.GetDeniedLeases(),
396 + writeJSON(w, types.AdminSettingsResponse{
397 + ApprovalMode: string(a.approveManager.GetApprovalMode()),
398 + ApprovedLeases: a.approveManager.GetApprovedLeases(),
399 + DeniedLeases: a.approveManager.GetDeniedLeases(),
400 })
401 }
402
403 func (a *Admin) handleApprovalModeRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
404 switch r.Method {
405 case http.MethodGet:
407 - writeJSON(w, map[string]any{
408 - "approval_mode": a.approveManager.GetApprovalMode(),
406 + writeJSON(w, types.AdminApprovalModeResponse{
407 + ApprovalMode: string(a.approveManager.GetApprovalMode()),
408 })
409 case http.MethodPost:
411 - var req struct {
412 - Mode string `json:"mode"`
413 - }
410 + var req types.AdminApprovalModeRequest
411 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
412 http.Error(w, "Invalid request body", http.StatusBadRequest)
413 return
@@ -423,8 +420,8 @@ func (a *Admin) handleApprovalModeRequest(w http.ResponseWriter, r *http.Request
420 a.approveManager.SetApprovalMode(mode)
421 a.SaveSettings(serv)
422 log.Info().Str("mode", string(mode)).Msg("[Admin] Approval mode changed")
426 - writeJSON(w, map[string]any{
427 - "approval_mode": mode,
423 + writeJSON(w, types.AdminApprovalModeResponse{
424 + ApprovalMode: string(mode),
425 })
426 default:
427 http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
@@ -505,9 +502,7 @@ func (a *Admin) handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, se
502
503 switch r.Method {
504 case http.MethodPost:
508 - var req struct {
509 - BPS int64 `json:"bps"`
510 - }
505 + var req types.AdminBPSRequest
506 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
507 http.Error(w, "Invalid request body", http.StatusBadRequest)
508 return
cmd/relay-server/main.go
+7 -7
@@ -17,7 +17,7 @@ import (
17 "gosuda.org/portal/cmd/relay-server/manager"
18 "gosuda.org/portal/portal"
19 "gosuda.org/portal/portal/sni"
20 - "gosuda.org/portal/sdk"
20 + "gosuda.org/portal/types"
21 )
22
23 const (
@@ -53,9 +53,9 @@ func main() {
53 }
54 bootstrapsCSV := strings.TrimSpace(os.Getenv("BOOTSTRAP_URIS"))
55 if bootstrapsCSV == "" {
56 - bootstrapsCSV = defaultBootstrapFrom(portalURL)
56 + bootstrapsCSV = types.DefaultBootstrapFrom(portalURL)
57 }
58 - sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort, "SNI_PORT")
58 + sniPort := types.ParsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
59 keylessDir := strings.TrimSpace(os.Getenv("KEYLESS_DIR"))
60 if keylessDir == "" {
61 keylessDir = defaultKeylessDir
@@ -73,7 +73,7 @@ func main() {
73 flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (Zone:Read + DNS:Edit) (env: CLOUDFLARE_TOKEN)")
74 flag.Parse()
75
76 - cfg.Bootstraps = parseURLs(bootstrapsCSV)
76 + cfg.Bootstraps = types.ParseURLs(bootstrapsCSV)
77 flagPortalURL = cfg.PortalURL
78 if err := runServer(cfg); err != nil {
79 log.Fatal().Err(err).Msg("execute root command")
@@ -90,9 +90,9 @@ func runServer(cfg relayServerConfig) error {
90 Strs("bootstrap_uris", cfg.Bootstraps).
91 Msg("[server] frontend configuration")
92
93 - baseHost := sdk.ExtractBaseDomain(cfg.PortalURL)
94 - rootSNI := portalRootHost(cfg.PortalURL)
95 - apiUpstreamAddr := loopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
93 + baseHost := types.ExtractBaseDomain(cfg.PortalURL)
94 + rootSNI := types.PortalRootHost(cfg.PortalURL)
95 + apiUpstreamAddr := types.LoopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
96 serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, baseHost, cfg.KeylessDir, cfg.CloudflareToken)
97 if err != nil {
98 return fmt.Errorf("create relay server: %w", err)
cmd/relay-server/registry.go
+26 -26
@@ -12,10 +12,10 @@ import (
12 "golang.org/x/net/websocket"
13
14 "gosuda.org/portal/portal"
15 - "gosuda.org/portal/sdk"
15 + "gosuda.org/portal/types"
16 )
17
18 -// SDKRegistry handles HTTP API for SDK lease registration
18 +// SDKRegistry handles HTTP API for client lease registration
19 type SDKRegistry struct{}
20
21 // HandleSDKRequest routes /sdk/* requests.
@@ -23,15 +23,15 @@ func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request,
23 path := strings.TrimSuffix(req.URL.Path, "/")
24
25 switch path {
26 - case sdk.SDKPathRegister:
26 + case types.PathSDKRegister:
27 r.handleRegister(w, req, serv)
28 - case sdk.SDKPathUnregister:
28 + case types.PathSDKUnregister:
29 r.handleUnregister(w, req, serv)
30 - case sdk.SDKPathRenew:
30 + case types.PathSDKRenew:
31 r.handleRenew(w, req, serv)
32 - case sdk.SDKPathDomain:
32 + case types.PathSDKDomain:
33 r.handleDomain(w, req, serv)
34 - case sdk.SDKPathConnect:
34 + case types.PathSDKConnect:
35 r.handleConnect(w, req, serv)
36 default:
37 http.NotFound(w, req)
@@ -54,10 +54,10 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
54 return
55 }
56
57 - var registerReq sdk.RegisterRequest
57 + var registerReq types.RegisterRequest
58 if err := json.NewDecoder(req.Body).Decode(&registerReq); err != nil {
59 log.Error().Err(err).Msg("[Registry] Failed to decode registration request")
60 - writeJSON(w, sdk.RegisterResponse{
60 + writeJSON(w, types.RegisterResponse{
61 Success: false,
62 Message: "invalid request body",
63 })
@@ -65,7 +65,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
65 }
66
67 if registerReq.LeaseID == "" {
68 - writeJSON(w, sdk.RegisterResponse{
68 + writeJSON(w, types.RegisterResponse{
69 Success: false,
70 Message: "lease_id is required",
71 })
@@ -73,7 +73,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
73 }
74
75 if registerReq.ReverseToken == "" {
76 - writeJSON(w, sdk.RegisterResponse{
76 + writeJSON(w, types.RegisterResponse{
77 Success: false,
78 Message: "reverse_token is required",
79 })
@@ -83,7 +83,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
83 // Ownership semantics: re-registration of an existing lease ID requires the same reverse token.
84 if entry, ok := serv.GetLeaseManager().GetLeaseByID(registerReq.LeaseID); ok && entry != nil && entry.Lease != nil {
85 if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(entry.Lease.ReverseToken)), []byte(registerReq.ReverseToken)) != 1 {
86 - writeJSON(w, sdk.RegisterResponse{
86 + writeJSON(w, types.RegisterResponse{
87 Success: false,
88 Message: "unauthorized lease registration",
89 })
@@ -103,7 +103,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
103
104 // Register with lease manager
105 if !serv.GetLeaseManager().UpdateLease(lease) {
106 - writeJSON(w, sdk.RegisterResponse{
106 + writeJSON(w, types.RegisterResponse{
107 Success: false,
108 Message: "failed to register lease (name conflict or policy violation)",
109 })
@@ -119,7 +119,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
119 if err := serv.GetSNIRouter().RegisterRoute(sniName, registerReq.LeaseID, registerReq.Name); err != nil {
120 // Keep lease and route state consistent on partial failure.
121 serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
122 - writeJSON(w, sdk.RegisterResponse{
122 + writeJSON(w, types.RegisterResponse{
123 Success: false,
124 Message: fmt.Sprintf("failed to register SNI route: %v", err),
125 })
@@ -134,9 +134,9 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
134 Msg("[Registry] Lease registered")
135
136 // Build public URL
137 - publicURL := servicePublicURL(flagPortalURL, registerReq.Name)
137 + publicURL := types.ServicePublicURL(flagPortalURL, registerReq.Name)
138
139 - writeJSON(w, sdk.RegisterResponse{
139 + writeJSON(w, types.RegisterResponse{
140 Success: true,
141 LeaseID: registerReq.LeaseID,
142 PublicURL: publicURL,
@@ -151,10 +151,10 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
151 return
152 }
153
154 - var unregisterReq sdk.UnregisterRequest
154 + var unregisterReq types.UnregisterRequest
155 if err := json.NewDecoder(req.Body).Decode(&unregisterReq); err != nil {
156 log.Error().Err(err).Msg("[Registry] Failed to decode unregistration request")
157 - writeJSON(w, sdk.APIResponse{
157 + writeJSON(w, types.APIResponse{
158 Success: false,
159 Message: "invalid request body",
160 })
@@ -170,7 +170,7 @@ func (r *SDKRegistry) handleUnregister(w http.ResponseWriter, req *http.Request,
170 serv.GetSNIRouter().UnregisterRouteByLeaseID(unregisterReq.LeaseID)
171 serv.GetReverseHub().DropLease(unregisterReq.LeaseID)
172
173 - writeJSON(w, sdk.APIResponse{
173 + writeJSON(w, types.APIResponse{
174 Success: true,
175 })
176 }
@@ -183,10 +183,10 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
183 return
184 }
185
186 - var renewReq sdk.RenewRequest
186 + var renewReq types.RenewRequest
187 if err := json.NewDecoder(req.Body).Decode(&renewReq); err != nil {
188 log.Error().Err(err).Msg("[Registry] Failed to decode renewal request")
189 - writeJSON(w, sdk.APIResponse{
189 + writeJSON(w, types.APIResponse{
190 Success: false,
191 Message: "invalid request body",
192 })
@@ -194,7 +194,7 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
194 }
195
196 if renewReq.ReverseToken == "" {
197 - writeJSON(w, sdk.RegisterResponse{
197 + writeJSON(w, types.RegisterResponse{
198 Success: false,
199 Message: "reverse_token is required",
200 })
@@ -204,14 +204,14 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
204 // Get existing lease
205 entry, ok := serv.GetLeaseManager().GetLeaseByID(renewReq.LeaseID)
206 if !ok {
207 - writeJSON(w, sdk.APIResponse{
207 + writeJSON(w, types.APIResponse{
208 Success: false,
209 Message: "lease not found",
210 })
211 return
212 }
213 if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(entry.Lease.ReverseToken)), []byte(renewReq.ReverseToken)) != 1 {
214 - writeJSON(w, sdk.APIResponse{
214 + writeJSON(w, types.APIResponse{
215 Success: false,
216 Message: "unauthorized lease renewal",
217 })
@@ -221,7 +221,7 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
221 // Update expiration
222 entry.Lease.Expires = time.Now().Add(30 * time.Second)
223 if !serv.GetLeaseManager().UpdateLease(entry.Lease) {
224 - writeJSON(w, sdk.APIResponse{
224 + writeJSON(w, types.APIResponse{
225 Success: false,
226 Message: "failed to renew lease",
227 })
@@ -241,7 +241,7 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
241 }
242 }
243
244 - writeJSON(w, sdk.APIResponse{
244 + writeJSON(w, types.APIResponse{
245 Success: true,
246 })
247 }
cmd/relay-server/serve.go
+13 -12
@@ -18,6 +18,7 @@ import (
18
19 "gosuda.org/portal/portal"
20 "gosuda.org/portal/portal/keyless"
21 + "gosuda.org/portal/types"
22 )
23
24 //go:embed dist/*
@@ -38,32 +39,32 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
39 frontend.ServeAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
40
41 // Portal app assets (JS, CSS, etc.) - served from /app/
41 - appMux.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
42 + appMux.HandleFunc(types.PathAppPrefix, func(w http.ResponseWriter, r *http.Request) {
43 setCORSHeaders(w)
44 if r.Method == http.MethodOptions {
45 w.WriteHeader(http.StatusOK)
46 return
47 }
47 - p := strings.TrimPrefix(r.URL.Path, "/app/")
48 + p := strings.TrimPrefix(r.URL.Path, types.PathAppPrefix)
49 frontend.ServeAppStatic(w, r, p, serv)
50 })
51
52 // Tunnel installer script and binaries
52 - appMux.HandleFunc("/tunnel", func(w http.ResponseWriter, r *http.Request) {
53 + appMux.HandleFunc(types.PathTunnelScript, func(w http.ResponseWriter, r *http.Request) {
54 serveTunnelScript(w, r)
55 })
55 - appMux.HandleFunc("/tunnel/bin/", func(w http.ResponseWriter, r *http.Request) {
56 + appMux.HandleFunc(types.PathTunnelBinary, func(w http.ResponseWriter, r *http.Request) {
57 serveTunnelBinary(w, r)
58 })
59
59 - // SDK Registry API for lease registration
60 + // SDK registry API for /sdk/* endpoints
61 registry := &SDKRegistry{}
61 - appMux.HandleFunc("/sdk/", func(w http.ResponseWriter, r *http.Request) {
62 + appMux.HandleFunc(types.PathSDKPrefix, func(w http.ResponseWriter, r *http.Request) {
63 registry.HandleSDKRequest(w, r, serv)
64 })
65
66 // Keyless signer endpoint.
66 - appMux.HandleFunc("/v1/sign", func(w http.ResponseWriter, r *http.Request) {
67 + appMux.HandleFunc(types.PathKeylessSign, func(w http.ResponseWriter, r *http.Request) {
68 handleKeylessSign(w, r, serv.GetKeylessSigner())
69 })
70
@@ -74,7 +75,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
75 frontend.ServeAppStatic(w, r, p, serv)
76 })
77
77 - appMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
78 + appMux.HandleFunc(types.PathHealthz, func(w http.ResponseWriter, r *http.Request) {
79 w.WriteHeader(http.StatusOK)
80 if _, err := w.Write([]byte("{\"status\":\"ok\"}")); err != nil {
81 log.Debug().Err(err).Msg("[healthz] failed to write response")
@@ -82,12 +83,12 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
83 })
84
85 // Admin API
85 - appMux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
86 + appMux.HandleFunc(types.PathAdminPrefix+"/", func(w http.ResponseWriter, r *http.Request) {
87 admin.HandleAdminRequest(w, r, serv)
88 })
89
90 // Create the main handler
90 - appDomain := defaultAppPattern(flagPortalURL)
91 + appDomain := types.DefaultAppPattern(flagPortalURL)
92 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 // Compatibility endpoints for legacy webclient deployments.
94 // Handle before host-based routing so stale service workers can recover.
@@ -101,7 +102,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
102 }
103
104 // Handle subdomain requests
104 - if isSubdomain(appDomain, r.Host) {
105 + if types.IsSubdomain(appDomain, r.Host) {
106 log.Debug().
107 Str("host", r.Host).
108 Str("url", r.URL.String()).
@@ -160,7 +161,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
161 // shouldProxyHTTP checks if the request should be proxied via HTTP.
162 // It returns leaseName, lease entry, and whether HTTP proxying should be used.
163 func shouldProxyHTTP(host string, serv *portal.RelayServer) (string, *portal.LeaseEntry, bool) {
163 - leaseName, ok := leaseNameFromHost(host, defaultAppPattern(flagPortalURL))
164 + leaseName, ok := types.LeaseNameFromHost(host, types.DefaultAppPattern(flagPortalURL))
165 if !ok {
166 log.Debug().Str("host", host).Msg("[proxy] shouldProxyHTTP: failed to extract lease name")
167 return "", nil, false
cmd/relay-server/utils.go
+2 -250
@@ -4,10 +4,7 @@ import (
4 "encoding/base64"
5 "encoding/json"
6 "fmt"
7 - "net"
7 "net/http"
9 - "net/url"
10 - "strconv"
8 "strings"
9 "time"
10
@@ -16,6 +13,7 @@ import (
13 "gosuda.org/portal/cmd/relay-server/manager"
14 "gosuda.org/portal/portal"
15 "gosuda.org/portal/portal/keyless"
16 + "gosuda.org/portal/types"
17 )
18
19 func isSecureRequest(r *http.Request) bool {
@@ -31,224 +29,6 @@ func isSecureRequest(r *http.Request) bool {
29 return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on")
30 }
31
34 -// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
35 -func parseURLs(raw string) []string {
36 - raw = strings.TrimSpace(raw)
37 - if raw == "" {
38 - return nil
39 - }
40 - parts := strings.Split(raw, ",")
41 - out := make([]string, 0, len(parts))
42 - for _, p := range parts {
43 - p = strings.TrimSpace(p)
44 - if p != "" {
45 - out = append(out, p)
46 - }
47 - }
48 - return out
49 -}
50 -
51 -func parsePortNumber(raw string, fallback int, source string) int {
52 - value := strings.TrimSpace(raw)
53 - if value == "" {
54 - return fallback
55 - }
56 - value = strings.TrimPrefix(value, ":")
57 - port, err := strconv.Atoi(value)
58 - if err != nil || port < 1 || port > 65535 {
59 - log.Warn().
60 - Str("source", source).
61 - Str("value", raw).
62 - Int("fallback_port", fallback).
63 - Msg("[server] invalid port value; using fallback")
64 - return fallback
65 - }
66 - return port
67 -}
68 -
69 -func loopbackForwardAddr(listenAddr string) string {
70 - raw := strings.TrimSpace(listenAddr)
71 - if raw == "" {
72 - return ""
73 - }
74 -
75 - port := ""
76 - switch {
77 - case strings.HasPrefix(raw, ":"):
78 - port = strings.TrimPrefix(raw, ":")
79 - case strings.Count(raw, ":") == 0:
80 - port = raw
81 - default:
82 - _, p, err := net.SplitHostPort(raw)
83 - if err != nil {
84 - return ""
85 - }
86 - port = p
87 - }
88 -
89 - portNum, err := strconv.Atoi(port)
90 - if err != nil || portNum < 1 || portNum > 65535 {
91 - return ""
92 - }
93 -
94 - return net.JoinHostPort("127.0.0.1", strconv.Itoa(portNum))
95 -}
96 -
97 -// isSubdomain reports whether host matches the given domain pattern.
98 -func isSubdomain(domain, host string) bool {
99 - if host == "" || domain == "" {
100 - return false
101 - }
102 -
103 - h := strings.ToLower(stripPort(stripScheme(host)))
104 - d := strings.ToLower(stripPort(stripScheme(domain)))
105 -
106 - if strings.HasPrefix(d, "*.") {
107 - suffix := d[1:]
108 - return len(h) > len(suffix) && strings.HasSuffix(h, suffix)
109 - }
110 -
111 - if h == d {
112 - return true
113 - }
114 -
115 - return strings.HasSuffix(h, "."+d)
116 -}
117 -
118 -func stripScheme(s string) string {
119 - s = strings.TrimSpace(s)
120 - s = strings.TrimSuffix(s, "/")
121 - s = strings.TrimPrefix(s, "http://")
122 - s = strings.TrimPrefix(s, "https://")
123 - return s
124 -}
125 -
126 -func stripWildCard(s string) string {
127 - s = strings.TrimSpace(s)
128 - s = strings.TrimPrefix(s, "*.")
129 - return s
130 -}
131 -
132 -func stripPort(s string) string {
133 - if s == "" {
134 - return s
135 - }
136 - if idx := strings.LastIndexByte(s, ':'); idx >= 0 && idx+1 < len(s) {
137 - port := s[idx+1:]
138 - digits := true
139 - for _, ch := range port {
140 - if ch < '0' || ch > '9' {
141 - digits = false
142 - break
143 - }
144 - }
145 - if digits {
146 - return s[:idx]
147 - }
148 - }
149 - return s
150 -}
151 -
152 -// defaultAppPattern builds a wildcard subdomain pattern from a base portal URL or host.
153 -func defaultAppPattern(base string) string {
154 - base = strings.TrimSpace(strings.TrimSuffix(base, "/"))
155 - if base == "" {
156 - return "*.localhost:4017"
157 - }
158 - host := stripWildCard(stripScheme(base))
159 - if host == "" {
160 - return "*.localhost:4017"
161 - }
162 - if strings.HasPrefix(host, "*.") {
163 - return host
164 - }
165 - return "*." + host
166 -}
167 -
168 -// defaultBootstrapFrom derives a relay API bootstrap URL from a base portal URL or host.
169 -func defaultBootstrapFrom(base string) string {
170 - base = strings.TrimSpace(base)
171 - if base == "" {
172 - return "http://localhost:4017"
173 - }
174 -
175 - if !strings.Contains(base, "://") {
176 - base = "http://" + base
177 - }
178 -
179 - u, err := url.Parse(strings.TrimSuffix(base, "/"))
180 - if err != nil || u.Host == "" {
181 - return "http://localhost:4017"
182 - }
183 - if u.Scheme != "http" && u.Scheme != "https" {
184 - return "http://localhost:4017"
185 - }
186 - if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
187 - return "http://localhost:4017"
188 - }
189 -
190 - u.Path = ""
191 - u.RawQuery = ""
192 - u.Fragment = ""
193 - return strings.TrimSuffix(u.String(), "/")
194 -}
195 -
196 -// portalHostPort returns normalized host[:port] from a portal URL-like input.
197 -func portalHostPort(portalURL string) string {
198 - return strings.ToLower(strings.TrimSpace(
199 - stripWildCard(stripScheme(portalURL)),
200 - ))
201 -}
202 -
203 -// servicePublicURL returns a service URL derived from portalURL and service name.
204 -func servicePublicURL(portalURL, serviceName string) string {
205 - serviceName = strings.TrimSpace(serviceName)
206 - if serviceName == "" {
207 - return ""
208 - }
209 -
210 - raw := strings.TrimSpace(portalURL)
211 - if raw == "" {
212 - return ""
213 - }
214 - if !strings.Contains(raw, "://") {
215 - raw = "http://" + raw
216 - }
217 -
218 - u, err := url.Parse(raw)
219 - if err != nil || strings.TrimSpace(u.Host) == "" {
220 - return ""
221 - }
222 -
223 - host := strings.TrimSpace(stripWildCard(u.Host))
224 - if host == "" {
225 - return ""
226 - }
227 -
228 - scheme := strings.TrimSpace(u.Scheme)
229 - if scheme == "" {
230 - scheme = "http"
231 - }
232 -
233 - return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
234 -}
235 -
236 -func portalRootHost(portalURL string) string {
237 - raw := strings.TrimSpace(portalURL)
238 - if raw == "" {
239 - return ""
240 - }
241 - if !strings.Contains(raw, "://") {
242 - raw = "https://" + raw
243 - }
244 -
245 - parsed, err := url.Parse(raw)
246 - if err != nil || parsed.Hostname() == "" {
247 - return ""
248 - }
249 - return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(parsed.Hostname())), "*.")
250 -}
251 -
32 // getContentType returns the MIME type for a file extension
33 func getContentType(ext string) string {
34 switch ext {
@@ -282,34 +62,6 @@ func setCORSHeaders(w http.ResponseWriter) {
62 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
63 }
64
285 -// leaseNameFromHost extracts the lease name from a subdomain host.
286 -func leaseNameFromHost(host, appURL string) (string, bool) {
287 - if !isSubdomain(appURL, host) {
288 - return "", false
289 - }
290 -
291 - normalizedHost := strings.ToLower(strings.TrimSpace(stripPort(host)))
292 - baseHost := strings.ToLower(strings.TrimSpace(
293 - stripPort(stripWildCard(stripScheme(appURL))),
294 - ))
295 -
296 - if normalizedHost == "" || baseHost == "" || normalizedHost == baseHost {
297 - return "", false
298 - }
299 -
300 - suffix := "." + baseHost
301 - if !strings.HasSuffix(normalizedHost, suffix) {
302 - return "", false
303 - }
304 -
305 - leaseName := strings.TrimSuffix(normalizedHost, suffix)
306 - if leaseName == "" || strings.Contains(leaseName, ".") {
307 - return "", false
308 - }
309 -
310 - return leaseName, true
311 -}
312 -
65 // leaseRow represents a lease entry for display in admin UI and frontend.
66 type leaseRow struct {
67 Peer string
@@ -422,7 +174,7 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
174 if linkLabel == "" {
175 linkLabel = identityID
176 }
425 - r.Link = fmt.Sprintf("//%s.%s/", linkLabel, portalHostPort(portalURL))
177 + r.Link = fmt.Sprintf("//%s.%s/", linkLabel, types.PortalHostPort(portalURL))
178 r.StaleRed = !connected && since >= 15*time.Second
179 r.Hide = entry.ParsedMetadata != nil && entry.ParsedMetadata.Hide
180 r.Metadata = metadataStr
portal/lease.go
+11 -27
@@ -6,34 +6,18 @@ import (
6 "strings"
7 "sync"
8 "time"
9 -)
9
11 -// ParsedMetadata holds struct-parsed metadata for better access
12 -type ParsedMetadata struct {
13 - Description string `json:"description"`
14 - Tags []string `json:"tags"`
15 - Thumbnail string `json:"thumbnail"`
16 - Owner string `json:"owner"`
17 - Hide bool `json:"hide"`
18 -}
10 + "gosuda.org/portal/types"
11 +)
12
13 // Lease represents a registered service.
14 type Lease struct {
22 - ID string `json:"id"`
23 - Name string `json:"name"`
24 - Metadata Metadata `json:"metadata"`
25 - Expires time.Time `json:"expires"`
26 - TLS bool `json:"tls"`
27 - ReverseToken string `json:"-"` // shared secret for reverse connect authentication
28 -}
29 -
30 -// Metadata holds service metadata
31 -type Metadata struct {
32 - Description string `json:"description,omitempty"`
33 - Tags []string `json:"tags,omitempty"`
34 - Thumbnail string `json:"thumbnail,omitempty"`
35 - Owner string `json:"owner,omitempty"`
36 - Hide bool `json:"hide,omitempty"`
15 + ID string `json:"id"`
16 + Name string `json:"name"`
17 + Metadata types.Metadata `json:"metadata"`
18 + Expires time.Time `json:"expires"`
19 + TLS bool `json:"tls"`
20 + ReverseToken string `json:"-"` // shared secret for reverse connect authentication
21 }
22
23 // LeaseEntry represents a registered lease with expiration tracking.
@@ -42,7 +26,7 @@ type LeaseEntry struct {
26 Expires time.Time
27 LastSeen time.Time
28 FirstSeen time.Time
45 - ParsedMetadata *ParsedMetadata // Cached parsed metadata
29 + ParsedMetadata *types.ParsedMetadata // Cached parsed metadata
30 }
31
32 type LeaseManager struct {
@@ -156,10 +140,10 @@ func (lm *LeaseManager) UpdateLease(lease *Lease) bool {
140 }
141
142 // Parse metadata once for cached access
159 - var parsedMeta *ParsedMetadata
143 + var parsedMeta *types.ParsedMetadata
144 metadataJSON, _ := json.Marshal(lease.Metadata)
145 if len(metadataJSON) > 0 {
162 - var meta ParsedMetadata
146 + var meta types.ParsedMetadata
147 if err := json.Unmarshal(metadataJSON, &meta); err == nil {
148 parsedMeta = &meta
149 }
sdk/client.go
+52 -55
@@ -5,11 +5,11 @@ import (
5 "crypto/rand"
6 "crypto/tls"
7 "encoding/hex"
8 + "errors"
9 "fmt"
10 "net"
11 "net/url"
12 "regexp"
12 - "strings"
13 "sync"
14 "time"
15
@@ -17,8 +17,52 @@ import (
17
18 "gosuda.org/portal/portal"
19 "gosuda.org/portal/portal/keyless"
20 + "gosuda.org/portal/types"
21 )
22
23 +// SDK-specific errors.
24 +var (
25 + ErrNoAvailableRelay = errors.New("no available relay")
26 + ErrClientClosed = errors.New("client is closed")
27 + ErrListenerExists = errors.New("listener already exists for this credential")
28 + ErrRelayExists = errors.New("relay already exists")
29 + ErrRelayNotFound = errors.New("relay not found")
30 + ErrInvalidName = errors.New("lease name contains invalid characters (only alphanumeric, hyphen, underscore allowed)")
31 + ErrFailedToCreateClient = errors.New("failed to create relay client")
32 + ErrInvalidMetadata = errors.New("invalid metadata")
33 +)
34 +
35 +// ClientConfig configures the SDK client.
36 +type ClientConfig struct {
37 + BootstrapServers []string
38 + ReverseDialTimeout time.Duration // Reverse websocket dial timeout (default: 5 seconds)
39 + TLS bool
40 +}
41 +
42 +// ClientOption configures ClientConfig.
43 +type ClientOption func(*ClientConfig)
44 +
45 +// WithBootstrapServers sets the bootstrap relay servers.
46 +func WithBootstrapServers(servers []string) ClientOption {
47 + return func(c *ClientConfig) {
48 + c.BootstrapServers = servers
49 + }
50 +}
51 +
52 +// WithReverseDialTimeout sets the reverse dial timeout.
53 +func WithReverseDialTimeout(timeout time.Duration) ClientOption {
54 + return func(c *ClientConfig) {
55 + c.ReverseDialTimeout = timeout
56 + }
57 +}
58 +
59 +// WithTLS enables keyless TLS mode using relay-derived defaults.
60 +func WithTLS() ClientOption {
61 + return func(c *ClientConfig) {
62 + c.TLS = true
63 + }
64 +}
65 +
66 // Client is a minimal client for lease registration with the relay.
67 type Client struct {
68 mu sync.Mutex
@@ -53,7 +97,7 @@ func isURLSafeName(name string) bool {
97 // Listen creates a listener and registers it with the relay.
98 // In TLS passthrough mode, this registers the lease and returns a listener
99 // that accepts connections from the relay.
56 -func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, error) {
100 +func (c *Client) Listen(name string, options ...types.MetadataOption) (net.Listener, error) {
101 c.mu.Lock()
102 defer c.mu.Unlock()
103
@@ -64,9 +108,9 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
108 return nil, ErrInvalidName
109 }
110
67 - relayAddrs, err := normalizeRelayAPIURLs(c.config.BootstrapServers)
111 + relayAddrs, err := types.NormalizeRelayAPIURLs(c.config.BootstrapServers)
112 if err != nil {
69 - return nil, err
113 + return nil, ErrNoAvailableRelay
114 }
115
116 lease, err := c.newLease(name, options...)
@@ -133,8 +177,8 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
177 return listener, nil
178 }
179
136 -func (c *Client) newLease(name string, options ...MetadataOption) (*portal.Lease, error) {
137 - var metadata portal.Metadata
180 +func (c *Client) newLease(name string, options ...types.MetadataOption) (*portal.Lease, error) {
181 + var metadata types.Metadata
182 for _, option := range options {
183 option(&metadata)
184 }
@@ -154,7 +198,7 @@ func (c *Client) newLease(name string, options ...MetadataOption) (*portal.Lease
198 Name: name,
199 TLS: c.config.TLS,
200 ReverseToken: hex.EncodeToString(tokenBytes),
157 - Metadata: portal.Metadata{
201 + Metadata: types.Metadata{
202 Description: metadata.Description,
203 Tags: metadata.Tags,
204 Thumbnail: metadata.Thumbnail,
@@ -179,7 +223,7 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
223 if keylessServerName == "" {
224 return nil, nil, fmt.Errorf("relay hostname is required: %s", relayAddr)
225 }
182 - baseDomain := ExtractBaseDomain(relayAddr)
226 + baseDomain := types.ExtractBaseDomain(relayAddr)
227 if baseDomain == "" {
228 return nil, nil, fmt.Errorf("keyless base domain is required for relay %s", relayAddr)
229 }
@@ -196,50 +240,3 @@ func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []fun
240 func (c *Client) Close() error {
241 return nil
242 }
199 -
200 -func ExtractBaseDomain(rawURL string) string {
201 - trimmed := strings.TrimSpace(rawURL)
202 - if trimmed == "" {
203 - return ""
204 - }
205 - if !strings.Contains(trimmed, "://") {
206 - trimmed = "https://" + trimmed
207 - }
208 -
209 - u, err := url.Parse(trimmed)
210 - if err != nil || u.Hostname() == "" {
211 - return ""
212 - }
213 -
214 - host := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(u.Hostname())), "*.")
215 - parts := strings.Split(host, ".")
216 - if len(parts) < 2 {
217 - return ""
218 - }
219 - return parts[len(parts)-2] + "." + parts[len(parts)-1]
220 -}
221 -
222 -func normalizeRelayAPIURLs(bootstrapServers []string) ([]string, error) {
223 - if len(bootstrapServers) == 0 {
224 - return nil, ErrNoAvailableRelay
225 - }
226 -
227 - seen := make(map[string]struct{}, len(bootstrapServers))
228 - out := make([]string, 0, len(bootstrapServers))
229 - for _, relay := range bootstrapServers {
230 - normalized, err := normalizeRelayAPIURL(relay)
231 - if err != nil {
232 - continue
233 - }
234 - if _, exists := seen[normalized]; exists {
235 - continue
236 - }
237 - seen[normalized] = struct{}{}
238 - out = append(out, normalized)
239 - }
240 -
241 - if len(out) == 0 {
242 - return nil, ErrNoAvailableRelay
243 - }
244 - return out, nil
245 -}
sdk/listener.go
+10 -54
@@ -19,6 +19,7 @@ import (
19 "golang.org/x/net/websocket"
20
21 "gosuda.org/portal/portal"
22 + "gosuda.org/portal/types"
23 )
24
25 const (
@@ -69,7 +70,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
70 return nil, fmt.Errorf("lease reverse token is required")
71 }
72
72 - apiURL, err := normalizeRelayAPIURL(relayAddr)
73 + apiURL, err := types.NormalizeRelayAPIURL(relayAddr)
74 if err != nil {
75 return nil, err
76 }
@@ -345,7 +346,7 @@ func (l *Listener) waitForReverseStart(conn net.Conn, expectedMarker byte) error
346 }
347
348 func (l *Listener) registerWithRelay() error {
348 - reqBody := RegisterRequest{
349 + reqBody := types.RegisterRequest{
350 LeaseID: l.lease.ID,
351 Name: l.lease.Name,
352 Metadata: l.lease.Metadata,
@@ -353,22 +354,22 @@ func (l *Listener) registerWithRelay() error {
354 ReverseToken: l.lease.ReverseToken,
355 }
356
356 - return l.postJSON(SDKPathRegister, reqBody)
357 + return l.postJSON(types.PathSDKRegister, reqBody)
358 }
359
360 func (l *Listener) unregisterFromRelay() error {
360 - reqBody := UnregisterRequest{
361 + reqBody := types.UnregisterRequest{
362 LeaseID: l.lease.ID,
363 }
363 - return l.postJSON(SDKPathUnregister, reqBody)
364 + return l.postJSON(types.PathSDKUnregister, reqBody)
365 }
366
367 func (l *Listener) sendKeepalive() error {
367 - reqBody := RenewRequest{
368 + reqBody := types.RenewRequest{
369 LeaseID: l.lease.ID,
370 ReverseToken: l.lease.ReverseToken,
371 }
371 - return l.postJSON(SDKPathRenew, reqBody)
372 + return l.postJSON(types.PathSDKRenew, reqBody)
373 }
374
375 func (l *Listener) postJSON(path string, body any) error {
@@ -400,7 +401,7 @@ func (l *Listener) postJSON(path string, body any) error {
401 return nil
402 }
403
403 - var apiResp APIResponse
404 + var apiResp types.APIResponse
405 if err := json.Unmarshal(data, &apiResp); err != nil {
406 // Non-JSON success payloads are treated as successful.
407 return nil
@@ -423,51 +424,6 @@ func isLeaseNotFoundError(err error) bool {
424 return strings.Contains(strings.ToLower(err.Error()), "lease not found")
425 }
426
426 -func normalizeRelayAPIURL(raw string) (string, error) {
427 - raw = strings.TrimSpace(raw)
428 - if raw == "" {
429 - return "", fmt.Errorf("empty relay URL")
430 - }
431 -
432 - // Accept host:port input.
433 - if !strings.Contains(raw, "://") {
434 - raw = "http://" + raw
435 - }
436 -
437 - u, err := url.Parse(raw)
438 - if err != nil {
439 - return "", fmt.Errorf("parse relay URL: %w", err)
440 - }
441 - if u.Host == "" {
442 - return "", fmt.Errorf("relay URL missing host: %q", raw)
443 - }
444 -
445 - if host := strings.ToLower(strings.TrimSpace(u.Hostname())); strings.HasSuffix(host, ".localhost") {
446 - port := u.Port()
447 - if port != "" {
448 - u.Host = net.JoinHostPort("localhost", port)
449 - } else {
450 - u.Host = "localhost"
451 - }
452 - }
453 -
454 - switch u.Scheme {
455 - case "http", "https":
456 - default:
457 - return "", fmt.Errorf("unsupported relay URL scheme: %q (use http/https)", u.Scheme)
458 - }
459 -
460 - if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
461 - return "", fmt.Errorf("relay URL must not include path: %q", raw)
462 - }
463 -
464 - u.RawQuery = ""
465 - u.Fragment = ""
466 - u.Path = ""
467 -
468 - return strings.TrimSuffix(u.String(), "/"), nil
469 -}
470 -
427 func relayConnectURL(relayAddr, leaseID, token string) (string, error) {
428 if strings.TrimSpace(leaseID) == "" {
429 return "", fmt.Errorf("leaseID is required")
@@ -488,7 +444,7 @@ func relayConnectURL(relayAddr, leaseID, token string) (string, error) {
444 default:
445 return "", fmt.Errorf("unsupported relay URL scheme: %q", u.Scheme)
446 }
491 - u.Path = SDKPathConnect
447 + u.Path = types.PathSDKConnect
448 q := u.Query()
449 q.Set("lease_id", leaseID)
450 u.RawQuery = q.Encode()
sdk/listener_test.go
+3 -2
@@ -8,6 +8,7 @@ import (
8 "time"
9
10 "gosuda.org/portal/portal"
11 + "gosuda.org/portal/types"
12 )
13
14 func TestNormalizeRelayAPIURL(t *testing.T) {
@@ -34,7 +35,7 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
35 t.Run(tt.name, func(t *testing.T) {
36 t.Parallel()
37
37 - got, err := normalizeRelayAPIURL(tt.in)
38 + got, err := types.NormalizeRelayAPIURL(tt.in)
39 if tt.wantErr {
40 if err == nil {
41 t.Fatalf("expected error for input %q, got none", tt.in)
@@ -45,7 +46,7 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
46 t.Fatalf("unexpected error for input %q: %v", tt.in, err)
47 }
48 if got != tt.want {
48 - t.Fatalf("normalizeRelayAPIURL(%q) = %q, want %q", tt.in, got, tt.want)
49 + t.Fatalf("types.NormalizeRelayAPIURL(%q) = %q, want %q", tt.in, got, tt.want)
50 }
51 })
52 }
sdk/types.go deleted
-119
@@ -1,119 +0,0 @@
1 -package sdk
2 -
3 -import (
4 - "errors"
5 - "time"
6 -
7 - "gosuda.org/portal/portal"
8 -)
9 -
10 -var (
11 - ErrNoAvailableRelay = errors.New("no available relay")
12 - ErrClientClosed = errors.New("client is closed")
13 - ErrListenerExists = errors.New("listener already exists for this credential")
14 - ErrRelayExists = errors.New("relay already exists")
15 - ErrRelayNotFound = errors.New("relay not found")
16 - ErrInvalidName = errors.New("lease name contains invalid characters (only alphanumeric, hyphen, underscore allowed)")
17 - ErrFailedToCreateClient = errors.New("failed to create relay client")
18 - ErrInvalidMetadata = errors.New("invalid metadata")
19 -)
20 -
21 -type ClientConfig struct {
22 - BootstrapServers []string
23 - ReverseDialTimeout time.Duration // Reverse websocket dial timeout (default: 5 seconds)
24 -
25 - TLS bool
26 -}
27 -
28 -type ClientOption func(*ClientConfig)
29 -
30 -func WithBootstrapServers(servers []string) ClientOption {
31 - return func(c *ClientConfig) {
32 - c.BootstrapServers = servers
33 - }
34 -}
35 -
36 -func WithReverseDialTimeout(timeout time.Duration) ClientOption {
37 - return func(c *ClientConfig) {
38 - c.ReverseDialTimeout = timeout
39 - }
40 -}
41 -
42 -// WithTLS enables keyless TLS mode using relay-derived defaults.
43 -func WithTLS() ClientOption {
44 - return func(c *ClientConfig) {
45 - c.TLS = true
46 - }
47 -}
48 -
49 -// MetadataOption configures Metadata
50 -type MetadataOption func(*portal.Metadata)
51 -
52 -func WithDescription(description string) MetadataOption {
53 - return func(m *portal.Metadata) {
54 - m.Description = description
55 - }
56 -}
57 -
58 -func WithTags(tags []string) MetadataOption {
59 - return func(m *portal.Metadata) {
60 - m.Tags = tags
61 - }
62 -}
63 -
64 -func WithThumbnail(thumbnail string) MetadataOption {
65 - return func(m *portal.Metadata) {
66 - m.Thumbnail = thumbnail
67 - }
68 -}
69 -
70 -func WithOwner(owner string) MetadataOption {
71 - return func(m *portal.Metadata) {
72 - m.Owner = owner
73 - }
74 -}
75 -
76 -func WithHide(hide bool) MetadataOption {
77 - return func(m *portal.Metadata) {
78 - m.Hide = hide
79 - }
80 -}
81 -
82 -// API Types for /sdk/ endpoints
83 -// These types are shared between SDK and relay server
84 -type RegisterRequest struct {
85 - LeaseID string `json:"lease_id"`
86 - Name string `json:"name"`
87 - Metadata portal.Metadata `json:"metadata"`
88 - TLS bool `json:"tls"`
89 - ReverseToken string `json:"reverse_token"`
90 -}
91 -
92 -type RegisterResponse struct {
93 - Success bool `json:"success"`
94 - Message string `json:"message,omitempty"`
95 - LeaseID string `json:"lease_id,omitempty"`
96 - PublicURL string `json:"public_url,omitempty"`
97 -}
98 -
99 -type UnregisterRequest struct {
100 - LeaseID string `json:"lease_id"`
101 -}
102 -
103 -type RenewRequest struct {
104 - LeaseID string `json:"lease_id"`
105 - ReverseToken string `json:"reverse_token"`
106 -}
107 -
108 -type APIResponse struct {
109 - Success bool `json:"success"`
110 - Message string `json:"message,omitempty"`
111 -}
112 -
113 -const (
114 - SDKPathRegister = "/sdk/register"
115 - SDKPathUnregister = "/sdk/unregister"
116 - SDKPathRenew = "/sdk/renew"
117 - SDKPathDomain = "/sdk/domain"
118 - SDKPathConnect = "/sdk/connect"
119 -)
types/api.go new
+129
@@ -0,0 +1,129 @@
1 +package types
2 +
3 +// API path constants for Portal relay server.
4 +
5 +// SDK API paths for lease registration and tunnel connections.
6 +const (
7 + PathSDKPrefix = "/sdk/"
8 + PathSDKRegister = "/sdk/register"
9 + PathSDKUnregister = "/sdk/unregister"
10 + PathSDKRenew = "/sdk/renew"
11 + PathSDKDomain = "/sdk/domain"
12 + PathSDKConnect = "/sdk/connect"
13 +
14 + // Admin API paths.
15 + PathAdminPrefix = "/admin"
16 + PathAdminLogin = "/admin/login"
17 + PathAdminLogout = "/admin/logout"
18 + PathAdminAuthStatus = "/admin/auth/status"
19 + PathAdminLeases = "/admin/leases"
20 + PathAdminLeasesBanned = "/admin/leases/banned"
21 + PathAdminStats = "/admin/stats"
22 + PathAdminSettings = "/admin/settings"
23 + PathAdminApprovalMode = "/admin/settings/approval-mode"
24 +
25 + // Keyless API paths.
26 + PathKeylessSign = "/v1/sign"
27 +
28 + // Health check path.
29 + PathHealthz = "/healthz"
30 +
31 + // Tunnel installer paths.
32 + PathTunnelScript = "/tunnel"
33 + PathTunnelBinary = "/tunnel/bin/"
34 +
35 + // App static assets path prefix.
36 + PathAppPrefix = "/app/"
37 +)
38 +
39 +// Client API types for /sdk/* endpoints.
40 +
41 +// RegisterRequest is the lease registration request.
42 +type RegisterRequest struct {
43 + LeaseID string `json:"lease_id"`
44 + Name string `json:"name"`
45 + Metadata Metadata `json:"metadata"`
46 + TLS bool `json:"tls"`
47 + ReverseToken string `json:"reverse_token"`
48 +}
49 +
50 +// RegisterResponse is the lease registration response.
51 +type RegisterResponse struct {
52 + Success bool `json:"success"`
53 + Message string `json:"message,omitempty"`
54 + LeaseID string `json:"lease_id,omitempty"`
55 + PublicURL string `json:"public_url,omitempty"`
56 +}
57 +
58 +// UnregisterRequest is the lease unregistration request.
59 +type UnregisterRequest struct {
60 + LeaseID string `json:"lease_id"`
61 +}
62 +
63 +// RenewRequest is the lease renewal request.
64 +type RenewRequest struct {
65 + LeaseID string `json:"lease_id"`
66 + ReverseToken string `json:"reverse_token"`
67 +}
68 +
69 +// APIResponse is a generic Client API response.
70 +type APIResponse struct {
71 + Success bool `json:"success"`
72 + Message string `json:"message,omitempty"`
73 +}
74 +
75 +// DomainResponse is the Client domain discovery response.
76 +type DomainResponse struct {
77 + Success bool `json:"success"`
78 + Message string `json:"message,omitempty"`
79 + BaseDomain string `json:"base_domain,omitempty"`
80 +}
81 +
82 +// Admin API types for /admin/* endpoints.
83 +
84 +// AdminLoginRequest is the admin login request body.
85 +type AdminLoginRequest struct {
86 + Key string `json:"key"`
87 +}
88 +
89 +// AdminLoginResponse is the admin login response.
90 +type AdminLoginResponse struct {
91 + Success bool `json:"success"`
92 + Error string `json:"error,omitempty"`
93 + Locked bool `json:"locked,omitempty"`
94 + RemainingSeconds int `json:"remaining_seconds,omitempty"`
95 +}
96 +
97 +// AdminAuthStatusResponse is the admin auth status response.
98 +type AdminAuthStatusResponse struct {
99 + Authenticated bool `json:"authenticated"`
100 + AuthEnabled bool `json:"auth_enabled"`
101 +}
102 +
103 +// AdminSettingsResponse is the admin settings response.
104 +type AdminSettingsResponse struct {
105 + ApprovalMode string `json:"approval_mode"`
106 + ApprovedLeases []string `json:"approved_leases"`
107 + DeniedLeases []string `json:"denied_leases"`
108 +}
109 +
110 +// AdminApprovalModeRequest is the request to change approval mode.
111 +type AdminApprovalModeRequest struct {
112 + Mode string `json:"mode"`
113 +}
114 +
115 +// AdminApprovalModeResponse is the approval mode response.
116 +type AdminApprovalModeResponse struct {
117 + ApprovalMode string `json:"approval_mode"`
118 +}
119 +
120 +// AdminBPSRequest is the request to set BPS limit for a lease.
121 +type AdminBPSRequest struct {
122 + BPS int64 `json:"bps"`
123 +}
124 +
125 +// AdminStatsResponse is the admin stats response.
126 +type AdminStatsResponse struct {
127 + LeasesCount int `json:"leases_count"`
128 + Uptime string `json:"uptime"`
129 +}
types/metadata.go new
+59
@@ -0,0 +1,59 @@
1 +// Package types defines all API request/response types and path constants
2 +// for the Portal relay server and SDK.
3 +package types
4 +
5 +// Metadata holds service metadata for a lease.
6 +type Metadata struct {
7 + Description string `json:"description,omitempty"`
8 + Tags []string `json:"tags,omitempty"`
9 + Thumbnail string `json:"thumbnail,omitempty"`
10 + Owner string `json:"owner,omitempty"`
11 + Hide bool `json:"hide,omitempty"`
12 +}
13 +
14 +// ParsedMetadata holds struct-parsed metadata for better access.
15 +type ParsedMetadata struct {
16 + Description string `json:"description"`
17 + Tags []string `json:"tags"`
18 + Thumbnail string `json:"thumbnail"`
19 + Owner string `json:"owner"`
20 + Hide bool `json:"hide"`
21 +}
22 +
23 +// MetadataOption configures Metadata.
24 +type MetadataOption func(*Metadata)
25 +
26 +// WithDescription sets the lease description.
27 +func WithDescription(description string) MetadataOption {
28 + return func(m *Metadata) {
29 + m.Description = description
30 + }
31 +}
32 +
33 +// WithTags sets the lease tags.
34 +func WithTags(tags []string) MetadataOption {
35 + return func(m *Metadata) {
36 + m.Tags = tags
37 + }
38 +}
39 +
40 +// WithThumbnail sets the lease thumbnail URL.
41 +func WithThumbnail(thumbnail string) MetadataOption {
42 + return func(m *Metadata) {
43 + m.Thumbnail = thumbnail
44 + }
45 +}
46 +
47 +// WithOwner sets the lease owner.
48 +func WithOwner(owner string) MetadataOption {
49 + return func(m *Metadata) {
50 + m.Owner = owner
51 + }
52 +}
53 +
54 +// WithHide sets whether to hide the lease from public listings.
55 +func WithHide(hide bool) MetadataOption {
56 + return func(m *Metadata) {
57 + m.Hide = hide
58 + }
59 +}
types/netutil.go new
+392
@@ -0,0 +1,392 @@
1 +package types
2 +
3 +import (
4 + "fmt"
5 + "net"
6 + "net/url"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +// ExtractBaseDomain extracts the base domain (e.g., "example.com") from a URL.
12 +// Returns empty string if the URL is invalid or has fewer than 2 domain parts.
13 +func ExtractBaseDomain(rawURL string) string {
14 + trimmed := strings.TrimSpace(rawURL)
15 + if trimmed == "" {
16 + return ""
17 + }
18 + if !strings.Contains(trimmed, "://") {
19 + trimmed = "https://" + trimmed
20 + }
21 +
22 + u, err := url.Parse(trimmed)
23 + if err != nil || u.Hostname() == "" {
24 + return ""
25 + }
26 +
27 + host := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(u.Hostname())), "*.")
28 + parts := strings.Split(host, ".")
29 + if len(parts) < 2 {
30 + return ""
31 + }
32 + return parts[len(parts)-2] + "." + parts[len(parts)-1]
33 +}
34 +
35 +// StripScheme removes http:// or https:// prefix from a string.
36 +func StripScheme(s string) string {
37 + s = strings.TrimSpace(s)
38 + s = strings.TrimSuffix(s, "/")
39 + s = strings.TrimPrefix(s, "http://")
40 + s = strings.TrimPrefix(s, "https://")
41 + return s
42 +}
43 +
44 +// StripWildcard removes *. prefix from a domain pattern.
45 +func StripWildcard(s string) string {
46 + s = strings.TrimSpace(s)
47 + s = strings.TrimPrefix(s, "*.")
48 + return s
49 +}
50 +
51 +// StripPort removes a trailing :port from a host string if present.
52 +func StripPort(s string) string {
53 + if s == "" {
54 + return s
55 + }
56 + if idx := strings.LastIndexByte(s, ':'); idx >= 0 && idx+1 < len(s) {
57 + port := s[idx+1:]
58 + digits := true
59 + for _, ch := range port {
60 + if ch < '0' || ch > '9' {
61 + digits = false
62 + break
63 + }
64 + }
65 + if digits {
66 + return s[:idx]
67 + }
68 + }
69 + return s
70 +}
71 +
72 +// IsSubdomain reports whether host matches the given domain pattern.
73 +// Pattern can be a wildcard like "*.example.com" or exact domain.
74 +func IsSubdomain(domain, host string) bool {
75 + if host == "" || domain == "" {
76 + return false
77 + }
78 +
79 + h := strings.ToLower(StripPort(StripScheme(host)))
80 + d := strings.ToLower(StripPort(StripScheme(domain)))
81 +
82 + if strings.HasPrefix(d, "*.") {
83 + suffix := d[1:]
84 + return len(h) > len(suffix) && strings.HasSuffix(h, suffix)
85 + }
86 +
87 + if h == d {
88 + return true
89 + }
90 +
91 + return strings.HasSuffix(h, "."+d)
92 +}
93 +
94 +// DefaultAppPattern builds a wildcard subdomain pattern from a base portal URL or host.
95 +func DefaultAppPattern(base string) string {
96 + base = strings.TrimSpace(strings.TrimSuffix(base, "/"))
97 + if base == "" {
98 + return "*.localhost:4017"
99 + }
100 + host := StripWildcard(StripScheme(base))
101 + if host == "" {
102 + return "*.localhost:4017"
103 + }
104 + if strings.HasPrefix(host, "*.") {
105 + return host
106 + }
107 + return "*." + host
108 +}
109 +
110 +// ServicePublicURL returns a service URL derived from portalURL and service name.
111 +func ServicePublicURL(portalURL, serviceName string) string {
112 + serviceName = strings.TrimSpace(serviceName)
113 + if serviceName == "" {
114 + return ""
115 + }
116 +
117 + raw := strings.TrimSpace(portalURL)
118 + if raw == "" {
119 + return ""
120 + }
121 + if !strings.Contains(raw, "://") {
122 + raw = "http://" + raw
123 + }
124 +
125 + u, err := url.Parse(raw)
126 + if err != nil || strings.TrimSpace(u.Host) == "" {
127 + return ""
128 + }
129 +
130 + host := strings.TrimSpace(StripWildcard(u.Host))
131 + if host == "" {
132 + return ""
133 + }
134 +
135 + scheme := strings.TrimSpace(u.Scheme)
136 + if scheme == "" {
137 + scheme = "http"
138 + }
139 +
140 + return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
141 +}
142 +
143 +// PortalHostPort returns normalized host[:port] from a portal URL-like input.
144 +func PortalHostPort(portalURL string) string {
145 + return strings.ToLower(strings.TrimSpace(
146 + StripWildcard(StripScheme(portalURL)),
147 + ))
148 +}
149 +
150 +// PortalRootHost extracts the root hostname from a portal URL.
151 +func PortalRootHost(portalURL string) string {
152 + raw := strings.TrimSpace(portalURL)
153 + if raw == "" {
154 + return ""
155 + }
156 + if !strings.Contains(raw, "://") {
157 + raw = "https://" + raw
158 + }
159 +
160 + parsed, err := url.Parse(raw)
161 + if err != nil || parsed.Hostname() == "" {
162 + return ""
163 + }
164 + return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(parsed.Hostname())), "*.")
165 +}
166 +
167 +// DefaultBootstrapFrom derives a relay API bootstrap URL from a base portal URL or host.
168 +func DefaultBootstrapFrom(base string) string {
169 + base = strings.TrimSpace(base)
170 + if base == "" {
171 + return "http://localhost:4017"
172 + }
173 +
174 + if !strings.Contains(base, "://") {
175 + base = "http://" + base
176 + }
177 +
178 + u, err := url.Parse(strings.TrimSuffix(base, "/"))
179 + if err != nil || u.Host == "" {
180 + return "http://localhost:4017"
181 + }
182 + if u.Scheme != "http" && u.Scheme != "https" {
183 + return "http://localhost:4017"
184 + }
185 + if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
186 + return "http://localhost:4017"
187 + }
188 +
189 + u.Path = ""
190 + u.RawQuery = ""
191 + u.Fragment = ""
192 + return strings.TrimSuffix(u.String(), "/")
193 +}
194 +
195 +// LeaseNameFromHost extracts the lease name from a subdomain host.
196 +func LeaseNameFromHost(host, appURL string) (string, bool) {
197 + if !IsSubdomain(appURL, host) {
198 + return "", false
199 + }
200 +
201 + normalizedHost := strings.ToLower(strings.TrimSpace(StripPort(host)))
202 + baseHost := strings.ToLower(strings.TrimSpace(
203 + StripPort(StripWildcard(StripScheme(appURL))),
204 + ))
205 +
206 + if normalizedHost == "" || baseHost == "" || normalizedHost == baseHost {
207 + return "", false
208 + }
209 +
210 + suffix := "." + baseHost
211 + if !strings.HasSuffix(normalizedHost, suffix) {
212 + return "", false
213 + }
214 +
215 + leaseName := strings.TrimSuffix(normalizedHost, suffix)
216 + if leaseName == "" || strings.Contains(leaseName, ".") {
217 + return "", false
218 + }
219 +
220 + return leaseName, true
221 +}
222 +
223 +// BuildSNIName constructs the SNI hostname for a lease.
224 +func BuildSNIName(leaseName, baseHost string) string {
225 + leaseName = strings.ToLower(strings.TrimSpace(leaseName))
226 + baseHost = strings.TrimSpace(baseHost)
227 + if leaseName == "" || baseHost == "" {
228 + return ""
229 + }
230 + return leaseName + "." + baseHost
231 +}
232 +
233 +// ParseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
234 +func ParseURLs(raw string) []string {
235 + raw = strings.TrimSpace(raw)
236 + if raw == "" {
237 + return nil
238 + }
239 + parts := strings.Split(raw, ",")
240 + out := make([]string, 0, len(parts))
241 + for _, p := range parts {
242 + p = strings.TrimSpace(p)
243 + if p != "" {
244 + out = append(out, p)
245 + }
246 + }
247 + return out
248 +}
249 +
250 +// ParsePortNumber parses a port number from a string, returning fallback on error.
251 +// The raw value may optionally start with a colon prefix.
252 +func ParsePortNumber(raw string, fallback int) int {
253 + value := strings.TrimSpace(raw)
254 + if value == "" {
255 + return fallback
256 + }
257 + value = strings.TrimPrefix(value, ":")
258 + port, err := strconv.Atoi(value)
259 + if err != nil || port < 1 || port > 65535 {
260 + return fallback
261 + }
262 + return port
263 +}
264 +
265 +// LoopbackForwardAddr converts a listen address to a loopback forward address.
266 +// For example, ":4017" becomes "127.0.0.1:4017".
267 +func LoopbackForwardAddr(listenAddr string) string {
268 + raw := strings.TrimSpace(listenAddr)
269 + if raw == "" {
270 + return ""
271 + }
272 +
273 + port := ""
274 + switch {
275 + case strings.HasPrefix(raw, ":"):
276 + port = strings.TrimPrefix(raw, ":")
277 + case strings.Count(raw, ":") == 0:
278 + port = raw
279 + default:
280 + _, p, err := net.SplitHostPort(raw)
281 + if err != nil {
282 + return ""
283 + }
284 + port = p
285 + }
286 +
287 + portNum, err := strconv.Atoi(port)
288 + if err != nil || portNum < 1 || portNum > 65535 {
289 + return ""
290 + }
291 +
292 + return net.JoinHostPort("127.0.0.1", strconv.Itoa(portNum))
293 +}
294 +
295 +// NormalizeTargetAddr normalizes a target address for dialing.
296 +// If the input is a URL (e.g., "http://localhost:8080"), it extracts the host.
297 +// Otherwise, it returns the trimmed input.
298 +func NormalizeTargetAddr(raw string) (string, error) {
299 + raw = strings.TrimSpace(raw)
300 + if raw == "" {
301 + return "", fmt.Errorf("empty host")
302 + }
303 +
304 + // Treat plain host[:port] as a dial target without URL parsing.
305 + if !strings.Contains(raw, "://") {
306 + return raw, nil
307 + }
308 +
309 + u, err := url.Parse(raw)
310 + if err != nil {
311 + return "", fmt.Errorf("parse target URL: %w", err)
312 + }
313 + if strings.TrimSpace(u.Host) == "" {
314 + return "", fmt.Errorf("missing host in URL")
315 + }
316 + return u.Host, nil
317 +}
318 +
319 +// NormalizeRelayAPIURL normalizes a relay API URL.
320 +// It accepts host:port input (defaults to http), validates the scheme,
321 +// normalizes localhost hostnames, and removes path/query/fragment.
322 +func NormalizeRelayAPIURL(raw string) (string, error) {
323 + raw = strings.TrimSpace(raw)
324 + if raw == "" {
325 + return "", fmt.Errorf("empty relay URL")
326 + }
327 +
328 + // Accept host:port input.
329 + if !strings.Contains(raw, "://") {
330 + raw = "http://" + raw
331 + }
332 +
333 + u, err := url.Parse(raw)
334 + if err != nil {
335 + return "", fmt.Errorf("parse relay URL: %w", err)
336 + }
337 + if u.Host == "" {
338 + return "", fmt.Errorf("relay URL missing host: %q", raw)
339 + }
340 +
341 + if host := strings.ToLower(strings.TrimSpace(u.Hostname())); strings.HasSuffix(host, ".localhost") {
342 + port := u.Port()
343 + if port != "" {
344 + u.Host = net.JoinHostPort("localhost", port)
345 + } else {
346 + u.Host = "localhost"
347 + }
348 + }
349 +
350 + switch u.Scheme {
351 + case "http", "https":
352 + default:
353 + return "", fmt.Errorf("unsupported relay URL scheme: %q (use http/https)", u.Scheme)
354 + }
355 +
356 + if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
357 + return "", fmt.Errorf("relay URL must not include path: %q", raw)
358 + }
359 +
360 + u.RawQuery = ""
361 + u.Fragment = ""
362 + u.Path = ""
363 +
364 + return strings.TrimSuffix(u.String(), "/"), nil
365 +}
366 +
367 +// NormalizeRelayAPIURLs normalizes a list of relay API URLs, deduplicating results.
368 +// Returns an error if no valid URLs remain after normalization.
369 +func NormalizeRelayAPIURLs(bootstrapServers []string) ([]string, error) {
370 + if len(bootstrapServers) == 0 {
371 + return nil, fmt.Errorf("no available relay")
372 + }
373 +
374 + seen := make(map[string]struct{}, len(bootstrapServers))
375 + out := make([]string, 0, len(bootstrapServers))
376 + for _, relay := range bootstrapServers {
377 + normalized, err := NormalizeRelayAPIURL(relay)
378 + if err != nil {
379 + continue
380 + }
381 + if _, exists := seen[normalized]; exists {
382 + continue
383 + }
384 + seen[normalized] = struct{}{}
385 + out = append(out, normalized)
386 + }
387 +
388 + if len(out) == 0 {
389 + return nil, fmt.Errorf("no available relay")
390 + }
391 + return out, nil
392 +}
types/netutil_test.go new
+58
@@ -0,0 +1,58 @@
1 +package types
2 +
3 +import "testing"
4 +
5 +func TestNormalizeTargetAddr(t *testing.T) {
6 + t.Parallel()
7 +
8 + tests := []struct {
9 + name string
10 + in string
11 + want string
12 + wantErr bool
13 + }{
14 + {
15 + name: "host and port",
16 + in: "localhost:3000",
17 + want: "localhost:3000",
18 + },
19 + {
20 + name: "url with scheme",
21 + in: "http://localhost:3000",
22 + want: "localhost:3000",
23 + },
24 + {
25 + name: "url missing host",
26 + in: "http:///only-path",
27 + wantErr: true,
28 + },
29 + {
30 + name: "empty",
31 + in: " ",
32 + wantErr: true,
33 + },
34 + }
35 +
36 + for _, tt := range tests {
37 + tt := tt
38 + t.Run(tt.name, func(t *testing.T) {
39 + t.Parallel()
40 +
41 + got, err := NormalizeTargetAddr(tt.in)
42 + if tt.wantErr {
43 + if err == nil {
44 + t.Fatalf("expected error for input %q", tt.in)
45 + }
46 + return
47 + }
48 +
49 + if err != nil {
50 + t.Fatalf("unexpected error for input %q: %v", tt.in, err)
51 + }
52 +
53 + if got != tt.want {
54 + t.Fatalf("NormalizeTargetAddr(%q) = %q, want %q", tt.in, got, tt.want)
55 + }
56 + })
57 + }
58 +}