fix(portal): add TCP port exhaustion error and split SupportsRawTCP field

- Add errTCPPortExhausted sentinel error so TCP port exhaustion returns APIErrorCodeTCPPortExhausted instead of the UDP error code. - Add SupportsRawTCP field for raw TCP port routing capability. - Restore SupportsTCP to always true (deprecated, TLS-based TCP stream). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 3, 2026 at 03:06 UTC 3e38a7de0756b529479f773fdafd78f35d99ce82
2 files changed +10 -2
portal/api_server.go
+8 -1
@@ -37,6 +37,7 @@ var (
37 errUDPCapacityExceeded = errors.New(types.APIErrorCodeUDPCapacityExceeded)
38 errTCPPortDisabled = errors.New(types.APIErrorCodeTCPPortDisabled)
39 errTCPPortCapacityExceeded = errors.New(types.APIErrorCodeTCPPortCapacityExceeded)
40 + errTCPPortExhausted = errors.New("no tcp ports available")
41 )
42
43 func (s *Server) newAPIServer(listener net.Listener, apiMux *http.ServeMux, apiTLS keyless.TLSMaterialConfig) (net.Listener, *http.Server, io.Closer, error) {
@@ -159,7 +160,8 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
160 IngressTLSAddr: ingressAddr,
161 SupportsTLS: true,
162 SupportsUDP: s.cfg.UDPPortCount > 0,
162 - SupportsTCP: s.cfg.TCPPortCount > 0,
163 + SupportsTCP: true,
164 + SupportsRawTCP: s.cfg.TCPPortCount > 0,
165 SupportsOverlayPeer: supportsOverlayPeer,
166 WireGuardPublicKey: s.wgConfig.PublicKey,
167 WireGuardEndpoint: s.wgConfig.Endpoint,
@@ -231,6 +233,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
233 utils.WriteAPIError(w, http.StatusConflict, types.APIErrorCodeHostnameConflict, err.Error())
234 case errors.Is(err, errIPBanned):
235 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, err.Error())
236 + case errors.Is(err, errTCPPortExhausted):
237 + utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeTCPPortExhausted, err.Error())
238 case errors.Is(err, transport.ErrPortExhausted):
239 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted, err.Error())
240 case errors.Is(err, errUDPDisabled):
@@ -616,6 +620,9 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
620 }
621 port, err := s.tcpPorts.Allocate(identity.Name)
622 if err != nil {
623 + if errors.Is(err, transport.ErrPortExhausted) {
624 + return types.RegisterResponse{}, errTCPPortExhausted
625 + }
626 return types.RegisterResponse{}, err
627 }
628 record.tcpPort = transport.NewRelayTCPPort(identityKey, port, stream)
types/identity.go
+2 -1
@@ -94,7 +94,8 @@ type RelayDescriptor struct {
94
95 SupportsTLS bool `json:"supports_tls,omitempty"`
96 SupportsUDP bool `json:"supports_udp,omitempty"`
97 - SupportsTCP bool `json:"supports_tcp,omitempty"`
97 + SupportsTCP bool `json:"supports_tcp,omitempty"` // Deprecated: always true. Use SupportsRawTCP instead.
98 + SupportsRawTCP bool `json:"supports_raw_tcp,omitempty"`
99 SupportsOverlayPeer bool `json:"supports_overlay_peer,omitempty"`
100 }
101