sdk: refactor listener, client

rabbitprincess committed Mar 7, 2026 at 20:54 UTC c3459e6fd558a250a8caa9d234a9ed2cfd37d8fa
12 files changed +749 -295
cmd/demo-app/main.go
+4 -6
@@ -18,7 +18,7 @@ import (
18
19 var (
20 flagServerURL string
21 - flagPort int
21 + flagAddr string
22 flagName string
23 flagDesc string
24 flagTags string
@@ -32,7 +32,7 @@ func main() {
32 logger := log.With().Str("component", "demo-app").Logger()
33
34 flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URLs (comma-separated, https only)")
35 - flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
35 + flag.StringVar(&flagAddr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (disable if empty)")
36 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
37 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
38 flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
@@ -74,13 +74,11 @@ func runDemo() error {
74 }
75 defer listener.Close()
76
77 - if err := sdk.RunHTTP(ctx, listener, newHandler(), sdk.HTTPServeOptions{
78 - LocalAddr: fmt.Sprintf(":%d", flagPort),
79 - }); err != nil {
77 + logger.Info().Strs("public_urls", listener.PublicURLs()).Str("local_addr", flagAddr).Msg("demo app registered with relay")
78 + if err := sdk.RunHTTP(ctx, listener, newHandler(), flagAddr); err != nil {
79 return err
80 }
81
83 - logger.Info().Strs("public_urls", listener.PublicURLs()).Int("local_port", flagPort).Msg("demo app registered with relay")
82 if ctx.Err() != nil {
83 logger.Info().Msg("demo app shutting down")
84 }
portal/admin/handler.go
+25 -25
@@ -84,7 +84,7 @@ func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
84 }
85
86 if !h.isAuthenticated(r) {
87 - writeAPIError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
87 + writeAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
88 return
89 }
90
@@ -111,17 +111,17 @@ func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
111
112 func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
113 if r.Method != http.MethodPost {
114 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
114 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
115 return
116 }
117 if !h.auth.AuthEnabled() {
118 - writeAPIError(w, http.StatusServiceUnavailable, "auth_disabled", "admin authentication is not configured")
118 + writeAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeAuthDisabled, "admin authentication is not configured")
119 return
120 }
121
122 clientIP := policy.ExtractClientIP(r, h.trustProxy)
123 if h.auth.IsIPLocked(clientIP) {
124 - writeAPIErrorWithData(w, http.StatusTooManyRequests, "auth_locked", "Too many failed attempts. Please try again later.", types.AdminLoginResponse{
124 + writeAPIErrorWithData(w, http.StatusTooManyRequests, types.APIErrorCodeAuthLocked, "Too many failed attempts. Please try again later.", types.AdminLoginResponse{
125 Locked: true,
126 RemainingSeconds: h.auth.LockRemainingSeconds(clientIP),
127 })
@@ -130,7 +130,7 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
130
131 var req types.AdminLoginRequest
132 if err := decodeJSON(w, r, &req); err != nil {
133 - writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
133 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
134 return
135 }
136 if !h.auth.ValidateKey(req.Key) {
@@ -139,14 +139,14 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
139 if locked {
140 resp.RemainingSeconds = h.auth.LockRemainingSeconds(clientIP)
141 }
142 - writeAPIErrorWithData(w, http.StatusUnauthorized, "invalid_key", "Invalid key", resp)
142 + writeAPIErrorWithData(w, http.StatusUnauthorized, types.APIErrorCodeInvalidKey, "Invalid key", resp)
143 return
144 }
145
146 h.auth.ResetFailedLogin(clientIP)
147 token, err := h.auth.CreateSession()
148 if err != nil {
149 - writeAPIError(w, http.StatusInternalServerError, "session_create_failed", "failed to create admin session")
149 + writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeSessionCreateFailed, "failed to create admin session")
150 return
151 }
152
@@ -164,7 +164,7 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
164
165 func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
166 if r.Method != http.MethodPost {
167 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
167 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
168 return
169 }
170
@@ -185,7 +185,7 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
185
186 func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
187 if r.Method != http.MethodGet {
188 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
188 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
189 return
190 }
191 writeAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
@@ -196,7 +196,7 @@ func (h *Handler) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
196
197 func (h *Handler) handleLeases(w http.ResponseWriter, r *http.Request) {
198 if r.Method != http.MethodGet {
199 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
199 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
200 return
201 }
202 writeAPIData(w, http.StatusOK, h.buildLeaseRows(h.server, true))
@@ -204,7 +204,7 @@ func (h *Handler) handleLeases(w http.ResponseWriter, r *http.Request) {
204
205 func (h *Handler) handleBannedLeases(w http.ResponseWriter, r *http.Request) {
206 if r.Method != http.MethodGet {
207 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
207 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
208 return
209 }
210 writeAPIData(w, http.StatusOK, h.runtime.BannedLeases())
@@ -212,7 +212,7 @@ func (h *Handler) handleBannedLeases(w http.ResponseWriter, r *http.Request) {
212
213 func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) {
214 if r.Method != http.MethodGet {
215 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
215 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
216 return
217 }
218 approver := h.runtime.Approver()
@@ -231,17 +231,17 @@ func (h *Handler) handleApprovalMode(w http.ResponseWriter, r *http.Request) {
231 case http.MethodPost:
232 var req types.AdminApprovalModeRequest
233 if err := decodeJSON(w, r, &req); err != nil {
234 - writeAPIError(w, http.StatusBadRequest, "invalid_request", "invalid request body")
234 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
235 return
236 }
237 if err := approver.SetMode(policy.Mode(req.Mode)); err != nil {
238 - writeAPIError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (must be 'auto' or 'manual')")
238 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
239 return
240 }
241 _ = h.settings.Save(h.runtime)
242 writeAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{ApprovalMode: string(approver.Mode())})
243 default:
244 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
244 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
245 }
246 }
247
@@ -255,7 +255,7 @@ func (h *Handler) handleLeaseAction(w http.ResponseWriter, r *http.Request, path
255
256 leaseID, ok := decodeLeaseID(parts[0])
257 if !ok {
258 - writeAPIError(w, http.StatusBadRequest, "invalid_lease_id", "invalid lease ID")
258 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidLeaseID, "invalid lease ID")
259 return
260 }
261
@@ -263,7 +263,7 @@ func (h *Handler) handleLeaseAction(w http.ResponseWriter, r *http.Request, path
263 case "ban":
264 h.handleLeaseBan(w, r, leaseID)
265 case "bps":
266 - writeAPIError(w, http.StatusNotImplemented, "feature_unavailable", "bps control is not enabled in this build")
266 + writeAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "bps control is not enabled in this build")
267 case "approve":
268 h.handleLeaseApproval(w, r, leaseID)
269 case "deny":
@@ -284,7 +284,7 @@ func (h *Handler) handleLeaseBan(w http.ResponseWriter, r *http.Request, leaseID
284 _ = h.settings.Save(h.runtime)
285 writeAPIOK(w, http.StatusOK)
286 default:
287 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
287 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
288 }
289 }
290
@@ -301,7 +301,7 @@ func (h *Handler) handleLeaseApproval(w http.ResponseWriter, r *http.Request, le
301 _ = h.settings.Save(h.runtime)
302 writeAPIOK(w, http.StatusOK)
303 default:
304 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
304 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
305 }
306 }
307
@@ -317,7 +317,7 @@ func (h *Handler) handleLeaseDenial(w http.ResponseWriter, r *http.Request, leas
317 _ = h.settings.Save(h.runtime)
318 writeAPIOK(w, http.StatusOK)
319 default:
320 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
320 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
321 }
322 }
323
@@ -329,7 +329,7 @@ func (h *Handler) handleIPBan(w http.ResponseWriter, r *http.Request, path strin
329 rawIP := strings.TrimSuffix(strings.TrimPrefix(path, types.PathAdminIPsPrefix), "/ban")
330 rawIP = strings.Trim(rawIP, "/")
331 if net.ParseIP(rawIP) == nil {
332 - writeAPIError(w, http.StatusBadRequest, "invalid_ip", "invalid IP address")
332 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
333 return
334 }
335
@@ -344,7 +344,7 @@ func (h *Handler) handleIPBan(w http.ResponseWriter, r *http.Request, path strin
344 _ = h.settings.Save(h.runtime)
345 writeAPIOK(w, http.StatusOK)
346 default:
347 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
347 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
348 }
349 }
350
@@ -379,7 +379,7 @@ func decodeLeaseID(encoded string) (string, bool) {
379 func writeAPIData(w http.ResponseWriter, status int, data any) {
380 w.Header().Set("Content-Type", "application/json")
381 w.WriteHeader(status)
382 - _ = json.NewEncoder(w).Encode(types.APIEnvelope{OK: true, Data: data})
382 + _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{OK: true, Data: data})
383 }
384
385 func writeAPIOK(w http.ResponseWriter, status int) {
@@ -389,7 +389,7 @@ func writeAPIOK(w http.ResponseWriter, status int) {
389 func writeAPIError(w http.ResponseWriter, status int, code, message string) {
390 w.Header().Set("Content-Type", "application/json")
391 w.WriteHeader(status)
392 - _ = json.NewEncoder(w).Encode(types.APIEnvelope{
392 + _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
393 OK: false,
394 Error: &types.APIError{Code: code, Message: message},
395 })
@@ -398,7 +398,7 @@ func writeAPIError(w http.ResponseWriter, status int, code, message string) {
398 func writeAPIErrorWithData(w http.ResponseWriter, status int, code, message string, data any) {
399 w.Header().Set("Content-Type", "application/json")
400 w.WriteHeader(status)
401 - _ = json.NewEncoder(w).Encode(types.APIEnvelope{
401 + _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
402 OK: false,
403 Data: data,
404 Error: &types.APIError{Code: code, Message: message},
portal/admin/handler_test.go
+2 -11
@@ -74,21 +74,12 @@ func TestLoginAndProtectedActions(t *testing.T) {
74 func decodeEnvelope[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
75 t.Helper()
76
77 - var envelope types.APIEnvelope
77 + var envelope types.APIEnvelope[T]
78 if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
79 t.Fatalf("Decode envelope error = %v", err)
80 }
81 if !envelope.OK {
82 t.Fatalf("envelope not OK: %+v", envelope)
83 }
84 - data, err := json.Marshal(envelope.Data)
85 - if err != nil {
86 - t.Fatalf("Marshal envelope data error = %v", err)
87 - }
88 -
89 - var out T
90 - if err := json.Unmarshal(data, &out); err != nil {
91 - t.Fatalf("Unmarshal envelope data error = %v", err)
92 - }
93 - return out
84 + return envelope.Data
85 }
portal/server.go
+30 -30
@@ -273,7 +273,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
273
274 func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
275 if r.Method != http.MethodGet {
276 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
276 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
277 return
278 }
279 name := r.URL.Query().Get("name")
@@ -285,27 +285,27 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
285
286 func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
287 if r.Method != http.MethodPost {
288 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
288 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
289 return
290 }
291 clientIP := s.clientIPFromRequest(r)
292 if s.isClientIPBanned(clientIP) {
293 - writeAPIError(w, http.StatusForbidden, "ip_banned", "request denied because source IP is banned")
293 + writeAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
294 return
295 }
296 var req types.RegisterRequest
297 if err := decodeJSONBody(w, r, &req); err != nil {
298 - writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
298 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
299 return
300 }
301 resp, err := s.registerLease(req, clientIP)
302 if err != nil {
303 - status, code := http.StatusBadRequest, "invalid_request"
303 + status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
304 if errors.Is(err, errHostnameConflict) {
305 - status, code = http.StatusConflict, "hostname_conflict"
305 + status, code = http.StatusConflict, types.APIErrorCodeHostnameConflict
306 }
307 if errors.Is(err, errIPBanned) {
308 - status, code = http.StatusForbidden, "ip_banned"
308 + status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
309 }
310 writeAPIError(w, status, code, err.Error())
311 return
@@ -315,30 +315,30 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
315
316 func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
317 if r.Method != http.MethodPost {
318 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
318 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
319 return
320 }
321 clientIP := s.clientIPFromRequest(r)
322 if s.isClientIPBanned(clientIP) {
323 - writeAPIError(w, http.StatusForbidden, "ip_banned", "request denied because source IP is banned")
323 + writeAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
324 return
325 }
326 var req types.RenewRequest
327 if err := decodeJSONBody(w, r, &req); err != nil {
328 - writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
328 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
329 return
330 }
331 resp, err := s.renewLease(req, clientIP)
332 if err != nil {
333 - status, code := http.StatusBadRequest, "invalid_request"
333 + status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
334 if errors.Is(err, errLeaseNotFound) {
335 - status, code = http.StatusNotFound, "lease_not_found"
335 + status, code = http.StatusNotFound, types.APIErrorCodeLeaseNotFound
336 }
337 if errors.Is(err, errUnauthorized) {
338 - status, code = http.StatusForbidden, "unauthorized"
338 + status, code = http.StatusForbidden, types.APIErrorCodeUnauthorized
339 }
340 if errors.Is(err, errIPBanned) {
341 - status, code = http.StatusForbidden, "ip_banned"
341 + status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
342 }
343 writeAPIError(w, status, code, err.Error())
344 return
@@ -348,21 +348,21 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
348
349 func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
350 if r.Method != http.MethodPost {
351 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
351 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
352 return
353 }
354 var req types.UnregisterRequest
355 if err := decodeJSONBody(w, r, &req); err != nil {
356 - writeAPIError(w, http.StatusBadRequest, "invalid_json", err.Error())
356 + writeAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
357 return
358 }
359 if err := s.unregisterLease(req); err != nil {
360 - status, code := http.StatusBadRequest, "invalid_request"
360 + status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
361 if errors.Is(err, errLeaseNotFound) {
362 - status, code = http.StatusNotFound, "lease_not_found"
362 + status, code = http.StatusNotFound, types.APIErrorCodeLeaseNotFound
363 }
364 if errors.Is(err, errUnauthorized) {
365 - status, code = http.StatusForbidden, "unauthorized"
365 + status, code = http.StatusForbidden, types.APIErrorCodeUnauthorized
366 }
367 writeAPIError(w, status, code, err.Error())
368 return
@@ -372,11 +372,11 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
372
373 func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
374 if r.Method != http.MethodGet {
375 - writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
375 + writeAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
376 return
377 }
378 if r.ProtoMajor != 1 {
379 - writeAPIError(w, http.StatusHTTPVersionNotSupported, "http11_only", "reverse connect requires HTTP/1.1")
379 + writeAPIError(w, http.StatusHTTPVersionNotSupported, types.APIErrorCodeHTTP11Only, "reverse connect requires HTTP/1.1")
380 return
381 }
382
@@ -384,33 +384,33 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
384 token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
385 clientIP := s.clientIPFromRequest(r)
386 if s.isClientIPBanned(clientIP) {
387 - writeAPIError(w, http.StatusForbidden, "ip_banned", "request denied because source IP is banned")
387 + writeAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
388 return
389 }
390
391 lease, err := s.findLeaseByID(leaseID)
392 if err != nil {
393 - writeAPIError(w, http.StatusNotFound, "lease_not_found", err.Error())
393 + writeAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
394 return
395 }
396 if !s.isLeaseRoutable(lease) {
397 - writeAPIError(w, http.StatusForbidden, "lease_rejected", "lease is not approved for routing")
397 + writeAPIError(w, http.StatusForbidden, types.APIErrorCodeLeaseRejected, "lease is not approved for routing")
398 return
399 }
400 if authErr := s.authorizeLeaseToken(lease, token); authErr != nil {
401 - writeAPIError(w, http.StatusForbidden, "unauthorized", authErr.Error())
401 + writeAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, authErr.Error())
402 return
403 }
404
405 hijacker, ok := w.(http.Hijacker)
406 if !ok {
407 - writeAPIError(w, http.StatusInternalServerError, "hijack_unsupported", "hijacking is not supported")
407 + writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeHijackUnsupported, "hijacking is not supported")
408 return
409 }
410
411 conn, rw, err := hijacker.Hijack()
412 if err != nil {
413 - writeAPIError(w, http.StatusInternalServerError, "hijack_failed", err.Error())
413 + writeAPIError(w, http.StatusInternalServerError, types.APIErrorCodeHijackFailed, err.Error())
414 return
415 }
416
@@ -737,7 +737,7 @@ func (s *Server) wrapAPIHandler(base http.Handler) http.Handler {
737 var (
738 errLeaseNotFound = errors.New("lease not found")
739 errIPBanned = errors.New("request denied because source IP is banned")
740 - errUnauthorized = errors.New("unauthorized")
740 + errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
741 errHostnameConflict = errors.New("hostname already registered")
742 )
743
@@ -774,7 +774,7 @@ func tokenMatches(expected, actual string) bool {
774 func writeAPIData(w http.ResponseWriter, status int, data any) {
775 w.Header().Set("Content-Type", "application/json")
776 w.WriteHeader(status)
777 - _ = json.NewEncoder(w).Encode(types.APIEnvelope{OK: true, Data: data})
777 + _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{OK: true, Data: data})
778 }
779
780 func writeAPIOK(w http.ResponseWriter, status int) {
@@ -784,7 +784,7 @@ func writeAPIOK(w http.ResponseWriter, status int) {
784 func writeAPIError(w http.ResponseWriter, status int, code, message string) {
785 w.Header().Set("Content-Type", "application/json")
786 w.WriteHeader(status)
787 - _ = json.NewEncoder(w).Encode(types.APIEnvelope{
787 + _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
788 OK: false,
789 Error: &types.APIError{Code: code, Message: message},
790 })
sdk/client.go
+76 -44
@@ -23,12 +23,14 @@ import (
23 )
24
25 const (
26 - defaultDialTimeout = 5 * time.Second
27 - defaultRequestTimeout = 15 * time.Second
28 - defaultHandshakeTimeout = 15 * time.Second
29 - defaultLeaseTTL = 2 * time.Minute
30 - defaultRenewBefore = 30 * time.Second
31 - defaultReadyTarget = 1
26 + defaultDialTimeout = 5 * time.Second
27 + defaultRequestTimeout = 15 * time.Second
28 + defaultHandshakeTimeout = 15 * time.Second
29 + defaultLeaseTTL = 2 * time.Minute
30 + defaultRenewBefore = 30 * time.Second
31 + defaultReadyTarget = 1
32 + defaultRetryDelay = 5 * time.Second
33 + defaultHTTPShutdownTimeout = 5 * time.Second
34 )
35
36 // ClientConfig configures the SDK client.
@@ -86,23 +88,18 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
88 return nil, errors.New("no relay urls configured")
89 }
90
89 - listenerCtx, cancel := context.WithCancel(ctx)
90 - listener := &Listener{
91 - baseContext: func() context.Context { return listenerCtx },
92 - ctxDone: listenerCtx.Done(),
93 - cancel: cancel,
94 - }
91 + listener := newListener(ctx)
92
93 entries := make([]*listenerLease, 0, len(c.clients))
94 acceptedCap := 0
95 for _, client := range c.clients {
99 - entry, entryAcceptedCap, err := client.listenEntry(listener, req)
96 + entry, err := client.listenEntry(listener, req)
97 if err != nil {
101 - cancel()
98 + listener.cancel()
99 return nil, errors.Join(err, closeListenerEntries(entries))
100 }
101 entries = append(entries, entry)
105 - acceptedCap += entryAcceptedCap
102 + acceptedCap += entry.readyTarget
103 }
104
105 if acceptedCap <= 0 {
@@ -111,10 +108,9 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
108
109 listener.accepted = make(chan acceptedConn, acceptedCap)
110 listener.entries = entries
111 + listener.activeCount = len(entries)
112 for _, entry := range entries {
115 - go entry.runSupervisor()
116 - go entry.runRenewLoop()
117 - entry.notify()
113 + entry.start()
114 }
115
116 return listener, nil
@@ -211,7 +207,7 @@ func (c *relayClient) Close() {
207 }
208 }
209
214 -func (c *relayClient) listenEntry(listener *Listener, req ListenRequest) (*listenerLease, int, error) {
210 +func (c *relayClient) listenEntry(listener *Listener, req ListenRequest) (*listenerLease, error) {
211 reverseToken := strings.TrimSpace(req.ReverseToken)
212 if reverseToken == "" {
213 reverseToken = randomToken()
@@ -225,7 +221,6 @@ func (c *relayClient) listenEntry(listener *Listener, req ListenRequest) (*liste
221 if leaseTTL <= 0 {
222 leaseTTL = defaultLeaseTTL
223 }
228 - acceptedCap := max(readyTarget*2, 1)
224
225 registerReq := types.RegisterRequest{
226 Name: req.Name,
@@ -237,14 +232,14 @@ func (c *relayClient) listenEntry(listener *Listener, req ListenRequest) (*liste
232 }
233
234 var registerResp types.RegisterResponse
240 - if err := c.doJSON(listener.baseContext(), http.MethodPost, types.PathSDKRegister, registerReq, &registerResp); err != nil {
241 - return nil, 0, err
235 + if err := c.doJSON(listener.ctx, http.MethodPost, types.PathSDKRegister, registerReq, &registerResp); err != nil {
236 + return nil, err
237 }
238
239 tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(c.baseURL.String(), registerResp.Hostnames)
240 if err != nil {
241 _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
247 - return nil, 0, err
242 + return nil, err
243 }
244
245 return &listenerLease{
@@ -254,15 +249,15 @@ func (c *relayClient) listenEntry(listener *Listener, req ListenRequest) (*liste
249 RelayURL: c.baseURL.String(),
250 LeaseID: registerResp.LeaseID,
251 Hostnames: append([]string(nil), registerResp.Hostnames...),
257 - Metadata: registerResp.Metadata,
252 + Metadata: cloneLeaseMetadata(registerResp.Metadata),
253 },
254 reverseToken: reverseToken,
255 leaseTTL: leaseTTL,
256 readyTarget: readyTarget,
257 tlsConfig: tlsConf,
258 tlsCloser: tlsCloser,
264 - signal: make(chan struct{}, 1),
265 - }, acceptedCap, nil
259 + active: true,
260 + }, nil
261 }
262
263 func (c *relayClient) doJSON(ctx context.Context, method, path string, payload any, out any) error {
@@ -275,7 +270,8 @@ func (c *relayClient) doJSON(ctx context.Context, method, path string, payload a
270 body = bytes.NewReader(buf)
271 }
272
278 - req, err := http.NewRequestWithContext(ctx, method, c.resolve(path), body)
273 + ref, _ := url.Parse(path)
274 + req, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(ref).String(), body)
275 if err != nil {
276 return err
277 }
@@ -287,15 +283,22 @@ func (c *relayClient) doJSON(ctx context.Context, method, path string, payload a
283 }
284 defer resp.Body.Close()
285
290 - var envelope apiEnvelope
286 + var envelope types.APIEnvelope[json.RawMessage]
287 if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
288 return fmt.Errorf("decode response: %w", err)
289 }
290 if !envelope.OK {
291 if envelope.Error == nil {
296 - return fmt.Errorf("api request failed with status %d", resp.StatusCode)
292 + return &types.APIRequestError{
293 + StatusCode: resp.StatusCode,
294 + Message: fmt.Sprintf("api request failed with status %d", resp.StatusCode),
295 + }
296 + }
297 + return &types.APIRequestError{
298 + StatusCode: resp.StatusCode,
299 + Code: envelope.Error.Code,
300 + Message: envelope.Error.Message,
301 }
298 - return fmt.Errorf("%s: %s", envelope.Error.Code, envelope.Error.Message)
302 }
303 if out == nil {
304 return nil
@@ -329,11 +332,8 @@ func (c *relayClient) openReverseSession(ctx context.Context, leaseID, reverseTo
332 return nil, err
333 }
334
332 - connectURL, err := url.Parse(c.resolve(types.PathSDKConnect))
333 - if err != nil {
334 - _ = conn.Close()
335 - return nil, err
336 - }
335 + connectRef, _ := url.Parse(types.PathSDKConnect)
336 + connectURL := c.baseURL.ResolveReference(connectRef)
337 query := connectURL.Query()
338 query.Set("lease_id", leaseID)
339 connectURL.RawQuery = query.Encode()
@@ -361,23 +361,55 @@ func (c *relayClient) openReverseSession(ctx context.Context, leaseID, reverseTo
361 defer resp.Body.Close()
362
363 if resp.StatusCode != http.StatusOK {
364 - body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
364 + apiErr := decodeAPIResponseError(resp)
365 _ = conn.Close()
366 - return nil, fmt.Errorf("reverse connect failed: %s", strings.TrimSpace(string(body)))
366 + return nil, apiErr
367 }
368
369 return wrapBufferedConn(conn, reader), nil
370 }
371
372 -func (c *relayClient) resolve(path string) string {
373 - ref, _ := url.Parse(path)
374 - return c.baseURL.ResolveReference(ref).String()
372 +func decodeAPIResponseError(resp *http.Response) error {
373 + if resp == nil {
374 + return &types.APIRequestError{Message: "empty api response"}
375 + }
376 +
377 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
378 + var envelope types.APIEnvelope[json.RawMessage]
379 + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Error != nil {
380 + return &types.APIRequestError{
381 + StatusCode: resp.StatusCode,
382 + Code: envelope.Error.Code,
383 + Message: envelope.Error.Message,
384 + }
385 + }
386 +
387 + return &types.APIRequestError{
388 + StatusCode: resp.StatusCode,
389 + Message: strings.TrimSpace(string(body)),
390 + }
391 +}
392 +
393 +func isLeaseResetError(err error) bool {
394 + var apiErr *types.APIRequestError
395 + if !errors.As(err, &apiErr) {
396 + return false
397 + }
398 + return apiErr.Code == types.APIErrorCodeLeaseNotFound
399 }
400
377 -type apiEnvelope struct {
378 - Error *types.APIError `json:"error"`
379 - Data json.RawMessage `json:"data"`
380 - OK bool `json:"ok"`
401 +func isTerminalEntryError(err error) bool {
402 + var apiErr *types.APIRequestError
403 + if !errors.As(err, &apiErr) {
404 + return false
405 + }
406 +
407 + switch apiErr.Code {
408 + case types.APIErrorCodeIPBanned, types.APIErrorCodeUnauthorized:
409 + return true
410 + default:
411 + return false
412 + }
413 }
414
415 func buildRootCAs(rootCAPEM []byte) (*x509.CertPool, error) {
sdk/client_test.go
+78
@@ -1,9 +1,17 @@
1 package sdk
2
3 import (
4 + "context"
5 + "crypto/tls"
6 + "encoding/json"
7 + "errors"
8 "net/http"
9 "net/http/httptest"
10 + "net/url"
11 "testing"
12 + "time"
13 +
14 + "github.com/gosuda/portal/v2/types"
15 )
16
17 func TestNewClientAutoTrustsLocalhostRelayCertificate(t *testing.T) {
@@ -71,3 +79,73 @@ func TestNewClientSupportsDedupedRelayURLs(t *testing.T) {
79 }
80 }
81 }
82 +
83 +func TestOpenReverseSessionPreservesAPIErrorCode(t *testing.T) {
84 + t.Parallel()
85 +
86 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 + if r.URL.Path != types.PathSDKConnect {
88 + t.Fatalf("request path = %q, want %q", r.URL.Path, types.PathSDKConnect)
89 + }
90 + if got := r.URL.Query().Get("lease_id"); got != "lease-123" {
91 + t.Fatalf("lease_id = %q, want %q", got, "lease-123")
92 + }
93 +
94 + w.Header().Set("Content-Type", "application/json")
95 + w.WriteHeader(http.StatusForbidden)
96 + if flusher, ok := w.(http.Flusher); ok {
97 + flusher.Flush()
98 + }
99 + time.Sleep(25 * time.Millisecond)
100 + _ = json.NewEncoder(w).Encode(types.APIEnvelope[any]{
101 + OK: false,
102 + Error: &types.APIError{Code: "unauthorized", Message: "bad reverse token"},
103 + })
104 + }))
105 + server.EnableHTTP2 = false
106 + server.StartTLS()
107 + defer server.Close()
108 +
109 + baseURL, err := url.Parse(server.URL)
110 + if err != nil {
111 + t.Fatalf("url.Parse() error = %v", err)
112 + }
113 +
114 + transport, ok := server.Client().Transport.(*http.Transport)
115 + if !ok {
116 + t.Fatalf("server client transport type = %T, want *http.Transport", server.Client().Transport)
117 + }
118 +
119 + relayClient := &relayClient{
120 + baseURL: baseURL,
121 + httpClient: &http.Client{
122 + Transport: transport.Clone(),
123 + Timeout: defaultRequestTimeout,
124 + },
125 + rawTLSConfig: &tls.Config{
126 + MinVersion: tls.VersionTLS12,
127 + ServerName: baseURL.Hostname(),
128 + RootCAs: transport.TLSClientConfig.RootCAs,
129 + NextProtos: []string{"http/1.1"},
130 + },
131 + }
132 +
133 + _, err = relayClient.openReverseSession(context.Background(), "lease-123", "tok_123")
134 + if err == nil {
135 + t.Fatal("openReverseSession() error = nil, want APIRequestError")
136 + }
137 +
138 + var apiErr *types.APIRequestError
139 + if !errors.As(err, &apiErr) {
140 + t.Fatalf("openReverseSession() error = %T, want *types.APIRequestError", err)
141 + }
142 + if apiErr.StatusCode != http.StatusForbidden {
143 + t.Fatalf("APIRequestError.StatusCode = %d, want %d", apiErr.StatusCode, http.StatusForbidden)
144 + }
145 + if apiErr.Code != "unauthorized" {
146 + t.Fatalf("APIRequestError.Code = %q, want %q", apiErr.Code, "unauthorized")
147 + }
148 + if apiErr.Message != "bad reverse token" {
149 + t.Fatalf("APIRequestError.Message = %q, want %q", apiErr.Message, "bad reverse token")
150 + }
151 +}
sdk/helper.go
+7 -20
@@ -7,37 +7,24 @@ import (
7 "net"
8 "net/http"
9 "strings"
10 - "time"
10
11 "golang.org/x/sync/errgroup"
12 )
13
15 -const defaultHTTPShutdownTimeout = 5 * time.Second
16 -
17 -type HTTPServeOptions struct {
18 - LocalAddr string
19 - ReadHeaderTimeout time.Duration
20 -}
21 -
22 -// RunHTTP serves one handler on the relay listener and, optionally, on a
23 -// local HTTP address for app-local access.
24 -func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, opts HTTPServeOptions) error {
25 - readHeaderTimeout := opts.ReadHeaderTimeout
26 - if readHeaderTimeout <= 0 {
27 - readHeaderTimeout = defaultRequestTimeout
28 - }
29 -
14 +// RunHTTP serves one handler on the relay listener and, when localAddr is set,
15 +// on the provided local HTTP address for app-local access.
16 +func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
17 relaySrv := &http.Server{
18 Handler: handler,
32 - ReadHeaderTimeout: readHeaderTimeout,
19 + ReadHeaderTimeout: defaultRequestTimeout,
20 }
21
22 var localSrv *http.Server
36 - if opts.LocalAddr != "" {
23 + if strings.TrimSpace(localAddr) != "" {
24 localSrv = &http.Server{
38 - Addr: opts.LocalAddr,
25 + Addr: strings.TrimSpace(localAddr),
26 Handler: handler,
40 - ReadHeaderTimeout: readHeaderTimeout,
27 + ReadHeaderTimeout: defaultRequestTimeout,
28 }
29 }
30
sdk/helper_test.go
+2 -4
@@ -25,7 +25,7 @@ func TestRunHTTPAppRelayOnly(t *testing.T) {
25 go func() {
26 errCh <- RunHTTP(ctx, listener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
27 _, _ = io.WriteString(w, "ok")
28 - }), HTTPServeOptions{})
28 + }), "")
29 }()
30
31 waitForHTTP(t, "http://"+listener.Addr().String())
@@ -64,9 +64,7 @@ func TestRunHTTPAppLocalAndRelay(t *testing.T) {
64 go func() {
65 errCh <- RunHTTP(ctx, relayListener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
66 _, _ = io.WriteString(w, "ok")
67 - }), HTTPServeOptions{
68 - LocalAddr: localAddr,
69 - })
67 + }), localAddr)
68 }()
69
70 waitForHTTP(t, "http://"+relayListener.Addr().String())
sdk/listener.go
+273 -117
@@ -42,30 +42,36 @@ func (e ListenerEntry) PublicURLs() []string {
42
43 func (e ListenerEntry) clone() ListenerEntry {
44 e.Hostnames = append([]string(nil), e.Hostnames...)
45 + e.Metadata = cloneLeaseMetadata(e.Metadata)
46 return e
47 }
48
49 type Listener struct {
49 - baseContext func() context.Context
50 - ctxDone <-chan struct{}
51 - cancel context.CancelFunc
52 - accepted chan acceptedConn
53 - entries []*listenerLease
54 - closeOnce sync.Once
50 + ctx context.Context
51 + cancel context.CancelFunc
52 + accepted chan acceptedConn
53 + entries []*listenerLease
54 + workers errgroup.Group
55 + closeOnce sync.Once
56 +
57 + mu sync.RWMutex
58 + activeCount int
59 + terminalErr error
60 }
61
62 type listenerLease struct {
58 - tlsCloser io.Closer
59 - tlsConfig *tls.Config
60 - parent *Listener
61 - client *relayClient
62 - signal chan struct{}
63 - info ListenerEntry
64 - reverseToken string
65 - readyTarget int
66 - leaseTTL time.Duration
67 - activeSessions int
68 - mu sync.Mutex
63 + parent *Listener
64 + client *relayClient
65 + reverseToken string
66 + readyTarget int
67 + leaseTTL time.Duration
68 +
69 + mu sync.RWMutex
70 + info ListenerEntry
71 + tlsConfig *tls.Config
72 + tlsCloser io.Closer
73 + active bool
74 + terminalErr error
75 }
76
77 type acceptedConn struct {
@@ -73,6 +79,25 @@ type acceptedConn struct {
79 entry ListenerEntry
80 }
81
82 +type sessionSnapshot struct {
83 + info ListenerEntry
84 + tlsConfig *tls.Config
85 + active bool
86 + terminalErr error
87 +}
88 +
89 +func newListener(ctx context.Context) *Listener {
90 + if ctx == nil {
91 + ctx = context.Background()
92 + }
93 +
94 + listenerCtx, cancel := context.WithCancel(ctx)
95 + return &Listener{
96 + ctx: listenerCtx,
97 + cancel: cancel,
98 + }
99 +}
100 +
101 func (l *Listener) Accept() (net.Conn, error) {
102 conn, _, err := l.AcceptEntry()
103 return conn, err
@@ -81,14 +106,16 @@ func (l *Listener) Accept() (net.Conn, error) {
106 // AcceptEntry returns the next accepted connection plus relay-specific lease
107 // metadata for callers that need to distinguish which relay claimed it.
108 func (l *Listener) AcceptEntry() (net.Conn, ListenerEntry, error) {
84 - select {
85 - case <-l.ctxDone:
86 - return nil, ListenerEntry{}, net.ErrClosed
87 - case accepted := <-l.accepted:
88 - if accepted.conn == nil {
89 - return nil, ListenerEntry{}, net.ErrClosed
109 + for {
110 + select {
111 + case <-l.ctx.Done():
112 + return nil, ListenerEntry{}, l.closeError()
113 + case accepted := <-l.accepted:
114 + if accepted.conn == nil {
115 + return nil, ListenerEntry{}, l.closeError()
116 + }
117 + return accepted.conn, accepted.entry.clone(), nil
118 }
91 - return accepted.conn, accepted.entry.clone(), nil
119 }
120 }
121
@@ -96,7 +123,8 @@ func (l *Listener) Close() error {
123 var closeErr error
124 l.closeOnce.Do(func() {
125 l.cancel()
99 - closeErr = closeListenerEntries(l.entries)
126 + closeErr = errors.Join(l.workers.Wait(), closeListenerEntries(l.entries))
127 + l.drainAccepted()
128 })
129 return closeErr
130 }
@@ -112,23 +140,28 @@ func (l *Listener) Addr() net.Addr {
140 func (l *Listener) Entries() []ListenerEntry {
141 entries := make([]ListenerEntry, 0, len(l.entries))
142 for _, entry := range l.entries {
115 - entries = append(entries, entry.info.clone())
143 + info, ok := entry.snapshotInfo()
144 + if !ok {
145 + continue
146 + }
147 + entries = append(entries, info)
148 }
149 return entries
150 }
151
152 func (l *Listener) singleEntry() (ListenerEntry, bool) {
121 - if len(l.entries) != 1 {
153 + entries := l.Entries()
154 + if len(entries) != 1 {
155 return ListenerEntry{}, false
156 }
124 - return l.entries[0].info.clone(), true
157 + return entries[0], true
158 }
159
160 // PublicURLs returns all public HTTPS URLs exposed by the listener.
161 func (l *Listener) PublicURLs() []string {
162 var urls []string
130 - for _, entry := range l.entries {
131 - urls = append(urls, entry.info.PublicURLs()...)
163 + for _, entry := range l.Entries() {
164 + urls = append(urls, entry.PublicURLs()...)
165 }
166 return urls
167 }
@@ -147,6 +180,7 @@ func closeListenerEntries(entries []*listenerLease) error {
180 if entry == nil {
181 continue
182 }
183 + entry := entry
184 group.Go(func() error {
185 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
186 defer cancel()
@@ -165,66 +199,86 @@ func closeListenerEntries(entries []*listenerLease) error {
199 return closeErr
200 }
201
168 -func (l *listenerLease) runSupervisor() {
169 - for {
170 - select {
171 - case <-l.parent.ctxDone:
172 - return
173 - case <-l.signal:
174 - }
202 +func (l *listenerLease) start() {
203 + if l == nil || l.parent == nil {
204 + return
205 + }
206
176 - for l.reserveSessionSlot() {
177 - go l.runSession()
178 - }
207 + l.parent.workers.Go(func() error {
208 + l.runRenewLoop()
209 + return nil
210 + })
211 + for i := 0; i < l.readyTarget; i++ {
212 + l.parent.workers.Go(func() error {
213 + l.runSessionWorker()
214 + return nil
215 + })
216 }
217 }
218
219 func (l *listenerLease) runRenewLoop() {
183 - interval := l.leaseTTL / 2
184 - if interval <= 0 {
185 - interval = 30 * time.Second
186 - }
187 - if defaultRenewBefore > 0 && l.leaseTTL > defaultRenewBefore {
188 - interval = l.leaseTTL - defaultRenewBefore
189 - }
190 - if interval <= 0 {
191 - interval = 30 * time.Second
192 - }
193 -
194 - ticker := time.NewTicker(interval)
220 + ticker := time.NewTicker(l.renewInterval())
221 defer ticker.Stop()
222
223 for {
224 select {
199 - case <-l.parent.ctxDone:
225 + case <-l.parent.ctx.Done():
226 return
227 case <-ticker.C:
202 - ctx, cancel := context.WithTimeout(l.context(), 10*time.Second)
203 - _ = l.client.renewLease(ctx, l.info.LeaseID, l.reverseToken, l.leaseTTL)
228 + if l.shouldStop() {
229 + return
230 + }
231 +
232 + ctx, cancel := context.WithTimeout(l.parent.ctx, 10*time.Second)
233 + err := l.client.renewLease(ctx, l.leaseID(), l.reverseToken, l.leaseTTL)
234 cancel()
235 + if err == nil {
236 + continue
237 + }
238 + if isLeaseResetError(err) || isTerminalEntryError(err) {
239 + l.stop(err)
240 + return
241 + }
242 }
243 }
244 }
245
209 -func (l *listenerLease) runSession() {
210 - defer l.releaseSessionSlot()
246 +func (l *listenerLease) runSessionWorker() {
247 + for {
248 + if l.shouldStop() {
249 + return
250 + }
251
212 - sessionCtx := l.context()
213 - conn, err := l.client.openReverseSession(sessionCtx, l.info.LeaseID, l.reverseToken)
214 - if err != nil {
215 - sleepOrDone(sessionCtx, time.Second)
216 - return
217 - }
252 + snapshot := l.sessionSnapshot()
253 + if !snapshot.active {
254 + return
255 + }
256
219 - if err := l.awaitActivation(conn); err != nil {
220 - _ = conn.Close()
221 - if !errors.Is(err, context.Canceled) && !errors.Is(err, net.ErrClosed) {
222 - sleepOrDone(sessionCtx, time.Second)
257 + conn, err := l.client.openReverseSession(l.parent.ctx, snapshot.info.LeaseID, l.reverseToken)
258 + if err != nil {
259 + if isLeaseResetError(err) || isTerminalEntryError(err) {
260 + l.stop(err)
261 + return
262 + }
263 + sleepOrDone(l.parent.ctx, defaultRetryDelay)
264 + continue
265 + }
266 +
267 + if err := l.parent.awaitActivation(conn, snapshot.tlsConfig, snapshot.info); err != nil {
268 + _ = conn.Close()
269 + if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
270 + return
271 + }
272 + if isLeaseResetError(err) || isTerminalEntryError(err) {
273 + l.stop(err)
274 + return
275 + }
276 + sleepOrDone(l.parent.ctx, defaultRetryDelay)
277 }
278 }
279 }
280
227 -func (l *listenerLease) awaitActivation(conn net.Conn) error {
281 +func (l *Listener) awaitActivation(conn net.Conn, tlsConfig *tls.Config, info ListenerEntry) error {
282 var marker [1]byte
283 for {
284 _ = conn.SetReadDeadline(time.Now().Add(2 * defaultHandshakeTimeout))
@@ -237,72 +291,190 @@ func (l *listenerLease) awaitActivation(conn net.Conn) error {
291 case types.MarkerKeepalive:
292 continue
293 case types.MarkerTLSStart:
240 - return l.activate(conn)
294 + return l.activate(conn, tlsConfig, info)
295 default:
296 return fmt.Errorf("unexpected reverse marker: 0x%02x", marker[0])
297 }
298 }
299 }
300
247 -func (l *listenerLease) activate(conn net.Conn) error {
248 - tlsConn := tls.Server(conn, l.tlsConfig.Clone())
249 - handshakeCtx, cancel := context.WithTimeout(l.context(), defaultHandshakeTimeout)
301 +func (l *Listener) activate(conn net.Conn, tlsConfig *tls.Config, info ListenerEntry) error {
302 + if tlsConfig == nil {
303 + return errors.New("missing tls config")
304 + }
305 +
306 + tlsConn := tls.Server(conn, tlsConfig.Clone())
307 + handshakeCtx, cancel := context.WithTimeout(l.ctx, defaultHandshakeTimeout)
308 defer cancel()
309 if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
310 return err
311 }
312
313 select {
256 - case <-l.parent.ctxDone:
314 + case <-l.ctx.Done():
315 _ = tlsConn.Close()
258 - return l.context().Err()
259 - case l.parent.accepted <- acceptedConn{conn: tlsConn, entry: l.info.clone()}:
316 + return l.closeError()
317 + case l.accepted <- acceptedConn{conn: tlsConn, entry: info.clone()}:
318 + return nil
319 + }
320 +}
321 +
322 +func (l *listenerLease) close(ctx context.Context) error {
323 + if l == nil {
324 return nil
325 }
326 +
327 + var closeErr error
328 + if l.client != nil {
329 + if err := l.client.unregisterLease(ctx, l.info.LeaseID, l.reverseToken); err != nil {
330 + closeErr = errors.Join(closeErr, err)
331 + }
332 + }
333 + if l.tlsCloser != nil {
334 + closeErr = errors.Join(closeErr, l.tlsCloser.Close())
335 + }
336 + return closeErr
337 +}
338 +
339 +func (l *listenerLease) renewInterval() time.Duration {
340 + interval := l.leaseTTL / 2
341 + if interval <= 0 {
342 + interval = 30 * time.Second
343 + }
344 + if defaultRenewBefore > 0 && l.leaseTTL > defaultRenewBefore {
345 + interval = l.leaseTTL - defaultRenewBefore
346 + }
347 + if interval <= 0 {
348 + interval = 30 * time.Second
349 + }
350 + return interval
351 }
352
264 -func (l *listenerLease) reserveSessionSlot() bool {
353 +func (l *Listener) fail(err error) {
354 l.mu.Lock()
266 - defer l.mu.Unlock()
267 - if l.isClosed() {
355 + if err != nil && l.terminalErr == nil {
356 + l.terminalErr = err
357 + }
358 + l.mu.Unlock()
359 + l.cancel()
360 +}
361 +
362 +func (l *Listener) closeError() error {
363 + l.mu.RLock()
364 + terminalErr := l.terminalErr
365 + l.mu.RUnlock()
366 + if terminalErr != nil {
367 + return terminalErr
368 + }
369 + if err := l.ctx.Err(); err != nil {
370 + if errors.Is(err, context.Canceled) {
371 + return net.ErrClosed
372 + }
373 + return err
374 + }
375 + return net.ErrClosed
376 +}
377 +
378 +func (l *Listener) isClosed() bool {
379 + if l == nil || l.ctx == nil {
380 return false
381 }
270 - if l.activeSessions >= l.readyTarget {
382 + select {
383 + case <-l.ctx.Done():
384 + return true
385 + default:
386 return false
387 }
273 - l.activeSessions++
274 - return true
388 }
389
277 -func (l *listenerLease) releaseSessionSlot() {
390 +func (l *Listener) drainAccepted() {
391 + for {
392 + select {
393 + case accepted := <-l.accepted:
394 + if accepted.conn != nil {
395 + _ = accepted.conn.Close()
396 + }
397 + default:
398 + return
399 + }
400 + }
401 +}
402 +
403 +func (l *Listener) entryStopped(_ *listenerLease, err error) {
404 l.mu.Lock()
279 - l.activeSessions--
405 + if l.activeCount > 0 {
406 + l.activeCount--
407 + }
408 + shouldFail := l.activeCount == 0
409 l.mu.Unlock()
281 - l.notify()
410 +
411 + if shouldFail {
412 + l.fail(err)
413 + }
414 }
415
284 -func (l *listenerLease) notify() {
285 - select {
286 - case l.signal <- struct{}{}:
287 - default:
416 +func (l *listenerLease) snapshotInfo() (ListenerEntry, bool) {
417 + snapshot := l.sessionSnapshot()
418 + if !snapshot.active {
419 + return ListenerEntry{}, false
420 }
421 + return snapshot.info, true
422 }
423
291 -func (l *listenerLease) close(ctx context.Context) error {
424 +func (l *listenerLease) leaseID() string {
425 + l.mu.RLock()
426 + defer l.mu.RUnlock()
427 + return l.info.LeaseID
428 +}
429 +
430 +func (l *listenerLease) sessionSnapshot() sessionSnapshot {
431 + l.mu.RLock()
432 + defer l.mu.RUnlock()
433 +
434 + return sessionSnapshot{
435 + info: l.info.clone(),
436 + tlsConfig: l.tlsConfig,
437 + active: l.active,
438 + terminalErr: l.terminalErr,
439 + }
440 +}
441 +
442 +func (l *listenerLease) shutdownState() (bool, error) {
443 + l.mu.RLock()
444 + defer l.mu.RUnlock()
445 + return !l.active, l.terminalErr
446 +}
447 +
448 +func (l *listenerLease) shouldStop() bool {
449 if l == nil {
293 - return nil
450 + return true
451 }
452 + if l.parent != nil && l.parent.isClosed() {
453 + return true
454 + }
455 + stopped, _ := l.shutdownState()
456 + return stopped
457 +}
458
296 - var closeErr error
297 - if l.client != nil {
298 - if err := l.client.unregisterLease(ctx, l.info.LeaseID, l.reverseToken); err != nil {
299 - closeErr = errors.Join(closeErr, err)
300 - }
459 +func (l *listenerLease) stop(err error) {
460 + if l == nil {
461 + return
462 }
302 - if l.tlsCloser != nil {
303 - closeErr = errors.Join(closeErr, l.tlsCloser.Close())
463 +
464 + l.mu.Lock()
465 + if !l.active {
466 + l.mu.Unlock()
467 + return
468 + }
469 + l.active = false
470 + if err != nil && l.terminalErr == nil {
471 + l.terminalErr = err
472 + }
473 + l.mu.Unlock()
474 +
475 + if l.parent != nil {
476 + l.parent.entryStopped(l, err)
477 }
305 - return closeErr
478 }
479
480 func sleepOrDone(ctx context.Context, d time.Duration) {
@@ -319,23 +491,7 @@ type listenerAddr string
491 func (a listenerAddr) Network() string { return "portal" }
492 func (a listenerAddr) String() string { return string(a) }
493
322 -func (l *listenerLease) context() context.Context {
323 - if l.parent != nil && l.parent.baseContext != nil {
324 - if ctx := l.parent.baseContext(); ctx != nil {
325 - return ctx
326 - }
327 - }
328 - return context.Background()
329 -}
330 -
331 -func (l *listenerLease) isClosed() bool {
332 - if l.parent == nil || l.parent.ctxDone == nil {
333 - return false
334 - }
335 - select {
336 - case <-l.parent.ctxDone:
337 - return true
338 - default:
339 - return false
340 - }
494 +func cloneLeaseMetadata(metadata types.LeaseMetadata) types.LeaseMetadata {
495 + metadata.Tags = append([]string(nil), metadata.Tags...)
496 + return metadata
497 }
sdk/listener_test.go
+186 -35
@@ -1,31 +1,35 @@
1 package sdk
2
3 import (
4 + "context"
5 + "errors"
6 "net"
7 "testing"
8 +
9 + "github.com/gosuda/portal/v2/types"
10 )
11
12 func TestListenerSingleEntryAccessors(t *testing.T) {
13 t.Parallel()
14
11 - listener := &Listener{
12 - entries: []*listenerLease{
13 - {
14 - info: ListenerEntry{
15 - RelayURL: "https://relay.example.com",
16 - LeaseID: "lease-1",
17 - Hostnames: []string{"app.relay.example.com"},
18 - },
15 + listener := newListener(context.Background())
16 + listener.entries = []*listenerLease{
17 + {
18 + info: ListenerEntry{
19 + RelayURL: "https://relay.example.com",
20 + LeaseID: "lease-1",
21 + Hostnames: []string{"app.relay.example.com"},
22 },
23 + active: true,
24 },
25 }
26
23 - entry, ok := listener.singleEntry()
24 - if !ok {
25 - t.Fatal("singleEntry() ok = false, want true")
27 + entries := listener.Entries()
28 + if len(entries) != 1 {
29 + t.Fatalf("Entries() len = %d, want 1", len(entries))
30 }
27 - if entry.LeaseID != "lease-1" {
28 - t.Fatalf("singleEntry().LeaseID = %q, want %q", entry.LeaseID, "lease-1")
31 + if listener.Addr().String() != "portal:lease-1" {
32 + t.Fatalf("Addr().String() = %q, want %q", listener.Addr().String(), "portal:lease-1")
33 }
34
35 publicURLs := listener.PublicURLs()
@@ -37,27 +41,28 @@ func TestListenerSingleEntryAccessors(t *testing.T) {
41 func TestListenerMultiEntryAccessors(t *testing.T) {
42 t.Parallel()
43
40 - listener := &Listener{
41 - entries: []*listenerLease{
42 - {
43 - info: ListenerEntry{
44 - RelayURL: "https://relay-a.example.com",
45 - LeaseID: "lease-a",
46 - Hostnames: []string{"a.example.com"},
47 - },
44 + listener := newListener(context.Background())
45 + listener.entries = []*listenerLease{
46 + {
47 + info: ListenerEntry{
48 + RelayURL: "https://relay-a.example.com",
49 + LeaseID: "lease-a",
50 + Hostnames: []string{"a.example.com"},
51 },
49 - {
50 - info: ListenerEntry{
51 - RelayURL: "https://relay-b.example.com",
52 - LeaseID: "lease-b",
53 - Hostnames: []string{"b.example.com"},
54 - },
52 + active: true,
53 + },
54 + {
55 + info: ListenerEntry{
56 + RelayURL: "https://relay-b.example.com",
57 + LeaseID: "lease-b",
58 + Hostnames: []string{"b.example.com"},
59 },
60 + active: true,
61 },
62 }
63
59 - if _, ok := listener.singleEntry(); ok {
60 - t.Fatal("singleEntry() ok = true, want false")
64 + if listener.Addr().String() != "portal:multi" {
65 + t.Fatalf("Addr().String() = %q, want %q", listener.Addr().String(), "portal:multi")
66 }
67
68 entries := listener.Entries()
@@ -71,19 +76,49 @@ func TestListenerMultiEntryAccessors(t *testing.T) {
76 }
77 }
78
79 +func TestListenerEntriesSkipInactiveLeases(t *testing.T) {
80 + t.Parallel()
81 +
82 + listener := newListener(context.Background())
83 + listener.entries = []*listenerLease{
84 + {
85 + info: ListenerEntry{
86 + RelayURL: "https://relay-a.example.com",
87 + LeaseID: "lease-a",
88 + Hostnames: []string{"a.example.com"},
89 + },
90 + active: true,
91 + },
92 + {
93 + info: ListenerEntry{
94 + RelayURL: "https://relay-b.example.com",
95 + LeaseID: "lease-b",
96 + Hostnames: []string{"b.example.com"},
97 + },
98 + active: false,
99 + },
100 + }
101 +
102 + entries := listener.Entries()
103 + if len(entries) != 1 {
104 + t.Fatalf("Entries() len = %d, want 1", len(entries))
105 + }
106 + if entries[0].LeaseID != "lease-a" {
107 + t.Fatalf("Entries()[0].LeaseID = %q, want %q", entries[0].LeaseID, "lease-a")
108 + }
109 +}
110 +
111 func TestListenerAcceptEntry(t *testing.T) {
112 t.Parallel()
113
77 - done := make(chan struct{})
114 + listener := newListener(context.Background())
115 + listener.accepted = make(chan acceptedConn, 2)
116 +
117 serverConn1, clientConn1 := net.Pipe()
118 defer clientConn1.Close()
119 serverConn2, clientConn2 := net.Pipe()
120 defer clientConn2.Close()
121
83 - listener := &Listener{
84 - ctxDone: done,
85 - accepted: make(chan acceptedConn, 1),
86 - }
122 listener.accepted <- acceptedConn{
123 conn: serverConn1,
124 entry: ListenerEntry{
@@ -92,6 +127,7 @@ func TestListenerAcceptEntry(t *testing.T) {
127 Hostnames: []string{"app.relay.example.com"},
128 },
129 }
130 + listener.accepted <- acceptedConn{conn: serverConn2}
131
132 conn, entry, err := listener.AcceptEntry()
133 if err != nil {
@@ -106,7 +142,6 @@ func TestListenerAcceptEntry(t *testing.T) {
142 t.Fatalf("AcceptEntry().LeaseID = %q, want %q", entry.LeaseID, "lease-1")
143 }
144
109 - listener.accepted <- acceptedConn{conn: serverConn2}
145 plainConn, err := listener.Accept()
146 if err != nil {
147 t.Fatalf("Accept() error = %v", err)
@@ -116,3 +151,119 @@ func TestListenerAcceptEntry(t *testing.T) {
151 t.Fatal("Accept() did not return the original connection")
152 }
153 }
154 +
155 +func TestListenerAcceptEntryReturnsTerminalError(t *testing.T) {
156 + t.Parallel()
157 +
158 + listener := newListener(context.Background())
159 + listener.accepted = make(chan acceptedConn, 1)
160 +
161 + wantErr := &types.APIRequestError{Code: types.APIErrorCodeUnauthorized, Message: "bad reverse token"}
162 + listener.fail(wantErr)
163 +
164 + conn, entry, err := listener.AcceptEntry()
165 + if conn != nil {
166 + t.Fatal("AcceptEntry() conn != nil, want nil")
167 + }
168 + if entry.RelayURL != "" || entry.LeaseID != "" || len(entry.Hostnames) != 0 || len(entry.Metadata.Tags) != 0 {
169 + t.Fatalf("AcceptEntry() entry = %#v, want zero value", entry)
170 + }
171 + if !errors.Is(err, wantErr) {
172 + t.Fatalf("AcceptEntry() error = %v, want %v", err, wantErr)
173 + }
174 +}
175 +
176 +func TestListenerStopEntryKeepsOtherLeasesActive(t *testing.T) {
177 + t.Parallel()
178 +
179 + listener := newListener(context.Background())
180 + entryA := &listenerLease{
181 + parent: listener,
182 + info: ListenerEntry{
183 + RelayURL: "https://relay-a.example.com",
184 + LeaseID: "lease-a",
185 + Hostnames: []string{"a.example.com"},
186 + },
187 + active: true,
188 + }
189 + entryB := &listenerLease{
190 + parent: listener,
191 + info: ListenerEntry{
192 + RelayURL: "https://relay-b.example.com",
193 + LeaseID: "lease-b",
194 + Hostnames: []string{"b.example.com"},
195 + },
196 + active: true,
197 + }
198 + listener.entries = []*listenerLease{entryA, entryB}
199 + listener.activeCount = 2
200 +
201 + entryA.stop(&types.APIRequestError{Code: types.APIErrorCodeUnauthorized, Message: "stopped"})
202 +
203 + if listener.isClosed() {
204 + t.Fatal("listener closed after stopping one entry, want active")
205 + }
206 +
207 + entries := listener.Entries()
208 + if len(entries) != 1 {
209 + t.Fatalf("Entries() len = %d, want 1", len(entries))
210 + }
211 + if entries[0].LeaseID != "lease-b" {
212 + t.Fatalf("Entries()[0].LeaseID = %q, want %q", entries[0].LeaseID, "lease-b")
213 + }
214 +}
215 +
216 +func TestListenerStopLastEntryCancelsListener(t *testing.T) {
217 + t.Parallel()
218 +
219 + listener := newListener(context.Background())
220 + entry := &listenerLease{
221 + parent: listener,
222 + info: ListenerEntry{
223 + RelayURL: "https://relay.example.com",
224 + LeaseID: "lease-1",
225 + Hostnames: []string{"app.relay.example.com"},
226 + },
227 + active: true,
228 + }
229 + listener.entries = []*listenerLease{entry}
230 + listener.activeCount = 1
231 + listener.accepted = make(chan acceptedConn, 1)
232 +
233 + wantErr := &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound, Message: "lease disappeared"}
234 + entry.stop(wantErr)
235 +
236 + if !listener.isClosed() {
237 + t.Fatal("listener is still active after stopping last entry")
238 + }
239 +
240 + _, _, err := listener.AcceptEntry()
241 + if !errors.Is(err, wantErr) {
242 + t.Fatalf("AcceptEntry() error = %v, want %v", err, wantErr)
243 + }
244 +}
245 +
246 +func TestListenerEntryCloneDeepCopiesMetadataTags(t *testing.T) {
247 + t.Parallel()
248 +
249 + entry := ListenerEntry{
250 + RelayURL: "https://relay.example.com",
251 + LeaseID: "lease-1",
252 + Hostnames: []string{"app.relay.example.com"},
253 + Metadata: types.LeaseMetadata{
254 + Owner: "alice",
255 + Tags: []string{"one", "two"},
256 + },
257 + }
258 +
259 + clone := entry.clone()
260 + clone.Hostnames[0] = "changed.example.com"
261 + clone.Metadata.Tags[0] = "changed"
262 +
263 + if entry.Hostnames[0] != "app.relay.example.com" {
264 + t.Fatalf("entry.Hostnames[0] = %q, want %q", entry.Hostnames[0], "app.relay.example.com")
265 + }
266 + if entry.Metadata.Tags[0] != "one" {
267 + t.Fatalf("entry.Metadata.Tags[0] = %q, want %q", entry.Metadata.Tags[0], "one")
268 + }
269 +}
types/api.go
+43 -3
@@ -1,6 +1,10 @@
1 package types
2
3 -import "time"
3 +import (
4 + "fmt"
5 + "strings"
6 + "time"
7 +)
8
9 const (
10 HeaderReverseToken = "X-Portal-Token"
@@ -8,8 +12,8 @@ const (
12 MarkerTLSStart = byte(0x02)
13 )
14
11 -type APIEnvelope struct {
12 - Data any `json:"data,omitempty"`
15 +type APIEnvelope[T any] struct {
16 + Data T `json:"data,omitempty"`
17 Error *APIError `json:"error,omitempty"`
18 OK bool `json:"ok"`
19 }
@@ -19,6 +23,42 @@ type APIError struct {
23 Message string `json:"message"`
24 }
25
26 +type APIRequestError struct {
27 + StatusCode int `json:"-"`
28 + Code string `json:"code,omitempty"`
29 + Message string `json:"message,omitempty"`
30 +}
31 +
32 +func (e *APIRequestError) Error() string {
33 + if e == nil {
34 + return ""
35 + }
36 + if strings.TrimSpace(e.Code) != "" {
37 + return e.Code + ": " + strings.TrimSpace(e.Message)
38 + }
39 + if strings.TrimSpace(e.Message) != "" {
40 + return strings.TrimSpace(e.Message)
41 + }
42 + if e.StatusCode > 0 {
43 + return fmt.Sprintf("api request failed with status %d", e.StatusCode)
44 + }
45 + return "api request failed"
46 +}
47 +
48 +func (e *APIRequestError) Is(target error) bool {
49 + other, ok := target.(*APIRequestError)
50 + if !ok {
51 + return false
52 + }
53 + if other.Code != "" && e.Code != other.Code {
54 + return false
55 + }
56 + if other.StatusCode != 0 && e.StatusCode != other.StatusCode {
57 + return false
58 + }
59 + return true
60 +}
61 +
62 type LeaseMetadata struct {
63 Description string `json:"description,omitempty"`
64 Owner string `json:"owner,omitempty"`
types/error_codes.go new
+23
@@ -0,0 +1,23 @@
1 +package types
2 +
3 +const (
4 + APIErrorCodeAuthDisabled = "auth_disabled"
5 + APIErrorCodeAuthLocked = "auth_locked"
6 + APIErrorCodeFeatureUnavailable = "feature_unavailable"
7 + APIErrorCodeHijackFailed = "hijack_failed"
8 + APIErrorCodeHijackUnsupported = "hijack_unsupported"
9 + APIErrorCodeHostnameConflict = "hostname_conflict"
10 + APIErrorCodeHTTP11Only = "http11_only"
11 + APIErrorCodeInvalidIP = "invalid_ip"
12 + APIErrorCodeInvalidJSON = "invalid_json"
13 + APIErrorCodeInvalidKey = "invalid_key"
14 + APIErrorCodeInvalidLeaseID = "invalid_lease_id"
15 + APIErrorCodeInvalidMode = "invalid_mode"
16 + APIErrorCodeInvalidRequest = "invalid_request"
17 + APIErrorCodeIPBanned = "ip_banned"
18 + APIErrorCodeLeaseNotFound = "lease_not_found"
19 + APIErrorCodeLeaseRejected = "lease_rejected"
20 + APIErrorCodeMethodNotAllowed = "method_not_allowed"
21 + APIErrorCodeSessionCreateFailed = "session_create_failed"
22 + APIErrorCodeUnauthorized = "unauthorized"
23 +)