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(®isterReq); 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
+}