feat(portal): add TCP port-based routing for non-TLS services

Add dedicated TCP port allocation (starting from port 40000) for services that don't use TLS, such as Minecraft servers and game servers. The relay bridges raw TCP connections to reverse sessions without TLS handshake. - Add RelayTCPPort transport with accept loop and bidirectional bridging - Add --tcp CLI flag and TCP_ENABLED env var (consistent with --udp) - Add tcp_enabled/tcp_addr wire fields and TCPEnabled/TCPAddr Go types - Add admin panel TCP port settings (enable/disable, max leases) - Add TCP port policy enforcement and error codes - Rename SupportsTCP to SupportsTLS for clarity, use SupportsTCP for port routing - Include TCP_PORT_COUNT env and port mapping in docker-compose.yml Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 3, 2026 at 02:38 UTC e126cd5414ff5ffe20bbbcf0b9f16cd1cd6113b5
26 files changed +567 -57
.env.example
+4
@@ -12,6 +12,10 @@ SNI_PORT=443
12 # UDP transport (0 = disabled). Set count > 0 to enable QUIC tunnel + allocate UDP ports starting from 50000.
13 # e.g., UDP_PORT_COUNT=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
14 UDP_PORT_COUNT=0
15 +# Raw TCP port transport (0 = disabled). Set count > 0 to allocate TCP ports starting from 40000
16 +# for non-TLS services (e.g., Minecraft, game servers). Also requires enabling TCP port in the admin panel.
17 +# e.g., TCP_PORT_COUNT=10 → ports 40000-40009.
18 +TCP_PORT_COUNT=0
19
20 # TLS/ACME and keyless materials
21 KEYLESS_DIR=/portal-certs
cmd/portal-tunnel/main.go
+3
@@ -47,6 +47,7 @@ type exposeFlags struct {
47 httpRoutes []string
48 udp bool
49 udpAddr string
50 + tcp bool
51 }
52
53 func runExposeCommand(args []string) error {
@@ -67,6 +68,7 @@ func runExposeCommand(args []string) error {
68 utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM form; repeat to aggregate multiple local HTTP services behind one public URL")
69 utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
70 utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
71 + utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED")
72
73 if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil {
74 if errors.Is(err, flag.ErrHelp) {
@@ -132,6 +134,7 @@ func runExposeCommand(args []string) error {
134 TargetAddr: flags.targetAddr,
135 UDPAddr: flags.udpAddr,
136 UDPEnabled: flags.udp,
137 + TCPEnabled: flags.tcp,
138 BanMITM: flags.banMITM,
139 Discovery: flags.discovery,
140 Metadata: types.LeaseMetadata{
cmd/relay-server/admin.go
+36
@@ -185,6 +185,10 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
185 Enabled: runtime.IsUDPEnabled(),
186 MaxLeases: runtime.UDPMaxLeases(),
187 },
188 + TCPPort: types.AdminTCPPortSettingsResponse{
189 + Enabled: runtime.IsTCPPortEnabled(),
190 + MaxLeases: runtime.TCPPortMaxLeases(),
191 + },
192 })
193 case types.PathAdminLandingPage:
194 if !utils.RequireMethod(w, r, http.MethodPost) {
@@ -217,6 +221,24 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
221 Enabled: runtime.IsUDPEnabled(),
222 MaxLeases: runtime.UDPMaxLeases(),
223 })
224 + case types.PathAdminTCPPort:
225 + if !utils.RequireMethod(w, r, http.MethodPost) {
226 + return
227 + }
228 + req, ok := utils.DecodeJSONRequestAs[types.AdminTCPPortSettingsRequest](w, r, 1<<16, invalidRequestBody)
229 + if !ok {
230 + return
231 + }
232 + if req.MaxLeases < 0 {
233 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "max_leases must be non-negative")
234 + return
235 + }
236 + runtime.SetTCPPortPolicy(req.Enabled, req.MaxLeases)
237 + saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
238 + utils.WriteAPIData(w, http.StatusOK, types.AdminTCPPortSettingsResponse{
239 + Enabled: runtime.IsTCPPortEnabled(),
240 + MaxLeases: runtime.TCPPortMaxLeases(),
241 + })
242 case types.PathAdminApproval:
243 if !utils.RequireMethod(w, r, http.MethodPost) {
244 return
@@ -412,6 +434,8 @@ func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled boo
434 approver := runtime.Approver()
435 udpEnabled := runtime.IsUDPEnabled()
436 udpMaxLeases := runtime.UDPMaxLeases()
437 + tcpPortEnabled := runtime.IsTCPPortEnabled()
438 + tcpPortMaxLeases := runtime.TCPPortMaxLeases()
439 payload := persistedAdminState{
440 ApprovalMode: string(approver.Mode()),
441 ApprovedIdentityKeys: approver.ApprovedKeys(),
@@ -421,6 +445,8 @@ func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled boo
445 IdentityBPS: runtime.BPSManager().IdentityBPSLimits(),
446 UDPEnabled: &udpEnabled,
447 UDPMaxLeases: &udpMaxLeases,
448 + TCPPortEnabled: &tcpPortEnabled,
449 + TCPPortMaxLeases: &tcpPortMaxLeases,
450 LandingPageEnabled: &landingPageEnabled,
451 }
452 _ = utils.WriteJSONFile(path, payload, 0o600)
@@ -435,6 +461,8 @@ type persistedAdminState struct {
461 IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
462 UDPEnabled *bool `json:"udp_enabled,omitempty"`
463 UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
464 + TCPPortEnabled *bool `json:"tcp_port_enabled,omitempty"`
465 + TCPPortMaxLeases *int `json:"tcp_port_max_leases,omitempty"`
466 LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
467 }
468
@@ -462,5 +490,13 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
490 case s.UDPMaxLeases != nil:
491 runtime.SetUDPPolicy(runtime.IsUDPEnabled(), *s.UDPMaxLeases)
492 }
493 + switch {
494 + case s.TCPPortEnabled != nil && s.TCPPortMaxLeases != nil:
495 + runtime.SetTCPPortPolicy(*s.TCPPortEnabled, *s.TCPPortMaxLeases)
496 + case s.TCPPortEnabled != nil:
497 + runtime.SetTCPPortPolicy(*s.TCPPortEnabled, runtime.TCPPortMaxLeases())
498 + case s.TCPPortMaxLeases != nil:
499 + runtime.SetTCPPortPolicy(runtime.IsTCPPortEnabled(), *s.TCPPortMaxLeases)
500 + }
501 return nil
502 }
cmd/relay-server/main.go
+4
@@ -35,6 +35,7 @@ type relayServerConfig struct {
35 APIPort int
36 SNIPort int
37 UDPPortCount int
38 + TCPPortCount int
39 LandingPageEnabled bool
40 Bootstraps string
41 DiscoveryEnabled bool
@@ -69,6 +70,7 @@ func runServeCommand(args []string) error {
70 utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT")
71 utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT")
72 utils.IntFlagEnv(fs, &cfg.UDPPortCount, "udp-port-count", 0, utils.ParseNonNegativeInt, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled)", "UDP_PORT_COUNT")
73 + utils.IntFlagEnv(fs, &cfg.TCPPortCount, "tcp-port-count", 0, utils.ParseNonNegativeInt, "Number of TCP ports to allocate for raw TCP leases, starting at port 40000 (0=disabled)", "TCP_PORT_COUNT")
74 utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
75 utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
76 utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
@@ -117,6 +119,7 @@ func runServeCommand(args []string) error {
119 Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
120 Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
121 Bool("udp_enabled", cfg.UDPPortCount > 0).
122 + Bool("tcp_port_enabled", cfg.TCPPortCount > 0).
123 Msg("configured relay server")
124
125 ctx, stop := utils.SignalContext()
@@ -159,6 +162,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
162 TrustProxyHeaders: cfg.TrustProxyHeaders,
163 DiscoveryEnabled: cfg.DiscoveryEnabled,
164 UDPPortCount: cfg.UDPPortCount,
165 + TCPPortCount: cfg.TCPPortCount,
166 })
167 if err != nil {
168 return fmt.Errorf("create relay server: %w", err)
docker-compose.yml
+4
@@ -12,6 +12,8 @@ services:
12 # Uncomment below when enabling UDP transport (UDP_PORT_COUNT > 0):
13 # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
14 # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
15 + # Uncomment below when enabling raw TCP port transport (TCP_PORT_COUNT > 0):
16 + # - "40000-40009:40000-40009" # adjust range to match TCP_PORT_COUNT
17 environment:
18 # Public routing, discovery, and relay identity persistence
19 PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
@@ -28,6 +30,8 @@ services:
30
31 # UDP transport (0 = disabled, set count > 0 to enable QUIC tunnel + UDP ports)
32 UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
33 + # Raw TCP port transport (0 = disabled, set count > 0 to allocate TCP ports for non-TLS services)
34 + TCP_PORT_COUNT: ${TCP_PORT_COUNT:-0}
35
36 # Admin/auth configuration
37 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
frontend/src/components/ServerListView.tsx
+77 -1
@@ -6,7 +6,7 @@ import { ServerCard } from "@/components/ServerCard";
6 import { TagCombobox } from "@/components/TagCombobox";
7 import { TunnelCommandModal } from "@/components/TunnelCommandModal";
8 import type { ClientServer } from "@/hooks/useServerList";
9 -import type { AdminServer, ApprovalMode, UDPSettings } from "@/hooks/useAdmin";
9 +import type { AdminServer, ApprovalMode, UDPSettings, TCPPortSettings } from "@/hooks/useAdmin";
10 import type { SortOption, StatusFilter } from "@/types/filters";
11 import { StatusSelect } from "@/components/select/StatusSelect";
12 import { BanStatusButtons } from "@/components/button/BanStatusButtons";
@@ -159,6 +159,8 @@ interface ServerListViewProps {
159 onLandingPageEnabledChange?: (enabled: boolean) => void | Promise<void>;
160 udpSettings?: UDPSettings;
161 onUDPSettingsChange?: (settings: UDPSettings) => void | Promise<void>;
162 + tcpPortSettings?: TCPPortSettings;
163 + onTCPPortSettingsChange?: (settings: TCPPortSettings) => void | Promise<void>;
164 onApproveStatusChange?: (
165 identityKey: string,
166 approve: boolean
@@ -207,6 +209,8 @@ export function ServerListView({
209 onLandingPageEnabledChange,
210 udpSettings,
211 onUDPSettingsChange,
212 + tcpPortSettings,
213 + onTCPPortSettingsChange,
214 onApproveStatusChange,
215 onDenyStatusChange,
216 onIPBanStatusChange,
@@ -406,17 +410,30 @@ export function ServerListView({
410 const [maxLeasesInput, setMaxLeasesInput] = useState(
411 String(udpSettings?.maxLeases ?? 0)
412 );
413 + const [tcpPortMaxLeasesInput, setTCPPortMaxLeasesInput] = useState(
414 + String(tcpPortSettings?.maxLeases ?? 0)
415 + );
416
417 useEffect(() => {
418 setMaxLeasesInput(String(udpSettings?.maxLeases ?? 0));
419 }, [udpSettings?.maxLeases]);
420
421 + useEffect(() => {
422 + setTCPPortMaxLeasesInput(String(tcpPortSettings?.maxLeases ?? 0));
423 + }, [tcpPortSettings?.maxLeases]);
424 +
425 const handleUDPToggle = (enabled: boolean) => {
426 if (onUDPSettingsChange && udpSettings) {
427 void onUDPSettingsChange({ ...udpSettings, enabled });
428 }
429 };
430
431 + const handleTCPPortToggle = (enabled: boolean) => {
432 + if (onTCPPortSettingsChange && tcpPortSettings) {
433 + void onTCPPortSettingsChange({ ...tcpPortSettings, enabled });
434 + }
435 + };
436 +
437 const handleLandingPageToggle = (enabled: boolean) => {
438 if (onLandingPageEnabledChange) {
439 void onLandingPageEnabledChange(enabled);
@@ -431,6 +448,14 @@ export function ServerListView({
448 }
449 };
450
451 + const handleTCPPortMaxLeasesSave = () => {
452 + if (onTCPPortSettingsChange && tcpPortSettings) {
453 + const value = Math.max(0, parseInt(tcpPortMaxLeasesInput, 10) || 0);
454 + setTCPPortMaxLeasesInput(String(value));
455 + void onTCPPortSettingsChange({ ...tcpPortSettings, maxLeases: value });
456 + }
457 + };
458 +
459 const adminFilterControls = (
460 <>
461 {onBanFilterChange && (
@@ -531,6 +556,57 @@ export function ServerListView({
556 </div>
557 </>
558 )}
559 + {onTCPPortSettingsChange && tcpPortSettings && (
560 + <>
561 + <div className="flex items-center gap-3">
562 + <span className="text-sm font-medium text-text-muted">TCP</span>
563 + <div className="flex rounded-lg overflow-hidden border border-foreground/20">
564 + <button
565 + onClick={() => handleTCPPortToggle(false)}
566 + className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${
567 + !tcpPortSettings.enabled
568 + ? "bg-primary text-primary-foreground"
569 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
570 + }`}
571 + >
572 + Disabled
573 + </button>
574 + <button
575 + onClick={() => handleTCPPortToggle(true)}
576 + className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
577 + tcpPortSettings.enabled
578 + ? "bg-primary text-primary-foreground"
579 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
580 + }`}
581 + >
582 + Enabled
583 + </button>
584 + </div>
585 + </div>
586 + <div className="flex items-center gap-3">
587 + <span className="text-sm font-medium text-text-muted">Max TCP</span>
588 + <div className="flex items-center gap-2">
589 + <input
590 + type="number"
591 + min="0"
592 + value={tcpPortMaxLeasesInput}
593 + onChange={(e) => setTCPPortMaxLeasesInput(e.target.value)}
594 + onKeyDown={(e) => {
595 + if (e.key === "Enter") handleTCPPortMaxLeasesSave();
596 + }}
597 + className="w-20 h-10 px-3 text-sm border border-foreground/20 rounded-lg bg-secondary text-foreground"
598 + placeholder="0"
599 + />
600 + <button
601 + onClick={handleTCPPortMaxLeasesSave}
602 + className="cursor-pointer h-10 px-4 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
603 + >
604 + Save
605 + </button>
606 + </div>
607 + </div>
608 + </>
609 + )}
610 </>
611 );
612
frontend/src/hooks/useAdmin.ts
+28
@@ -27,6 +27,7 @@ type AdminSnapshotResponse = {
27 landing_page_enabled?: boolean;
28 leases?: AdminLeaseData[];
29 udp?: { enabled: boolean; max_leases: number };
30 + tcp_port?: { enabled: boolean; max_leases: number };
31 };
32
33 type LeaseActionResult = ApprovalModeResponse;
@@ -48,6 +49,11 @@ export interface UDPSettings {
49 maxLeases: number;
50 }
51
52 +export interface TCPPortSettings {
53 + enabled: boolean;
54 + maxLeases: number;
55 +}
56 +
57 const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
58 invalid_mode: "Invalid approval mode. Choose auto or manual and retry.",
59 invalid_address: "Selected address is invalid. Refresh and try again.",
@@ -156,6 +162,7 @@ interface AdminSnapshot {
162 approvalMode: ApprovalMode;
163 landingPageEnabled: boolean;
164 udpSettings: UDPSettings;
165 + tcpPortSettings: TCPPortSettings;
166 }
167
168 async function loadAdminSnapshot(): Promise<AdminSnapshot> {
@@ -170,6 +177,10 @@ async function loadAdminSnapshot(): Promise<AdminSnapshot> {
177 enabled: snapshot?.udp?.enabled ?? false,
178 maxLeases: snapshot?.udp?.max_leases ?? 0,
179 },
180 + tcpPortSettings: {
181 + enabled: snapshot?.tcp_port?.enabled ?? false,
182 + maxLeases: snapshot?.tcp_port?.max_leases ?? 0,
183 + },
184 };
185 }
186
@@ -178,6 +189,7 @@ export function useAdmin() {
189 const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
190 const [landingPageEnabled, setLandingPageEnabled] = useState(true);
191 const [udpSettings, setUDPSettings] = useState<UDPSettings>({ enabled: false, maxLeases: 0 });
192 + const [tcpPortSettings, setTCPPortSettings] = useState<TCPPortSettings>({ enabled: false, maxLeases: 0 });
193 const [loading, setLoading] = useState(true);
194 const [error, setError] = useState("");
195
@@ -188,6 +200,7 @@ export function useAdmin() {
200 setApprovalMode(snapshot.approvalMode);
201 setLandingPageEnabled(snapshot.landingPageEnabled);
202 setUDPSettings(snapshot.udpSettings);
203 + setTCPPortSettings(snapshot.tcpPortSettings);
204 };
205
206 const fetchData = async () => {
@@ -349,6 +362,19 @@ export function useAdmin() {
362 });
363 };
364
365 + const handleTCPPortSettingsChange = async (settings: TCPPortSettings) => {
366 + await runAdminAction(async () => {
367 + const response = await apiClient.post<{ enabled: boolean; max_leases: number }>(
368 + API_PATHS.admin.tcpPortSettings,
369 + { enabled: settings.enabled, max_leases: settings.maxLeases }
370 + );
371 + setTCPPortSettings({
372 + enabled: response?.enabled ?? settings.enabled,
373 + maxLeases: response?.max_leases ?? settings.maxLeases,
374 + });
375 + });
376 + };
377 +
378 const handleLandingPageEnabledChange = async (enabled: boolean) => {
379 await runAdminAction(async () => {
380 const response = await apiClient.post<LandingPageSettingsResponse>(
@@ -424,6 +450,7 @@ export function useAdmin() {
450 approvalMode,
451 landingPageEnabled,
452 udpSettings,
453 + tcpPortSettings,
454 loading,
455 error,
456 handleBanFilterChange,
@@ -432,6 +459,7 @@ export function useAdmin() {
459 handleApprovalModeChange,
460 handleLandingPageEnabledChange,
461 handleUDPSettingsChange,
462 + handleTCPPortSettingsChange,
463 handleApproveStatus,
464 handleDenyStatus,
465 handleIPBanStatus,
frontend/src/lib/apiPaths.ts
+1
@@ -10,6 +10,7 @@ export const API_PATHS = {
10 approvalMode: "/admin/settings/approval-mode",
11 landingPage: "/admin/settings/landing-page",
12 udpSettings: "/admin/settings/udp",
13 + tcpPortSettings: "/admin/settings/tcp-port",
14 },
15 sdk: {
16 prefix: "/sdk",
frontend/src/pages/Admin.tsx
+4
@@ -21,6 +21,7 @@ export function Admin() {
21 approvalMode,
22 landingPageEnabled,
23 udpSettings,
24 + tcpPortSettings,
25 favorites,
26 loading,
27 error,
@@ -35,6 +36,7 @@ export function Admin() {
36 handleApprovalModeChange,
37 handleLandingPageEnabledChange,
38 handleUDPSettingsChange,
39 + handleTCPPortSettingsChange,
40 handleApproveStatus,
41 handleDenyStatus,
42 handleIPBanStatus,
@@ -92,12 +94,14 @@ export function Admin() {
94 approvalMode={approvalMode}
95 landingPageEnabled={landingPageEnabled}
96 udpSettings={udpSettings}
97 + tcpPortSettings={tcpPortSettings}
98 onBanFilterChange={handleBanFilterChange}
99 onBanStatusChange={handleBanStatus}
100 onBPSChange={handleBPSChange}
101 onApprovalModeChange={handleApprovalModeChange}
102 onLandingPageEnabledChange={handleLandingPageEnabledChange}
103 onUDPSettingsChange={handleUDPSettingsChange}
104 + onTCPPortSettingsChange={handleTCPPortSettingsChange}
105 onApproveStatusChange={handleApproveStatus}
106 onDenyStatusChange={handleDenyStatus}
107 onIPBanStatusChange={handleIPBanStatus}
portal/api_server.go
+50 -11
@@ -26,15 +26,17 @@ import (
26 )
27
28 var (
29 - errFeatureUnavailable = errors.New(types.APIErrorCodeFeatureUnavailable)
30 - errHostnameConflict = errors.New(types.APIErrorCodeHostnameConflict)
31 - errIPBanned = errors.New(types.APIErrorCodeIPBanned)
32 - errLeaseNotFound = errors.New(types.APIErrorCodeLeaseNotFound)
33 - errLeaseRejected = errors.New(types.APIErrorCodeLeaseRejected)
34 - errTransportMismatch = errors.New(types.APIErrorCodeTransportMismatch)
35 - errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
36 - errUDPDisabled = errors.New(types.APIErrorCodeUDPDisabled)
37 - errUDPCapacityExceeded = errors.New(types.APIErrorCodeUDPCapacityExceeded)
29 + errFeatureUnavailable = errors.New(types.APIErrorCodeFeatureUnavailable)
30 + errHostnameConflict = errors.New(types.APIErrorCodeHostnameConflict)
31 + errIPBanned = errors.New(types.APIErrorCodeIPBanned)
32 + errLeaseNotFound = errors.New(types.APIErrorCodeLeaseNotFound)
33 + errLeaseRejected = errors.New(types.APIErrorCodeLeaseRejected)
34 + errTransportMismatch = errors.New(types.APIErrorCodeTransportMismatch)
35 + errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
36 + errUDPDisabled = errors.New(types.APIErrorCodeUDPDisabled)
37 + errUDPCapacityExceeded = errors.New(types.APIErrorCodeUDPCapacityExceeded)
38 + errTCPPortDisabled = errors.New(types.APIErrorCodeTCPPortDisabled)
39 + errTCPPortCapacityExceeded = errors.New(types.APIErrorCodeTCPPortCapacityExceeded)
40 )
41
42 func (s *Server) newAPIServer(listener net.Listener, apiMux *http.ServeMux, apiTLS keyless.TLSMaterialConfig) (net.Listener, *http.Server, io.Closer, error) {
@@ -155,8 +157,9 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
157 ExpiresAt: now.Add(2 * types.DiscoveryPollInterval),
158 APIHTTPSAddr: s.cfg.PortalURL,
159 IngressTLSAddr: ingressAddr,
158 - SupportsTCP: true,
160 + SupportsTLS: true,
161 SupportsUDP: s.cfg.UDPPortCount > 0,
162 + SupportsTCP: s.cfg.TCPPortCount > 0,
163 SupportsOverlayPeer: supportsOverlayPeer,
164 WireGuardPublicKey: s.wgConfig.PublicKey,
165 WireGuardEndpoint: s.wgConfig.Endpoint,
@@ -234,6 +237,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
237 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUDPDisabled, err.Error())
238 case errors.Is(err, errUDPCapacityExceeded):
239 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeUDPCapacityExceeded, err.Error())
240 + case errors.Is(err, errTCPPortDisabled):
241 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeTCPPortDisabled, err.Error())
242 + case errors.Is(err, errTCPPortCapacityExceeded):
243 + utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeTCPPortCapacityExceeded, err.Error())
244 default:
245 utils.InvalidRequestError(err).Write(w)
246 }
@@ -275,6 +282,10 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
282 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
283 return
284 }
285 + if req.TCPEnabled && s.cfg.TCPPortCount <= 0 {
286 + utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
287 + return
288 + }
289
290 resp, err := s.registry.issueRegisterChallenge(req, domain, registerURI)
291 if err != nil {
@@ -556,6 +567,17 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
567 return types.RegisterResponse{}, errUDPCapacityExceeded
568 }
569 }
570 + if req.TCPEnabled {
571 + if s.cfg.TCPPortCount <= 0 {
572 + return types.RegisterResponse{}, errFeatureUnavailable
573 + }
574 + if !s.registry.policy.IsTCPPortEnabled() {
575 + return types.RegisterResponse{}, errTCPPortDisabled
576 + }
577 + if max := s.registry.policy.TCPPortMaxLeases(); max > 0 && s.registry.CountTCPPortLeases() >= max {
578 + return types.RegisterResponse{}, errTCPPortCapacityExceeded
579 + }
580 + }
581 accessToken, claims, err := auth.IssueLeaseAccessToken(s.identity.PrivateKey, s.identity.Address, s.cfg.PortalURL, identity, ttl)
582 if err != nil {
583 return types.RegisterResponse{}, err
@@ -563,6 +585,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
585 issuedAt := claims.IssuedAt.Time().UTC()
586 expiresAt := claims.Expiry.Time().UTC()
587 identityKey := identity.Key()
588 + stream := transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit)
589 record := &leaseRecord{
590 Identity: identity,
591 Hostname: hostname,
@@ -573,7 +596,8 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
596 ClientIP: clientIP,
597 ReportedIP: utils.SanitizeReportedIP(reportedIP),
598 UDPEnabled: req.UDPEnabled,
576 - stream: transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit),
599 + TCPEnabled: req.TCPEnabled,
600 + stream: stream,
601 }
602 if req.UDPEnabled {
603 if s.ports == nil {
@@ -586,6 +610,17 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
610 record.datagram = transport.NewRelayDatagram(identityKey, port)
611 record.ports = s.ports
612 }
613 + if req.TCPEnabled {
614 + if s.tcpPorts == nil {
615 + return types.RegisterResponse{}, errors.New("tcp port allocation not available")
616 + }
617 + port, err := s.tcpPorts.Allocate(identity.Name)
618 + if err != nil {
619 + return types.RegisterResponse{}, err
620 + }
621 + record.tcpPort = transport.NewRelayTCPPort(identityKey, port, stream)
622 + record.tcpPorts = s.tcpPorts
623 + }
624
625 if err := record.Start(); err != nil {
626 record.Close()
@@ -610,10 +645,14 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
645 ExpiresAt: expiresAt,
646 AccessToken: accessToken,
647 UDPEnabled: record.UDPEnabled,
648 + TCPEnabled: record.TCPEnabled,
649 }
650 if record.datagram != nil {
651 resp.UDPAddr = fmt.Sprintf("%s:%d", s.identity.Name, record.datagram.UDPPort())
652 }
653 + if record.tcpPort != nil {
654 + resp.TCPAddr = fmt.Sprintf("%s:%d", s.identity.Name, record.tcpPort.TCPPort())
655 + }
656
657 return resp, nil
658 }
portal/auth/auth.go
+1
@@ -133,6 +133,7 @@ func NewRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string
133 Metadata: req.Metadata.Copy(),
134 TTL: req.TTL,
135 UDPEnabled: req.UDPEnabled,
136 + TCPEnabled: req.TCPEnabled,
137 }
138
139 return &RegisterChallenge{
portal/lease.go
+46 -2
@@ -3,6 +3,7 @@ package portal
3 import (
4 "context"
5 "errors"
6 + "fmt"
7 "strings"
8 "sync"
9 "time"
@@ -168,6 +169,14 @@ func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeReques
169 return types.RegisterChallengeResponse{}, errUDPCapacityExceeded
170 }
171 }
172 + if req.TCPEnabled {
173 + if !r.policy.IsTCPPortEnabled() {
174 + return types.RegisterChallengeResponse{}, errTCPPortDisabled
175 + }
176 + if max := r.policy.TCPPortMaxLeases(); max > 0 && r.CountTCPPortLeases() >= max {
177 + return types.RegisterChallengeResponse{}, errTCPPortCapacityExceeded
178 + }
179 + }
180
181 now := time.Now().UTC()
182 challenge, err := auth.NewRegisterChallenge(req, domain, uri, now, defaultRegisterChallengeTTL)
@@ -262,6 +271,19 @@ func (r *leaseRegistry) CountDatagramLeases() int {
271 return count
272 }
273
274 +func (r *leaseRegistry) CountTCPPortLeases() int {
275 + r.mu.RLock()
276 + defer r.mu.RUnlock()
277 + now := time.Now()
278 + count := 0
279 + for _, record := range r.leasesByKey {
280 + if record.tcpPort != nil && now.Before(record.ExpiresAt) {
281 + count++
282 + }
283 + }
284 + return count
285 +}
286 +
287 func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
288 if record == nil {
289 return types.Lease{}
@@ -274,8 +296,12 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
296 LastSeenAt: record.LastSeenAt,
297 Hostname: record.Hostname,
298 UDPEnabled: record.UDPEnabled,
299 + TCPEnabled: record.TCPEnabled,
300 Metadata: record.Metadata.Copy(),
301 }
302 + if record.tcpPort != nil {
303 + snapshot.TCPAddr = fmt.Sprintf("%s:%d", record.Hostname, record.tcpPort.TCPPort())
304 + }
305 if record.stream != nil {
306 snapshot.Ready = record.stream.ReadyCount()
307 }
@@ -291,9 +317,12 @@ type leaseRecord struct {
317 ReportedIP string
318 Hostname string
319 UDPEnabled bool
320 + TCPEnabled bool
321 Metadata types.LeaseMetadata
322 datagram *transport.RelayDatagram
323 ports *transport.PortAllocator
324 + tcpPort *transport.RelayTCPPort
325 + tcpPorts *transport.PortAllocator
326 stream *transport.RelayStream
327 startErr error
328 startOnce sync.Once
@@ -321,12 +350,20 @@ func (r *leaseRegistry) AdminSnapshot(record *leaseRecord) types.AdminLease {
350 }
351
352 func (r *leaseRecord) Start() error {
324 - if r == nil || r.datagram == nil {
353 + if r == nil {
354 return nil
355 }
356
357 r.startOnce.Do(func() {
329 - r.startErr = r.datagram.Start(context.Background())
358 + if r.datagram != nil {
359 + r.startErr = r.datagram.Start(context.Background())
360 + if r.startErr != nil {
361 + return
362 + }
363 + }
364 + if r.tcpPort != nil {
365 + r.startErr = r.tcpPort.Start(context.Background())
366 + }
367 })
368 return r.startErr
369 }
@@ -345,6 +382,13 @@ func (r *leaseRecord) Close() {
382 r.ports.Release(port)
383 }
384 }
385 + if r.tcpPort != nil {
386 + port := r.tcpPort.TCPPort()
387 + r.tcpPort.Close()
388 + if port > 0 && r.tcpPorts != nil {
389 + r.tcpPorts.Release(port)
390 + }
391 + }
392 }
393
394 type routeTable struct {
portal/policy/runtime.go
+30
@@ -11,6 +11,8 @@ type Runtime struct {
11 bannedIdentityKeys map[string]struct{}
12 udpEnabled bool
13 udpMaxLeases int
14 + tcpPortEnabled bool
15 + tcpPortMaxLeases int
16 mu sync.RWMutex
17 }
18
@@ -158,6 +160,34 @@ func (r *Runtime) UDPMaxLeases() int {
160 return r.udpMaxLeases
161 }
162
163 +func (r *Runtime) SetTCPPortPolicy(enabled bool, maxLeases int) {
164 + if r == nil {
165 + return
166 + }
167 + r.mu.Lock()
168 + r.tcpPortEnabled = enabled
169 + r.tcpPortMaxLeases = maxLeases
170 + r.mu.Unlock()
171 +}
172 +
173 +func (r *Runtime) IsTCPPortEnabled() bool {
174 + if r == nil {
175 + return false
176 + }
177 + r.mu.RLock()
178 + defer r.mu.RUnlock()
179 + return r.tcpPortEnabled
180 +}
181 +
182 +func (r *Runtime) TCPPortMaxLeases() int {
183 + if r == nil {
184 + return 0
185 + }
186 + r.mu.RLock()
187 + defer r.mu.RUnlock()
188 + return r.tcpPortMaxLeases
189 +}
190 +
191 func (r *Runtime) ForgetIdentity(key string) {
192 if r == nil {
193 return
portal/server.go
+14 -1
@@ -35,6 +35,7 @@ const (
35 defaultClientHelloWait = 2 * time.Second
36 defaultControlBodyLimit = 4 << 20
37 defaultUDPPortBase = 50000
38 + defaultTCPPortBase = 40000
39 defaultWGRecoveryFailures = 3
40 )
41
@@ -58,6 +59,7 @@ type ServerConfig struct {
59 TrustProxyHeaders bool
60 DiscoveryEnabled bool
61 UDPPortCount int
62 + TCPPortCount int
63 }
64
65 type Server struct {
@@ -72,6 +74,7 @@ type Server struct {
74 group *errgroup.Group
75 registry *leaseRegistry
76 ports *transport.PortAllocator
77 + tcpPorts *transport.PortAllocator
78 identity types.Identity
79 wgConfig wireguard.Config
80 cfg ServerConfig
@@ -143,15 +146,24 @@ func NewServer(cfg ServerConfig) (*Server, error) {
146 Msg("generated relay identity and saved it to disk")
147 }
148
149 + tcpPortMin, tcpPortMax := 0, 0
150 + if cfg.TCPPortCount > 0 {
151 + tcpPortMin = defaultTCPPortBase
152 + tcpPortMax = defaultTCPPortBase + cfg.TCPPortCount - 1
153 + }
154 +
155 policy := policy.NewRuntime()
156 policy.SetUDPPolicy(cfg.UDPPortCount > 0, 0)
157 + policy.SetTCPPortPolicy(cfg.TCPPortCount > 0, 0)
158 registry := newLeaseRegistry(policy)
159 ports := transport.NewPortAllocator(portMin, portMax, 5*time.Minute)
160 + tcpPorts := transport.NewPortAllocator(tcpPortMin, tcpPortMax, 5*time.Minute)
161
162 s := &Server{
163 cfg: cfg,
164 registry: registry,
165 ports: ports,
166 + tcpPorts: tcpPorts,
167 identity: identity,
168 wgConfig: wgConfig,
169 trustedProxyCIDRs: trustedProxyCIDRs,
@@ -252,7 +264,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
264 Str("acme_dns_provider", s.cfg.ACME.DNSProvider).
265 Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
266 Bool("wireguard_enabled", s.wgConfig.PrivateKey != "").
255 - Bool("udp_enabled", s.cfg.UDPPortCount > 0)
267 + Bool("udp_enabled", s.cfg.UDPPortCount > 0).
268 + Bool("tcp_port_enabled", s.cfg.TCPPortCount > 0)
269 if s.quicTunnel != nil {
270 logEvent = logEvent.Str("internal_quic_tunnel_addr", s.quicTunnel.Addr().String())
271 }
portal/server_test.go
+1 -1
@@ -56,7 +56,7 @@ func mustRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
56 WireGuardPublicKey: wireGuardPublicKey,
57 WireGuardEndpoint: net.JoinHostPort(utils.PortalRootHost(relayURL), "51820"),
58 OverlayIPv4: overlayIPv4,
59 - SupportsTCP: true,
59 + SupportsTLS: true,
60 SupportsOverlayPeer: true,
61 })
62 if err != nil {
portal/transport/stream_client.go
+19
@@ -133,6 +133,15 @@ func (s *ClientStream) runSession(
133 onReady()
134 }
135 return true, nil
136 + case types.MarkerRawTCPStart:
137 + if err := s.activateRaw(ctx, conn); err != nil {
138 + _ = conn.Close()
139 + return true, err
140 + }
141 + if onReady != nil {
142 + onReady()
143 + }
144 + return true, nil
145 default:
146 _ = conn.Close()
147 return false, fmt.Errorf("unexpected reverse marker: 0x%02x", marker[0])
@@ -165,6 +174,16 @@ func (s *ClientStream) activate(ctx context.Context, conn net.Conn, currentTLSCo
174 }
175 }
176
177 +func (s *ClientStream) activateRaw(ctx context.Context, conn net.Conn) error {
178 + select {
179 + case <-ctx.Done():
180 + _ = conn.Close()
181 + return ctx.Err()
182 + case s.accepted <- conn:
183 + return nil
184 + }
185 +}
186 +
187 func (s *ClientStream) sessionOpened() {
188 if s == nil {
189 return
portal/transport/stream_port_relay.go new
+162
@@ -0,0 +1,162 @@
1 +package transport
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "net"
7 + "sync"
8 + "time"
9 +
10 + "github.com/rs/zerolog/log"
11 +)
12 +
13 +const defaultTCPPortClaimTimeout = 10 * time.Second
14 +
15 +// RelayTCPPort owns a TCP listener on an allocated port for one lease.
16 +// Incoming connections are bridged to reverse sessions claimed from the
17 +// associated RelayStream using raw TCP (no TLS).
18 +type RelayTCPPort struct {
19 + identityKey string
20 + port int
21 + listener net.Listener
22 + stream *RelayStream
23 +
24 + cancel context.CancelFunc
25 + closeOnce sync.Once
26 +}
27 +
28 +func NewRelayTCPPort(identityKey string, port int, stream *RelayStream) *RelayTCPPort {
29 + return &RelayTCPPort{
30 + identityKey: identityKey,
31 + port: port,
32 + stream: stream,
33 + }
34 +}
35 +
36 +func (t *RelayTCPPort) Start(ctx context.Context) error {
37 + if t == nil || t.port <= 0 {
38 + return nil
39 + }
40 +
41 + addr := &net.TCPAddr{Port: t.port}
42 + listener, err := net.ListenTCP("tcp", addr)
43 + if err != nil {
44 + return err
45 + }
46 + t.listener = listener
47 +
48 + relayCtx, cancel := context.WithCancel(ctx)
49 + t.cancel = cancel
50 + go t.acceptLoop(relayCtx)
51 +
52 + log.Info().
53 + Str("component", "tcp-port-relay").
54 + Str("identity_key", t.identityKey).
55 + Int("port", t.port).
56 + Msg("tcp port relay started")
57 +
58 + return nil
59 +}
60 +
61 +func (t *RelayTCPPort) Close() {
62 + if t == nil {
63 + return
64 + }
65 +
66 + t.closeOnce.Do(func() {
67 + if t.cancel != nil {
68 + t.cancel()
69 + }
70 + if t.listener != nil {
71 + _ = t.listener.Close()
72 + }
73 + log.Info().
74 + Str("component", "tcp-port-relay").
75 + Str("identity_key", t.identityKey).
76 + Int("port", t.port).
77 + Msg("tcp port relay stopped")
78 + })
79 +}
80 +
81 +func (t *RelayTCPPort) TCPPort() int {
82 + if t == nil {
83 + return 0
84 + }
85 + return t.port
86 +}
87 +
88 +func (t *RelayTCPPort) acceptLoop(ctx context.Context) {
89 + for {
90 + conn, err := t.listener.Accept()
91 + if err != nil {
92 + if ctx.Err() != nil {
93 + return
94 + }
95 + var netErr net.Error
96 + if errors.As(err, &netErr) && netErr.Timeout() {
97 + continue
98 + }
99 + log.Warn().
100 + Str("component", "tcp-port-relay").
101 + Str("identity_key", t.identityKey).
102 + Err(err).
103 + Msg("accept loop exiting")
104 + return
105 + }
106 +
107 + go t.handleConn(ctx, conn)
108 + }
109 +}
110 +
111 +func (t *RelayTCPPort) handleConn(ctx context.Context, conn net.Conn) {
112 + claimCtx, cancel := context.WithTimeout(ctx, defaultTCPPortClaimTimeout)
113 + defer cancel()
114 +
115 + session, err := t.stream.ClaimRaw(claimCtx)
116 + if err != nil {
117 + _ = conn.Close()
118 + log.Warn().
119 + Str("component", "tcp-port-relay").
120 + Str("identity_key", t.identityKey).
121 + Err(err).
122 + Msg("failed to claim reverse session for tcp port connection")
123 + return
124 + }
125 +
126 + bridgeConns(conn, session)
127 +}
128 +
129 +// bridgeConns copies data bidirectionally between two connections.
130 +func bridgeConns(left, right net.Conn) {
131 + defer left.Close()
132 + defer right.Close()
133 +
134 + done := make(chan struct{})
135 + go func() {
136 + defer close(done)
137 + copyAndCloseWrite(right, left)
138 + }()
139 + copyAndCloseWrite(left, right)
140 + <-done
141 +}
142 +
143 +func copyAndCloseWrite(dst, src net.Conn) {
144 + buf := make([]byte, 32*1024)
145 + for {
146 + nr, readErr := src.Read(buf)
147 + if nr > 0 {
148 + if _, writeErr := dst.Write(buf[:nr]); writeErr != nil {
149 + break
150 + }
151 + }
152 + if readErr != nil {
153 + break
154 + }
155 + }
156 + type closeWriter interface {
157 + CloseWrite() error
158 + }
159 + if cw, ok := dst.(closeWriter); ok {
160 + _ = cw.CloseWrite()
161 + }
162 +}
portal/transport/stream_relay.go
+14 -2
@@ -66,6 +66,14 @@ func (b *RelayStream) OfferConn(conn net.Conn) error {
66 }
67
68 func (b *RelayStream) Claim(ctx context.Context) (net.Conn, error) {
69 + return b.claimWithMarker(ctx, types.MarkerTLSStart)
70 +}
71 +
72 +func (b *RelayStream) ClaimRaw(ctx context.Context) (net.Conn, error) {
73 + return b.claimWithMarker(ctx, types.MarkerRawTCPStart)
74 +}
75 +
76 +func (b *RelayStream) claimWithMarker(ctx context.Context, marker byte) (net.Conn, error) {
77 for {
78 b.mu.Lock()
79 if b.closedErr != nil {
@@ -82,7 +90,7 @@ func (b *RelayStream) Claim(ctx context.Context) (net.Conn, error) {
90 if session.IsClosed() {
91 continue
92 }
85 - if err := session.Activate(); err != nil {
93 + if err := session.activateWithMarker(marker); err != nil {
94 _ = session.Close()
95 continue
96 }
@@ -246,6 +254,10 @@ func (s *relaySession) StartIdle() {
254 }
255
256 func (s *relaySession) Activate() error {
257 + return s.activateWithMarker(types.MarkerTLSStart)
258 +}
259 +
260 +func (s *relaySession) activateWithMarker(marker byte) error {
261 s.mu.Lock()
262 if s.state != sessionIdle {
263 state := s.state
@@ -272,7 +284,7 @@ func (s *relaySession) Activate() error {
284 return net.ErrClosed
285 }
286 _ = s.conn.SetWriteDeadline(time.Now().Add(defaultSessionWriteLimit))
275 - _, err := s.conn.Write([]byte{types.MarkerTLSStart})
287 + _, err := s.conn.Write([]byte{marker})
288 _ = s.conn.SetWriteDeadline(time.Time{})
289 if err != nil {
290 _ = s.Close()
sdk/api_client.go
+2 -1
@@ -83,7 +83,7 @@ func (a *apiClient) close() {
83 }
84 }
85
86 -func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEnabled bool) (types.RegisterResponse, error) {
86 +func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, error) {
87 if err := a.ensureHTTPClient(ctx); err != nil {
88 return types.RegisterResponse{}, err
89 }
@@ -94,6 +94,7 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
94 Metadata: a.metadata.Copy(),
95 TTL: int(ttl / time.Second),
96 UDPEnabled: udpEnabled,
97 + TCPEnabled: tcpEnabled,
98 }
99 if err := utils.HTTPDoAPIPath(ctx, a.httpClient, a.baseURL, http.MethodPost, types.PathSDKRegisterChallenge, challengeReq, nil, &challenge); err != nil {
100 return types.RegisterResponse{}, err
sdk/expose.go
+4
@@ -28,6 +28,7 @@ type Exposure struct {
28 TargetAddr string
29 UDPAddr string
30 udpEnabled bool
31 + tcpEnabled bool
32 banMITM bool
33 metadata types.LeaseMetadata
34 rootCAPEM []byte
@@ -52,6 +53,7 @@ type ExposeConfig struct {
53 TargetAddr string
54 UDPAddr string
55 UDPEnabled bool
56 + TCPEnabled bool
57 BanMITM bool
58 Discovery bool
59 Metadata types.LeaseMetadata
@@ -100,6 +102,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
102 TargetAddr: targetAddr,
103 UDPAddr: udpAddr,
104 udpEnabled: cfg.UDPEnabled,
105 + tcpEnabled: cfg.TCPEnabled,
106 banMITM: cfg.BanMITM,
107 metadata: cfg.Metadata.Copy(),
108 rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
@@ -326,6 +329,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
329 listener, err := NewListener(context.Background(), relayURL, ListenerConfig{
330 Identity: e.identity.Copy(),
331 UDPEnabled: e.udpEnabled,
332 + TCPEnabled: e.tcpEnabled,
333 BanMITM: e.banMITM,
334 Metadata: e.metadata.Copy(),
335 RootCAPEM: append([]byte(nil), e.rootCAPEM...),
sdk/listener.go
+13 -10
@@ -24,6 +24,7 @@ import (
24 type ListenerConfig struct {
25 Identity types.Identity
26 UDPEnabled bool
27 + TCPEnabled bool
28 BanMITM bool
29 Metadata types.LeaseMetadata
30 RootCAPEM []byte
@@ -56,15 +57,16 @@ type Listener struct {
57 closeOnce sync.Once
58 registerOnce sync.Once
59
59 - banMITM bool
60 - identity types.Identity
61 - relaySet *discovery.RelaySet
62 - mu sync.Mutex
63 - hostname string
64 - udpAddr string
65 - metadata types.LeaseMetadata
66 - tlsConfig *tls.Config
67 - tlsCloser io.Closer
60 + banMITM bool
61 + tcpEnabled bool
62 + identity types.Identity
63 + relaySet *discovery.RelaySet
64 + mu sync.Mutex
65 + hostname string
66 + udpAddr string
67 + metadata types.LeaseMetadata
68 + tlsConfig *tls.Config
69 + tlsCloser io.Closer
70 }
71
72 // NewListener creates one relay listener and its dedicated relay transport for one relay URL.
@@ -95,6 +97,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
97 identity: api.identity.Copy(),
98 metadata: cfg.Metadata.Copy(),
99 banMITM: cfg.BanMITM,
100 + tcpEnabled: cfg.TCPEnabled,
101 relaySet: cfg.relaySet,
102 }
103 l.mitmManager = newMITMManager(listenerCtx, l)
@@ -441,7 +444,7 @@ func (l *Listener) renewLease(ctx context.Context) error {
444 }
445
446 func (l *Listener) registerAndConfigure(ctx context.Context) error {
444 - resp, err := l.api.registerLease(ctx, l.leaseTTL, l.datagram != nil)
447 + resp, err := l.api.registerLease(ctx, l.leaseTTL, l.datagram != nil, l.tcpEnabled)
448 if err != nil {
449 return err
450 }
types/api.go
+18 -4
@@ -67,6 +67,7 @@ type RegisterChallengeRequest struct {
67 Metadata LeaseMetadata `json:"metadata"`
68 TTL int `json:"ttl,omitempty"`
69 UDPEnabled bool `json:"udp_enabled,omitempty"`
70 + TCPEnabled bool `json:"tcp_enabled,omitempty"`
71 }
72
73 type RegisterChallengeResponse struct {
@@ -82,6 +83,8 @@ type RegisterResponse struct {
83 AccessToken string `json:"access_token"`
84 UDPAddr string `json:"udp_addr,omitempty"`
85 UDPEnabled bool `json:"udp_enabled,omitempty"`
86 + TCPAddr string `json:"tcp_addr,omitempty"`
87 + TCPEnabled bool `json:"tcp_enabled,omitempty"`
88 }
89
90 type DiscoveryResponse struct {
@@ -140,10 +143,11 @@ type AdminAuthStatusResponse struct {
143 }
144
145 type AdminSnapshotResponse struct {
143 - ApprovalMode string `json:"approval_mode"`
144 - LandingPageEnabled bool `json:"landing_page_enabled"`
145 - Leases []AdminLease `json:"leases,omitempty"`
146 - UDP AdminUDPSettingsResponse `json:"udp"`
146 + ApprovalMode string `json:"approval_mode"`
147 + LandingPageEnabled bool `json:"landing_page_enabled"`
148 + Leases []AdminLease `json:"leases,omitempty"`
149 + UDP AdminUDPSettingsResponse `json:"udp"`
150 + TCPPort AdminTCPPortSettingsResponse `json:"tcp_port"`
151 }
152
153 type AdminApprovalModeRequest struct {
@@ -175,3 +179,13 @@ type AdminUDPSettingsResponse struct {
179 Enabled bool `json:"enabled"`
180 MaxLeases int `json:"max_leases"`
181 }
182 +
183 +type AdminTCPPortSettingsRequest struct {
184 + Enabled bool `json:"enabled"`
185 + MaxLeases int `json:"max_leases"`
186 +}
187 +
188 +type AdminTCPPortSettingsResponse struct {
189 + Enabled bool `json:"enabled"`
190 + MaxLeases int `json:"max_leases"`
191 +}
types/error.go
+26 -23
@@ -1,29 +1,32 @@
1 package types
2
3 const (
4 - APIErrorCodeAuthDisabled = "auth_disabled"
5 - APIErrorCodeFeatureUnavailable = "feature_unavailable"
6 - APIErrorCodeHijackFailed = "hijack_failed"
7 - APIErrorCodeHijackUnsupported = "hijack_unsupported"
8 - APIErrorCodeHostnameConflict = "hostname_conflict"
9 - APIErrorCodeHTTP11Only = "http11_only"
10 - APIErrorCodeInvalidAddress = "invalid_address"
11 - APIErrorCodeInvalidIP = "invalid_ip"
12 - APIErrorCodeInvalidJSON = "invalid_json"
13 - APIErrorCodeInvalidKey = "invalid_key"
14 - APIErrorCodeInvalidMode = "invalid_mode"
15 - APIErrorCodeInvalidRequest = "invalid_request"
16 - APIErrorCodeInternal = "internal"
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 - APIErrorCodeUDPPortExhausted = "udp_port_exhausted"
24 - APIErrorCodeUDPDisabled = "udp_disabled"
25 - APIErrorCodeUDPCapacityExceeded = "udp_capacity_exceeded"
26 - APIErrorCodeTransportMismatch = "transport_mismatch"
4 + APIErrorCodeAuthDisabled = "auth_disabled"
5 + APIErrorCodeFeatureUnavailable = "feature_unavailable"
6 + APIErrorCodeHijackFailed = "hijack_failed"
7 + APIErrorCodeHijackUnsupported = "hijack_unsupported"
8 + APIErrorCodeHostnameConflict = "hostname_conflict"
9 + APIErrorCodeHTTP11Only = "http11_only"
10 + APIErrorCodeInvalidAddress = "invalid_address"
11 + APIErrorCodeInvalidIP = "invalid_ip"
12 + APIErrorCodeInvalidJSON = "invalid_json"
13 + APIErrorCodeInvalidKey = "invalid_key"
14 + APIErrorCodeInvalidMode = "invalid_mode"
15 + APIErrorCodeInvalidRequest = "invalid_request"
16 + APIErrorCodeInternal = "internal"
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 + APIErrorCodeUDPPortExhausted = "udp_port_exhausted"
24 + APIErrorCodeUDPDisabled = "udp_disabled"
25 + APIErrorCodeUDPCapacityExceeded = "udp_capacity_exceeded"
26 + APIErrorCodeTCPPortExhausted = "tcp_port_exhausted"
27 + APIErrorCodeTCPPortDisabled = "tcp_port_disabled"
28 + APIErrorCodeTCPPortCapacityExceeded = "tcp_port_capacity_exceeded"
29 + APIErrorCodeTransportMismatch = "transport_mismatch"
30
31 MITMProbeReasonExporterMismatch = "tls_exporter_mismatch"
32 MITMProbeReasonProbeTimeout = "probe_timeout"
types/identity.go
+4 -1
@@ -57,6 +57,8 @@ type Lease struct {
57 LastSeenAt time.Time
58 Hostname string
59 UDPEnabled bool
60 + TCPEnabled bool
61 + TCPAddr string
62 Metadata LeaseMetadata
63 Ready int
64 }
@@ -90,8 +92,9 @@ type RelayDescriptor struct {
92 OverlayIPv4 string `json:"overlay_ipv4,omitempty"`
93 OverlayCIDRs []string `json:"overlay_cidrs,omitempty"`
94
93 - SupportsTCP bool `json:"supports_tcp,omitempty"`
95 + SupportsTLS bool `json:"supports_tls,omitempty"`
96 SupportsUDP bool `json:"supports_udp,omitempty"`
97 + SupportsTCP bool `json:"supports_tcp,omitempty"`
98 SupportsOverlayPeer bool `json:"supports_overlay_peer,omitempty"`
99 }
100
types/paths.go
+1
@@ -18,6 +18,7 @@ const (
18 PathAdminApproval = "/admin/settings/approval-mode"
19 PathAdminLandingPage = "/admin/settings/landing-page"
20 PathAdminUDP = "/admin/settings/udp"
21 + PathAdminTCPPort = "/admin/settings/tcp-port"
22 PathAdminIPsPrefix = "/admin/ips/"
23 PathInstallShell = "/install.sh"
24 PathInstallPowerShell = "/install.ps1"
types/types.go
+1
@@ -8,4 +8,5 @@ const (
8 HeaderAccessToken = "X-Portal-Access-Token"
9 MarkerKeepalive = byte(0x00)
10 MarkerTLSStart = byte(0x02)
11 + MarkerRawTCPStart = byte(0x03)
12 )