refactor udp codes, remove quic 443 serving

Kim committed Mar 19, 2026 at 15:40 UTC b2b57b8f4133ac71b9a556d9008a41d1ff6116a9
36 files changed +2008 -2554
.env.example
+4
@@ -4,6 +4,9 @@ PORTAL_URL=https://localhost:4017
4 # Listener ports
5 API_PORT=4017
6 SNI_PORT=443
7 +UDP_ENABLED=true
8 +UDP_PORT_MIN=29900
9 +UDP_PORT_MAX=29999
10
11 # TLS/ACME and keyless materials
12 KEYLESS_DIR=./.portal-certs
@@ -18,6 +21,7 @@ AWS_ACCESS_KEY_ID=
21 AWS_SECRET_ACCESS_KEY=
22 AWS_SESSION_TOKEN=
23 AWS_REGION=
24 +AWS_DEFAULT_REGION=
25 AWS_HOSTED_ZONE_ID=
26
27 # Admin/auth configuration
cmd/demo-app/main.go
+1 -1
@@ -66,7 +66,7 @@ func runDemo() error {
66 return fmt.Errorf("resolve relay urls: %w", err)
67 }
68
69 - exposure, err := sdk.Expose(ctx, relayURLs, flagName, types.TransportTCP, types.LeaseMetadata{
69 + exposure, err := sdk.Expose(ctx, relayURLs, flagName, false, types.LeaseMetadata{
70 Description: flagDesc,
71 Tags: utils.SplitCSV(flagTags),
72 Owner: flagOwner,
cmd/demo-udp/main.go new
+141
@@ -0,0 +1,141 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "errors"
7 + "flag"
8 + "fmt"
9 + "net"
10 + "net/http"
11 + "os"
12 + "os/signal"
13 + "syscall"
14 + "time"
15 +
16 + "github.com/rs/zerolog"
17 + "github.com/rs/zerolog/log"
18 +
19 + "github.com/gosuda/portal/v2/sdk"
20 + "github.com/gosuda/portal/v2/types"
21 + "github.com/gosuda/portal/v2/utils"
22 +)
23 +
24 +var (
25 + flagRelayURLs string
26 + flagDefaultRelays bool
27 + flagName string
28 + flagDesc string
29 + flagTags string
30 + flagOwner string
31 + flagHide bool
32 + flagThumbnail string
33 +)
34 +
35 +func main() {
36 + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
37 + logger := log.With().Str("component", "demo-udp").Logger()
38 +
39 + flag.StringVar(&flagRelayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; appended to registry.json defaults unless --default-relays=false is set) [env: RELAYS]")
40 + flag.BoolVar(&flagDefaultRelays, "default-relays", utils.ParseBoolEnv("DEFAULT_RELAYS", false), "include repository registry.json default relays [env: DEFAULT_RELAYS]")
41 + flag.StringVar(&flagName, "name", "demo-udp", "public hostname prefix (single DNS label)")
42 + flag.StringVar(&flagDesc, "description", "Portal demo UDP echo service", "lease description")
43 + flag.StringVar(&flagTags, "tags", "demo,udp,echo", "comma-separated lease tags")
44 + flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
45 + flag.StringVar(&flagThumbnail, "thumbnail", "", "lease thumbnail")
46 + flag.BoolVar(&flagHide, "hide", true, "hide this lease from listings")
47 +
48 + flag.Parse()
49 +
50 + if err := runDemoUDP(); err != nil {
51 + logger.Error().Err(err).Msg("demo udp command failed")
52 + os.Exit(1)
53 + }
54 +}
55 +
56 +func runDemoUDP() error {
57 + logger := log.With().Str("component", "demo-udp").Logger()
58 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
59 + defer stop()
60 +
61 + relayURLs := utils.SplitCSV(flagRelayURLs)
62 + if flagDefaultRelays {
63 + relayURLs = sdk.WithDefaultRelayURLs(ctx, "", relayURLs...)
64 + }
65 + relayURLs, err := utils.NormalizeRelayURLs(relayURLs)
66 + if err != nil {
67 + return fmt.Errorf("resolve relay urls: %w", err)
68 + }
69 +
70 + exposure, err := sdk.Expose(ctx, relayURLs, flagName, true, types.LeaseMetadata{
71 + Description: flagDesc,
72 + Tags: utils.SplitCSV(flagTags),
73 + Owner: flagOwner,
74 + Thumbnail: flagThumbnail,
75 + Hide: flagHide,
76 + })
77 + if err != nil {
78 + return fmt.Errorf("exposure listen error: %w", err)
79 + }
80 + if exposure == nil {
81 + return errors.New("demo udp requires at least one relay")
82 + }
83 + defer exposure.Close()
84 +
85 + udpAddrs, err := exposure.WaitDatagramReady(ctx)
86 + if err != nil {
87 + return fmt.Errorf("wait for udp readiness: %w", err)
88 + }
89 + for _, udpAddr := range udpAddrs {
90 + logger.Info().Str("udp_addr", udpAddr).Msg("demo udp relay ready")
91 + }
92 +
93 + go runUDPEchoLoop(ctx, exposure, logger)
94 +
95 + if err := exposure.RunHTTP(ctx, newInfoHandler(exposure), ""); err != nil {
96 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
97 + err = nil
98 + }
99 + return err
100 + }
101 +
102 + if ctx.Err() != nil {
103 + logger.Info().Msg("demo udp shutting down")
104 + }
105 + logger.Info().Msg("demo udp shutdown complete")
106 + return nil
107 +}
108 +
109 +func runUDPEchoLoop(ctx context.Context, exposure *sdk.Exposure, logger zerolog.Logger) {
110 + for {
111 + frame, _, _, _, reply, err := exposure.AcceptDatagram()
112 + if err != nil {
113 + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
114 + return
115 + }
116 + logger.Warn().Err(err).Msg("demo udp accept failed")
117 + return
118 + }
119 +
120 + payload := append([]byte(nil), frame.Payload...)
121 + if len(payload) == 0 {
122 + payload = []byte("pong")
123 + }
124 + if err := reply(payload); err != nil && ctx.Err() == nil && !errors.Is(err, net.ErrClosed) {
125 + logger.Warn().Err(err).Uint32("flow_id", frame.FlowID).Msg("demo udp reply failed")
126 + return
127 + }
128 + }
129 +}
130 +
131 +func newInfoHandler(exposure *sdk.Exposure) http.Handler {
132 + mux := http.NewServeMux()
133 + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
134 + w.Header().Set("Content-Type", "application/json")
135 + _ = json.NewEncoder(w).Encode(map[string]any{
136 + "message": "demo-udp is running",
137 + "udp_addrs": exposure.UDPAddrs(),
138 + })
139 + })
140 + return mux
141 +}
cmd/portal-tunnel/main.go
+59 -59
@@ -71,12 +71,14 @@ func runExposeCommand(args []string) error {
71 var (
72 relayCSV string
73 target string
74 + udpAddr string
75 name string
76 desc string
77 tags string
78 thumbnail string
79 owner string
80 hide bool
81 + udp bool
82 )
83 fs.StringVar(&relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
84 fs.BoolVar(&defaultRelays, "default-relays", defaultRelays, "Include public registry relays")
@@ -87,8 +89,8 @@ func runExposeCommand(args []string) error {
89 fs.StringVar(&owner, "owner", "", "Service owner metadata")
90 fs.BoolVar(&hide, "hide", false, "Hide service from discovery")
91
90 - var transport string
91 - fs.StringVar(&transport, "transport", types.TransportBoth, "Transport mode: tcp, udp, or both (default: both)")
92 + fs.BoolVar(&udp, "udp", utils.ParseBoolEnv("UDP_ENABLED", false), "Enable public UDP relay in addition to the default TCP relay")
93 + fs.StringVar(&udpAddr, "udp-addr", strings.TrimSpace(os.Getenv("UDP_ADDR")), "Local UDP target address for relayed datagrams (host:port or port only) when --udp is enabled")
94 fs.Usage = func() {
95 printExposeUsage(fs.Output())
96 }
@@ -120,6 +122,26 @@ func runExposeCommand(args []string) error {
122 }
123 target = targetAddr
124 }
125 + udpAddr = strings.TrimSpace(udpAddr)
126 + switch {
127 + case udp && udpAddr == "":
128 + printExposeUsage(os.Stderr)
129 + return errors.New("--udp-addr is required when --udp is enabled")
130 + case !udp && udpAddr != "":
131 + printExposeUsage(os.Stderr)
132 + return errors.New("--udp-addr requires --udp")
133 + case udpAddr != "":
134 + if _, err := strconv.Atoi(udpAddr); err == nil {
135 + udpAddr = net.JoinHostPort("127.0.0.1", udpAddr)
136 + } else {
137 + targetAddr, err := utils.NormalizeTargetAddr(udpAddr)
138 + if err != nil {
139 + printExposeUsage(os.Stderr)
140 + return fmt.Errorf("invalid udp target %q: %w", udpAddr, err)
141 + }
142 + udpAddr = targetAddr
143 + }
144 + }
145
146 if strings.TrimSpace(name) == "" {
147 if strings.TrimSpace(cfg.ClientID) == "" {
@@ -155,8 +177,9 @@ func runExposeCommand(args []string) error {
177 stop,
178 relayURLs,
179 target,
180 + udpAddr,
181 name,
159 - transport,
182 + udp,
183 types.LeaseMetadata{
184 Description: desc,
185 Tags: utils.SplitCSV(tags),
@@ -220,19 +243,14 @@ func runTunnel(
243 ctx context.Context,
244 stop func(),
245 relayURLs []string,
223 - target string,
246 + tcpTarget string,
247 + udpTarget string,
248 name string,
225 - transport string,
249 + udpEnabled bool,
250 metadata types.LeaseMetadata,
251 ) error {
252 logger := log.With().Str("component", "portal").Logger()
229 - capabilities, err := types.ParseLeaseCapabilities(transport)
230 - if err != nil {
231 - return err
232 - }
233 - transport = capabilities.Transport()
234 -
235 - exposure, err := sdk.Expose(ctx, relayURLs, name, transport, metadata)
253 + exposure, err := sdk.Expose(ctx, relayURLs, name, udpEnabled, metadata)
254 if err != nil {
255 return fmt.Errorf("service %s: failed to start relays: %w", name, err)
256 }
@@ -241,33 +259,31 @@ func runTunnel(
259 }
260 defer exposure.Close()
261
244 - // UDP is best-effort — attach to existing lease, log and continue if it fails.
245 - if capabilities.SupportsDatagram() && !capabilities.SupportsStream() {
246 - runErr := runUDPBestEffort(ctx, exposure, target)
247 - if errors.Is(runErr, context.Canceled) {
248 - runErr = nil
249 - }
250 - closeErr := exposure.Close()
251 - if runErr != nil && stop != nil {
252 - stop()
253 - }
254 - return errors.Join(runErr, closeErr)
255 - }
256 -
257 - if capabilities.SupportsDatagram() {
262 + var udpErrCh chan error
263 + if udpEnabled {
264 + udpErrCh = make(chan error, 1)
265 go func() {
259 - if err := runUDPBestEffort(ctx, exposure, target); err != nil && ctx.Err() == nil {
260 - logger.Warn().Err(err).Msg("udp transport disabled")
266 + if err := runUDPProxy(ctx, exposure, udpTarget); err != nil && ctx.Err() == nil {
267 + udpErrCh <- err
268 + if stop != nil {
269 + stop()
270 + }
271 }
272 }()
273 }
274
275 logger.Info().
276 Str("release_version", types.ReleaseVersion).
267 - Str("local", target).
277 + Str("tcp_target", tcpTarget).
278 Str("service_name", name).
279 Strs("relays", exposure.RelayURLs()).
280 Msg("starting portal tunnel")
281 + if udpEnabled {
282 + logger.Info().
283 + Str("udp_target", udpTarget).
284 + Str("service_name", name).
285 + Msg("udp relay enabled")
286 + }
287
288 var connWG sync.WaitGroup
289 var connCount atomic.Int64
@@ -277,14 +293,24 @@ func runTunnel(
293 _ = exposure.Close()
294 }()
295
280 - waitErr := proxyRelayConnections(ctx, exposure, target, &connWG, &connCount)
296 + waitErr := proxyRelayConnections(ctx, exposure, tcpTarget, &connWG, &connCount)
297 if waitErr != nil && stop != nil {
298 stop()
299 }
300 + var udpErr error
301 + if udpErrCh != nil {
302 + select {
303 + case udpErr = <-udpErrCh:
304 + default:
305 + }
306 + }
307 closeErr := exposure.Close()
308 if waitErr != nil {
309 logger.Error().Err(waitErr).Msg("relay supervisor exited with error")
310 }
311 + if udpErr != nil {
312 + logger.Error().Err(udpErr).Msg("udp proxy exited with error")
313 + }
314 if closeErr != nil {
315 logger.Error().Err(closeErr).Msg("relay shutdown failed")
316 }
@@ -306,35 +332,7 @@ func runTunnel(
332 }
333
334 logger.Info().Msg("tunnel shutdown complete")
309 - return errors.Join(waitErr, closeErr)
310 -}
311 -
312 -// runUDPBestEffort waits for the exposure datagram plane and proxies it to the
313 -// local UDP target.
314 -func runUDPBestEffort(ctx context.Context, exposure *sdk.Exposure, target string) error {
315 - logger := log.With().Str("component", "portal-tunnel-udp").Logger()
316 -
317 - udpAddrs, err := exposure.WaitDatagramReady(ctx)
318 - if err != nil {
319 - if ctx.Err() != nil || errors.Is(err, context.Canceled) {
320 - return ctx.Err()
321 - }
322 - return fmt.Errorf("wait for udp readiness: %w", err)
323 - }
324 - if len(udpAddrs) == 0 {
325 - if ctx.Err() != nil {
326 - return ctx.Err()
327 - }
328 - return errors.New("relay did not expose any UDP listeners")
329 - }
330 -
331 - for _, udpAddr := range udpAddrs {
332 - logger.Info().
333 - Str("udp_addr", udpAddr).
334 - Msg("UDP tunnel ready")
335 - }
336 -
337 - return proxyExposureDatagrams(ctx, exposure, target)
335 + return errors.Join(waitErr, udpErr, closeErr)
336 }
337
338 func resolveRelayURLs(ctx context.Context, registryURL string, inputs []string, includeDefaultRelays bool) ([]string, error) {
@@ -405,6 +403,7 @@ func printRootUsage(w io.Writer) {
403 fmt.Fprintln(w, "Examples:")
404 fmt.Fprintln(w, " portal expose 3000")
405 fmt.Fprintln(w, " portal expose --name my-app localhost:8080")
406 + fmt.Fprintln(w, " portal expose --udp --udp-addr 127.0.0.1:5353 3000")
407 fmt.Fprintln(w, " portal list")
408 }
409
@@ -415,6 +414,7 @@ func printExposeUsage(w io.Writer) {
414 fmt.Fprintln(w, "Examples:")
415 fmt.Fprintln(w, " portal expose 3000")
416 fmt.Fprintln(w, " portal expose --name my-app localhost:8080")
417 + fmt.Fprintln(w, " portal expose --udp --udp-addr 127.0.0.1:5353 3000")
418 fmt.Fprintln(w, " portal expose --relays https://portal.example.com --default-relays=false 3000")
419 }
420
cmd/portal-tunnel/relays.go
+46 -18
@@ -134,6 +134,34 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
134 return err
135 }
136
137 +// runUDPProxy waits for the exposure datagram plane and proxies it to the
138 +// configured local UDP target.
139 +func runUDPProxy(ctx context.Context, exposure *sdk.Exposure, udpTarget string) error {
140 + logger := log.With().Str("component", "portal-tunnel-udp").Logger()
141 +
142 + udpAddrs, err := exposure.WaitDatagramReady(ctx)
143 + if err != nil {
144 + if ctx.Err() != nil || errors.Is(err, context.Canceled) {
145 + return ctx.Err()
146 + }
147 + return fmt.Errorf("wait for udp readiness: %w", err)
148 + }
149 + if len(udpAddrs) == 0 {
150 + if ctx.Err() != nil {
151 + return ctx.Err()
152 + }
153 + return errors.New("relay did not expose any UDP listeners")
154 + }
155 +
156 + for _, udpAddr := range udpAddrs {
157 + logger.Info().
158 + Str("udp_addr", udpAddr).
159 + Msg("UDP tunnel ready")
160 + }
161 +
162 + return proxyExposureDatagrams(ctx, exposure, udpTarget)
163 +}
164 +
165 // proxyExposureDatagrams receives datagrams from the exposure datagram plane
166 // and forwards them to the local UDP service, relaying responses back.
167 func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAddr string) error {
@@ -141,7 +169,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
169
170 targetAddr, err := utils.NormalizeTargetAddr(localAddr)
171 if err != nil {
144 - return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
172 + return fmt.Errorf("invalid --udp-addr value %q: %w", localAddr, err)
173 }
174
175 resolvedAddr, err := net.ResolveUDPAddr("udp", targetAddr)
@@ -255,7 +283,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
283
284 logger.Info().Str("target", targetAddr).Msg("udp proxy loop started, waiting for datagrams")
285 for {
258 - dg, err := exposure.AcceptDatagram()
286 + frame, leaseID, relayURL, udpAddr, reply, err := exposure.AcceptDatagram()
287 if err != nil {
288 if ctx.Err() != nil {
289 return ctx.Err()
@@ -267,36 +295,36 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
295 }
296
297 logger.Debug().
270 - Uint32("flow_id", dg.FlowID).
271 - Int("bytes", len(dg.Payload)).
272 - Str("lease_id", dg.LeaseID).
273 - Str("relay_url", dg.RelayURL).
274 - Str("udp_addr", dg.UDPAddr).
298 + Uint32("flow_id", frame.FlowID).
299 + Int("bytes", len(frame.Payload)).
300 + Str("lease_id", leaseID).
301 + Str("relay_url", relayURL).
302 + Str("udp_addr", udpAddr).
303 Str("target", targetAddr).
304 Msg("datagram received from relay, forwarding to local")
305
306 key := flowKey{
279 - flowID: dg.FlowID,
280 - leaseID: dg.LeaseID,
281 - relayURL: dg.RelayURL,
307 + flowID: frame.FlowID,
308 + leaseID: leaseID,
309 + relayURL: relayURL,
310 }
283 - localConn, err := getOrCreateFlow(key, dg.Reply)
311 + localConn, err := getOrCreateFlow(key, reply)
312 if err != nil {
313 logger.Warn().
314 Err(err).
287 - Uint32("flow_id", dg.FlowID).
288 - Str("lease_id", dg.LeaseID).
289 - Str("relay_url", dg.RelayURL).
315 + Uint32("flow_id", frame.FlowID).
316 + Str("lease_id", leaseID).
317 + Str("relay_url", relayURL).
318 Msg("dial local udp failed")
319 continue
320 }
321
294 - if _, err := localConn.Write(dg.Payload); err != nil {
322 + if _, err := localConn.Write(frame.Payload); err != nil {
323 logger.Warn().
324 Err(err).
297 - Uint32("flow_id", dg.FlowID).
298 - Str("lease_id", dg.LeaseID).
299 - Str("relay_url", dg.RelayURL).
325 + Uint32("flow_id", frame.FlowID).
326 + Str("lease_id", leaseID).
327 + Str("relay_url", relayURL).
328 Msg("write to local udp failed")
329 }
330 }
cmd/relay-server/main.go
+10 -4
@@ -22,7 +22,7 @@ import (
22 const (
23 defaultAPIPort = 4017
24 defaultSNIPort = 443
25 - defaultUDPPortMin = 29000
25 + defaultUDPPortMin = 29900
26 defaultUDPPortMax = 29999
27 defaultPortalURL = "https://localhost:4017"
28 defaultKeylessDir = "./.portal-certs"
@@ -32,6 +32,7 @@ type relayServerConfig struct {
32 PortalURL string
33 APIPort int
34 SNIPort int
35 + UDPEnabled bool
36 UDPPortMin int
37 UDPPortMax int
38 AdminSecretKey string
@@ -59,6 +60,7 @@ func main() {
60 }
61 apiPort := parsePortNumber(os.Getenv("API_PORT"), defaultAPIPort)
62 sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
63 + udpEnabled := utils.ParseBoolEnv("UDP_ENABLED", false)
64 udpPortMin := parsePortNumber(os.Getenv("UDP_PORT_MIN"), defaultUDPPortMin)
65 udpPortMax := parsePortNumber(os.Getenv("UDP_PORT_MAX"), defaultUDPPortMax)
66 adminSecretKey := trimmedEnv("ADMIN_SECRET_KEY")
@@ -84,7 +86,8 @@ func main() {
86
87 flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
88 flag.IntVar(&cfg.APIPort, "api-port", apiPort, "Admin/API server port (env: API_PORT)")
87 - flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port number (env: SNI_PORT)")
89 + flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "TCP SNI router port number (env: SNI_PORT)")
90 + flag.BoolVar(&cfg.UDPEnabled, "udp", udpEnabled, "enable UDP relay ports and the internal QUIC tunnel (env: UDP_ENABLED)")
91 flag.IntVar(&cfg.UDPPortMin, "udp-port-min", udpPortMin, "Minimum UDP port for lease allocation (env: UDP_PORT_MIN)")
92 flag.IntVar(&cfg.UDPPortMax, "udp-port-max", udpPortMax, "Maximum UDP port for lease allocation (env: UDP_PORT_MAX)")
93
@@ -105,6 +108,7 @@ func main() {
108 logger.Info().
109 Str("release_version", types.ReleaseVersion).
110 Str("portal_url", cfg.PortalURL).
111 + Bool("udp_enabled", cfg.UDPEnabled).
112 Msg("configured relay server")
113
114 if err := runServer(cfg); err != nil {
@@ -139,6 +143,7 @@ func runServer(cfg relayServerConfig) error {
143 },
144 APIListenAddr: apiListenAddr,
145 SNIListenAddr: sniListenAddr,
146 + UDPEnabled: cfg.UDPEnabled,
147 TrustedProxyCIDRs: trustedProxyCIDRs,
148 TrustProxyHeaders: cfg.TrustProxyHeaders,
149 UDPPortMin: cfg.UDPPortMin,
@@ -162,9 +167,10 @@ func runServer(cfg relayServerConfig) error {
167 Str("sni_addr", server.SNIAddr()).
168 Str("root_host", rootHost).
169 Str("acme_dns_provider", cfg.ACMEDNSProvider).
170 + Bool("udp_enabled", cfg.UDPEnabled).
171 Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1")
166 - if quicAddr := server.QUICAddr(); quicAddr != "" {
167 - logEvent = logEvent.Str("quic_addr", quicAddr)
172 + if quicAddr := server.QUICTunnelAddr(); quicAddr != "" {
173 + logEvent = logEvent.Str("internal_quic_tunnel_addr", quicAddr)
174 }
175 logEvent.Msg("relay server started")
176
docker-compose.yml
+9 -4
@@ -4,18 +4,23 @@ services:
4 build:
5 context: .
6 dockerfile: Dockerfile
7 - network_mode: host
7 stop_grace_period: 30s
8 + ports:
9 + - "${API_PORT:-4017}:${API_PORT:-4017}"
10 + - "${API_PORT:-4017}:${API_PORT:-4017}/udp"
11 + - "${SNI_PORT:-443}:${SNI_PORT:-443}"
12 + - "${UDP_PORT_MIN:-29900}-${UDP_PORT_MAX:-29999}:${UDP_PORT_MIN:-29900}-${UDP_PORT_MAX:-29999}/udp"
13 environment:
14 # Public routing
15 PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
16
13 - # Listener ports (bound directly on host via host networking)
17 + # Listener ports (published to the host below)
18 API_PORT: ${API_PORT:-4017}
19 SNI_PORT: ${SNI_PORT:-443}
20 + UDP_ENABLED: ${UDP_ENABLED:-true}
21
17 - # UDP port allocation range for QUIC/UDP leases
18 - UDP_PORT_MIN: ${UDP_PORT_MIN:-29000}
22 + # UDP port allocation range for UDP-enabled leases
23 + UDP_PORT_MIN: ${UDP_PORT_MIN:-29900}
24 UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
25
26 # Admin/auth configuration
docs/deployment.md
+5 -5
@@ -8,7 +8,7 @@ You need:
8
9 - A public domain (example: `example.com`)
10 - A public Linux server with a static public IP
11 -- Open inbound ports: `443/tcp`, `4017/tcp`, `4017/udp`, `29000-29999/udp` (UDP range for QUIC/UDP leases)
11 +- Open inbound ports: `443/tcp`, `4017/tcp`, `4017/udp`, `29900-29999/udp` (UDP range for QUIC/UDP leases)
12 - Docker and Docker Compose
13 - A DNS provider account for ACME DNS-01 automation with a supported provider (`cloudflare` or `route53`)
14
@@ -112,11 +112,11 @@ Equivalent relay flags:
112
113 ### 3.2 UDP/QUIC Transport
114
115 -Portal automatically starts a QUIC tunnel listener on `API_PORT/udp` (default `:4017/udp`) and allocates raw UDP ports from the `UDP_PORT_MIN`–`UDP_PORT_MAX` range (default `29000`–`29999`) for UDP leases. The tunnel CLI starts both TCP and UDP transports by default; if the relay does not support UDP, the tunnel falls back to TCP-only.
115 +Portal automatically starts a QUIC tunnel listener on `API_PORT/udp` (default `:4017/udp`) and allocates raw UDP ports from the `UDP_PORT_MIN`–`UDP_PORT_MAX` range (default `29900`–`29999`) for UDP leases.
116
117 | Variable | Default | Description |
118 |---|---|---|
119 -| `UDP_PORT_MIN` | `29000` | Start of the UDP port allocation range |
119 +| `UDP_PORT_MIN` | `29900` | Start of the UDP port allocation range |
120 | `UDP_PORT_MAX` | `29999` | End of the UDP port allocation range |
121
122 > **Docker note:** Use `network_mode: host` for the portal container to avoid Docker iptables port-mapping overhead. Docker creates one iptables rule per mapped port, so large UDP ranges cause very slow container start/stop. Host networking bypasses this entirely and allows dynamic UDP port allocation. See the nginx-proxy examples for the recommended setup.
@@ -266,7 +266,7 @@ Required inbound ports:
266 - `443/tcp` — SNI router (tenant TLS passthrough)
267 - `4017/tcp` — Admin/API listener
268 - `4017/udp` — QUIC tunnel listener (tunnel ↔ relay)
269 -- `29000-29999/udp` — Raw UDP lease ports (client ↔ relay, adjust to match `UDP_PORT_MAX`)
269 +- `29900-29999/udp` — Raw UDP lease ports (client ↔ relay, adjust to match `UDP_PORT_MAX`)
270
271 UFW example:
272
@@ -274,6 +274,6 @@ UFW example:
274 sudo ufw allow 443/tcp
275 sudo ufw allow 4017/tcp
276 sudo ufw allow 4017/udp
277 -sudo ufw allow 29000:29999/udp
277 +sudo ufw allow 29900:29999/udp
278 sudo ufw status
279 ```
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+2 -2
@@ -52,7 +52,7 @@ services:
52 # ─── portal relay ───────────────────────────────────────────────────────────
53 # NAT-traversal relay with host networking.
54 # TCP (4017, 4443) is reached by nginx via host.docker.internal.
55 - # UDP (4017, 29000-29999) is bound directly on the host.
55 + # UDP (4017, 29900-29999) is bound directly on the host.
56 # SNI_PORT is 4443 to avoid conflicting with nginx on 443.
57 portal:
58 image: ghcr.io/gosuda/portal:2
@@ -63,7 +63,7 @@ services:
63 PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
64 API_PORT: ${API_PORT:-4017}
65 SNI_PORT: ${SNI_PORT:-4443}
66 - UDP_PORT_MIN: ${UDP_PORT_MIN:-29000}
66 + UDP_PORT_MIN: ${UDP_PORT_MIN:-29900}
67 UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
68 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
69 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
docs/examples/nginx-proxy/.env.example
+6 -6
@@ -1,16 +1,16 @@
1 # Portal + nginx reverse proxy configuration
2 # Copy this file to .env and fill in the values.
3
4 -# Public routing (no port — nginx handles :443 externally)
4 +# Public routing (no port; nginx handles :443 externally)
5 PORTAL_URL=https://portal.example.com
6
7 -# Internal listener ports
7 +# Listener ports bound directly on the host via host networking
8 API_PORT=4017
9 SNI_PORT=443
10
11 -# UDP port allocation range for QUIC/UDP leases (default: 29000-29999)
12 -# UDP_PORT_MIN=29000
13 -# UDP_PORT_MAX=29999
11 +# UDP port allocation range for QUIC/UDP leases
12 +UDP_PORT_MIN=29900
13 +UDP_PORT_MAX=29999
14
15 # Admin secret for the /admin UI
16 ADMIN_SECRET_KEY=
@@ -23,4 +23,4 @@ KEYLESS_DIR=/portal-certs
23
24 # Trust forwarded headers from nginx (required behind reverse proxy)
25 TRUST_PROXY_HEADERS=true
26 -TRUSTED_PROXY_CIDRS=
26 +TRUSTED_PROXY_CIDRS=127.0.0.0/8
docs/examples/nginx-proxy/docker-compose.yaml
+3 -3
@@ -6,7 +6,7 @@
6 # ├─ portal.example.com → 127.0.0.1:8443 (nginx L7, TLS termination) → 127.0.0.1:4017
7 # └─ *.portal.example.com → 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
8 # portal:4017/udp (QUIC tunnel listener — direct on host)
9 -# portal:29000-29999/udp (per-lease UDP relay ports — direct on host)
9 +# portal:29900-29999/udp (per-lease UDP relay ports — direct on host)
10 #
11 # Portal uses host networking so UDP ports are dynamically bound on the host
12 # without Docker iptables rules. This avoids the slow container start/stop
@@ -44,7 +44,7 @@ services:
44 # ─── portal relay ───────────────────────────────────────────────────────────
45 # Relay server with host networking.
46 # TCP (4017, 4443) is reached by nginx via 127.0.0.1.
47 - # UDP (4017, 29000-29999) is bound directly on the host — no port mapping.
47 + # UDP (4017, 29900-29999) is bound directly on the host — no port mapping.
48 # SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
49 portal:
50 image: ghcr.io/gosuda/portal:2
@@ -60,7 +60,7 @@ services:
60 SNI_PORT: ${SNI_PORT:-4443}
61
62 # UDP port allocation range for QUIC/UDP leases.
63 - UDP_PORT_MIN: ${UDP_PORT_MIN:-29000}
63 + UDP_PORT_MIN: ${UDP_PORT_MIN:-29900}
64 UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
65
66 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
go.mod
+1 -1
@@ -11,7 +11,6 @@ require (
11 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
12 github.com/quic-go/quic-go v0.59.0
13 github.com/rs/zerolog v1.34.0
14 - golang.org/x/crypto v0.48.0
14 golang.org/x/net v0.50.0
15 golang.org/x/sync v0.19.0
16 )
@@ -33,6 +32,7 @@ require (
32 github.com/mattn/go-colorable v0.1.13 // indirect
33 github.com/mattn/go-isatty v0.0.20 // indirect
34 github.com/miekg/dns v1.1.72 // indirect
35 + golang.org/x/crypto v0.48.0 // indirect
36 golang.org/x/mod v0.32.0 // indirect
37 golang.org/x/sys v0.41.0 // indirect
38 golang.org/x/text v0.34.0 // indirect
portal/api_server.go
+123 -54
@@ -1,7 +1,9 @@
1 package portal
2
3 import (
4 + "context"
5 "crypto/tls"
6 + "encoding/json"
7 "errors"
8 "fmt"
9 "io"
@@ -10,20 +12,24 @@ import (
12 "strings"
13 "time"
14
15 + "github.com/quic-go/quic-go"
16 "github.com/rs/zerolog/log"
17
15 - "github.com/gosuda/portal/v2/portal/datagram"
18 "github.com/gosuda/portal/v2/portal/keyless"
19 "github.com/gosuda/portal/v2/portal/policy"
20 + "github.com/gosuda/portal/v2/portal/transport"
21 "github.com/gosuda/portal/v2/types"
22 "github.com/gosuda/portal/v2/utils"
23 )
24
25 var (
23 - errLeaseNotFound = errors.New(types.APIErrorCodeLeaseNotFound)
24 - errIPBanned = errors.New(types.APIErrorCodeIPBanned)
25 - errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
26 - errHostnameConflict = errors.New(types.APIErrorCodeHostnameConflict)
26 + errFeatureUnavailable = errors.New(types.APIErrorCodeFeatureUnavailable)
27 + errHostnameConflict = errors.New(types.APIErrorCodeHostnameConflict)
28 + errIPBanned = errors.New(types.APIErrorCodeIPBanned)
29 + errLeaseNotFound = errors.New(types.APIErrorCodeLeaseNotFound)
30 + errLeaseRejected = errors.New(types.APIErrorCodeLeaseRejected)
31 + errTransportMismatch = errors.New(types.APIErrorCodeTransportMismatch)
32 + errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
33 )
34
35 func (s *Server) newAPIServer(listener net.Listener, apiMux *http.ServeMux, apiTLS keyless.TLSMaterialConfig) (net.Listener, *http.Server, io.Closer, error) {
@@ -121,13 +127,16 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
127 resp, err := s.registerLease(req, clientIP)
128 if err != nil {
129 status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
130 + if errors.Is(err, errFeatureUnavailable) {
131 + status, code = http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable
132 + }
133 if errors.Is(err, errHostnameConflict) {
134 status, code = http.StatusConflict, types.APIErrorCodeHostnameConflict
135 }
136 if errors.Is(err, errIPBanned) {
137 status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
138 }
130 - if errors.Is(err, datagram.ErrPortExhausted) {
139 + if errors.Is(err, transport.ErrPortExhausted) {
140 status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted
141 }
142 utils.WriteAPIError(w, status, code, err.Error())
@@ -219,22 +228,23 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
228 return
229 }
230
222 - lease, err := s.findLeaseByID(leaseID)
223 - if err != nil {
231 + lease, err := s.admitLeaseByID(leaseID, token, false)
232 + switch {
233 + case errors.Is(err, errLeaseNotFound):
234 utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
235 return
226 - }
227 - if !s.registry.policy.IsLeaseRoutable(lease.ID) {
236 + case errors.Is(err, errLeaseRejected):
237 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeLeaseRejected, "lease is not approved for routing")
238 return
230 - }
231 - if authErr := s.authorizeLeaseToken(lease, token); authErr != nil {
232 - utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, authErr.Error())
239 + case errors.Is(err, errUnauthorized):
240 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
241 return
234 - }
235 - if !lease.SupportsStream() {
242 + case errors.Is(err, errTransportMismatch):
243 utils.WriteAPIError(w, http.StatusConflict, types.APIErrorCodeTransportMismatch, "lease does not support stream transport")
244 return
245 + case err != nil:
246 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
247 + return
248 }
249
250 hijacker, ok := w.(http.Hijacker)
@@ -258,22 +268,24 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
268 return
269 }
270
261 - session := newReverseSession(conn, s.cfg.IdleKeepaliveInterval)
262 - streamBroker := lease.StreamBroker()
263 - if streamBroker == nil {
264 - _ = session.Close()
271 + stream := lease.stream
272 + if stream == nil {
273 + _ = conn.Close()
274 return
275 }
276
268 - if err := streamBroker.Offer(session); err != nil {
277 + remoteAddr := ""
278 + if conn.RemoteAddr() != nil {
279 + remoteAddr = conn.RemoteAddr().String()
280 + }
281 + if err := stream.OfferConn(conn); err != nil {
282 log.Warn().
283 Err(err).
284 Str("component", "relay-server").
285 Str("lease_id", lease.ID).
286 Str("lease_name", lease.Name).
274 - Str("remote_addr", session.RemoteAddr()).
287 + Str("remote_addr", remoteAddr).
288 Msg("sdk reverse rejected")
276 - _ = session.Close()
289 return
290 }
291
@@ -282,11 +294,77 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
294 Str("component", "relay-server").
295 Str("lease_id", lease.ID).
296 Str("lease_name", lease.Name).
285 - Str("remote_addr", session.RemoteAddr()).
286 - Int("ready", streamBroker.ReadyCount()).
297 + Str("remote_addr", remoteAddr).
298 + Int("ready", stream.ReadyCount()).
299 Msg("sdk reverse connected")
300 }
301
302 +func (s *Server) handleQUICTunnelConn(conn *quic.Conn) {
303 + stream, err := conn.AcceptStream(context.Background())
304 + if err != nil {
305 + _ = conn.CloseWithError(1, "stream accept failed")
306 + return
307 + }
308 +
309 + _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
310 + var msg types.QUICControlMessage
311 + if err := json.NewDecoder(io.LimitReader(stream, defaultControlBodyLimit)).Decode(&msg); err != nil {
312 + _ = conn.CloseWithError(1, "control read failed")
313 + return
314 + }
315 + _ = stream.SetReadDeadline(time.Time{})
316 + if strings.TrimSpace(msg.LeaseID) == "" || strings.TrimSpace(msg.ReverseToken) == "" {
317 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: "invalid_control_message"})
318 + _ = conn.CloseWithError(1, "invalid control message")
319 + return
320 + }
321 +
322 + lease, err := s.admitLeaseByID(msg.LeaseID, msg.ReverseToken, true)
323 + switch {
324 + case errors.Is(err, errLeaseNotFound):
325 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: types.APIErrorCodeLeaseNotFound})
326 + _ = conn.CloseWithError(1, "lease not found")
327 + return
328 + case errors.Is(err, errUnauthorized):
329 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: types.APIErrorCodeUnauthorized})
330 + _ = conn.CloseWithError(1, "unauthorized")
331 + return
332 + case errors.Is(err, errLeaseRejected):
333 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: types.APIErrorCodeLeaseRejected})
334 + _ = conn.CloseWithError(1, "lease rejected")
335 + return
336 + case errors.Is(err, errTransportMismatch):
337 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: types.APIErrorCodeTransportMismatch})
338 + _ = conn.CloseWithError(1, "transport mismatch")
339 + return
340 + case err != nil:
341 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: types.APIErrorCodeInvalidRequest})
342 + _ = conn.CloseWithError(1, "invalid control message")
343 + return
344 + }
345 +
346 + dg := lease.datagram
347 + if dg == nil {
348 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: types.APIErrorCodeTransportMismatch})
349 + _ = conn.CloseWithError(1, "transport mismatch")
350 + return
351 + }
352 + if err := dg.Register(conn); err != nil {
353 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: "broker_closed"})
354 + _ = conn.CloseWithError(1, "broker closed")
355 + return
356 + }
357 +
358 + _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: true})
359 + s.registry.Touch(lease.ID, conn.RemoteAddr().String(), time.Now())
360 + log.Info().
361 + Str("component", "quic-tunnel-listener").
362 + Str("lease_id", lease.ID).
363 + Str("lease_name", lease.Name).
364 + Str("remote_addr", conn.RemoteAddr().String()).
365 + Msg("quic tunnel connected")
366 +}
367 +
368 func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (types.RegisterResponse, error) {
369 name, err := utils.NormalizeDNSLabel(req.Name)
370 if err != nil {
@@ -308,27 +386,13 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
386 ttl = time.Duration(req.TTL) * time.Second
387 }
388
311 - capabilities, err := types.ParseLeaseCapabilities(req.Transport)
312 - if err != nil {
389 + if err := s.requireDatagramPlane(req.UDPEnabled); err != nil {
390 return types.RegisterResponse{}, err
391 }
315 - transport := capabilities.Transport()
392
393 leaseID := utils.RandomID("lease_")
394 now := time.Now()
395 expiresAt := now.Add(ttl)
320 - runtime, err := newLeaseRuntime(leaseRuntimeConfig{
321 - Capabilities: capabilities,
322 - IdleInterval: s.cfg.IdleKeepaliveInterval,
323 - LeaseID: leaseID,
324 - LeaseName: name,
325 - PortAllocator: s.ports,
326 - ReadyLimit: s.cfg.ReadyQueueLimit,
327 - })
328 - if err != nil {
329 - return types.RegisterResponse{}, err
330 - }
331 -
396 record := &leaseRecord{
397 Lease: types.Lease{
398 ID: leaseID,
@@ -339,20 +403,30 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
403 FirstSeenAt: now,
404 LastSeenAt: now,
405 ClientIP: clientIP,
342 - Transport: transport,
343 - UDPPort: runtime.UDPPort(),
406 + UDPEnabled: req.UDPEnabled,
407 },
408 ReverseToken: req.ReverseToken,
346 - Runtime: runtime,
409 + stream: transport.NewRelayStream(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
410 + }
411 + if req.UDPEnabled {
412 + if s.ports == nil {
413 + return types.RegisterResponse{}, errors.New("udp port allocation not available")
414 + }
415 + port, err := s.ports.Allocate(name)
416 + if err != nil {
417 + return types.RegisterResponse{}, fmt.Errorf("allocate udp port: %w", err)
418 + }
419 + record.datagram = transport.NewRelayDatagram(leaseID, port)
420 + record.ports = s.ports
421 }
422
349 - if err := runtime.Start(); err != nil {
350 - runtime.Close(s.ports)
423 + if err := record.Start(); err != nil {
424 + record.Close()
425 return types.RegisterResponse{}, err
426 }
427
428 if err := s.registry.Register(record); err != nil {
355 - runtime.Close(s.ports)
429 + record.Close()
430 return types.RegisterResponse{}, err
431 }
432
@@ -361,12 +435,11 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
435 Hostname: hostname,
436 Metadata: record.Metadata,
437 ExpiresAt: expiresAt,
364 - ConnectURL: strings.TrimRight(s.cfg.PortalURL, "/") + types.PathSDKConnect,
365 - Transport: transport,
438 + UDPEnabled: record.UDPEnabled,
439 }
367 - if record.SupportsDatagram() {
368 - resp.UDPAddr = fmt.Sprintf("%s:%d", s.rootHost, record.UDPPort())
369 - resp.QUICAddr = s.quicPublicAddr()
440 + resp.ConnectURL = strings.TrimRight(s.cfg.PortalURL, "/") + types.PathSDKConnect
441 + if record.datagram != nil {
442 + resp.UDPAddr = fmt.Sprintf("%s:%d", s.rootHost, record.datagram.UDPPort())
443 }
444
445 return resp, nil
@@ -398,10 +471,6 @@ func (s *Server) unregisterLease(req types.UnregisterRequest) error {
471 return nil
472 }
473
401 -func (s *Server) findLeaseByID(leaseID string) (*leaseRecord, error) {
402 - return s.registry.FindByID(leaseID)
403 -}
404 -
474 func (s *Server) authorizeLeaseToken(record *leaseRecord, token string) error {
475 if record == nil {
476 return errLeaseNotFound
portal/broker_test.go deleted
-206
@@ -1,206 +0,0 @@
1 -package portal
2 -
3 -import (
4 - "context"
5 - "errors"
6 - "io"
7 - "net"
8 - "testing"
9 - "time"
10 -
11 - "github.com/gosuda/portal/v2/types"
12 -)
13 -
14 -const brokerAsyncTestTimeout = 5 * time.Second
15 -
16 -func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
17 - t.Parallel()
18 -
19 - serverConn, clientConn := net.Pipe()
20 - t.Cleanup(func() {
21 - _ = serverConn.Close()
22 - _ = clientConn.Close()
23 - })
24 -
25 - broker := newStreamBroker("lease-test", time.Hour, 2)
26 - session := newReverseSession(serverConn, time.Hour)
27 - if err := broker.Offer(session); err != nil {
28 - t.Fatalf("Offer() error = %v", err)
29 - }
30 -
31 - markerCh := make(chan byte, 1)
32 - errCh := make(chan error, 1)
33 - go func() {
34 - var marker [1]byte
35 - if _, err := io.ReadFull(clientConn, marker[:]); err != nil {
36 - errCh <- err
37 - return
38 - }
39 - markerCh <- marker[0]
40 - }()
41 -
42 - claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
43 - defer cancel()
44 -
45 - claimed, err := broker.Claim(claimCtx)
46 - if err != nil {
47 - t.Fatalf("Claim() error = %v", err)
48 - }
49 - if claimed != session {
50 - t.Fatalf("Claim() returned unexpected session")
51 - }
52 -
53 - select {
54 - case err := <-errCh:
55 - t.Fatalf("ReadFull() error = %v", err)
56 - case marker := <-markerCh:
57 - if marker != types.MarkerTLSStart {
58 - t.Fatalf("marker = 0x%02x, want 0x%02x", marker, types.MarkerTLSStart)
59 - }
60 - case <-time.After(brokerAsyncTestTimeout):
61 - t.Fatal("timed out waiting for activation marker")
62 - }
63 -}
64 -
65 -func TestLeaseBrokerCloseClosesIdleSessions(t *testing.T) {
66 - t.Parallel()
67 -
68 - serverConn, clientConn := net.Pipe()
69 - broker := newStreamBroker("lease-test", time.Hour, 2)
70 - session := newReverseSession(serverConn, time.Hour)
71 - if err := broker.Offer(session); err != nil {
72 - t.Fatalf("Offer() error = %v", err)
73 - }
74 -
75 - broker.Close()
76 -
77 - buf := make([]byte, 1)
78 - _ = clientConn.SetReadDeadline(time.Now().Add(time.Second))
79 - if _, err := clientConn.Read(buf); err == nil {
80 - t.Fatal("Read() succeeded, want connection close")
81 - }
82 -}
83 -
84 -func TestLeaseBrokerCloseUnblocksClaim(t *testing.T) {
85 - t.Parallel()
86 -
87 - broker := newStreamBroker("lease-test", time.Hour, 2)
88 - claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
89 - defer cancel()
90 -
91 - started := make(chan struct{})
92 - errCh := make(chan error, 1)
93 - go func() {
94 - close(started)
95 - _, err := broker.Claim(claimCtx)
96 - errCh <- err
97 - }()
98 -
99 - <-started
100 - select {
101 - case err := <-errCh:
102 - t.Fatalf("Claim() returned before Close(): %v", err)
103 - case <-time.After(50 * time.Millisecond):
104 - }
105 -
106 - broker.Close()
107 -
108 - select {
109 - case err := <-errCh:
110 - if !errors.Is(err, errBrokerClosed) {
111 - t.Fatalf("Claim() error = %v, want %v", err, errBrokerClosed)
112 - }
113 - case <-time.After(brokerAsyncTestTimeout):
114 - t.Fatal("timed out waiting for closed claim")
115 - }
116 -}
117 -
118 -func TestLeaseBrokerClaimWaitsForLateOffer(t *testing.T) {
119 - t.Parallel()
120 -
121 - broker := newStreamBroker("lease-test", time.Hour, 2)
122 -
123 - serverConn, clientConn := net.Pipe()
124 - t.Cleanup(func() {
125 - _ = serverConn.Close()
126 - _ = clientConn.Close()
127 - })
128 -
129 - session := newReverseSession(serverConn, time.Hour)
130 - claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
131 - defer cancel()
132 -
133 - markerCh := make(chan byte, 1)
134 - errCh := make(chan error, 1)
135 - go func() {
136 - var marker [1]byte
137 - if _, err := io.ReadFull(clientConn, marker[:]); err != nil {
138 - errCh <- err
139 - return
140 - }
141 - markerCh <- marker[0]
142 - }()
143 -
144 - type claimResult struct {
145 - session *reverseSession
146 - err error
147 - }
148 - started := make(chan struct{})
149 - resultCh := make(chan claimResult, 1)
150 - go func() {
151 - close(started)
152 - claimed, err := broker.Claim(claimCtx)
153 - resultCh <- claimResult{session: claimed, err: err}
154 - }()
155 -
156 - <-started
157 - select {
158 - case result := <-resultCh:
159 - t.Fatalf("Claim() returned before Offer(): %#v", result)
160 - case <-time.After(50 * time.Millisecond):
161 - }
162 -
163 - if err := broker.Offer(session); err != nil {
164 - t.Fatalf("Offer() error = %v", err)
165 - }
166 -
167 - select {
168 - case result := <-resultCh:
169 - if result.err != nil {
170 - t.Fatalf("Claim() error = %v", result.err)
171 - }
172 - if result.session != session {
173 - t.Fatalf("Claim() returned unexpected session")
174 - }
175 - select {
176 - case err := <-errCh:
177 - t.Fatalf("ReadFull() error = %v", err)
178 - case marker := <-markerCh:
179 - if marker != types.MarkerTLSStart {
180 - t.Fatalf("marker = 0x%02x, want 0x%02x", marker, types.MarkerTLSStart)
181 - }
182 - case <-time.After(brokerAsyncTestTimeout):
183 - t.Fatal("timed out waiting for activation marker")
184 - }
185 - case <-time.After(brokerAsyncTestTimeout):
186 - t.Fatal("timed out waiting for claim")
187 - }
188 -}
189 -
190 -func TestLeaseBrokerClaimTimesOutWithoutSessions(t *testing.T) {
191 - t.Parallel()
192 -
193 - broker := newStreamBroker("lease-test", time.Hour, 2)
194 -
195 - claimCtx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
196 - defer cancel()
197 -
198 - start := time.Now()
199 - _, err := broker.Claim(claimCtx)
200 - if !errors.Is(err, context.DeadlineExceeded) {
201 - t.Fatalf("Claim() error = %v, want %v", err, context.DeadlineExceeded)
202 - }
203 - if time.Since(start) < 40*time.Millisecond {
204 - t.Fatalf("Claim() returned too early: %v", time.Since(start))
205 - }
206 -}
portal/datagram/flow.go deleted
-193
@@ -1,193 +0,0 @@
1 -package datagram
2 -
3 -import (
4 - "sync"
5 - "time"
6 -
7 - "github.com/quic-go/quic-go"
8 - "github.com/rs/zerolog/log"
9 -
10 - "github.com/gosuda/portal/v2/types"
11 -)
12 -
13 -const (
14 - defaultFlowIdleTimeout = 30 * time.Second
15 - defaultFlowCleanupInterval = 30 * time.Second
16 -)
17 -
18 -type flowReplyFunc func([]byte) error
19 -
20 -type flowState struct {
21 - key string
22 - lastSeen time.Time
23 - reply flowReplyFunc
24 -}
25 -
26 -// FlowMux manages a single QUIC connection from a tunnel for one lease.
27 -// All UDP traffic for the lease is multiplexed over DATAGRAM frames on this
28 -// connection, identified by flow IDs.
29 -type FlowMux struct {
30 - leaseID string
31 -
32 - session *Session
33 - flowTable map[uint32]*flowState // flowID -> client addr + liveness + reply path
34 - addrIndex map[string]uint32 // ingress key -> flowID
35 - nextFlow uint32
36 - mu sync.Mutex
37 -}
38 -
39 -func NewFlowMux(leaseID string) *FlowMux {
40 - mux := &FlowMux{
41 - leaseID: leaseID,
42 - session: NewSession(256, true, func(err error) {
43 - log.Warn().
44 - Err(err).
45 - Str("component", "quic-flow-mux").
46 - Str("lease_id", leaseID).
47 - Msg("quic receive loop ended")
48 - }),
49 - flowTable: make(map[uint32]*flowState),
50 - addrIndex: make(map[string]uint32),
51 - nextFlow: 1,
52 - }
53 - go mux.runDispatchLoop()
54 - go mux.runCleanupLoop()
55 - return mux
56 -}
57 -
58 -// Register stores the QUIC connection from the tunnel for this lease.
59 -// Replaces any existing connection.
60 -func (b *FlowMux) Register(conn *quic.Conn) error {
61 - if _, err := b.session.Bind(conn); err != nil {
62 - return err
63 - }
64 -
65 - log.Info().
66 - Str("component", "quic-flow-mux").
67 - Str("lease_id", b.leaseID).
68 - Str("remote_addr", conn.RemoteAddr().String()).
69 - Msg("quic tunnel connection registered")
70 - return nil
71 -}
72 -
73 -// HasConnection reports whether a tunnel QUIC connection is active.
74 -func (b *FlowMux) HasConnection() bool {
75 - return b.session.HasConnection()
76 -}
77 -
78 -// SendDatagram encodes a flow-framed datagram and sends it to the tunnel.
79 -func (b *FlowMux) SendDatagram(flowID uint32, payload []byte) error {
80 - return b.session.Send(flowID, payload)
81 -}
82 -
83 -// TouchFlow assigns a flow ID for an ingress key and updates its liveness and reply path.
84 -// If the key already has a flow, the existing ID is returned.
85 -func (b *FlowMux) TouchFlow(key string, reply flowReplyFunc) uint32 {
86 - now := time.Now()
87 -
88 - b.mu.Lock()
89 - defer b.mu.Unlock()
90 -
91 - if id, ok := b.addrIndex[key]; ok {
92 - if flow, exists := b.flowTable[id]; exists && flow != nil {
93 - flow.lastSeen = now
94 - if reply != nil {
95 - flow.reply = reply
96 - }
97 - return id
98 - }
99 - delete(b.addrIndex, key)
100 - }
101 -
102 - id := b.nextFlow
103 - b.nextFlow++
104 - b.flowTable[id] = &flowState{
105 - key: key,
106 - lastSeen: now,
107 - reply: reply,
108 - }
109 - b.addrIndex[key] = id
110 - return id
111 -}
112 -
113 -func (b *FlowMux) runDispatchLoop() {
114 - incoming := b.session.Incoming()
115 - for {
116 - select {
117 - case <-b.session.Done():
118 - return
119 - case frame := <-incoming:
120 - b.dispatch(frame)
121 - }
122 - }
123 -}
124 -
125 -func (b *FlowMux) dispatch(frame types.DatagramFrame) {
126 - b.mu.Lock()
127 - flow, ok := b.flowTable[frame.FlowID]
128 - if !ok || flow == nil || flow.reply == nil {
129 - b.mu.Unlock()
130 - return
131 - }
132 -
133 - flow.lastSeen = time.Now()
134 - reply := flow.reply
135 - b.mu.Unlock()
136 -
137 - if err := reply(frame.Payload); err != nil {
138 - log.Warn().
139 - Err(err).
140 - Str("component", "quic-flow-mux").
141 - Str("lease_id", b.leaseID).
142 - Uint32("flow_id", frame.FlowID).
143 - Msg("flow writeback failed")
144 - b.forgetFlow(frame.FlowID)
145 - }
146 -}
147 -
148 -func (b *FlowMux) runCleanupLoop() {
149 - ticker := time.NewTicker(defaultFlowCleanupInterval)
150 - defer ticker.Stop()
151 -
152 - for {
153 - select {
154 - case <-b.session.Done():
155 - return
156 - case now := <-ticker.C:
157 - b.expireIdleFlows(now)
158 - }
159 - }
160 -}
161 -
162 -func (b *FlowMux) expireIdleFlows(now time.Time) {
163 - b.mu.Lock()
164 - defer b.mu.Unlock()
165 -
166 - for flowID, flow := range b.flowTable {
167 - if flow == nil || now.Sub(flow.lastSeen) > defaultFlowIdleTimeout {
168 - if flow != nil {
169 - delete(b.addrIndex, flow.key)
170 - }
171 - delete(b.flowTable, flowID)
172 - }
173 - }
174 -}
175 -
176 -func (b *FlowMux) forgetFlow(flowID uint32) {
177 - b.mu.Lock()
178 - defer b.mu.Unlock()
179 -
180 - flow, ok := b.flowTable[flowID]
181 - if !ok {
182 - return
183 - }
184 - if flow != nil {
185 - delete(b.addrIndex, flow.key)
186 - }
187 - delete(b.flowTable, flowID)
188 -}
189 -
190 -// Stop tears down the QUIC connection and signals done.
191 -func (b *FlowMux) Stop() {
192 - b.session.Stop("lease stopped")
193 -}
portal/datagram/port_allocator.go deleted
-97
@@ -1,97 +0,0 @@
1 -package datagram
2 -
3 -import (
4 - "errors"
5 - "sort"
6 - "sync"
7 - "time"
8 -)
9 -
10 -var ErrPortExhausted = errors.New("no udp ports available")
11 -
12 -type portReservation struct {
13 - port int
14 - expiresAt time.Time
15 -}
16 -
17 -// PortAllocator manages a pool of UDP ports for dynamic per-lease allocation.
18 -type PortAllocator struct {
19 - available []int
20 - inUse map[int]string
21 - reserved map[string]portReservation
22 - grace time.Duration
23 - mu sync.Mutex
24 -}
25 -
26 -func NewPortAllocator(min, max int, grace time.Duration) *PortAllocator {
27 - available := make([]int, 0, max-min+1)
28 - for p := min; p <= max; p++ {
29 - available = append(available, p)
30 - }
31 - return &PortAllocator{
32 - available: available,
33 - inUse: make(map[int]string),
34 - reserved: make(map[string]portReservation),
35 - grace: grace,
36 - }
37 -}
38 -
39 -func (a *PortAllocator) Allocate(name string) (int, error) {
40 - a.mu.Lock()
41 - defer a.mu.Unlock()
42 -
43 - a.cleanupExpiredLocked(time.Now())
44 -
45 - if res, ok := a.reserved[name]; ok {
46 - delete(a.reserved, name)
47 - a.inUse[res.port] = name
48 - return res.port, nil
49 - }
50 -
51 - if len(a.available) == 0 {
52 - return 0, ErrPortExhausted
53 - }
54 -
55 - port := a.available[0]
56 - a.available = a.available[1:]
57 - a.inUse[port] = name
58 - return port, nil
59 -}
60 -
61 -func (a *PortAllocator) Release(port int) {
62 - a.mu.Lock()
63 - defer a.mu.Unlock()
64 -
65 - name, ok := a.inUse[port]
66 - if !ok {
67 - return
68 - }
69 - delete(a.inUse, port)
70 -
71 - if prev, exists := a.reserved[name]; exists {
72 - a.sortedInsertLocked(prev.port)
73 - }
74 -
75 - a.reserved[name] = portReservation{
76 - port: port,
77 - expiresAt: time.Now().Add(a.grace),
78 - }
79 -
80 - a.cleanupExpiredLocked(time.Now())
81 -}
82 -
83 -func (a *PortAllocator) cleanupExpiredLocked(now time.Time) {
84 - for name, res := range a.reserved {
85 - if now.After(res.expiresAt) {
86 - delete(a.reserved, name)
87 - a.sortedInsertLocked(res.port)
88 - }
89 - }
90 -}
91 -
92 -func (a *PortAllocator) sortedInsertLocked(port int) {
93 - i := sort.SearchInts(a.available, port)
94 - a.available = append(a.available, 0)
95 - copy(a.available[i+1:], a.available[i:])
96 - a.available[i] = port
97 -}
portal/datagram/relay.go deleted
-119
@@ -1,119 +0,0 @@
1 -package datagram
2 -
3 -import (
4 - "context"
5 - "errors"
6 - "fmt"
7 - "net"
8 - "sync"
9 - "time"
10 -
11 - "github.com/rs/zerolog/log"
12 -)
13 -
14 -const DefaultMaxPacketSize = 1350
15 -
16 -// Relay binds a UDP port for a lease and relays datagrams bidirectionally
17 -// between raw UDP clients and the tunnel's QUIC connection via the flow mux.
18 -type Relay struct {
19 - leaseID string
20 - port int
21 - flowMux *FlowMux
22 - conn *net.UDPConn
23 -
24 - cancel context.CancelFunc
25 - closeOnce sync.Once
26 -}
27 -
28 -func NewRelay(leaseID string, port int, flowMux *FlowMux) *Relay {
29 - return &Relay{
30 - leaseID: leaseID,
31 - port: port,
32 - flowMux: flowMux,
33 - }
34 -}
35 -
36 -func (r *Relay) Start(ctx context.Context) error {
37 - addr := &net.UDPAddr{Port: r.port}
38 - conn, err := net.ListenUDP("udp", addr)
39 - if err != nil {
40 - return fmt.Errorf("listen udp :%d: %w", r.port, err)
41 - }
42 - r.conn = conn
43 -
44 - relayCtx, cancel := context.WithCancel(ctx)
45 - r.cancel = cancel
46 -
47 - go r.readLoop(relayCtx)
48 -
49 - log.Info().
50 - Str("component", "udp-relay").
51 - Str("lease_id", r.leaseID).
52 - Int("port", r.port).
53 - Msg("udp relay started")
54 -
55 - return nil
56 -}
57 -
58 -func (r *Relay) Stop() {
59 - r.closeOnce.Do(func() {
60 - if r.cancel != nil {
61 - r.cancel()
62 - }
63 - if r.conn != nil {
64 - _ = r.conn.Close()
65 - }
66 - log.Info().
67 - Str("component", "udp-relay").
68 - Str("lease_id", r.leaseID).
69 - Int("port", r.port).
70 - Msg("udp relay stopped")
71 - })
72 -}
73 -
74 -func (r *Relay) readLoop(ctx context.Context) {
75 - buf := make([]byte, DefaultMaxPacketSize)
76 - for {
77 - select {
78 - case <-ctx.Done():
79 - return
80 - default:
81 - }
82 -
83 - _ = r.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
84 - n, clientAddr, err := r.conn.ReadFromUDP(buf)
85 - if err != nil {
86 - if ctx.Err() != nil {
87 - return
88 - }
89 - var netErr net.Error
90 - if errors.As(err, &netErr) && netErr.Timeout() {
91 - continue
92 - }
93 - log.Warn().
94 - Str("component", "udp-relay").
95 - Str("lease_id", r.leaseID).
96 - Err(err).
97 - Msg("readLoop exiting: unexpected read error")
98 - return
99 - }
100 -
101 - flowID := r.flowMux.TouchFlow("udp:"+clientAddr.String(), func(payload []byte) error {
102 - _, err := r.conn.WriteToUDP(payload, clientAddr)
103 - return err
104 - })
105 - payload := make([]byte, n)
106 - copy(payload, buf[:n])
107 -
108 - if err := r.flowMux.SendDatagram(flowID, payload); err != nil {
109 - log.Warn().
110 - Str("component", "udp-relay").
111 - Str("lease_id", r.leaseID).
112 - Err(err).
113 - Uint32("flow_id", flowID).
114 - Int("bytes", n).
115 - Msg("send datagram to tunnel failed, dropping packet")
116 - continue
117 - }
118 - }
119 -}
portal/datagram/sni_parse.go deleted
-350
@@ -1,350 +0,0 @@
1 -package datagram
2 -
3 -import (
4 - "crypto"
5 - "crypto/aes"
6 - "crypto/cipher"
7 - "encoding/binary"
8 - "errors"
9 - "fmt"
10 -
11 - "golang.org/x/crypto/hkdf"
12 -)
13 -
14 -var quicV1InitialSalt = []byte{
15 - 0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
16 - 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
17 - 0xcc, 0xbb, 0x7f, 0x0a,
18 -}
19 -
20 -var errNotQUICInitial = errors.New("not a quic initial packet")
21 -var errSNINotFound = errors.New("sni not found in quic initial")
22 -
23 -func ParseQUICInitialSNI(packet []byte) (string, error) {
24 - if len(packet) < 5 {
25 - return "", errNotQUICInitial
26 - }
27 -
28 - firstByte := packet[0]
29 - if firstByte&0x80 == 0 {
30 - return "", errNotQUICInitial
31 - }
32 -
33 - packetType := (firstByte & 0x30) >> 4
34 - if packetType != 0 {
35 - return "", errNotQUICInitial
36 - }
37 -
38 - version := binary.BigEndian.Uint32(packet[1:5])
39 - if version == 0 {
40 - return "", errNotQUICInitial
41 - }
42 -
43 - offset := 5
44 - if offset >= len(packet) {
45 - return "", errNotQUICInitial
46 - }
47 - dcidLen := int(packet[offset])
48 - offset++
49 - if offset+dcidLen > len(packet) {
50 - return "", errNotQUICInitial
51 - }
52 - dcid := packet[offset : offset+dcidLen]
53 - offset += dcidLen
54 -
55 - if offset >= len(packet) {
56 - return "", errNotQUICInitial
57 - }
58 - scidLen := int(packet[offset])
59 - offset++
60 - offset += scidLen
61 - if offset > len(packet) {
62 - return "", errNotQUICInitial
63 - }
64 -
65 - tokenLen, n := readVarint(packet[offset:])
66 - if n <= 0 {
67 - return "", errNotQUICInitial
68 - }
69 - offset += n + int(tokenLen)
70 - if offset > len(packet) {
71 - return "", errNotQUICInitial
72 - }
73 -
74 - payloadLen, n := readVarint(packet[offset:])
75 - if n <= 0 {
76 - return "", errNotQUICInitial
77 - }
78 - offset += n
79 - _ = payloadLen
80 -
81 - clientSecret, err := deriveInitialClientSecret(dcid, version)
82 - if err != nil {
83 - return "", fmt.Errorf("derive initial secret: %w", err)
84 - }
85 -
86 - hp, err := deriveHPKey(clientSecret)
87 - if err != nil {
88 - return "", fmt.Errorf("derive hp key: %w", err)
89 - }
90 -
91 - key, err := deriveKey(clientSecret)
92 - if err != nil {
93 - return "", fmt.Errorf("derive key: %w", err)
94 - }
95 -
96 - iv, err := deriveIV(clientSecret)
97 - if err != nil {
98 - return "", fmt.Errorf("derive iv: %w", err)
99 - }
100 -
101 - pnOffset := offset
102 - sampleOffset := pnOffset + 4
103 - if sampleOffset+16 > len(packet) {
104 - return "", errNotQUICInitial
105 - }
106 - sample := packet[sampleOffset : sampleOffset+16]
107 -
108 - block, err := aes.NewCipher(hp)
109 - if err != nil {
110 - return "", fmt.Errorf("aes cipher: %w", err)
111 - }
112 - mask := make([]byte, aes.BlockSize)
113 - block.Encrypt(mask, sample)
114 -
115 - unmaskedFirst := packet[0] ^ (mask[0] & 0x0f)
116 - pnLength := int(unmaskedFirst&0x03) + 1
117 -
118 - pnBytes := make([]byte, pnLength)
119 - for i := range pnLength {
120 - pnBytes[i] = packet[pnOffset+i] ^ mask[1+i]
121 - }
122 -
123 - var pn uint32
124 - for _, b := range pnBytes {
125 - pn = (pn << 8) | uint32(b)
126 - }
127 -
128 - nonce := make([]byte, len(iv))
129 - copy(nonce, iv)
130 - for i := range nonce {
131 - if i >= len(nonce)-4 {
132 - nonce[i] ^= byte(pn >> (8 * (len(nonce) - 1 - i)))
133 - }
134 - }
135 -
136 - payloadOffset := pnOffset + pnLength
137 - if payloadOffset >= len(packet) {
138 - return "", errNotQUICInitial
139 - }
140 -
141 - aad := make([]byte, payloadOffset)
142 - copy(aad, packet[:payloadOffset])
143 - aad[0] = unmaskedFirst
144 - copy(aad[pnOffset:], pnBytes)
145 -
146 - aeadBlock, err := aes.NewCipher(key)
147 - if err != nil {
148 - return "", fmt.Errorf("aead cipher: %w", err)
149 - }
150 - aead, err := cipher.NewGCM(aeadBlock)
151 - if err != nil {
152 - return "", fmt.Errorf("gcm: %w", err)
153 - }
154 -
155 - plaintext, err := aead.Open(nil, nonce, packet[payloadOffset:], aad)
156 - if err != nil {
157 - return "", fmt.Errorf("decrypt initial payload: %w", err)
158 - }
159 -
160 - return extractSNIFromCryptoFrames(plaintext)
161 -}
162 -
163 -func extractSNIFromCryptoFrames(frames []byte) (string, error) {
164 - offset := 0
165 - for offset < len(frames) {
166 - frameType := frames[offset]
167 - offset++
168 -
169 - switch frameType {
170 - case 0x00, 0x01:
171 - continue
172 - case 0x06:
173 - _, n := readVarint(frames[offset:])
174 - if n <= 0 {
175 - return "", errSNINotFound
176 - }
177 - offset += n
178 -
179 - dataLen, n := readVarint(frames[offset:])
180 - if n <= 0 {
181 - return "", errSNINotFound
182 - }
183 - offset += n
184 -
185 - if offset+int(dataLen) > len(frames) {
186 - return "", errSNINotFound
187 - }
188 - cryptoData := frames[offset : offset+int(dataLen)]
189 - offset += int(dataLen)
190 -
191 - sni, err := parseTLSClientHelloSNI(cryptoData)
192 - if err == nil {
193 - return sni, nil
194 - }
195 - default:
196 - return "", errSNINotFound
197 - }
198 - }
199 - return "", errSNINotFound
200 -}
201 -
202 -func parseTLSClientHelloSNI(data []byte) (string, error) {
203 - if len(data) < 4 || data[0] != 0x01 {
204 - return "", errSNINotFound
205 - }
206 - msgLen := int(data[1])<<16 | int(data[2])<<8 | int(data[3])
207 - if len(data) < 4+msgLen {
208 - return "", errSNINotFound
209 - }
210 - body := data[4 : 4+msgLen]
211 -
212 - if len(body) < 34 {
213 - return "", errSNINotFound
214 - }
215 - offset := 34
216 -
217 - if offset >= len(body) {
218 - return "", errSNINotFound
219 - }
220 - sessionIDLen := int(body[offset])
221 - offset += 1 + sessionIDLen
222 -
223 - if offset+2 > len(body) {
224 - return "", errSNINotFound
225 - }
226 - cipherSuitesLen := int(body[offset])<<8 | int(body[offset+1])
227 - offset += 2 + cipherSuitesLen
228 -
229 - if offset >= len(body) {
230 - return "", errSNINotFound
231 - }
232 - compMethodsLen := int(body[offset])
233 - offset += 1 + compMethodsLen
234 -
235 - if offset+2 > len(body) {
236 - return "", errSNINotFound
237 - }
238 - extensionsLen := int(body[offset])<<8 | int(body[offset+1])
239 - offset += 2
240 -
241 - extEnd := min(offset+extensionsLen, len(body))
242 -
243 - for offset+4 <= extEnd {
244 - extType := int(body[offset])<<8 | int(body[offset+1])
245 - extLen := int(body[offset+2])<<8 | int(body[offset+3])
246 - offset += 4
247 -
248 - if offset+extLen > extEnd {
249 - return "", errSNINotFound
250 - }
251 - if extType == 0x0000 {
252 - return parseSNIExtension(body[offset : offset+extLen])
253 - }
254 - offset += extLen
255 - }
256 -
257 - return "", errSNINotFound
258 -}
259 -
260 -func parseSNIExtension(data []byte) (string, error) {
261 - if len(data) < 2 {
262 - return "", errSNINotFound
263 - }
264 - listLen := int(data[0])<<8 | int(data[1])
265 - offset := 2
266 - end := min(offset+listLen, len(data))
267 -
268 - for offset+3 <= end {
269 - nameType := data[offset]
270 - nameLen := int(data[offset+1])<<8 | int(data[offset+2])
271 - offset += 3
272 - if nameType == 0x00 {
273 - if offset+nameLen > end {
274 - return "", errSNINotFound
275 - }
276 - return string(data[offset : offset+nameLen]), nil
277 - }
278 - offset += nameLen
279 - }
280 - return "", errSNINotFound
281 -}
282 -
283 -func deriveInitialClientSecret(dcid []byte, version uint32) ([]byte, error) {
284 - salt := quicV1InitialSalt
285 -
286 - initialSecret := hkdf.Extract(crypto.SHA256.New, dcid, salt)
287 -
288 - clientInitialSecret := make([]byte, 32)
289 - r := hkdf.Expand(crypto.SHA256.New, initialSecret, hkdfLabel([]byte("client in"), 32))
290 - if _, err := r.Read(clientInitialSecret); err != nil {
291 - return nil, err
292 - }
293 - return clientInitialSecret, nil
294 -}
295 -
296 -func deriveHPKey(secret []byte) ([]byte, error) {
297 - hp := make([]byte, 16)
298 - r := hkdf.Expand(crypto.SHA256.New, secret, hkdfLabel([]byte("quic hp"), 16))
299 - if _, err := r.Read(hp); err != nil {
300 - return nil, err
301 - }
302 - return hp, nil
303 -}
304 -
305 -func deriveKey(secret []byte) ([]byte, error) {
306 - key := make([]byte, 16)
307 - r := hkdf.Expand(crypto.SHA256.New, secret, hkdfLabel([]byte("quic key"), 16))
308 - if _, err := r.Read(key); err != nil {
309 - return nil, err
310 - }
311 - return key, nil
312 -}
313 -
314 -func deriveIV(secret []byte) ([]byte, error) {
315 - iv := make([]byte, 12)
316 - r := hkdf.Expand(crypto.SHA256.New, secret, hkdfLabel([]byte("quic iv"), 12))
317 - if _, err := r.Read(iv); err != nil {
318 - return nil, err
319 - }
320 - return iv, nil
321 -}
322 -
323 -func hkdfLabel(label []byte, length int) []byte {
324 - fullLabel := append([]byte("tls13 "), label...)
325 - out := make([]byte, 2+1+len(fullLabel)+1)
326 - out[0] = byte(length >> 8)
327 - out[1] = byte(length)
328 - out[2] = byte(len(fullLabel))
329 - copy(out[3:], fullLabel)
330 - out[3+len(fullLabel)] = 0
331 - return out
332 -}
333 -
334 -func readVarint(data []byte) (uint64, int) {
335 - if len(data) == 0 {
336 - return 0, -1
337 - }
338 - prefix := data[0] >> 6
339 - length := 1 << prefix
340 -
341 - if len(data) < length {
342 - return 0, -1
343 - }
344 -
345 - val := uint64(data[0] & 0x3f)
346 - for i := 1; i < length; i++ {
347 - val = (val << 8) | uint64(data[i])
348 - }
349 - return val, length
350 -}
portal/lease.go
+20 -145
@@ -8,8 +8,8 @@ import (
8 "sync"
9 "time"
10
11 - portaldatagram "github.com/gosuda/portal/v2/portal/datagram"
11 "github.com/gosuda/portal/v2/portal/policy"
12 + "github.com/gosuda/portal/v2/portal/transport"
13 "github.com/gosuda/portal/v2/types"
14 "github.com/gosuda/portal/v2/utils"
15 )
@@ -221,8 +221,12 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
221 snapshot.Metadata = snapshot.Metadata.Copy()
222 clientIP := record.ClientIP
223 snapshot.BPS = r.policy.BPSManager().LeaseBPS(record.ID)
224 - snapshot.Ready = record.ReadyCount()
225 - snapshot.UDPPort = record.UDPPort()
224 + if record.stream != nil {
225 + snapshot.Ready = record.stream.ReadyCount()
226 + }
227 + if record.datagram != nil {
228 + snapshot.UDPPort = record.datagram.UDPPort()
229 + }
230 snapshot.IsApproved = r.policy.EffectiveApproval(record.ID)
231 snapshot.IsBanned = r.policy.IsLeaseBanned(record.ID)
232 snapshot.IsDenied = r.policy.IsLeaseDenied(record.ID)
@@ -233,169 +237,40 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
237 type leaseRecord struct {
238 types.Lease
239 ReverseToken string
236 - Runtime *leaseRuntime
237 -}
238 -
239 -func (r *leaseRecord) SupportsDatagram() bool {
240 - return r != nil && r.Runtime != nil && r.Runtime.SupportsDatagram()
241 -}
242 -
243 -func (r *leaseRecord) SupportsStream() bool {
244 - return r != nil && r.Runtime != nil && r.Runtime.SupportsStream()
245 -}
246 -
247 -func (r *leaseRecord) ReadyCount() int {
248 - if r == nil || r.Runtime == nil {
249 - return 0
250 - }
251 - return r.Runtime.ReadyCount()
252 -}
253 -
254 -func (r *leaseRecord) StreamBroker() *streamBroker {
255 - if r == nil || r.Runtime == nil {
256 - return nil
257 - }
258 - return r.Runtime.StreamBroker()
259 -}
260 -
261 -func (r *leaseRecord) DatagramFlowMux() *portaldatagram.FlowMux {
262 - if r == nil || r.Runtime == nil {
263 - return nil
264 - }
265 - return r.Runtime.DatagramFlowMux()
266 -}
267 -
268 -func (r *leaseRecord) UDPPort() int {
269 - if r == nil || r.Runtime == nil {
270 - return 0
271 - }
272 - return r.Runtime.UDPPort()
273 -}
274 -
275 -type leaseRuntime struct {
276 - capabilities types.LeaseCapabilities
277 - datagram *leaseDatagramRuntime
240 + datagram *transport.RelayDatagram
241 + ports *transport.PortAllocator
242 + stream *transport.RelayStream
243 startErr error
244 startOnce sync.Once
280 - stream *leaseStreamRuntime
281 -}
282 -
283 -type leaseRuntimeConfig struct {
284 - Capabilities types.LeaseCapabilities
285 - IdleInterval time.Duration
286 - LeaseID string
287 - LeaseName string
288 - PortAllocator *portaldatagram.PortAllocator
289 - ReadyLimit int
290 -}
291 -
292 -type leaseStreamRuntime struct {
293 - broker *streamBroker
294 -}
295 -
296 -type leaseDatagramRuntime struct {
297 - flowMux *portaldatagram.FlowMux
298 - port int
299 - relay *portaldatagram.Relay
300 -}
301 -
302 -func newLeaseRuntime(cfg leaseRuntimeConfig) (*leaseRuntime, error) {
303 - runtime := &leaseRuntime{capabilities: cfg.Capabilities}
304 -
305 - if cfg.Capabilities.SupportsStream() {
306 - runtime.stream = &leaseStreamRuntime{
307 - broker: newStreamBroker(cfg.LeaseID, cfg.IdleInterval, cfg.ReadyLimit),
308 - }
309 - }
310 -
311 - if cfg.Capabilities.SupportsDatagram() {
312 - if cfg.PortAllocator == nil {
313 - return nil, errors.New("udp port allocation not available")
314 - }
315 - port, err := cfg.PortAllocator.Allocate(cfg.LeaseName)
316 - if err != nil {
317 - return nil, fmt.Errorf("allocate udp port: %w", err)
318 - }
319 -
320 - flowMux := portaldatagram.NewFlowMux(cfg.LeaseID)
321 - runtime.datagram = &leaseDatagramRuntime{
322 - flowMux: flowMux,
323 - port: port,
324 - relay: portaldatagram.NewRelay(cfg.LeaseID, port, flowMux),
325 - }
326 - }
327 -
328 - return runtime, nil
245 }
246
331 -func (r *leaseRuntime) Start() error {
332 - if r == nil || r.datagram == nil || r.datagram.relay == nil {
247 +func (r *leaseRecord) Start() error {
248 + if r == nil || r.datagram == nil {
249 return nil
250 }
251
252 r.startOnce.Do(func() {
337 - r.startErr = r.datagram.relay.Start(context.Background())
253 + r.startErr = r.datagram.Start(context.Background())
254 })
339 -
255 return r.startErr
256 }
257
343 -func (r *leaseRuntime) Close(ports *portaldatagram.PortAllocator) {
258 +func (r *leaseRecord) Close() {
259 if r == nil {
260 return
261 }
347 - if r.stream != nil && r.stream.broker != nil {
348 - r.stream.broker.Close()
262 + if r.stream != nil {
263 + r.stream.Close()
264 }
265 if r.datagram != nil {
351 - if r.datagram.flowMux != nil {
352 - r.datagram.flowMux.Stop()
353 - }
354 - if r.datagram.relay != nil {
355 - r.datagram.relay.Stop()
356 - }
357 - if r.datagram.port > 0 && ports != nil {
358 - ports.Release(r.datagram.port)
266 + port := r.datagram.UDPPort()
267 + r.datagram.Close()
268 + if port > 0 && r.ports != nil {
269 + r.ports.Release(port)
270 }
271 }
272 }
273
363 -func (r *leaseRuntime) SupportsDatagram() bool {
364 - return r != nil && r.capabilities.SupportsDatagram()
365 -}
366 -
367 -func (r *leaseRuntime) SupportsStream() bool {
368 - return r != nil && r.capabilities.SupportsStream()
369 -}
370 -
371 -func (r *leaseRuntime) ReadyCount() int {
372 - if r == nil || r.stream == nil || r.stream.broker == nil {
373 - return 0
374 - }
375 - return r.stream.broker.ReadyCount()
376 -}
377 -
378 -func (r *leaseRuntime) StreamBroker() *streamBroker {
379 - if r == nil || r.stream == nil {
380 - return nil
381 - }
382 - return r.stream.broker
383 -}
384 -
385 -func (r *leaseRuntime) DatagramFlowMux() *portaldatagram.FlowMux {
386 - if r == nil || r.datagram == nil {
387 - return nil
388 - }
389 - return r.datagram.flowMux
390 -}
391 -
392 -func (r *leaseRuntime) UDPPort() int {
393 - if r == nil || r.datagram == nil {
394 - return 0
395 - }
396 - return r.datagram.port
397 -}
398 -
274 type routeTable struct {
275 exact map[string]string
276 }
portal/lease_test.go
+11 -18
@@ -3,22 +3,15 @@ package portal
3 import (
4 "context"
5 "errors"
6 + "net"
7 "testing"
8 "time"
9
10 "github.com/gosuda/portal/v2/portal/policy"
11 + "github.com/gosuda/portal/v2/portal/transport"
12 "github.com/gosuda/portal/v2/types"
13 )
14
13 -func newTestStreamLeaseRuntime(leaseID string) *leaseRuntime {
14 - return &leaseRuntime{
15 - capabilities: types.LeaseCapabilities{Stream: true},
16 - stream: &leaseStreamRuntime{
17 - broker: newStreamBroker(leaseID, time.Minute, 1),
18 - },
19 - }
20 -}
21 -
15 func TestLeaseRegistryLifecycle(t *testing.T) {
16 t.Parallel()
17
@@ -31,7 +24,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
24 ExpiresAt: time.Now().Add(30 * time.Second),
25 },
26 ReverseToken: "tok_1",
34 - Runtime: newTestStreamLeaseRuntime("lease_1"),
27 + stream: transport.NewRelayStream("lease_1", time.Minute, 1),
28 }
29
30 if err := registry.Register(record); err != nil {
@@ -81,7 +74,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
74 ExpiresAt: time.Now().Add(30 * time.Second),
75 },
76 ReverseToken: "tok_wildcard",
84 - Runtime: newTestStreamLeaseRuntime("lease_wildcard"),
77 + stream: transport.NewRelayStream("lease_wildcard", time.Minute, 1),
78 }
79 if err := registry.Register(wildcardLease); err != nil {
80 t.Fatalf("Register(wildcard) error = %v", err)
@@ -101,7 +94,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
94 ExpiresAt: time.Now().Add(30 * time.Second),
95 },
96 ReverseToken: "tok_conflict",
104 - Runtime: newTestStreamLeaseRuntime("lease_conflict"),
97 + stream: transport.NewRelayStream("lease_conflict", time.Minute, 1),
98 }
99 err := registry.Register(conflict)
100 if !errors.Is(err, errHostnameConflict) {
@@ -127,7 +120,7 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
120 ClientIP: "203.0.113.20",
121 },
122 ReverseToken: "tok_policy",
130 - Runtime: newTestStreamLeaseRuntime("lease_policy"),
123 + stream: transport.NewRelayStream("lease_policy", time.Minute, 1),
124 }
125 if err := registry.Register(record); err != nil {
126 t.Fatalf("Register() error = %v", err)
@@ -161,7 +154,7 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
154
155 registry := newLeaseRegistry(policy.NewRuntime())
156 registry.onExpired = func(r *leaseRecord) {
164 - r.Runtime.Close(nil)
157 + r.Close()
158 }
159 record := &leaseRecord{
160 Lease: types.Lease{
@@ -170,21 +163,21 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
163 ExpiresAt: time.Now().Add(-time.Second),
164 },
165 ReverseToken: "tok_expired",
173 - Runtime: newTestStreamLeaseRuntime("lease_expired"),
166 + stream: transport.NewRelayStream("lease_expired", time.Minute, 1),
167 }
168 if err := registry.Register(record); err != nil {
169 t.Fatalf("Register() error = %v", err)
170 }
171
172 for _, lease := range registry.removeExpired(time.Now()) {
180 - lease.Runtime.Close(nil)
173 + lease.Close()
174 }
175
176 if _, ok := registry.Lookup("expired.example.com"); ok {
177 t.Fatal("Lookup() after removeExpired() = true, want false")
178 }
186 - if _, err := record.StreamBroker().Claim(context.Background()); !errors.Is(err, errBrokerClosed) {
187 - t.Fatalf("Claim() after removeExpired() error = %v, want %v", err, errBrokerClosed)
179 + if _, err := record.stream.Claim(context.Background()); !errors.Is(err, net.ErrClosed) {
180 + t.Fatalf("Claim() after removeExpired() error = %v, want %v", err, net.ErrClosed)
181 }
182 }
183
portal/server.go
+96 -304
@@ -2,7 +2,7 @@ package portal
2
3 import (
4 "context"
5 - "encoding/json"
5 + "crypto/tls"
6 "errors"
7 "fmt"
8 "io"
@@ -11,47 +11,37 @@ import (
11 "sync"
12 "time"
13
14 - "crypto/tls"
15 -
14 "github.com/gosuda/keyless_tls/relay/l4"
15 "github.com/quic-go/quic-go"
16 "github.com/rs/zerolog/log"
17 "golang.org/x/sync/errgroup"
18
19 "github.com/gosuda/portal/v2/portal/acme"
22 - "github.com/gosuda/portal/v2/portal/datagram"
20 "github.com/gosuda/portal/v2/portal/keyless"
21 "github.com/gosuda/portal/v2/portal/policy"
22 + "github.com/gosuda/portal/v2/portal/transport"
23 "github.com/gosuda/portal/v2/types"
24 "github.com/gosuda/portal/v2/utils"
25 )
26
27 const (
30 - defaultLeaseTTL = 30 * time.Second
31 - defaultClaimTimeout = 10 * time.Second
32 - defaultIdleKeepalive = 15 * time.Second
33 - defaultReadyQueueLimit = 8
34 - defaultClientHelloWait = 2 * time.Second
35 - defaultControlBodyLimit = 4 << 20
36 - defaultSessionWriteLimit = 5 * time.Second
37 - defaultQUICSNIRouteIdle = 30 * time.Second
38 - defaultQUICSNICleanup = 5 * time.Second
39 -
40 - defaultUDPPortMin = 29000
41 - defaultUDPPortMax = 29999
28 + defaultLeaseTTL = 30 * time.Second
29 + defaultClaimTimeout = 10 * time.Second
30 + defaultIdleKeepalive = 15 * time.Second
31 + defaultReadyQueueLimit = 8
32 + defaultClientHelloWait = 2 * time.Second
33 + defaultControlBodyLimit = 4 << 20
34 + defaultUDPPortMin = 29900
35 + defaultUDPPortMax = 29999
36 )
37
44 -type quicSNIRoute struct {
45 - flowMux *datagram.FlowMux
46 - lastSeen time.Time
47 -}
48 -
38 type ServerConfig struct {
39 PortalURL string
40 ACME acme.Config
41 APIListenAddr string
42 SNIListenAddr string
43 QUICListenAddr string
44 + UDPEnabled bool
45 TrustedProxyCIDRs []*net.IPNet
46 LeaseTTL time.Duration
47 ClaimTimeout time.Duration
@@ -64,22 +54,19 @@ type ServerConfig struct {
54 }
55
56 type Server struct {
67 - sniListener net.Listener
68 - apiListener net.Listener
69 - apiServer *http.Server
70 - apiTLSClose io.Closer
71 - acmeManager *acme.Manager
72 - quicTunnel *quic.Listener
73 - quicSNI net.PacketConn
74 - cancel context.CancelFunc
75 - group *errgroup.Group
76 - registry *leaseRegistry
77 - ports *datagram.PortAllocator
78 - cfg ServerConfig
79 - rootHost string
80 - shutdownOnce sync.Once
81 - quicSNIRoutes map[string]quicSNIRoute
82 - quicSNIMu sync.RWMutex
57 + sniListener net.Listener
58 + apiListener net.Listener
59 + apiServer *http.Server
60 + apiTLSClose io.Closer
61 + acmeManager *acme.Manager
62 + quicTunnel *quic.Listener
63 + cancel context.CancelFunc
64 + group *errgroup.Group
65 + registry *leaseRegistry
66 + ports *transport.PortAllocator
67 + cfg ServerConfig
68 + rootHost string
69 + shutdownOnce sync.Once
70 }
71
72 func NewServer(cfg ServerConfig) (*Server, error) {
@@ -105,14 +92,13 @@ func NewServer(cfg ServerConfig) (*Server, error) {
92 }
93
94 registry := newLeaseRegistry(policy.NewRuntime())
108 - ports := datagram.NewPortAllocator(cfg.UDPPortMin, cfg.UDPPortMax, 5*time.Minute)
95 + ports := transport.NewPortAllocator(cfg.UDPPortMin, cfg.UDPPortMax, 5*time.Minute)
96
97 s := &Server{
111 - cfg: cfg,
112 - rootHost: rootHost,
113 - registry: registry,
114 - ports: ports,
115 - quicSNIRoutes: make(map[string]quicSNIRoute),
98 + cfg: cfg,
99 + rootHost: rootHost,
100 + registry: registry,
101 + ports: ports,
102 }
103
104 // Tear down all lease resources when leases expire via TTL janitor.
@@ -173,11 +159,10 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
159 group.Go(func() error { return s.watchContext(groupCtx) })
160 s.acmeManager.Start(serverCtx)
161
176 - if err := s.startQUICTunnelListener(apiTLS); err != nil {
177 - log.Warn().Err(err).Msg("quic tunnel listener disabled")
178 - }
179 - if err := s.startQUICSNIRouter(); err != nil {
180 - log.Warn().Err(err).Msg("quic sni router disabled")
162 + if s.cfg.UDPEnabled {
163 + if err := s.startQUICTunnelListener(apiTLS); err != nil {
164 + log.Warn().Err(err).Msg("quic tunnel listener disabled")
165 + }
166 }
167
168 return nil
@@ -204,9 +189,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
189 if s.quicTunnel != nil {
190 _ = s.quicTunnel.Close()
191 }
207 - if s.quicSNI != nil {
208 - _ = s.quicSNI.Close()
209 - }
192 if s.sniListener != nil {
193 if err := s.sniListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
194 shutdownErr = err
@@ -248,7 +230,7 @@ func (s *Server) SNIAddr() string {
230 return s.sniListener.Addr().String()
231 }
232
251 -func (s *Server) QUICAddr() string {
233 +func (s *Server) QUICTunnelAddr() string {
234 if s.quicTunnel == nil {
235 return ""
236 }
@@ -342,7 +324,7 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
324 return
325 }
326
345 - broker, err := s.resolveStreamBroker(serverName)
327 + stream, err := s.resolveStream(serverName)
328 if err != nil {
329 _ = wrappedConn.Close()
330 return
@@ -351,14 +333,13 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
333 claimCtx, cancel := context.WithTimeout(ctx, s.cfg.ClaimTimeout)
334 defer cancel()
335
354 - session, err := broker.Claim(claimCtx)
336 + session, err := stream.Claim(claimCtx)
337 if err != nil {
338 _ = wrappedConn.Close()
339 return
340 }
341
360 - BridgeConns(wrappedConn, session.Conn())
361 - _ = session.Close()
342 + BridgeConns(wrappedConn, session)
343 }
344
345 func (s *Server) bridgeToAPI(ctx context.Context, conn net.Conn) {
@@ -389,77 +370,55 @@ func (s *Server) lookupRoutableLease(serverName string) (*leaseRecord, error) {
370 return record, nil
371 }
372
392 -func (s *Server) resolveStreamBroker(serverName string) (*streamBroker, error) {
373 +func (s *Server) resolveStream(serverName string) (*transport.RelayStream, error) {
374 record, err := s.lookupRoutableLease(serverName)
375 if err != nil {
376 return nil, err
377 }
397 - if !record.SupportsStream() {
378 + if record.stream == nil {
379 return nil, errors.New("transport mismatch")
380 }
400 - streamBroker := record.StreamBroker()
401 - if streamBroker == nil {
402 - return nil, errors.New("stream broker unavailable")
381 + return record.stream, nil
382 +}
383 +
384 +func (s *Server) datagramPlaneReady() bool {
385 + if s == nil || !s.cfg.UDPEnabled {
386 + return false
387 + }
388 + if s.group == nil {
389 + return true
390 }
404 - return streamBroker, nil
391 + return s.quicTunnel != nil
392 }
393
407 -func (s *Server) resolveDatagramFlowMux(serverName string) (*datagram.FlowMux, error) {
408 - if serverName == s.rootHost {
409 - return nil, errors.New("root host does not accept datagram routes")
394 +func (s *Server) requireDatagramPlane(udpEnabled bool) error {
395 + if !udpEnabled {
396 + return nil
397 + }
398 + if s.datagramPlaneReady() {
399 + return nil
400 }
401 + return errFeatureUnavailable
402 +}
403
412 - record, err := s.lookupRoutableLease(serverName)
404 +func (s *Server) admitLeaseByID(leaseID, token string, requireDatagram bool) (*leaseRecord, error) {
405 + record, err := s.registry.FindByID(leaseID)
406 if err != nil {
407 return nil, err
408 }
416 - if !record.SupportsDatagram() {
417 - return nil, errors.New("transport mismatch")
409 + if !s.registry.policy.IsLeaseRoutable(record.ID) {
410 + return nil, errLeaseRejected
411 }
419 - flowMux := record.DatagramFlowMux()
420 - if flowMux == nil {
421 - return nil, errors.New("flow mux unavailable")
412 + if err := s.authorizeLeaseToken(record, token); err != nil {
413 + return nil, err
414 }
423 - return flowMux, nil
424 -}
425 -
426 -type quicControlMessage struct {
427 - LeaseID string `json:"lease_id"`
428 - ReverseToken string `json:"reverse_token"`
429 -}
430 -
431 -func (s *Server) watchContext(ctx context.Context) error {
432 - <-ctx.Done()
433 - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
434 - defer cancel()
435 - return s.Shutdown(shutdownCtx)
436 -}
437 -
438 -func BridgeConns(left, right net.Conn) {
439 - defer left.Close()
440 - defer right.Close()
441 -
442 - var group errgroup.Group
443 - group.Go(func() error {
444 - _, err := io.Copy(right, left)
445 - closeWrite(right)
446 - return err
447 - })
448 - group.Go(func() error {
449 - _, err := io.Copy(left, right)
450 - closeWrite(left)
451 - return err
452 - })
453 - _ = group.Wait()
454 -}
455 -
456 -func closeWrite(conn net.Conn) {
457 - type closeWriter interface {
458 - CloseWrite() error
415 + if record.stream == nil {
416 + return nil, errTransportMismatch
417 }
460 - if cw, ok := conn.(closeWriter); ok {
461 - _ = cw.CloseWrite()
418 + if requireDatagram && record.datagram == nil {
419 + return nil, errTransportMismatch
420 }
421 + return record, nil
422 }
423
424 func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error {
@@ -493,25 +452,8 @@ func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error
452
453 log.Info().
454 Str("component", "relay-server").
496 - Str("quic_addr", listener.Addr().String()).
497 - Msg("quic tunnel listener started")
498 - return nil
499 -}
500 -
501 -func (s *Server) startQUICSNIRouter() error {
502 - var listenConfig net.ListenConfig
503 - conn, err := listenConfig.ListenPacket(context.Background(), "udp", s.cfg.SNIListenAddr)
504 - if err != nil {
505 - return fmt.Errorf("listen quic sni udp: %w", err)
506 - }
507 -
508 - s.quicSNI = conn
509 - s.group.Go(func() error { return s.runQUICSNIRouter(conn) })
510 -
511 - log.Info().
512 - Str("component", "relay-server").
513 - Str("quic_sni_addr", conn.LocalAddr().String()).
514 - Msg("quic sni router started")
455 + Str("internal_quic_tunnel_addr", listener.Addr().String()).
456 + Msg("internal quic tunnel listener started")
457 return nil
458 }
459
@@ -528,193 +470,43 @@ func (s *Server) runQUICTunnelListener(listener *quic.Listener) error {
470 }
471 }
472
531 -func (s *Server) handleQUICTunnelConn(conn *quic.Conn) {
532 - stream, err := conn.AcceptStream(context.Background())
533 - if err != nil {
534 - _ = conn.CloseWithError(1, "stream accept failed")
535 - return
536 - }
537 -
538 - _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
539 - var msg quicControlMessage
540 - buf := make([]byte, 4096)
541 - n, err := stream.Read(buf)
542 - if err != nil {
543 - _ = conn.CloseWithError(1, "control read failed")
544 - return
545 - }
546 - if err := json.Unmarshal(buf[:n], &msg); err != nil {
547 - _, _ = stream.Write([]byte(`{"ok":false,"error":"invalid_control_message"}`))
548 - _ = conn.CloseWithError(1, "invalid control message")
549 - return
550 - }
551 - _ = stream.SetReadDeadline(time.Time{})
552 -
553 - lease, err := s.findLeaseByID(msg.LeaseID)
554 - switch {
555 - case err != nil:
556 - _, _ = stream.Write([]byte(`{"ok":false,"error":"lease_not_found"}`))
557 - _ = conn.CloseWithError(1, "lease not found")
558 - return
559 - case s.authorizeLeaseToken(lease, msg.ReverseToken) != nil:
560 - _, _ = stream.Write([]byte(`{"ok":false,"error":"unauthorized"}`))
561 - _ = conn.CloseWithError(1, "unauthorized")
562 - return
563 - }
564 -
565 - flowMux := lease.DatagramFlowMux()
566 - if flowMux == nil {
567 - _, _ = stream.Write([]byte(`{"ok":false,"error":"transport_mismatch"}`))
568 - _ = conn.CloseWithError(1, "transport mismatch")
569 - return
570 - }
571 - if err := flowMux.Register(conn); err != nil {
572 - _, _ = stream.Write([]byte(`{"ok":false,"error":"broker_closed"}`))
573 - _ = conn.CloseWithError(1, "broker closed")
473 +func (s *Server) closeLease(record *leaseRecord) {
474 + if record == nil {
475 return
476 }
576 -
577 - _, _ = stream.Write([]byte(`{"ok":true}`))
578 - s.registry.Touch(lease.ID, conn.RemoteAddr().String(), time.Now())
579 - log.Info().
580 - Str("component", "quic-tunnel-listener").
581 - Str("lease_id", lease.ID).
582 - Str("lease_name", lease.Name).
583 - Str("remote_addr", conn.RemoteAddr().String()).
584 - Msg("quic tunnel connected")
477 + record.Close()
478 }
479
587 -func (s *Server) runQUICSNIRouter(conn net.PacketConn) error {
588 - buf := make([]byte, 65535)
589 - for {
590 - _ = conn.SetReadDeadline(time.Now().Add(defaultQUICSNICleanup))
591 - n, addr, err := conn.ReadFrom(buf)
592 - if err != nil {
593 - if errors.Is(err, net.ErrClosed) {
594 - return nil
595 - }
596 - var netErr net.Error
597 - if errors.As(err, &netErr) && netErr.Timeout() {
598 - s.cleanupQUICSNIRoutes(time.Now())
599 - continue
600 - }
601 - return err
602 - }
603 -
604 - packet := make([]byte, n)
605 - copy(packet, buf[:n])
606 - s.handleQUICSNIPacket(packet, addr, time.Now())
607 - }
480 +func (s *Server) watchContext(ctx context.Context) error {
481 + <-ctx.Done()
482 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
483 + defer cancel()
484 + return s.Shutdown(shutdownCtx)
485 }
486
610 -func (s *Server) handleQUICSNIPacket(packet []byte, srcAddr net.Addr, now time.Time) {
611 - cacheKey := srcAddr.String()
612 - serverName, err := datagram.ParseQUICInitialSNI(packet)
613 - var flowMux *datagram.FlowMux
614 - if err == nil && serverName != "" {
615 - flowMux, err = s.resolveDatagramFlowMux(utils.NormalizeHostname(serverName))
616 - if err != nil || flowMux == nil {
617 - return
618 - }
619 - s.storeQUICSNIRoute(cacheKey, flowMux, now)
620 - } else {
621 - var ok bool
622 - flowMux, ok = s.lookupQUICSNIRoute(cacheKey, now)
623 - if !ok {
624 - return
625 - }
626 - }
627 -
628 - udpAddr, ok := srcAddr.(*net.UDPAddr)
629 - if !ok {
630 - return
631 - }
632 - if s.quicSNI == nil {
633 - return
634 - }
487 +func BridgeConns(left, right net.Conn) {
488 + defer left.Close()
489 + defer right.Close()
490
636 - flowID := flowMux.TouchFlow("quic:"+cacheKey, func(payload []byte) error {
637 - _, err := s.quicSNI.WriteTo(payload, udpAddr)
491 + var group errgroup.Group
492 + group.Go(func() error {
493 + _, err := io.Copy(right, left)
494 + closeWrite(right)
495 return err
496 })
640 - if err := flowMux.SendDatagram(flowID, packet); err != nil {
641 - s.deleteQUICSNIRoute(cacheKey)
642 - }
643 -}
644 -
645 -func (s *Server) lookupQUICSNIRoute(key string, now time.Time) (*datagram.FlowMux, bool) {
646 - s.quicSNIMu.Lock()
647 - defer s.quicSNIMu.Unlock()
648 -
649 - route, ok := s.quicSNIRoutes[key]
650 - if !ok || route.flowMux == nil {
651 - delete(s.quicSNIRoutes, key)
652 - return nil, false
653 - }
654 - if now.Sub(route.lastSeen) > defaultQUICSNIRouteIdle || !route.flowMux.HasConnection() {
655 - delete(s.quicSNIRoutes, key)
656 - return nil, false
657 - }
658 -
659 - route.lastSeen = now
660 - s.quicSNIRoutes[key] = route
661 - return route.flowMux, true
662 -}
663 -
664 -func (s *Server) storeQUICSNIRoute(key string, flowMux *datagram.FlowMux, now time.Time) {
665 - s.quicSNIMu.Lock()
666 - s.quicSNIRoutes[key] = quicSNIRoute{
667 - flowMux: flowMux,
668 - lastSeen: now,
669 - }
670 - s.quicSNIMu.Unlock()
671 -}
672 -
673 -func (s *Server) deleteQUICSNIRoute(key string) {
674 - s.quicSNIMu.Lock()
675 - delete(s.quicSNIRoutes, key)
676 - s.quicSNIMu.Unlock()
677 -}
678 -
679 -func (s *Server) cleanupQUICSNIRoutes(now time.Time) {
680 - s.quicSNIMu.Lock()
681 - defer s.quicSNIMu.Unlock()
682 -
683 - for key, route := range s.quicSNIRoutes {
684 - if route.flowMux == nil || now.Sub(route.lastSeen) > defaultQUICSNIRouteIdle || !route.flowMux.HasConnection() {
685 - delete(s.quicSNIRoutes, key)
686 - }
687 - }
688 -}
689 -
690 -func (s *Server) clearQUICSNIRoutesForFlowMux(flowMux *datagram.FlowMux) {
691 - if flowMux == nil {
692 - return
693 - }
694 -
695 - s.quicSNIMu.Lock()
696 - defer s.quicSNIMu.Unlock()
697 -
698 - for key, route := range s.quicSNIRoutes {
699 - if route.flowMux == flowMux {
700 - delete(s.quicSNIRoutes, key)
701 - }
702 - }
497 + group.Go(func() error {
498 + _, err := io.Copy(left, right)
499 + closeWrite(left)
500 + return err
501 + })
502 + _ = group.Wait()
503 }
504
705 -// closeLease tears down all resources associated with a single lease record.
706 -func (s *Server) closeLease(record *leaseRecord) {
707 - if record == nil || record.Runtime == nil {
708 - return
505 +func closeWrite(conn net.Conn) {
506 + type closeWriter interface {
507 + CloseWrite() error
508 }
710 - s.clearQUICSNIRoutesForFlowMux(record.DatagramFlowMux())
711 - record.Runtime.Close(s.ports)
712 -}
713 -
714 -func (s *Server) quicPublicAddr() string {
715 - _, port, err := net.SplitHostPort(s.cfg.QUICListenAddr)
716 - if err != nil {
717 - port = "4017"
509 + if cw, ok := conn.(closeWriter); ok {
510 + _ = cw.CloseWrite()
511 }
719 - return net.JoinHostPort(s.rootHost, port)
512 }
portal/server_test.go
+15 -19
@@ -21,6 +21,7 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
21 ACME: acme.Config{KeyDir: t.TempDir()},
22 APIListenAddr: "127.0.0.1:0",
23 SNIListenAddr: "127.0.0.1:0",
24 + UDPEnabled: true,
25 })
26 if err != nil {
27 t.Fatalf("NewServer() error = %v", err)
@@ -83,6 +84,7 @@ func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
84 ACME: acme.Config{BaseDomain: "other.example.com", KeyDir: t.TempDir()},
85 APIListenAddr: "127.0.0.1:0",
86 SNIListenAddr: "127.0.0.1:0",
87 + UDPEnabled: true,
88 })
89 if err != nil {
90 t.Fatalf("NewServer() error = %v", err)
@@ -101,7 +103,8 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
103 t.Parallel()
104
105 server, err := NewServer(ServerConfig{
104 - PortalURL: "https://portal.example.com",
106 + PortalURL: "https://portal.example.com",
107 + UDPEnabled: true,
108 })
109 if err != nil {
110 t.Fatalf("NewServer() error = %v", err)
@@ -137,7 +140,8 @@ func TestRegisterLeaseRejectsInvalidName(t *testing.T) {
140 t.Parallel()
141
142 server, err := NewServer(ServerConfig{
140 - PortalURL: "https://portal.example.com",
143 + PortalURL: "https://portal.example.com",
144 + UDPEnabled: true,
145 })
146 if err != nil {
147 t.Fatalf("NewServer() error = %v", err)
@@ -152,11 +156,12 @@ func TestRegisterLeaseRejectsInvalidName(t *testing.T) {
156 }
157 }
158
155 -func TestRegisterLeaseBuildsDatagramOnlyRuntime(t *testing.T) {
159 +func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
160 t.Parallel()
161
162 server, err := NewServer(ServerConfig{
159 - PortalURL: "https://portal.example.com",
163 + PortalURL: "https://portal.example.com",
164 + UDPEnabled: true,
165 })
166 if err != nil {
167 t.Fatalf("NewServer() error = %v", err)
@@ -165,7 +170,7 @@ func TestRegisterLeaseBuildsDatagramOnlyRuntime(t *testing.T) {
170 resp, err := server.registerLease(types.RegisterRequest{
171 Name: "demo-udp",
172 ReverseToken: "tok_udp",
168 - Transport: types.TransportUDP,
173 + UDPEnabled: true,
174 }, "203.0.113.10")
175 if err != nil {
176 t.Fatalf("registerLease() error = %v", err)
@@ -175,25 +180,16 @@ func TestRegisterLeaseBuildsDatagramOnlyRuntime(t *testing.T) {
180 if !ok {
181 t.Fatal("registry.Get() = false, want registered lease")
182 }
178 - if record.SupportsStream() {
179 - t.Fatal("SupportsStream() = true, want false")
183 + if record.stream == nil {
184 + t.Fatal("stream = nil, want stream runtime")
185 }
181 - if !record.SupportsDatagram() {
182 - t.Fatal("SupportsDatagram() = false, want true")
186 + if record.datagram == nil {
187 + t.Fatal("datagram = nil, want datagram runtime")
188 }
184 - if record.StreamBroker() != nil {
185 - t.Fatal("StreamBroker() != nil, want nil")
186 - }
187 - if record.DatagramFlowMux() == nil {
188 - t.Fatal("DatagramFlowMux() = nil, want flow mux")
189 - }
190 - if got := record.UDPPort(); got == 0 {
189 + if got := record.datagram.UDPPort(); got == 0 {
190 t.Fatal("UDPPort() = 0, want allocated port")
191 }
192 if resp.UDPAddr == "" {
193 t.Fatal("RegisterResponse.UDPAddr = empty, want public udp address")
194 }
196 - if resp.QUICAddr == "" {
197 - t.Fatal("RegisterResponse.QUICAddr = empty, want quic address")
198 - }
195 }
portal/transport/datagram_client.go new
+137
@@ -0,0 +1,137 @@
1 +package transport
2 +
3 +import (
4 + "context"
5 + "net"
6 + "time"
7 +
8 + "github.com/quic-go/quic-go"
9 + "github.com/rs/zerolog/log"
10 +
11 + "github.com/gosuda/portal/v2/types"
12 + "github.com/gosuda/portal/v2/utils"
13 +)
14 +
15 +type ClientDatagramState struct {
16 + LeaseID string
17 + ReverseToken string
18 +}
19 +
20 +type ClientDatagram struct {
21 + session *datagramSession
22 +}
23 +
24 +func NewClientDatagram(onReceiveError func(error)) *ClientDatagram {
25 + return &ClientDatagram{
26 + session: newDatagramSession(256, false, onReceiveError),
27 + }
28 +}
29 +
30 +func (d *ClientDatagram) RunLoop(
31 + ctx context.Context,
32 + currentState func() (ClientDatagramState, bool),
33 + open func(context.Context, ClientDatagramState) (*quic.Conn, error),
34 +) {
35 + for {
36 + select {
37 + case <-ctx.Done():
38 + d.session.Stop("listener context closed")
39 + return
40 + default:
41 + }
42 +
43 + state, ok := currentState()
44 + if !ok {
45 + if !utils.SleepOrDone(ctx, time.Second) {
46 + d.session.Stop("listener context closed")
47 + return
48 + }
49 + continue
50 + }
51 +
52 + conn, err := open(ctx, state)
53 + if err != nil {
54 + log.Warn().
55 + Err(err).
56 + Str("component", "sdk-datagram-plane").
57 + Str("lease_id", state.LeaseID).
58 + Msg("quic session open failed, retrying")
59 + if !utils.SleepOrDone(ctx, 2*time.Second) {
60 + d.session.Stop("listener context closed")
61 + return
62 + }
63 + continue
64 + }
65 +
66 + log.Info().
67 + Str("component", "sdk-datagram-plane").
68 + Str("lease_id", state.LeaseID).
69 + Str("remote_addr", conn.RemoteAddr().String()).
70 + Msg("quic tunnel connected")
71 +
72 + recvDone, err := d.session.Bind(conn)
73 + if err != nil {
74 + if ctx.Err() != nil {
75 + return
76 + }
77 + log.Warn().
78 + Err(err).
79 + Str("component", "sdk-datagram-plane").
80 + Str("lease_id", state.LeaseID).
81 + Msg("quic session bind failed")
82 + if !utils.SleepOrDone(ctx, time.Second) {
83 + return
84 + }
85 + continue
86 + }
87 +
88 + select {
89 + case <-ctx.Done():
90 + d.session.Stop("listener context closed")
91 + return
92 + case <-recvDone:
93 + }
94 +
95 + if !utils.SleepOrDone(ctx, time.Second) {
96 + return
97 + }
98 + }
99 +}
100 +
101 +func (d *ClientDatagram) Accept(done <-chan struct{}) (types.DatagramFrame, error) {
102 + if d == nil || d.session == nil {
103 + return types.DatagramFrame{}, net.ErrClosed
104 + }
105 +
106 + select {
107 + case <-done:
108 + return types.DatagramFrame{}, net.ErrClosed
109 + case dg := <-d.session.incoming:
110 + return dg, nil
111 + }
112 +}
113 +
114 +func (d *ClientDatagram) Send(flowID uint32, payload []byte) error {
115 + if d == nil || d.session == nil {
116 + return net.ErrClosed
117 + }
118 + return d.session.Send(flowID, payload)
119 +}
120 +
121 +func (d *ClientDatagram) Connected() bool {
122 + return d != nil && d.session != nil && d.session.hasConnection()
123 +}
124 +
125 +func (d *ClientDatagram) Clear(reason string) {
126 + if d == nil || d.session == nil {
127 + return
128 + }
129 + d.session.Clear(reason)
130 +}
131 +
132 +func (d *ClientDatagram) Close() {
133 + if d == nil || d.session == nil {
134 + return
135 + }
136 + d.session.Stop("listener closed")
137 +}
portal/transport/datagram_relay.go new
+379
@@ -0,0 +1,379 @@
1 +package transport
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "net"
8 + "sort"
9 + "sync"
10 + "time"
11 +
12 + "github.com/quic-go/quic-go"
13 + "github.com/rs/zerolog/log"
14 +
15 + "github.com/gosuda/portal/v2/types"
16 +)
17 +
18 +const (
19 + DefaultMaxPacketSize = 1350
20 + defaultFlowIdleTimeout = 30 * time.Second
21 + defaultFlowCleanupInterval = 30 * time.Second
22 +)
23 +
24 +var ErrPortExhausted = errors.New("no udp ports available")
25 +
26 +type flowReplyFunc func([]byte) error
27 +
28 +type flowState struct {
29 + key string
30 + lastSeen time.Time
31 + reply flowReplyFunc
32 +}
33 +
34 +type portReservation struct {
35 + port int
36 + expiresAt time.Time
37 +}
38 +
39 +// PortAllocator manages a pool of UDP ports for dynamic per-lease allocation.
40 +type PortAllocator struct {
41 + available []int
42 + inUse map[int]string
43 + reserved map[string]portReservation
44 + grace time.Duration
45 + mu sync.Mutex
46 +}
47 +
48 +func NewPortAllocator(min, max int, grace time.Duration) *PortAllocator {
49 + available := make([]int, 0, max-min+1)
50 + for p := min; p <= max; p++ {
51 + available = append(available, p)
52 + }
53 + return &PortAllocator{
54 + available: available,
55 + inUse: make(map[int]string),
56 + reserved: make(map[string]portReservation),
57 + grace: grace,
58 + }
59 +}
60 +
61 +func (a *PortAllocator) Allocate(name string) (int, error) {
62 + a.mu.Lock()
63 + defer a.mu.Unlock()
64 +
65 + a.cleanupExpiredLocked(time.Now())
66 +
67 + if res, ok := a.reserved[name]; ok {
68 + delete(a.reserved, name)
69 + a.inUse[res.port] = name
70 + return res.port, nil
71 + }
72 +
73 + if len(a.available) == 0 {
74 + return 0, ErrPortExhausted
75 + }
76 +
77 + port := a.available[0]
78 + a.available = a.available[1:]
79 + a.inUse[port] = name
80 + return port, nil
81 +}
82 +
83 +func (a *PortAllocator) Release(port int) {
84 + a.mu.Lock()
85 + defer a.mu.Unlock()
86 +
87 + name, ok := a.inUse[port]
88 + if !ok {
89 + return
90 + }
91 + delete(a.inUse, port)
92 +
93 + if prev, exists := a.reserved[name]; exists {
94 + a.sortedInsertLocked(prev.port)
95 + }
96 +
97 + a.reserved[name] = portReservation{
98 + port: port,
99 + expiresAt: time.Now().Add(a.grace),
100 + }
101 +
102 + a.cleanupExpiredLocked(time.Now())
103 +}
104 +
105 +func (a *PortAllocator) cleanupExpiredLocked(now time.Time) {
106 + for name, res := range a.reserved {
107 + if now.After(res.expiresAt) {
108 + delete(a.reserved, name)
109 + a.sortedInsertLocked(res.port)
110 + }
111 + }
112 +}
113 +
114 +func (a *PortAllocator) sortedInsertLocked(port int) {
115 + i := sort.SearchInts(a.available, port)
116 + a.available = append(a.available, 0)
117 + copy(a.available[i+1:], a.available[i:])
118 + a.available[i] = port
119 +}
120 +
121 +// Datagram owns the UDP and QUIC datagram runtime for one lease.
122 +type RelayDatagram struct {
123 + leaseID string
124 + port int
125 + session *datagramSession
126 + flowTable map[uint32]*flowState
127 + addrIndex map[string]uint32
128 + nextFlow uint32
129 +
130 + conn *net.UDPConn
131 +
132 + cancel context.CancelFunc
133 + closeOnce sync.Once
134 + mu sync.Mutex
135 +}
136 +
137 +func NewRelayDatagram(leaseID string, port int) *RelayDatagram {
138 + d := &RelayDatagram{
139 + leaseID: leaseID,
140 + port: port,
141 + session: newDatagramSession(256, true, func(err error) {
142 + log.Warn().
143 + Err(err).
144 + Str("component", "quic-flow-mux").
145 + Str("lease_id", leaseID).
146 + Msg("quic receive loop ended")
147 + }),
148 + flowTable: make(map[uint32]*flowState),
149 + addrIndex: make(map[string]uint32),
150 + nextFlow: 1,
151 + }
152 + go d.runDispatchLoop()
153 + go d.runCleanupLoop()
154 + return d
155 +}
156 +
157 +func (d *RelayDatagram) Start(ctx context.Context) error {
158 + if d == nil || d.port <= 0 {
159 + return nil
160 + }
161 +
162 + addr := &net.UDPAddr{Port: d.port}
163 + conn, err := net.ListenUDP("udp", addr)
164 + if err != nil {
165 + return fmt.Errorf("listen udp :%d: %w", d.port, err)
166 + }
167 + d.conn = conn
168 +
169 + relayCtx, cancel := context.WithCancel(ctx)
170 + d.cancel = cancel
171 + go d.readLoop(relayCtx)
172 +
173 + log.Info().
174 + Str("component", "udp-relay").
175 + Str("lease_id", d.leaseID).
176 + Int("port", d.port).
177 + Msg("udp relay started")
178 +
179 + return nil
180 +}
181 +
182 +func (d *RelayDatagram) Close() {
183 + if d == nil {
184 + return
185 + }
186 +
187 + d.closeOnce.Do(func() {
188 + if d.cancel != nil {
189 + d.cancel()
190 + }
191 + d.session.Stop("lease stopped")
192 + if d.conn != nil {
193 + _ = d.conn.Close()
194 + }
195 + log.Info().
196 + Str("component", "udp-relay").
197 + Str("lease_id", d.leaseID).
198 + Int("port", d.port).
199 + Msg("udp relay stopped")
200 + })
201 +}
202 +
203 +func (d *RelayDatagram) Register(conn *quic.Conn) error {
204 + if _, err := d.session.Bind(conn); err != nil {
205 + return err
206 + }
207 +
208 + log.Info().
209 + Str("component", "quic-flow-mux").
210 + Str("lease_id", d.leaseID).
211 + Str("remote_addr", conn.RemoteAddr().String()).
212 + Msg("quic tunnel connection registered")
213 + return nil
214 +}
215 +
216 +func (d *RelayDatagram) SendDatagram(flowID uint32, payload []byte) error {
217 + if d == nil {
218 + return net.ErrClosed
219 + }
220 + return d.session.Send(flowID, payload)
221 +}
222 +
223 +func (d *RelayDatagram) TouchFlow(key string, reply func([]byte) error) uint32 {
224 + now := time.Now()
225 +
226 + d.mu.Lock()
227 + defer d.mu.Unlock()
228 +
229 + if id, ok := d.addrIndex[key]; ok {
230 + if flow, exists := d.flowTable[id]; exists && flow != nil {
231 + flow.lastSeen = now
232 + if reply != nil {
233 + flow.reply = reply
234 + }
235 + return id
236 + }
237 + delete(d.addrIndex, key)
238 + }
239 +
240 + id := d.nextFlow
241 + d.nextFlow++
242 + d.flowTable[id] = &flowState{
243 + key: key,
244 + lastSeen: now,
245 + reply: reply,
246 + }
247 + d.addrIndex[key] = id
248 + return id
249 +}
250 +
251 +func (d *RelayDatagram) UDPPort() int {
252 + if d == nil {
253 + return 0
254 + }
255 + return d.port
256 +}
257 +
258 +func (d *RelayDatagram) runDispatchLoop() {
259 + for {
260 + select {
261 + case <-d.session.Done():
262 + return
263 + case frame := <-d.session.incoming:
264 + d.dispatch(frame)
265 + }
266 + }
267 +}
268 +
269 +func (d *RelayDatagram) dispatch(frame types.DatagramFrame) {
270 + d.mu.Lock()
271 + flow, ok := d.flowTable[frame.FlowID]
272 + if !ok || flow == nil || flow.reply == nil {
273 + d.mu.Unlock()
274 + return
275 + }
276 +
277 + flow.lastSeen = time.Now()
278 + reply := flow.reply
279 + d.mu.Unlock()
280 +
281 + if err := reply(frame.Payload); err != nil {
282 + log.Warn().
283 + Err(err).
284 + Str("component", "quic-flow-mux").
285 + Str("lease_id", d.leaseID).
286 + Uint32("flow_id", frame.FlowID).
287 + Msg("flow writeback failed")
288 + d.forgetFlow(frame.FlowID)
289 + }
290 +}
291 +
292 +func (d *RelayDatagram) runCleanupLoop() {
293 + ticker := time.NewTicker(defaultFlowCleanupInterval)
294 + defer ticker.Stop()
295 +
296 + for {
297 + select {
298 + case <-d.session.Done():
299 + return
300 + case now := <-ticker.C:
301 + d.expireIdleFlows(now)
302 + }
303 + }
304 +}
305 +
306 +func (d *RelayDatagram) expireIdleFlows(now time.Time) {
307 + d.mu.Lock()
308 + defer d.mu.Unlock()
309 +
310 + for flowID, flow := range d.flowTable {
311 + if flow == nil || now.Sub(flow.lastSeen) > defaultFlowIdleTimeout {
312 + if flow != nil {
313 + delete(d.addrIndex, flow.key)
314 + }
315 + delete(d.flowTable, flowID)
316 + }
317 + }
318 +}
319 +
320 +func (d *RelayDatagram) forgetFlow(flowID uint32) {
321 + d.mu.Lock()
322 + defer d.mu.Unlock()
323 +
324 + flow, ok := d.flowTable[flowID]
325 + if !ok {
326 + return
327 + }
328 + if flow != nil {
329 + delete(d.addrIndex, flow.key)
330 + }
331 + delete(d.flowTable, flowID)
332 +}
333 +
334 +func (d *RelayDatagram) readLoop(ctx context.Context) {
335 + buf := make([]byte, DefaultMaxPacketSize)
336 + for {
337 + select {
338 + case <-ctx.Done():
339 + return
340 + default:
341 + }
342 +
343 + _ = d.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
344 + n, clientAddr, err := d.conn.ReadFromUDP(buf)
345 + if err != nil {
346 + if ctx.Err() != nil {
347 + return
348 + }
349 + var netErr net.Error
350 + if errors.As(err, &netErr) && netErr.Timeout() {
351 + continue
352 + }
353 + log.Warn().
354 + Str("component", "udp-relay").
355 + Str("lease_id", d.leaseID).
356 + Err(err).
357 + Msg("readLoop exiting: unexpected read error")
358 + return
359 + }
360 +
361 + flowID := d.TouchFlow("udp:"+clientAddr.String(), func(payload []byte) error {
362 + _, err := d.conn.WriteToUDP(payload, clientAddr)
363 + return err
364 + })
365 + payload := make([]byte, n)
366 + copy(payload, buf[:n])
367 +
368 + if err := d.SendDatagram(flowID, payload); err != nil {
369 + log.Warn().
370 + Str("component", "udp-relay").
371 + Str("lease_id", d.leaseID).
372 + Err(err).
373 + Uint32("flow_id", flowID).
374 + Int("bytes", n).
375 + Msg("send datagram to tunnel failed, dropping packet")
376 + continue
377 + }
378 + }
379 +}
portal/transport/datagram_session.go renamed
+17 -23
@@ -1,8 +1,9 @@
1 -package datagram
1 +package transport
2
3 import (
4 "context"
5 "errors"
6 + "net"
7 "sync"
8
9 "github.com/quic-go/quic-go"
@@ -10,13 +11,10 @@ import (
11 "github.com/gosuda/portal/v2/types"
12 )
13
13 -var (
14 - ErrNoConnection = errors.New("no quic connection registered")
15 - ErrSessionClosed = errors.New("quic datagram session closed")
16 -)
14 +var errNoConnection = errors.New("no quic connection registered")
15
18 -// Session owns one active QUIC DATAGRAM connection and exposes decoded frames.
19 -type Session struct {
16 +// datagramSession owns one active QUIC DATAGRAM connection and exposes decoded frames.
17 +type datagramSession struct {
18 incoming chan types.DatagramFrame
19 dropIncoming bool
20 onReceiveError func(error)
@@ -27,12 +25,12 @@ type Session struct {
25 closed bool
26 }
27
30 -func NewSession(bufferSize int, dropIncoming bool, onReceiveError func(error)) *Session {
28 +func newDatagramSession(bufferSize int, dropIncoming bool, onReceiveError func(error)) *datagramSession {
29 if bufferSize <= 0 {
30 bufferSize = 256
31 }
32
35 - return &Session{
33 + return &datagramSession{
34 incoming: make(chan types.DatagramFrame, bufferSize),
35 dropIncoming: dropIncoming,
36 onReceiveError: onReceiveError,
@@ -42,7 +40,7 @@ func NewSession(bufferSize int, dropIncoming bool, onReceiveError func(error)) *
40
41 // Bind installs a new active QUIC connection and starts the receive loop.
42 // Any previously active connection is replaced and closed.
45 -func (s *Session) Bind(conn *quic.Conn) (<-chan struct{}, error) {
43 +func (s *datagramSession) Bind(conn *quic.Conn) (<-chan struct{}, error) {
44 if conn == nil {
45 return nil, errors.New("quic connection is required")
46 }
@@ -51,7 +49,7 @@ func (s *Session) Bind(conn *quic.Conn) (<-chan struct{}, error) {
49 if s.closed {
50 s.mu.Unlock()
51 _ = conn.CloseWithError(0, "session closed")
54 - return nil, ErrSessionClosed
52 + return nil, net.ErrClosed
53 }
54 old := s.conn
55 s.conn = conn
@@ -66,37 +64,33 @@ func (s *Session) Bind(conn *quic.Conn) (<-chan struct{}, error) {
64 return recvDone, nil
65 }
66
69 -func (s *Session) Incoming() <-chan types.DatagramFrame {
70 - return s.incoming
71 -}
72 -
73 -func (s *Session) Done() <-chan struct{} {
67 +func (s *datagramSession) Done() <-chan struct{} {
68 return s.done
69 }
70
77 -func (s *Session) HasConnection() bool {
71 +func (s *datagramSession) hasConnection() bool {
72 s.mu.Lock()
73 defer s.mu.Unlock()
74 return s.conn != nil && !s.closed
75 }
76
83 -func (s *Session) Send(flowID uint32, payload []byte) error {
77 +func (s *datagramSession) Send(flowID uint32, payload []byte) error {
78 s.mu.Lock()
79 conn := s.conn
80 closed := s.closed
81 s.mu.Unlock()
82
83 if closed {
90 - return ErrSessionClosed
84 + return net.ErrClosed
85 }
86 if conn == nil {
93 - return ErrNoConnection
87 + return errNoConnection
88 }
89 return conn.SendDatagram(types.EncodeDatagram(flowID, payload))
90 }
91
92 // Clear closes the active connection but keeps the session reusable.
99 -func (s *Session) Clear(reason string) {
93 +func (s *datagramSession) Clear(reason string) {
94 s.mu.Lock()
95 conn := s.conn
96 s.conn = nil
@@ -108,7 +102,7 @@ func (s *Session) Clear(reason string) {
102 }
103
104 // Stop permanently closes the session and any active connection.
111 -func (s *Session) Stop(reason string) {
105 +func (s *datagramSession) Stop(reason string) {
106 s.mu.Lock()
107 if s.closed {
108 s.mu.Unlock()
@@ -125,7 +119,7 @@ func (s *Session) Stop(reason string) {
119 }
120 }
121
128 -func (s *Session) receiveLoop(conn *quic.Conn, recvDone chan struct{}) {
122 +func (s *datagramSession) receiveLoop(conn *quic.Conn, recvDone chan struct{}) {
123 defer close(recvDone)
124
125 for {
portal/transport/stream_client.go new
+188
@@ -0,0 +1,188 @@
1 +package transport
2 +
3 +import (
4 + "context"
5 + "crypto/tls"
6 + "errors"
7 + "fmt"
8 + "io"
9 + "net"
10 + "sync"
11 + "time"
12 +
13 + "github.com/gosuda/portal/v2/types"
14 +)
15 +
16 +type ClientStream struct {
17 + accepted chan net.Conn
18 + activeSessions int
19 + handshakeTimeout time.Duration
20 + mu sync.Mutex
21 +}
22 +
23 +func NewClientStream(readyTarget int, handshakeTimeout time.Duration) *ClientStream {
24 + return &ClientStream{
25 + accepted: make(chan net.Conn, max(readyTarget*2, 1)),
26 + handshakeTimeout: handshakeTimeout,
27 + }
28 +}
29 +
30 +func (s *ClientStream) Accept(done <-chan struct{}) (net.Conn, error) {
31 + if s == nil {
32 + return nil, net.ErrClosed
33 + }
34 + select {
35 + case <-done:
36 + return nil, net.ErrClosed
37 + case conn := <-s.accepted:
38 + if conn == nil {
39 + return nil, net.ErrClosed
40 + }
41 + return conn, nil
42 + }
43 +}
44 +
45 +func (s *ClientStream) RunLoop(
46 + ctx context.Context,
47 + open func(context.Context) (net.Conn, error),
48 + currentTLSConfig func() *tls.Config,
49 + onReady func(),
50 + onInactive func(),
51 + retry func(context.Context, string, error, int) bool,
52 +) {
53 + var retries int
54 +
55 + for {
56 + claimed, err := s.runSession(ctx, open, currentTLSConfig, onReady)
57 + switch {
58 + case err == nil:
59 + retries = 0
60 + case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
61 + return
62 + case claimed:
63 + retries = 0
64 + default:
65 + retries++
66 + if s.ActiveSessions() == 0 && onInactive != nil {
67 + onInactive()
68 + }
69 + if retry == nil || !retry(ctx, "reverse session connect", err, retries) {
70 + return
71 + }
72 + }
73 + }
74 +}
75 +
76 +func (s *ClientStream) ActiveSessions() int {
77 + if s == nil {
78 + return 0
79 + }
80 +
81 + s.mu.Lock()
82 + defer s.mu.Unlock()
83 + return s.activeSessions
84 +}
85 +
86 +func (s *ClientStream) Drain() {
87 + if s == nil {
88 + return
89 + }
90 + for {
91 + select {
92 + case conn := <-s.accepted:
93 + if conn != nil {
94 + _ = conn.Close()
95 + }
96 + default:
97 + return
98 + }
99 + }
100 +}
101 +
102 +func (s *ClientStream) runSession(
103 + ctx context.Context,
104 + open func(context.Context) (net.Conn, error),
105 + currentTLSConfig func() *tls.Config,
106 + onReady func(),
107 +) (bool, error) {
108 + conn, err := open(ctx)
109 + if err != nil {
110 + return false, err
111 + }
112 + s.sessionOpened()
113 + defer s.sessionClosed()
114 +
115 + var marker [1]byte
116 + for {
117 + _ = conn.SetReadDeadline(time.Now().Add(2 * s.handshakeTimeout))
118 + if _, err := io.ReadFull(conn, marker[:]); err != nil {
119 + _ = conn.Close()
120 + return false, err
121 + }
122 + _ = conn.SetReadDeadline(time.Time{})
123 +
124 + switch marker[0] {
125 + case types.MarkerKeepalive:
126 + continue
127 + case types.MarkerTLSStart:
128 + if err := s.activate(ctx, conn, currentTLSConfig); err != nil {
129 + _ = conn.Close()
130 + return true, err
131 + }
132 + if onReady != nil {
133 + onReady()
134 + }
135 + return true, nil
136 + default:
137 + _ = conn.Close()
138 + return false, fmt.Errorf("unexpected reverse marker: 0x%02x", marker[0])
139 + }
140 + }
141 +}
142 +
143 +func (s *ClientStream) activate(ctx context.Context, conn net.Conn, currentTLSConfig func() *tls.Config) error {
144 + var tlsCfg *tls.Config
145 + if currentTLSConfig != nil {
146 + tlsCfg = currentTLSConfig()
147 + }
148 + if tlsCfg == nil {
149 + return errors.New("tls config is unavailable")
150 + }
151 +
152 + tlsConn := tls.Server(conn, tlsCfg)
153 + handshakeCtx, cancel := context.WithTimeout(ctx, s.handshakeTimeout)
154 + defer cancel()
155 + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
156 + return err
157 + }
158 +
159 + select {
160 + case <-ctx.Done():
161 + _ = tlsConn.Close()
162 + return ctx.Err()
163 + case s.accepted <- tlsConn:
164 + return nil
165 + }
166 +}
167 +
168 +func (s *ClientStream) sessionOpened() {
169 + if s == nil {
170 + return
171 + }
172 +
173 + s.mu.Lock()
174 + s.activeSessions++
175 + s.mu.Unlock()
176 +}
177 +
178 +func (s *ClientStream) sessionClosed() {
179 + if s == nil {
180 + return
181 + }
182 +
183 + s.mu.Lock()
184 + if s.activeSessions > 0 {
185 + s.activeSessions--
186 + }
187 + s.mu.Unlock()
188 +}
portal/transport/stream_relay.go renamed
+76 -44
@@ -1,4 +1,4 @@
1 -package portal
1 +package transport
2
3 import (
4 "context"
@@ -13,23 +13,22 @@ import (
13 "github.com/gosuda/portal/v2/types"
14 )
15
16 -var (
17 - errBrokerClosed = errors.New("lease broker closed")
18 - errBrokerFull = errors.New("broker ready queue full")
19 -)
16 +const defaultSessionWriteLimit = 5 * time.Second
17 +
18 +var errStreamFull = errors.New("stream ready queue full")
19
21 -type streamBroker struct {
20 +type RelayStream struct {
21 notify chan struct{}
22 leaseID string
24 - ready []*reverseSession
23 + ready []*relaySession
24 idleInterval time.Duration
25 readyLimit int
26 closedErr error
27 mu sync.Mutex
28 }
29
31 -func newStreamBroker(leaseID string, idleInterval time.Duration, readyLimit int) *streamBroker {
32 - return &streamBroker{
30 +func NewRelayStream(leaseID string, idleInterval time.Duration, readyLimit int) *RelayStream {
31 + return &RelayStream{
32 leaseID: leaseID,
33 idleInterval: idleInterval,
34 readyLimit: readyLimit,
@@ -37,21 +36,24 @@ func newStreamBroker(leaseID string, idleInterval time.Duration, readyLimit int)
36 }
37 }
38
40 -func (b *streamBroker) Offer(session *reverseSession) error {
41 - if session == nil {
42 - return errors.New("reverse session is required")
39 +func (b *RelayStream) OfferConn(conn net.Conn) error {
40 + if conn == nil {
41 + return errors.New("reverse connection is required")
42 }
43 + session := newRelaySession(conn, b.idleInterval)
44
45 b.mu.Lock()
46 if b.closedErr != nil {
47 err := b.closedErr
48 b.mu.Unlock()
49 + _ = session.Close()
50 return err
51 }
52
53 if b.readyLimit > 0 && len(b.ready) >= b.readyLimit {
54 b.mu.Unlock()
54 - return errBrokerFull
55 + _ = session.Close()
56 + return errStreamFull
57 }
58
59 session.StartIdle()
@@ -63,7 +65,7 @@ func (b *streamBroker) Offer(session *reverseSession) error {
65 return nil
66 }
67
66 -func (b *streamBroker) Claim(ctx context.Context) (*reverseSession, error) {
68 +func (b *RelayStream) Claim(ctx context.Context) (net.Conn, error) {
69 for {
70 b.mu.Lock()
71 if b.closedErr != nil {
@@ -96,12 +98,12 @@ func (b *streamBroker) Claim(ctx context.Context) (*reverseSession, error) {
98 }
99 }
100
99 -func (b *streamBroker) Close() {
101 +func (b *RelayStream) Close() {
102 b.mu.Lock()
103 sessions := b.ready
104 b.ready = nil
105 if b.closedErr == nil {
104 - b.closedErr = errBrokerClosed
106 + b.closedErr = net.ErrClosed
107 }
108 b.signalLocked()
109 b.mu.Unlock()
@@ -111,13 +113,13 @@ func (b *streamBroker) Close() {
113 }
114 }
115
114 -func (b *streamBroker) ReadyCount() int {
116 +func (b *RelayStream) ReadyCount() int {
117 b.mu.Lock()
118 defer b.mu.Unlock()
119 return len(b.ready)
120 }
121
120 -func (b *streamBroker) watchSession(session *reverseSession) {
122 +func (b *RelayStream) watchSession(session *relaySession) {
123 <-session.Done()
124
125 var readyCount int
@@ -133,64 +135,94 @@ func (b *streamBroker) watchSession(session *reverseSession) {
135 log.Info().
136 Str("component", "relay-server").
137 Str("lease_id", b.leaseID).
136 - Str("remote_addr", session.RemoteAddr()).
138 + Str("remote_addr", session.remoteAddrString()).
139 Int("ready", readyCount).
140 Msg("sdk reverse disconnected")
141 b.signalLocked()
142 b.mu.Unlock()
143 }
144
143 -func (b *streamBroker) signalLocked() {
145 +func (b *RelayStream) signalLocked() {
146 select {
147 case b.notify <- struct{}{}:
148 default:
149 }
150 }
151
150 -type reverseSessionState int
152 +type sessionState int
153
154 const (
153 - reverseSessionIdle reverseSessionState = iota
154 - reverseSessionClaimed
155 - reverseSessionClosed
155 + sessionIdle sessionState = iota
156 + sessionClaimed
157 + sessionClosed
158 )
159
158 -type reverseSession struct {
160 +type relaySession struct {
161 conn net.Conn
162 keepaliveStop chan struct{}
163 keepaliveDone chan struct{}
164 done chan struct{}
165 idleInterval time.Duration
164 - state reverseSessionState
166 + state sessionState
167 closeOnce sync.Once
168 mu sync.Mutex
169 }
170
169 -func newReverseSession(conn net.Conn, idleInterval time.Duration) *reverseSession {
170 - return &reverseSession{
171 +func newRelaySession(conn net.Conn, idleInterval time.Duration) *relaySession {
172 + return &relaySession{
173 conn: conn,
174 idleInterval: idleInterval,
173 - state: reverseSessionIdle,
175 + state: sessionIdle,
176 done: make(chan struct{}),
177 }
178 }
179
178 -func (s *reverseSession) Conn() net.Conn {
179 - return s.conn
180 +func (s *relaySession) Read(p []byte) (int, error) {
181 + return s.conn.Read(p)
182 +}
183 +
184 +func (s *relaySession) Write(p []byte) (int, error) {
185 + return s.conn.Write(p)
186 +}
187 +
188 +func (s *relaySession) LocalAddr() net.Addr {
189 + if s == nil || s.conn == nil {
190 + return nil
191 + }
192 + return s.conn.LocalAddr()
193 +}
194 +
195 +func (s *relaySession) RemoteAddr() net.Addr {
196 + if s == nil || s.conn == nil {
197 + return nil
198 + }
199 + return s.conn.RemoteAddr()
200 +}
201 +
202 +func (s *relaySession) SetDeadline(t time.Time) error {
203 + return s.conn.SetDeadline(t)
204 +}
205 +
206 +func (s *relaySession) SetReadDeadline(t time.Time) error {
207 + return s.conn.SetReadDeadline(t)
208 +}
209 +
210 +func (s *relaySession) SetWriteDeadline(t time.Time) error {
211 + return s.conn.SetWriteDeadline(t)
212 }
213
182 -func (s *reverseSession) Done() <-chan struct{} {
214 +func (s *relaySession) Done() <-chan struct{} {
215 return s.done
216 }
217
186 -func (s *reverseSession) RemoteAddr() string {
218 +func (s *relaySession) remoteAddrString() string {
219 if s == nil || s.conn == nil || s.conn.RemoteAddr() == nil {
220 return ""
221 }
222 return s.conn.RemoteAddr().String()
223 }
224
193 -func (s *reverseSession) IsClosed() bool {
225 +func (s *relaySession) IsClosed() bool {
226 select {
227 case <-s.done:
228 return true
@@ -199,9 +231,9 @@ func (s *reverseSession) IsClosed() bool {
231 }
232 }
233
202 -func (s *reverseSession) StartIdle() {
234 +func (s *relaySession) StartIdle() {
235 s.mu.Lock()
204 - if s.state != reverseSessionIdle || s.keepaliveStop != nil {
236 + if s.state != sessionIdle || s.keepaliveStop != nil {
237 s.mu.Unlock()
238 return
239 }
@@ -214,9 +246,9 @@ func (s *reverseSession) StartIdle() {
246 go s.runKeepalive(stop, done)
247 }
248
217 -func (s *reverseSession) Activate() error {
249 +func (s *relaySession) Activate() error {
250 s.mu.Lock()
219 - if s.state != reverseSessionIdle {
251 + if s.state != sessionIdle {
252 state := s.state
253 s.mu.Unlock()
254 return fmt.Errorf("session not idle: %d", state)
@@ -225,7 +257,7 @@ func (s *reverseSession) Activate() error {
257 done := s.keepaliveDone
258 s.keepaliveStop = nil
259 s.keepaliveDone = nil
228 - s.state = reverseSessionClaimed
260 + s.state = sessionClaimed
261 s.mu.Unlock()
262
263 if stop != nil {
@@ -237,7 +269,7 @@ func (s *reverseSession) Activate() error {
269
270 s.mu.Lock()
271 defer s.mu.Unlock()
240 - if s.state == reverseSessionClosed {
272 + if s.state == sessionClosed {
273 return net.ErrClosed
274 }
275 _ = s.conn.SetWriteDeadline(time.Now().Add(defaultSessionWriteLimit))
@@ -249,7 +281,7 @@ func (s *reverseSession) Activate() error {
281 return err
282 }
283
252 -func (s *reverseSession) Close() error {
284 +func (s *relaySession) Close() error {
285 var err error
286 s.closeOnce.Do(func() {
287 s.mu.Lock()
@@ -257,7 +289,7 @@ func (s *reverseSession) Close() error {
289 done := s.keepaliveDone
290 s.keepaliveStop = nil
291 s.keepaliveDone = nil
260 - s.state = reverseSessionClosed
292 + s.state = sessionClosed
293 conn := s.conn
294 s.mu.Unlock()
295
@@ -274,7 +306,7 @@ func (s *reverseSession) Close() error {
306 return err
307 }
308
277 -func (s *reverseSession) runKeepalive(stop <-chan struct{}, done chan<- struct{}) {
309 +func (s *relaySession) runKeepalive(stop <-chan struct{}, done chan<- struct{}) {
310 defer close(done)
311
312 ticker := time.NewTicker(s.idleInterval)
@@ -290,7 +322,7 @@ func (s *reverseSession) runKeepalive(stop <-chan struct{}, done chan<- struct{}
322 }
323
324 s.mu.Lock()
293 - if s.state != reverseSessionIdle {
325 + if s.state != sessionIdle {
326 s.mu.Unlock()
327 return
328 }
sdk/api_client.go
+23 -35
@@ -33,6 +33,8 @@ const (
33 defaultHTTPShutdownTimeout = 5 * time.Second
34 )
35
36 +var errRelayIncompatible = errors.New("relay is incompatible")
37 +
38 type apiClient struct {
39 baseURL *url.URL
40 httpClient *http.Client
@@ -90,14 +92,14 @@ func (a *apiClient) close() {
92 }
93 }
94
93 -func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, transport string) (types.RegisterResponse, error) {
95 +func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEnabled bool) (types.RegisterResponse, error) {
96 var resp types.RegisterResponse
95 - if err := a.doJSON(ctx, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
97 + if err := a.doJSONWithClient(ctx, a.httpClient, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
98 Name: a.name,
99 Metadata: a.metadata.Copy(),
100 ReverseToken: a.reverseToken,
101 TTL: int(ttl / time.Second),
100 - Transport: transport,
102 + UDPEnabled: udpEnabled,
103 }, &resp); err != nil {
104 return types.RegisterResponse{}, err
105 }
@@ -157,16 +159,21 @@ func (a *apiClient) ensureReady(ctx context.Context) error {
159 func (a *apiClient) ensureCompatible(ctx context.Context, httpClient *http.Client) error {
160 var resp types.DomainResponse
161 if err := a.doJSONWithClient(ctx, httpClient, http.MethodGet, types.PathSDKDomain, nil, &resp); err != nil {
160 - return fmt.Errorf("check relay compatibility: %w", err)
162 + err = fmt.Errorf("check relay compatibility: %w", err)
163 + var netErr net.Error
164 + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) {
165 + return err
166 + }
167 + return fmt.Errorf("%w: %w", errRelayIncompatible, err)
168 }
169 if strings.TrimSpace(resp.Version) != types.SDKProtocolVersion {
163 - return fmt.Errorf("relay sdk version mismatch: relay=%q client=%q", strings.TrimSpace(resp.Version), types.SDKProtocolVersion)
170 + return fmt.Errorf("%w: relay sdk version mismatch: relay=%q client=%q", errRelayIncompatible, strings.TrimSpace(resp.Version), types.SDKProtocolVersion)
171 }
172 return nil
173 }
174
175 func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Duration) error {
169 - return a.doJSON(ctx, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
176 + return a.doJSONWithClient(ctx, a.httpClient, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
177 LeaseID: leaseID,
178 ReverseToken: a.reverseToken,
179 TTL: int(ttl / time.Second),
@@ -174,7 +181,7 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
181 }
182
183 func (a *apiClient) unregisterLease(ctx context.Context, leaseID string) error {
177 - return a.doJSON(ctx, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
184 + return a.doJSONWithClient(ctx, a.httpClient, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
185 LeaseID: leaseID,
186 ReverseToken: a.reverseToken,
187 }, nil)
@@ -228,10 +235,6 @@ func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net
235 return wrapBufferedConn(conn, reader), nil
236 }
237
231 -func (a *apiClient) doJSON(ctx context.Context, method, path string, payload any, out any) error {
232 - return a.doJSONWithClient(ctx, a.httpClient, method, path, payload, out)
233 -}
234 -
238 func (a *apiClient) doJSONWithClient(ctx context.Context, httpClient *http.Client, method, path string, payload any, out any) error {
239 if httpClient == nil {
240 return errors.New("api client is not ready")
@@ -296,7 +299,7 @@ func (c *bufferedConn) Read(p []byte) (int, error) {
299 }
300
301 // openQUICSession opens a QUIC connection to the relay for datagram transport.
299 -func (a *apiClient) openQUICSession(ctx context.Context, quicAddr, leaseID, reverseToken string) (*quic.Conn, error) {
302 +func (a *apiClient) openQUICSession(ctx context.Context, leaseID, reverseToken string) (*quic.Conn, error) {
303 tlsConf := a.rawTLSConfig.Clone()
304 tlsConf.NextProtos = []string{"portal-tunnel"}
305
@@ -306,12 +309,7 @@ func (a *apiClient) openQUICSession(ctx context.Context, quicAddr, leaseID, reve
309 MaxIdleTimeout: 60 * time.Second,
310 }
311
309 - dialAddr := utils.EnsurePort(a.baseURL.Host)
310 - if quicAddr != "" {
311 - dialAddr = quicAddr
312 - }
313 -
314 - conn, err := quic.DialAddr(ctx, dialAddr, tlsConf, quicConf)
312 + conn, err := quic.DialAddr(ctx, utils.EnsurePort(a.baseURL.Host), tlsConf, quicConf)
313 if err != nil {
314 return nil, fmt.Errorf("quic dial: %w", err)
315 }
@@ -322,31 +320,21 @@ func (a *apiClient) openQUICSession(ctx context.Context, quicAddr, leaseID, reve
320 return nil, fmt.Errorf("open control stream: %w", err)
321 }
322
325 - controlMsg, _ := json.Marshal(map[string]string{
326 - "lease_id": leaseID,
327 - "reverse_token": reverseToken,
328 - })
329 - if _, err := stream.Write(controlMsg); err != nil {
323 + controlMsg := types.QUICControlMessage{
324 + LeaseID: leaseID,
325 + ReverseToken: reverseToken,
326 + }
327 + if err := json.NewEncoder(stream).Encode(controlMsg); err != nil {
328 _ = conn.CloseWithError(1, "control write failed")
329 return nil, fmt.Errorf("write control: %w", err)
330 }
331
332 _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
335 - buf := make([]byte, 4096)
336 - n, err := stream.Read(buf)
337 - if err != nil {
333 + var resp types.QUICControlResponse
334 + if err := json.NewDecoder(io.LimitReader(stream, 4096)).Decode(&resp); err != nil {
335 _ = conn.CloseWithError(1, "control read failed")
336 return nil, fmt.Errorf("read control response: %w", err)
337 }
341 -
342 - var resp struct {
343 - OK bool `json:"ok"`
344 - Error string `json:"error,omitempty"`
345 - }
346 - if err := json.Unmarshal(buf[:n], &resp); err != nil {
347 - _ = conn.CloseWithError(1, "invalid response")
348 - return nil, fmt.Errorf("decode control response: %w", err)
349 - }
338 if !resp.OK {
339 _ = conn.CloseWithError(1, resp.Error)
340 return nil, fmt.Errorf("quic connect rejected: %s", resp.Error)
sdk/expose.go
+406 -382
@@ -20,19 +20,17 @@ import (
20 // Exposure owns the lifecycle of one or more relay listeners and accepts
21 // traffic from all of them through one net.Listener.
22 type Exposure struct {
23 - capabilities types.LeaseCapabilities
24 - listener net.Listener
25 - listeners []*Listener
26 - datagrams chan ExposureDatagram
27 - done chan struct{}
23 + udpEnabled bool
24 + listener net.Listener
25 + listeners []*Listener
26 + datagrams chan exposureDatagram
27 + done chan struct{}
28
29 closeOnce sync.Once
30 connSeq atomic.Uint64
31 }
32
33 -// ExposureDatagram represents one datagram received from any relay backing an
34 -// exposure. Reply sends a response back through the same relay flow.
35 -type ExposureDatagram struct {
33 +type exposureDatagram struct {
34 FlowID uint32
35 LeaseID string
36 Payload []byte
@@ -42,21 +40,14 @@ type ExposureDatagram struct {
40 reply func([]byte) error
41 }
42
45 -func (d ExposureDatagram) Reply(payload []byte) error {
46 - if d.reply == nil {
47 - return errors.New("reply path is unavailable")
48 - }
49 - return d.reply(payload)
43 +func (e *Exposure) SupportsStream() bool {
44 + return e != nil
45 }
46
47 // Expose creates relay listeners for each normalized relay URL and exposes a
48 // merged listener for accepting traffic from all of them. Empty relay input
49 // returns nil, nil so callers can fall back to local-only serving.
55 -func Expose(ctx context.Context, relayUrls []string, name string, transport string, metadata types.LeaseMetadata) (*Exposure, error) {
56 - if ctx == nil {
57 - ctx = context.Background()
58 - }
59 -
50 +func Expose(ctx context.Context, relayUrls []string, name string, udpEnabled bool, metadata types.LeaseMetadata) (*Exposure, error) {
51 relayURLs, err := utils.NormalizeRelayURLs(relayUrls)
52 if err != nil {
53 return nil, err
@@ -64,11 +55,6 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
55 if len(relayURLs) == 0 {
56 return nil, nil
57 }
67 - capabilities, err := types.ParseLeaseCapabilities(transport)
68 - if err != nil {
69 - return nil, err
70 - }
71 -
58 listeners := make([]*Listener, 0, len(relayURLs))
59 cleanup := func() error {
60 var closeErr error
@@ -82,9 +68,9 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
68
69 for _, relayURL := range relayURLs {
70 listener, err := NewListener(ctx, relayURL, ListenerConfig{
85 - Name: name,
86 - Transport: capabilities.Transport(),
87 - Metadata: metadata,
71 + Name: name,
72 + UDPEnabled: udpEnabled,
73 + Metadata: metadata,
74 })
75 if err != nil {
76 return nil, errors.Join(fmt.Errorf("listen %q: %w", relayURL, err), cleanup())
@@ -93,28 +79,24 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
79 listeners = append(listeners, listener)
80 }
81
96 - var merged net.Listener
97 - if capabilities.SupportsStream() {
98 - mergedListeners := make([]net.Listener, 0, len(listeners))
99 - for _, listener := range listeners {
100 - mergedListeners = append(mergedListeners, listener)
101 - }
102 -
103 - merged, err = mergeListeners(mergedListeners...)
104 - if err != nil {
105 - return nil, errors.Join(fmt.Errorf("merge listeners: %w", err), cleanup())
106 - }
82 + mergedListeners := make([]net.Listener, 0, len(listeners))
83 + for _, listener := range listeners {
84 + mergedListeners = append(mergedListeners, listener)
85 + }
86 + merged, err := mergeListeners(mergedListeners...)
87 + if err != nil {
88 + return nil, errors.Join(fmt.Errorf("merge listeners: %w", err), cleanup())
89 }
90
91 exposure := &Exposure{
110 - capabilities: capabilities,
111 - listener: merged,
112 - listeners: listeners,
113 - datagrams: make(chan ExposureDatagram, max(len(listeners)*32, 1)),
114 - done: make(chan struct{}),
92 + udpEnabled: udpEnabled,
93 + listener: merged,
94 + listeners: listeners,
95 + datagrams: make(chan exposureDatagram, max(len(listeners)*32, 1)),
96 + done: make(chan struct{}),
97 }
98 go exposure.monitorStartupCounts(ctx)
117 - if exposure.SupportsDatagram() {
99 + if exposure.UDPEnabled() {
100 exposure.attachDatagramPlanes(ctx)
101 }
102
@@ -127,7 +109,22 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
109 return exposure, nil
110 }
111
130 -// Accept implements net.Listener by accepting from the merged relay listener.
112 +// RelayURLs returns the normalized relay URLs backing the exposure.
113 +func (e *Exposure) RelayURLs() []string {
114 + if e == nil || len(e.listeners) == 0 {
115 + return nil
116 + }
117 +
118 + out := make([]string, 0, len(e.listeners))
119 + for _, listener := range e.listeners {
120 + if listener == nil {
121 + continue
122 + }
123 + out = append(out, listener.relayURL)
124 + }
125 + return out
126 +}
127 +
128 func (e *Exposure) Accept() (net.Conn, error) {
129 if e == nil || e.listener == nil {
130 return nil, net.ErrClosed
@@ -159,7 +156,6 @@ func (e *Exposure) Accept() (net.Conn, error) {
156 }, nil
157 }
158
162 -// Addr implements net.Listener.
159 func (e *Exposure) Addr() net.Addr {
160 if e == nil || e.listener == nil {
161 return listenerAddr("portal:exposure")
@@ -167,70 +163,6 @@ func (e *Exposure) Addr() net.Addr {
163 return e.listener.Addr()
164 }
165
170 -// AcceptDatagram returns datagrams from any relay datagram plane attached to
171 -// the exposure.
172 -func (e *Exposure) AcceptDatagram() (ExposureDatagram, error) {
173 - if e == nil || !e.SupportsDatagram() {
174 - return ExposureDatagram{}, net.ErrClosed
175 - }
176 -
177 - select {
178 - case <-e.done:
179 - return ExposureDatagram{}, net.ErrClosed
180 - case dg := <-e.datagrams:
181 - return dg, nil
182 - }
183 -}
184 -
185 -// RelayURLs returns the normalized relay URLs backing the exposure.
186 -func (e *Exposure) RelayURLs() []string {
187 - if e == nil || len(e.listeners) == 0 {
188 - return nil
189 - }
190 -
191 - out := make([]string, 0, len(e.listeners))
192 - for _, listener := range e.listeners {
193 - if listener == nil {
194 - continue
195 - }
196 - out = append(out, listener.relayURL)
197 - }
198 - return out
199 -}
200 -
201 -// UDPAddrs returns the current public UDP addresses exposed by the datagram
202 -// plane, deduplicated across all backing relays.
203 -func (e *Exposure) UDPAddrs() []string {
204 - if e == nil || len(e.listeners) == 0 || !e.SupportsDatagram() {
205 - return nil
206 - }
207 -
208 - out := make([]string, 0, len(e.listeners))
209 - seen := make(map[string]struct{})
210 - for _, listener := range e.listeners {
211 - if listener == nil {
212 - continue
213 - }
214 -
215 - listener.mu.Lock()
216 - udpAddr := listener.udpAddr
217 - listener.mu.Unlock()
218 - if udpAddr == "" {
219 - continue
220 - }
221 - if _, ok := seen[udpAddr]; ok {
222 - continue
223 - }
224 - seen[udpAddr] = struct{}{}
225 - out = append(out, udpAddr)
226 - }
227 - if len(out) == 0 {
228 - return nil
229 - }
230 - return out
231 -}
232 -
233 -// PublicURLs returns the de-duplicated public URLs exposed by the exposure.
166 func (e *Exposure) PublicURLs() []string {
167 if e == nil || len(e.listeners) == 0 {
168 return nil
@@ -258,174 +190,15 @@ func (e *Exposure) PublicURLs() []string {
190 return out
191 }
192
261 -// RunHTTP serves one handler on the exposure and, when localAddr is set, on
262 -// the provided local HTTP address for app-local access. Nil exposures support
263 -// local-only serving.
193 func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr string) error {
265 - var relayListener net.Listener
266 - if e != nil && e.listener != nil {
267 - relayListener = e
268 - }
269 - return RunHTTP(ctx, relayListener, handler, localAddr)
270 -}
271 -
272 -// WaitDatagramReady blocks until at least one backing relay has published a
273 -// public UDP address for this exposure.
274 -func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
275 - if e == nil || !e.SupportsDatagram() {
276 - return nil, errors.New("exposure does not have datagram transport enabled")
277 - }
278 - if ctx == nil {
279 - ctx = context.Background()
280 - }
281 -
282 - ticker := time.NewTicker(50 * time.Millisecond)
283 - defer ticker.Stop()
284 -
285 - for {
286 - if addrs := e.UDPAddrs(); len(addrs) > 0 {
287 - return addrs, nil
288 - }
289 -
290 - select {
291 - case <-e.done:
292 - return nil, net.ErrClosed
293 - case <-ctx.Done():
294 - return nil, ctx.Err()
295 - case <-ticker.C:
296 - }
297 - }
298 -}
299 -
300 -func (e *Exposure) attachDatagramPlanes(ctx context.Context) {
301 - for _, listener := range e.listeners {
302 - if listener == nil {
303 - continue
304 - }
305 -
306 - go e.attachDatagramPlane(ctx, listener)
307 - }
308 -}
309 -
310 -func (e *Exposure) attachDatagramPlane(ctx context.Context, listener *Listener) {
311 - err := listener.WaitDatagramReady(ctx)
312 - if err != nil {
313 - switch {
314 - case e.closed():
315 - return
316 - case ctx != nil && ctx.Err() != nil:
317 - return
318 - case errors.Is(err, net.ErrClosed), errors.Is(err, context.Canceled):
319 - return
320 - default:
321 - log.Warn().
322 - Err(err).
323 - Str("relay_url", listener.relayURL).
324 - Msg("attach datagram plane failed")
325 - return
326 - }
327 - }
328 -
329 - e.forwardDatagrams(listener.relayURL, listener)
330 -}
331 -
332 -func (e *Exposure) forwardDatagrams(relayURL string, listener *Listener) {
333 - for {
334 - dg, err := listener.AcceptDatagram()
335 - if err != nil {
336 - if e.closed() || errors.Is(err, net.ErrClosed) {
337 - return
338 - }
339 - log.Warn().
340 - Err(err).
341 - Str("relay_url", relayURL).
342 - Str("lease_id", listener.LeaseID()).
343 - Msg("datagram accept failed")
344 - return
345 - }
346 -
347 - flowID := dg.FlowID
348 - reply := func(payload []byte) error {
349 - return listener.SendDatagram(flowID, payload)
350 - }
351 -
352 - select {
353 - case <-e.done:
354 - return
355 - case e.datagrams <- ExposureDatagram{
356 - FlowID: flowID,
357 - LeaseID: listener.LeaseID(),
358 - Payload: append([]byte(nil), dg.Payload...),
359 - RelayURL: relayURL,
360 - UDPAddr: listener.UDPAddr(),
361 - reply: reply,
362 - }:
363 - }
364 - }
365 -}
366 -
367 -// Close closes the merged listener and all underlying relay listeners.
368 -func (e *Exposure) Close() error {
369 - if e == nil {
370 - return nil
371 - }
372 -
373 - var closeErr error
374 - e.closeOnce.Do(func() {
375 - if e.done != nil {
376 - close(e.done)
377 - }
378 - if e.listener != nil {
379 - closeErr = errors.Join(closeErr, e.listener.Close())
380 - }
381 - for _, listener := range e.listeners {
382 - if listener != nil {
383 - closeErr = errors.Join(closeErr, listener.Close())
384 - }
385 - }
386 -
387 - event := log.Info().
388 - Int("relay_count", len(e.listeners)).
389 - Strs("relays", e.RelayURLs())
390 - if closeErr != nil {
391 - event = log.Warn().
392 - Err(closeErr).
393 - Int("relay_count", len(e.listeners)).
394 - Strs("relays", e.RelayURLs())
395 - }
396 - event.Msg("exposure closed")
397 - })
398 - return closeErr
399 -}
400 -
401 -func (e *Exposure) SupportsDatagram() bool {
402 - return e != nil && e.capabilities.SupportsDatagram()
403 -}
404 -
405 -func (e *Exposure) SupportsStream() bool {
406 - return e != nil && e.capabilities.SupportsStream()
407 -}
408 -
409 -func (e *Exposure) closed() bool {
410 - if e == nil || e.done == nil {
411 - return true
412 - }
413 -
414 - select {
415 - case <-e.done:
416 - return true
417 - default:
418 - return false
194 + if e == nil || e.listener == nil {
195 + return RunHTTP(ctx, nil, handler, localAddr)
196 }
197 + return RunHTTP(ctx, e, handler, localAddr)
198 }
199
422 -// RunHTTP serves one handler on relayListener and, when localAddr is set, on
423 -// the provided local HTTP address for app-local access.
200 func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
201 localAddr = strings.TrimSpace(localAddr)
426 - if ctx == nil {
427 - ctx = context.Background()
428 - }
202
203 if relayListener == nil && localAddr == "" {
204 return errors.New("relay listener or local address is required")
@@ -525,119 +298,22 @@ func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handl
298 return errors.Join(serveErr, shutdownErr)
299 }
300
528 -func (e *Exposure) monitorStartupCounts(ctx context.Context) {
529 - if e == nil {
530 - return
301 +func mergeListeners(listeners ...net.Listener) (net.Listener, error) {
302 + if len(listeners) == 0 {
303 + return nil, errors.New("at least one listener is required")
304 }
305
533 - ticker := time.NewTicker(time.Second)
534 - defer ticker.Stop()
535 - prevStatuses := make(map[string]listenerStatus, len(e.listeners))
536 - firstRun := true
537 -
538 - for {
539 - readyCount, inactiveCount := 0, 0
540 - activated := make([]string, 0)
541 - deactivated := make([]string, 0)
542 - for _, listener := range e.listeners {
543 - status := listenerStatusInactive
544 - if listener != nil {
545 - status = listener.StartupStatus()
546 - }
547 - if status == listenerStatusReady {
548 - readyCount++
549 - } else {
550 - inactiveCount++
551 - }
552 -
553 - if listener == nil {
554 - continue
555 - }
556 - if prev, ok := prevStatuses[listener.relayURL]; ok && prev != status {
557 - if status == listenerStatusReady {
558 - activated = append(activated, listener.relayURL)
559 - } else {
560 - deactivated = append(deactivated, listener.relayURL)
561 - }
562 - }
563 - prevStatuses[listener.relayURL] = status
564 - }
565 -
566 - if firstRun || len(activated) > 0 || len(deactivated) > 0 {
567 - event := log.Info().
568 - Int("inactive", inactiveCount).
569 - Int("ready", readyCount)
570 - if len(activated) > 0 {
571 - event = event.Strs("activated", activated)
572 - }
573 - if len(deactivated) > 0 {
574 - event = event.Strs("deactivated", deactivated)
575 - }
576 - event.Msg("relay status")
577 - firstRun = false
578 - }
579 -
580 - select {
581 - case <-e.done:
582 - return
583 - case <-ctx.Done():
584 - return
585 - case <-ticker.C:
586 - }
587 - }
588 -}
589 -
590 -type exposureConn struct {
591 - net.Conn
592 - id uint64
593 - localAddr string
594 - remoteAddr string
595 - closeOnce sync.Once
596 -}
597 -
598 -func (c *exposureConn) Close() error {
599 - var closeErr error
600 - c.closeOnce.Do(func() {
601 - closeErr = c.Conn.Close()
602 - if errors.Is(closeErr, net.ErrClosed) {
603 - closeErr = nil
604 - }
605 -
606 - event := log.Info().
607 - Uint64("conn_id", c.id).
608 - Str("local_addr", c.localAddr).
609 - Str("remote_addr", c.remoteAddr)
610 - if closeErr != nil {
611 - event = log.Warn().
612 - Err(closeErr).
613 - Uint64("conn_id", c.id).
614 - Str("local_addr", c.localAddr).
615 - Str("remote_addr", c.remoteAddr)
616 - }
617 - event.Msg("exposure connection closed")
618 - })
619 - return closeErr
620 -}
621 -
622 -// mergeListeners fans in multiple listeners into one net.Listener. It keeps
623 -// serving accepts from remaining listeners when one listener stops, and returns
624 -// a terminal error only after all source listeners have stopped.
625 -func mergeListeners(listeners ...net.Listener) (net.Listener, error) {
626 - if len(listeners) == 0 {
627 - return nil, errors.New("at least one listener is required")
628 - }
629 -
630 - merged := &mergedListener{
631 - listeners: make([]net.Listener, 0, len(listeners)),
632 - accepted: make(chan net.Conn),
633 - closed: make(chan struct{}),
634 - }
635 - for i, listener := range listeners {
636 - if listener == nil {
637 - return nil, fmt.Errorf("listener %d is nil", i)
638 - }
639 - merged.listeners = append(merged.listeners, listener)
640 - }
306 + merged := &mergedListener{
307 + listeners: make([]net.Listener, 0, len(listeners)),
308 + accepted: make(chan net.Conn),
309 + closed: make(chan struct{}),
310 + }
311 + for i, listener := range listeners {
312 + if listener == nil {
313 + return nil, fmt.Errorf("listener %d is nil", i)
314 + }
315 + merged.listeners = append(merged.listeners, listener)
316 + }
317
318 merged.active = len(merged.listeners)
319 for _, listener := range merged.listeners {
@@ -762,3 +438,351 @@ func (l *mergedListener) terminalErrorOr(fallback error) error {
438 }
439 return l.terminalErr
440 }
441 +
442 +type exposureConn struct {
443 + net.Conn
444 + id uint64
445 + localAddr string
446 + remoteAddr string
447 + closeOnce sync.Once
448 +}
449 +
450 +func (c *exposureConn) Close() error {
451 + var closeErr error
452 + c.closeOnce.Do(func() {
453 + closeErr = c.Conn.Close()
454 + if errors.Is(closeErr, net.ErrClosed) {
455 + closeErr = nil
456 + }
457 +
458 + event := log.Info().
459 + Uint64("conn_id", c.id).
460 + Str("local_addr", c.localAddr).
461 + Str("remote_addr", c.remoteAddr)
462 + if closeErr != nil {
463 + event = log.Warn().
464 + Err(closeErr).
465 + Uint64("conn_id", c.id).
466 + Str("local_addr", c.localAddr).
467 + Str("remote_addr", c.remoteAddr)
468 + }
469 + event.Msg("exposure connection closed")
470 + })
471 + return closeErr
472 +}
473 +
474 +// Close closes the merged listener and all underlying relay listeners.
475 +func (e *Exposure) Close() error {
476 + if e == nil {
477 + return nil
478 + }
479 +
480 + var closeErr error
481 + e.closeOnce.Do(func() {
482 + if e.done != nil {
483 + close(e.done)
484 + }
485 + if e.listener != nil {
486 + closeErr = errors.Join(closeErr, e.listener.Close())
487 + }
488 + for _, listener := range e.listeners {
489 + if listener != nil {
490 + closeErr = errors.Join(closeErr, listener.Close())
491 + }
492 + }
493 +
494 + event := log.Info().
495 + Int("relay_count", len(e.listeners)).
496 + Strs("relays", e.RelayURLs())
497 + if closeErr != nil {
498 + event = log.Warn().
499 + Err(closeErr).
500 + Int("relay_count", len(e.listeners)).
501 + Strs("relays", e.RelayURLs())
502 + }
503 + event.Msg("exposure closed")
504 + })
505 + return closeErr
506 +}
507 +
508 +func (e *Exposure) SupportsDatagram() bool {
509 + return e != nil && e.udpEnabled
510 +}
511 +
512 +func (e *Exposure) UDPEnabled() bool {
513 + return e != nil && e.udpEnabled
514 +}
515 +
516 +func (e *Exposure) AcceptDatagram() (types.DatagramFrame, string, string, string, func([]byte) error, error) {
517 + if e == nil || !e.SupportsDatagram() {
518 + return types.DatagramFrame{}, "", "", "", nil, net.ErrClosed
519 + }
520 +
521 + select {
522 + case <-e.done:
523 + return types.DatagramFrame{}, "", "", "", nil, net.ErrClosed
524 + case dg := <-e.datagrams:
525 + reply := dg.reply
526 + if reply == nil {
527 + reply = func([]byte) error { return errors.New("reply path is unavailable") }
528 + }
529 + return types.DatagramFrame{
530 + FlowID: dg.FlowID,
531 + Payload: dg.Payload,
532 + }, dg.LeaseID, dg.RelayURL, dg.UDPAddr, reply, nil
533 + }
534 +}
535 +
536 +func (e *Exposure) UDPAddrs() []string {
537 + if e == nil || len(e.listeners) == 0 || !e.SupportsDatagram() {
538 + return nil
539 + }
540 +
541 + out := make([]string, 0, len(e.listeners))
542 + seen := make(map[string]struct{})
543 + for _, listener := range e.listeners {
544 + if listener == nil {
545 + continue
546 + }
547 +
548 + listener.mu.Lock()
549 + udpAddr := listener.udpAddr
550 + listener.mu.Unlock()
551 + if udpAddr == "" {
552 + continue
553 + }
554 + if _, ok := seen[udpAddr]; ok {
555 + continue
556 + }
557 + seen[udpAddr] = struct{}{}
558 + out = append(out, udpAddr)
559 + }
560 + if len(out) == 0 {
561 + return nil
562 + }
563 + return out
564 +}
565 +
566 +func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
567 + if e == nil || !e.SupportsDatagram() {
568 + return nil, errors.New("exposure does not have udp enabled")
569 + }
570 +
571 + ticker := time.NewTicker(50 * time.Millisecond)
572 + defer ticker.Stop()
573 +
574 + for {
575 + if addrs := e.readyUDPAddrs(); len(addrs) > 0 {
576 + return addrs, nil
577 + }
578 + if e.allDatagramNegotiationsResolvedWithoutDatagram() {
579 + return nil, errors.New("relay did not expose udp")
580 + }
581 +
582 + select {
583 + case <-e.done:
584 + return nil, net.ErrClosed
585 + case <-ctx.Done():
586 + return nil, ctx.Err()
587 + case <-ticker.C:
588 + }
589 + }
590 +}
591 +
592 +func (e *Exposure) readyUDPAddrs() []string {
593 + if e == nil || len(e.listeners) == 0 || !e.SupportsDatagram() {
594 + return nil
595 + }
596 +
597 + out := make([]string, 0, len(e.listeners))
598 + seen := make(map[string]struct{})
599 + for _, listener := range e.listeners {
600 + if listener == nil || !listener.datagramConnected() {
601 + continue
602 + }
603 +
604 + udpAddr := listener.UDPAddr()
605 + if udpAddr == "" {
606 + continue
607 + }
608 + if _, ok := seen[udpAddr]; ok {
609 + continue
610 + }
611 + seen[udpAddr] = struct{}{}
612 + out = append(out, udpAddr)
613 + }
614 + if len(out) == 0 {
615 + return nil
616 + }
617 + return out
618 +}
619 +
620 +func (e *Exposure) allDatagramNegotiationsResolvedWithoutDatagram() bool {
621 + if e == nil || len(e.listeners) == 0 {
622 + return true
623 + }
624 +
625 + resolved := 0
626 + for _, listener := range e.listeners {
627 + if listener == nil {
628 + resolved++
629 + continue
630 + }
631 +
632 + registered, enabled := listener.datagramNegotiationState()
633 + if !registered {
634 + if listener.done() {
635 + resolved++
636 + }
637 + continue
638 + }
639 + if enabled {
640 + return false
641 + }
642 + resolved++
643 + }
644 +
645 + return resolved == len(e.listeners)
646 +}
647 +
648 +func (e *Exposure) attachDatagramPlanes(ctx context.Context) {
649 + for _, listener := range e.listeners {
650 + if listener == nil {
651 + continue
652 + }
653 +
654 + go e.attachDatagramPlane(ctx, listener)
655 + }
656 +}
657 +
658 +func (e *Exposure) attachDatagramPlane(ctx context.Context, listener *Listener) {
659 + err := listener.WaitDatagramReady(ctx)
660 + if err != nil {
661 + switch {
662 + case e.closed():
663 + return
664 + case ctx != nil && ctx.Err() != nil:
665 + return
666 + case errors.Is(err, net.ErrClosed), errors.Is(err, context.Canceled):
667 + return
668 + default:
669 + log.Warn().
670 + Err(err).
671 + Str("relay_url", listener.relayURL).
672 + Msg("attach datagram plane failed")
673 + return
674 + }
675 + }
676 +
677 + e.forwardDatagrams(listener.relayURL, listener)
678 +}
679 +
680 +func (e *Exposure) forwardDatagrams(relayURL string, listener *Listener) {
681 + for {
682 + dg, err := listener.AcceptDatagram()
683 + if err != nil {
684 + if e.closed() || errors.Is(err, net.ErrClosed) {
685 + return
686 + }
687 + log.Warn().
688 + Err(err).
689 + Str("relay_url", relayURL).
690 + Str("lease_id", listener.LeaseID()).
691 + Msg("datagram accept failed")
692 + return
693 + }
694 +
695 + flowID := dg.FlowID
696 + reply := func(payload []byte) error {
697 + return listener.SendDatagram(flowID, payload)
698 + }
699 +
700 + select {
701 + case <-e.done:
702 + return
703 + case e.datagrams <- exposureDatagram{
704 + FlowID: flowID,
705 + LeaseID: listener.LeaseID(),
706 + Payload: append([]byte(nil), dg.Payload...),
707 + RelayURL: relayURL,
708 + UDPAddr: listener.UDPAddr(),
709 + reply: reply,
710 + }:
711 + }
712 + }
713 +}
714 +
715 +func (e *Exposure) closed() bool {
716 + if e == nil || e.done == nil {
717 + return true
718 + }
719 +
720 + select {
721 + case <-e.done:
722 + return true
723 + default:
724 + return false
725 + }
726 +}
727 +
728 +func (e *Exposure) monitorStartupCounts(ctx context.Context) {
729 + if e == nil {
730 + return
731 + }
732 +
733 + ticker := time.NewTicker(time.Second)
734 + defer ticker.Stop()
735 + prevStatuses := make(map[string]listenerStatus, len(e.listeners))
736 + firstRun := true
737 +
738 + for {
739 + readyCount, inactiveCount := 0, 0
740 + activated := make([]string, 0)
741 + deactivated := make([]string, 0)
742 + for _, listener := range e.listeners {
743 + status := listenerStatusInactive
744 + if listener != nil {
745 + status = listener.StartupStatus()
746 + }
747 + if status == listenerStatusReady {
748 + readyCount++
749 + } else {
750 + inactiveCount++
751 + }
752 +
753 + if listener == nil {
754 + continue
755 + }
756 + if prev, ok := prevStatuses[listener.relayURL]; ok && prev != status {
757 + if status == listenerStatusReady {
758 + activated = append(activated, listener.relayURL)
759 + } else {
760 + deactivated = append(deactivated, listener.relayURL)
761 + }
762 + }
763 + prevStatuses[listener.relayURL] = status
764 + }
765 +
766 + if firstRun || len(activated) > 0 || len(deactivated) > 0 {
767 + event := log.Info().
768 + Int("inactive", inactiveCount).
769 + Int("ready", readyCount)
770 + if len(activated) > 0 {
771 + event = event.Strs("activated", activated)
772 + }
773 + if len(deactivated) > 0 {
774 + event = event.Strs("deactivated", deactivated)
775 + }
776 + event.Msg("relay status")
777 + firstRun = false
778 + }
779 +
780 + select {
781 + case <-e.done:
782 + return
783 + case <-ctx.Done():
784 + return
785 + case <-ticker.C:
786 + }
787 + }
788 +}
sdk/listener.go
+190 -348
@@ -4,7 +4,6 @@ import (
4 "context"
5 "crypto/tls"
6 "errors"
7 - "fmt"
7 "io"
8 "net"
9 "net/url"
@@ -12,10 +11,11 @@ import (
11 "sync"
12 "time"
13
14 + "github.com/quic-go/quic-go"
15 "github.com/rs/zerolog/log"
16
17 - "github.com/gosuda/portal/v2/portal/datagram"
17 "github.com/gosuda/portal/v2/portal/keyless"
18 + "github.com/gosuda/portal/v2/portal/transport"
19 "github.com/gosuda/portal/v2/types"
20 "github.com/gosuda/portal/v2/utils"
21 )
@@ -23,7 +23,7 @@ import (
23 type ListenerConfig struct {
24 Name string
25 ReverseToken string
26 - Transport string // "tcp", "udp", or "both"; defaults to "tcp"
26 + UDPEnabled bool
27 Metadata types.LeaseMetadata
28 RootCAPEM []byte
29 DialTimeout time.Duration
@@ -43,36 +43,26 @@ const (
43 listenerStatusReady listenerStatus = "ready"
44 )
45
46 -type datagramState struct {
47 - leaseID string
48 - reverseToken string
49 - quicAddr string
50 -}
51 -
46 type Listener struct {
53 - tlsCloser io.Closer
54 - tlsConfig *tls.Config
55 - readyTarget int
56 - retryCount int
57 - retryWait time.Duration
58 - leaseTTL time.Duration
59 - renewBefore time.Duration
60 - handshakeTimeout time.Duration
61 - doneCh <-chan struct{}
62 - cancel context.CancelFunc
63 - api *apiClient
64 - accepted chan net.Conn
65 - capabilities types.LeaseCapabilities
66 - relayURL string
67 - transport string
68 - startupStatus listenerStatus
69 - activeSessions int
70 - leaseID string
71 - hostname string
72 - udpAddr string
73 - quicAddr string
74 - metadata types.LeaseMetadata
75 - datagram *datagram.Session
47 + tlsCloser io.Closer
48 + tlsConfig *tls.Config
49 + readyTarget int
50 + retryCount int
51 + retryWait time.Duration
52 + leaseTTL time.Duration
53 + renewBefore time.Duration
54 + doneCh <-chan struct{}
55 + cancel context.CancelFunc
56 + api *apiClient
57 + relayURL string
58 + startupStatus listenerStatus
59 + leaseID string
60 + hostname string
61 + udpAddr string
62 + udpEnabled bool
63 + metadata types.LeaseMetadata
64 + stream *transport.ClientStream
65 + datagram *transport.ClientDatagram
66
67 registered chan struct{} // closed after first successful registration
68 closeOnce sync.Once
@@ -83,10 +73,6 @@ type Listener struct {
73 // NewListener creates one relay listener and its dedicated relay transport for one relay URL.
74 // Only local config validation fails immediately; relay startup runs in the background until ready.
75 func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Listener, error) {
86 - if ctx == nil {
87 - ctx = context.Background()
88 - }
89 -
76 listenerCtx, cancel := context.WithCancel(ctx)
77 readyTarget := utils.IntOrDefault(cfg.ReadyTarget, defaultReadyTarget)
78 leaseTTL := utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
@@ -100,33 +86,23 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
86 return nil, err
87 }
88
103 - capabilities, err := types.ParseLeaseCapabilities(cfg.Transport)
104 - if err != nil {
105 - cancel()
106 - api.close()
107 - return nil, err
108 - }
109 - transport := capabilities.Transport()
110 -
89 l := &Listener{
112 - doneCh: listenerCtx.Done(),
113 - cancel: cancel,
114 - api: api,
115 - accepted: make(chan net.Conn, max(readyTarget*2, 1)),
116 - capabilities: capabilities,
117 - registered: make(chan struct{}),
118 - relayURL: api.baseURL.String(),
119 - transport: transport,
120 - startupStatus: listenerStatusInactive,
121 - readyTarget: readyTarget,
122 - retryCount: cfg.RetryCount,
123 - retryWait: retryWait,
124 - leaseTTL: leaseTTL,
125 - renewBefore: renewBefore,
126 - handshakeTimeout: handshakeTimeout,
127 - }
128 - if capabilities.SupportsDatagram() {
129 - l.datagram = datagram.NewSession(256, false, func(err error) {
90 + doneCh: listenerCtx.Done(),
91 + cancel: cancel,
92 + api: api,
93 + registered: make(chan struct{}),
94 + relayURL: api.baseURL.String(),
95 + startupStatus: listenerStatusInactive,
96 + readyTarget: readyTarget,
97 + retryCount: cfg.RetryCount,
98 + retryWait: retryWait,
99 + leaseTTL: leaseTTL,
100 + renewBefore: renewBefore,
101 + udpEnabled: cfg.UDPEnabled,
102 + }
103 + l.stream = transport.NewClientStream(readyTarget, handshakeTimeout)
104 + if cfg.UDPEnabled {
105 + l.datagram = transport.NewClientDatagram(func(err error) {
106 log.Warn().
107 Err(err).
108 Str("component", "sdk-datagram-plane").
@@ -136,8 +112,11 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
112 }
113
114 if l.datagram != nil {
139 - go l.runDatagramLoop(listenerCtx)
115 + go l.datagram.RunLoop(listenerCtx, l.currentDatagramState, func(ctx context.Context, state transport.ClientDatagramState) (*quic.Conn, error) {
116 + return l.api.openQUICSession(ctx, state.LeaseID, state.ReverseToken)
117 + })
118 }
119 +
120 go l.runStartup(listenerCtx)
121 return l, nil
122 }
@@ -149,12 +128,24 @@ func (l *Listener) runStartup(ctx context.Context) {
128 err := l.registerAndConfigure(ctx)
129 switch {
130 case err == nil:
152 - if l.SupportsStream() {
153 - for i := 0; i < l.readyTarget; i++ {
154 - go l.runSessionLoop(ctx)
155 - }
156 - } else {
157 - l.setStartupStatus(listenerStatusReady)
131 + for i := 0; i < l.readyTarget; i++ {
132 + go l.stream.RunLoop(
133 + ctx,
134 + func(ctx context.Context) (net.Conn, error) {
135 + l.mu.Lock()
136 + leaseID := l.leaseID
137 + l.mu.Unlock()
138 + return l.api.openReverseSession(ctx, leaseID)
139 + },
140 + func() *tls.Config {
141 + l.mu.Lock()
142 + defer l.mu.Unlock()
143 + return l.tlsConfig
144 + },
145 + func() { l.setStartupStatus(listenerStatusReady) },
146 + func() { l.setStartupStatus(listenerStatusInactive) },
147 + l.retryOrClose,
148 + )
149 }
150 go l.runRenewLoop(ctx)
151 publicURL := l.PublicURL()
@@ -169,6 +160,15 @@ func (l *Listener) runStartup(ctx context.Context) {
160 case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
161 return
162 default:
163 + if isPermanentRegistrationError(err) {
164 + log.Error().
165 + Err(err).
166 + Str("relay_url", l.relayURL).
167 + Str("lease_id", l.LeaseID()).
168 + Msg("lease registration failed; closing listener")
169 + _ = l.Close()
170 + return
171 + }
172 retries++
173 if !l.retryOrClose(ctx, "lease registration", err, retries) {
174 return
@@ -177,18 +177,6 @@ func (l *Listener) runStartup(ctx context.Context) {
177 }
178 }
179
180 -func (l *Listener) Accept() (net.Conn, error) {
181 - if !l.SupportsStream() {
182 - return nil, net.ErrClosed
183 - }
184 - select {
185 - case <-l.doneCh:
186 - return nil, net.ErrClosed
187 - case conn := <-l.accepted:
188 - return conn, nil
189 - }
190 -}
191 -
180 func (l *Listener) Close() error {
181 var closeErr error
182 l.closeOnce.Do(func() {
@@ -199,17 +187,21 @@ func (l *Listener) Close() error {
187 l.mu.Lock()
188 leaseID := l.leaseID
189 tlsCloser := l.tlsCloser
190 + stream := l.stream
191 datagram := l.datagram
192 api := l.api
193 l.leaseID = ""
194 l.hostname = ""
195 + l.udpAddr = ""
196 l.tlsConfig = nil
197 l.tlsCloser = nil
198 l.mu.Unlock()
199
210 - l.drainAccepted()
200 + if stream != nil {
201 + stream.Drain()
202 + }
203 if datagram != nil {
212 - datagram.Stop("listener closed")
204 + datagram.Close()
205 }
206
207 if api != nil && leaseID != "" {
@@ -227,6 +219,13 @@ func (l *Listener) Close() error {
219 return closeErr
220 }
221
222 +func (l *Listener) Accept() (net.Conn, error) {
223 + if l.stream == nil {
224 + return nil, net.ErrClosed
225 + }
226 + return l.stream.Accept(l.doneCh)
227 +}
228 +
229 func (l *Listener) Addr() net.Addr {
230 l.mu.Lock()
231 defer l.mu.Unlock()
@@ -255,10 +254,6 @@ func (l *Listener) Metadata() types.LeaseMetadata {
254 }
255
256 func (l *Listener) PublicURL() string {
258 - if !l.SupportsStream() {
259 - return ""
260 - }
261 -
257 l.mu.Lock()
258 hostname := l.hostname
259 relayURL := l.relayURL
@@ -284,96 +279,105 @@ func (l *Listener) PublicURL() string {
279 }).String()
280 }
281
287 -func (l *Listener) runSessionLoop(ctx context.Context) {
288 - var retries int
282 +func (l *Listener) ActiveSessions() int {
283 + if l == nil || l.stream == nil {
284 + return 0
285 + }
286 + return l.stream.ActiveSessions()
287 +}
288
290 - for {
291 - claimed, err := l.runSession(ctx)
292 - switch {
293 - case err == nil:
294 - retries = 0
295 - case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
296 - return
297 - case claimed:
298 - // A claimed connection already reached the data plane.
299 - // Do not spend retry budget on browser-side TLS failures or disconnects.
300 - retries = 0
301 - default:
302 - retries++
303 - if l.ActiveSessions() == 0 {
304 - l.setStartupStatus(listenerStatusInactive)
305 - }
306 - if !l.retryOrClose(ctx, "reverse session connect", err, retries) {
307 - return
308 - }
309 - }
289 +func (l *Listener) AcceptDatagram() (types.DatagramFrame, error) {
290 + if l == nil || !l.activeSupportsDatagram() || l.datagram == nil {
291 + return types.DatagramFrame{}, net.ErrClosed
292 }
293 + return l.datagram.Accept(l.doneCh)
294 }
295
313 -func (l *Listener) runDatagramLoop(ctx context.Context) {
314 - for {
315 - select {
316 - case <-ctx.Done():
317 - l.datagram.Stop("listener context closed")
318 - return
319 - default:
320 - }
296 +func (l *Listener) SendDatagram(flowID uint32, payload []byte) error {
297 + if l == nil || !l.activeSupportsDatagram() || l.datagram == nil {
298 + return net.ErrClosed
299 + }
300 + return l.datagram.Send(flowID, payload)
301 +}
302
322 - state, ok := l.currentDatagramState()
323 - if !ok {
324 - if !utils.SleepOrDone(ctx, time.Second) {
325 - l.datagram.Stop("listener context closed")
326 - return
327 - }
328 - continue
329 - }
303 +func (l *Listener) UDPAddr() string {
304 + l.mu.Lock()
305 + defer l.mu.Unlock()
306 + return l.udpAddr
307 +}
308
331 - conn, err := l.api.openQUICSession(ctx, state.quicAddr, state.leaseID, state.reverseToken)
332 - if err != nil {
333 - log.Warn().
334 - Err(err).
335 - Str("component", "sdk-datagram-plane").
336 - Str("lease_id", state.leaseID).
337 - Msg("quic session open failed, retrying")
338 - if !utils.SleepOrDone(ctx, 2*time.Second) {
339 - l.datagram.Stop("listener context closed")
340 - return
341 - }
342 - continue
343 - }
309 +func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool) {
310 + if l == nil || !l.activeSupportsDatagram() {
311 + return transport.ClientDatagramState{}, false
312 + }
313
345 - log.Info().
346 - Str("component", "sdk-datagram-plane").
347 - Str("lease_id", state.leaseID).
348 - Str("remote_addr", conn.RemoteAddr().String()).
349 - Msg("quic tunnel connected")
314 + l.mu.Lock()
315 + defer l.mu.Unlock()
316
351 - recvDone, err := l.datagram.Bind(conn)
352 - if err != nil {
353 - if ctx.Err() != nil {
354 - return
355 - }
356 - log.Warn().
357 - Err(err).
358 - Str("component", "sdk-datagram-plane").
359 - Str("lease_id", state.leaseID).
360 - Msg("quic session bind failed")
361 - if !utils.SleepOrDone(ctx, time.Second) {
362 - return
363 - }
364 - continue
317 + if l.api == nil || l.leaseID == "" {
318 + return transport.ClientDatagramState{}, false
319 + }
320 +
321 + return transport.ClientDatagramState{
322 + LeaseID: l.leaseID,
323 + ReverseToken: l.api.reverseToken,
324 + }, true
325 +}
326 +
327 +func (l *Listener) WaitDatagramReady(ctx context.Context) error {
328 + if l == nil || !l.UDPEnabled() {
329 + return errors.New("lease does not have udp enabled")
330 + }
331 + if err := l.WaitRegistered(ctx); err != nil {
332 + return err
333 + }
334 + if !l.activeSupportsDatagram() {
335 + return errors.New("relay did not enable udp")
336 + }
337 + if l.UDPAddr() == "" {
338 + return errors.New("lease registration did not expose udp address")
339 + }
340 +
341 + ticker := time.NewTicker(50 * time.Millisecond)
342 + defer ticker.Stop()
343 +
344 + for {
345 + if l.datagramConnected() {
346 + return nil
347 }
348
349 select {
350 + case <-l.doneCh:
351 + return net.ErrClosed
352 case <-ctx.Done():
369 - l.datagram.Stop("listener context closed")
370 - return
371 - case <-recvDone:
353 + return ctx.Err()
354 + case <-ticker.C:
355 }
356 + }
357 +}
358
374 - if !utils.SleepOrDone(ctx, time.Second) {
375 - return
376 - }
359 +func (l *Listener) activeSupportsDatagram() bool {
360 + if l == nil || !l.udpEnabled {
361 + return false
362 + }
363 + l.mu.Lock()
364 + defer l.mu.Unlock()
365 + return l.udpAddr != ""
366 +}
367 +
368 +func (l *Listener) datagramConnected() bool {
369 + return l != nil && l.datagram != nil && l.datagram.Connected()
370 +}
371 +
372 +func (l *Listener) datagramNegotiationState() (registered bool, enabled bool) {
373 + if l == nil {
374 + return true, false
375 + }
376 + select {
377 + case <-l.registered:
378 + return true, l.activeSupportsDatagram()
379 + default:
380 + return false, false
381 }
382 }
383
@@ -412,64 +416,6 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
416 }
417 }
418
415 -func (l *Listener) runSession(ctx context.Context) (bool, error) {
416 - l.mu.Lock()
417 - leaseID := l.leaseID
418 - l.mu.Unlock()
419 -
420 - conn, err := l.api.openReverseSession(ctx, leaseID)
421 - if err != nil {
422 - return false, err
423 - }
424 - l.sessionOpened()
425 - defer l.sessionClosed()
426 -
427 - var marker [1]byte
428 - for {
429 - _ = conn.SetReadDeadline(time.Now().Add(2 * l.handshakeTimeout))
430 - if _, err := io.ReadFull(conn, marker[:]); err != nil {
431 - _ = conn.Close()
432 - return false, err
433 - }
434 - _ = conn.SetReadDeadline(time.Time{})
435 -
436 - switch marker[0] {
437 - case types.MarkerKeepalive:
438 - continue
439 - case types.MarkerTLSStart:
440 - if err := l.activate(ctx, conn); err != nil {
441 - _ = conn.Close()
442 - return true, err
443 - }
444 - return true, nil
445 - default:
446 - _ = conn.Close()
447 - return false, fmt.Errorf("unexpected reverse marker: 0x%02x", marker[0])
448 - }
449 - }
450 -}
451 -
452 -func (l *Listener) activate(ctx context.Context, conn net.Conn) error {
453 - l.mu.Lock()
454 - tlsCfg := l.tlsConfig
455 - l.mu.Unlock()
456 -
457 - tlsConn := tls.Server(conn, tlsCfg)
458 - handshakeCtx, cancel := context.WithTimeout(ctx, l.handshakeTimeout)
459 - defer cancel()
460 - if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
461 - return err
462 - }
463 -
464 - select {
465 - case <-ctx.Done():
466 - _ = tlsConn.Close()
467 - return ctx.Err()
468 - case l.accepted <- tlsConn:
469 - return nil
470 - }
471 -}
472 -
419 func (l *Listener) renewLease(ctx context.Context) error {
420 l.mu.Lock()
421 leaseID := l.leaseID
@@ -481,7 +427,7 @@ func (l *Listener) renewLease(ctx context.Context) error {
427 if err == nil {
428 return nil
429 }
484 - if !isLeaseNotFound(err) {
430 + if !errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound}) {
431 return err
432 }
433
@@ -496,21 +442,26 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
442 return err
443 }
444
499 - resp, err := l.api.registerLease(ctx, l.leaseTTL, l.transport)
445 + resp, err := l.api.registerLease(ctx, l.leaseTTL, l.udpEnabled)
446 if err != nil {
447 return err
448 }
449 + if l.udpEnabled && !resp.UDPEnabled {
450 + _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
451 + return &types.APIRequestError{
452 + Code: types.APIErrorCodeFeatureUnavailable,
453 + Message: "relay did not enable required udp support",
454 + }
455 + }
456
457 var (
458 tlsConf *tls.Config
459 tlsCloser io.Closer
460 )
508 - if l.SupportsStream() {
509 - tlsConf, tlsCloser, err = keyless.BuildClientTLSConfig(l.api.baseURL.String(), []string{resp.Hostname})
510 - if err != nil {
511 - _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
512 - return err
513 - }
461 + tlsConf, tlsCloser, err = keyless.BuildClientTLSConfig(l.api.baseURL.String(), []string{resp.Hostname})
462 + if err != nil {
463 + _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
464 + return err
465 }
466
467 if ctx.Err() != nil {
@@ -535,7 +486,6 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
486 l.leaseID = resp.LeaseID
487 l.hostname = resp.Hostname
488 l.udpAddr = resp.UDPAddr
538 - l.quicAddr = resp.QUICAddr
489 l.metadata = resp.Metadata.Copy()
490 l.tlsConfig = tlsConf
491 l.tlsCloser = tlsCloser
@@ -552,70 +502,19 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
502 }
503
504 func (l *Listener) SupportsDatagram() bool {
555 - return l != nil && l.capabilities.SupportsDatagram()
505 + return l != nil && l.udpEnabled
506 }
507
508 func (l *Listener) SupportsStream() bool {
559 - return l != nil && l.capabilities.SupportsStream()
509 + return l != nil
510 }
511
562 -func (l *Listener) AcceptDatagram() (types.DatagramFrame, error) {
563 - if l == nil || !l.SupportsDatagram() || l.datagram == nil {
564 - return types.DatagramFrame{}, net.ErrClosed
565 - }
566 -
567 - select {
568 - case <-l.doneCh:
569 - return types.DatagramFrame{}, net.ErrClosed
570 - case dg := <-l.datagram.Incoming():
571 - return dg, nil
572 - }
573 -}
574 -
575 -func (l *Listener) SendDatagram(flowID uint32, payload []byte) error {
576 - if l == nil || !l.SupportsDatagram() || l.datagram == nil {
577 - return net.ErrClosed
578 - }
579 - return l.datagram.Send(flowID, payload)
580 -}
581 -
582 -func (l *Listener) UDPAddr() string {
583 - l.mu.Lock()
584 - defer l.mu.Unlock()
585 - return l.udpAddr
586 -}
587 -
588 -func (l *Listener) QUICAddr() string {
589 - l.mu.Lock()
590 - defer l.mu.Unlock()
591 - return l.quicAddr
592 -}
593 -
594 -func (l *Listener) currentDatagramState() (datagramState, bool) {
595 - if l == nil || !l.SupportsDatagram() {
596 - return datagramState{}, false
597 - }
598 -
599 - l.mu.Lock()
600 - defer l.mu.Unlock()
601 -
602 - if l.api == nil || l.leaseID == "" {
603 - return datagramState{}, false
604 - }
605 -
606 - return datagramState{
607 - leaseID: l.leaseID,
608 - reverseToken: l.api.reverseToken,
609 - quicAddr: l.quicAddr,
610 - }, true
512 +func (l *Listener) UDPEnabled() bool {
513 + return l != nil && l.udpEnabled
514 }
515
516 // WaitRegistered blocks until the first successful lease registration or context cancellation.
517 func (l *Listener) WaitRegistered(ctx context.Context) error {
615 - if ctx == nil {
616 - ctx = context.Background()
617 - }
618 -
518 select {
519 case <-l.registered:
520 return nil
@@ -626,21 +525,6 @@ func (l *Listener) WaitRegistered(ctx context.Context) error {
525 }
526 }
527
629 -// WaitDatagramReady blocks until the listener has registered a datagram-capable
630 -// lease and the relay has assigned its UDP/QUIC endpoints.
631 -func (l *Listener) WaitDatagramReady(ctx context.Context) error {
632 - if l == nil || !l.SupportsDatagram() {
633 - return errors.New("lease does not have datagram transport enabled")
634 - }
635 - if err := l.WaitRegistered(ctx); err != nil {
636 - return err
637 - }
638 - if l.UDPAddr() == "" && l.QUICAddr() == "" {
639 - return errors.New("lease registration did not expose datagram addresses")
640 - }
641 - return nil
642 -}
643 -
528 func (l *Listener) reregister(ctx context.Context) error {
529 requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
530 defer cancel()
@@ -648,8 +532,12 @@ func (l *Listener) reregister(ctx context.Context) error {
532 return l.registerAndConfigure(requestCtx)
533 }
534
651 -func isLeaseNotFound(err error) bool {
652 - return errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound})
535 +func isPermanentRegistrationError(err error) bool {
536 + return errors.Is(err, errRelayIncompatible) ||
537 + errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeFeatureUnavailable}) ||
538 + errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeTransportMismatch}) ||
539 + errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeHostnameConflict}) ||
540 + errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeIPBanned})
541 }
542
543 func (l *Listener) retryOrClose(ctx context.Context, operation string, err error, retries int) bool {
@@ -704,19 +592,6 @@ func (l *Listener) done() bool {
592 }
593 }
594
707 -func (l *Listener) drainAccepted() {
708 - for {
709 - select {
710 - case conn := <-l.accepted:
711 - if conn != nil {
712 - _ = conn.Close()
713 - }
714 - default:
715 - return
716 - }
717 - }
718 -}
719 -
595 func (l *Listener) setStartupStatus(status listenerStatus) {
596 if l == nil {
597 return
@@ -735,36 +610,3 @@ func (l *Listener) StartupStatus() listenerStatus {
610 defer l.mu.Unlock()
611 return l.startupStatus
612 }
738 -
739 -func (l *Listener) ActiveSessions() int {
740 - if l == nil {
741 - return 0
742 - }
743 -
744 - l.mu.Lock()
745 - defer l.mu.Unlock()
746 - return l.activeSessions
747 -}
748 -
749 -func (l *Listener) sessionOpened() {
750 - if l == nil {
751 - return
752 - }
753 -
754 - l.mu.Lock()
755 - l.activeSessions++
756 - l.startupStatus = listenerStatusReady
757 - l.mu.Unlock()
758 -}
759 -
760 -func (l *Listener) sessionClosed() {
761 - if l == nil {
762 - return
763 - }
764 -
765 - l.mu.Lock()
766 - if l.activeSessions > 0 {
767 - l.activeSessions--
768 - }
769 - l.mu.Unlock()
770 -}
sdk/registry.go
-3
@@ -12,9 +12,6 @@ import (
12 // WithDefaultRelayURLs fetches the default Portal relay registry and appends
13 // any explicit relay inputs before normalization.
14 func WithDefaultRelayURLs(ctx context.Context, registryURL string, explicit ...string) []string {
15 - if ctx == nil {
16 - ctx = context.Background()
17 - }
15 if registryURL == "" {
16 registryURL = types.PortalRelayRegistryURL
17 }
sdk/sdk_test.go
+26 -53
@@ -154,8 +154,8 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
154 if registerReq.TTL != 42 {
155 t.Fatalf("register request TTL = %d, want 42", registerReq.TTL)
156 }
157 - if registerReq.Transport != types.TransportTCP {
158 - t.Fatalf("register request Transport = %q, want %q", registerReq.Transport, types.TransportTCP)
157 + if registerReq.UDPEnabled {
158 + t.Fatal("register request UDPEnabled = true, want false")
159 }
160 if registerReq.Name != "demo-app" {
161 t.Fatalf("register request Name = %q, want %q", registerReq.Name, "demo-app")
@@ -350,7 +350,7 @@ func TestNewListenerRetriesForeverWhenRetryCountIsNegative(t *testing.T) {
350 }
351
352 func TestExposeNoRelayInputs(t *testing.T) {
353 - exposure, err := Expose(context.Background(), nil, "demo", types.TransportBoth, types.LeaseMetadata{})
353 + exposure, err := Expose(context.Background(), nil, "demo", false, types.LeaseMetadata{})
354 if err != nil {
355 t.Fatalf("Expose() error = %v", err)
356 }
@@ -359,7 +359,7 @@ func TestExposeNoRelayInputs(t *testing.T) {
359 }
360 }
361
362 -func TestNewListenerTransportUDPDoesNotOpenReverseSessions(t *testing.T) {
362 +func TestNewListenerUDPEnabledKeepsStreamAndDatagram(t *testing.T) {
363 var connectCount atomic.Int32
364 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
365 switch r.URL.Path {
@@ -374,18 +374,17 @@ func TestNewListenerTransportUDPDoesNotOpenReverseSessions(t *testing.T) {
374 writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
375 OK: true,
376 Data: types.RegisterResponse{
377 - LeaseID: "lease-udp",
378 - Hostname: "demo.example.com",
379 - UDPAddr: "demo.example.com:29000",
380 - QUICAddr: "demo.example.com:4017",
381 - Transport: types.TransportUDP,
377 + LeaseID: "lease-udp",
378 + Hostname: "demo.example.com",
379 + UDPAddr: "demo.example.com:29900",
380 + UDPEnabled: true,
381 },
382 })
383 case types.PathSDKConnect:
384 connectCount.Add(1)
385 writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
386 OK: false,
388 - Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "stream should be disabled"},
387 + Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "reverse session denied"},
388 })
389 case types.PathSDKRenew:
390 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
@@ -401,9 +400,9 @@ func TestNewListenerTransportUDPDoesNotOpenReverseSessions(t *testing.T) {
400 defer server.Close()
401
402 listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
404 - Name: "demo",
405 - Transport: types.TransportUDP,
406 - LeaseTTL: 100 * time.Millisecond,
403 + Name: "demo",
404 + UDPEnabled: true,
405 + LeaseTTL: 100 * time.Millisecond,
406 })
407 if err != nil {
408 t.Fatalf("NewListener() error = %v", err)
@@ -415,17 +414,17 @@ func TestNewListenerTransportUDPDoesNotOpenReverseSessions(t *testing.T) {
414 })
415 time.Sleep(150 * time.Millisecond)
416
418 - if connectCount.Load() != 0 {
419 - t.Fatalf("connect count = %d, want 0", connectCount.Load())
417 + if connectCount.Load() == 0 {
418 + t.Fatal("connect count = 0, want reverse session attempts")
419 }
421 - if got := listener.PublicURL(); got != "" {
422 - t.Fatalf("PublicURL() = %q, want empty", got)
420 + if got := listener.PublicURL(); got == "" {
421 + t.Fatal("PublicURL() = empty, want public url")
422 }
423 if !listener.SupportsDatagram() {
424 t.Fatal("SupportsDatagram() = false, want true")
425 }
427 - if listener.SupportsStream() {
428 - t.Fatal("SupportsStream() = true, want false")
426 + if !listener.SupportsStream() {
427 + t.Fatal("SupportsStream() = false, want true")
428 }
429 }
430
@@ -443,11 +442,10 @@ func TestListenerWaitDatagramReadyPublishesRelayAddresses(t *testing.T) {
442 writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
443 OK: true,
444 Data: types.RegisterResponse{
446 - LeaseID: "lease-udp",
447 - Hostname: "demo.example.com",
448 - UDPAddr: "demo.example.com:29000",
449 - QUICAddr: "demo.example.com:4017",
450 - Transport: types.TransportUDP,
445 + LeaseID: "lease-udp",
446 + Hostname: "demo.example.com",
447 + UDPAddr: "demo.example.com:29900",
448 + UDPEnabled: true,
449 },
450 })
451 case types.PathSDKRenew:
@@ -464,8 +462,8 @@ func TestListenerWaitDatagramReadyPublishesRelayAddresses(t *testing.T) {
462 defer server.Close()
463
464 listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
467 - Name: "demo",
468 - Transport: types.TransportUDP,
465 + Name: "demo",
466 + UDPEnabled: true,
467 })
468 if err != nil {
469 t.Fatalf("NewListener() error = %v", err)
@@ -475,33 +473,8 @@ func TestListenerWaitDatagramReadyPublishesRelayAddresses(t *testing.T) {
473 if err := listener.WaitDatagramReady(context.Background()); err != nil {
474 t.Fatalf("WaitDatagramReady() error = %v", err)
475 }
478 - if got := listener.UDPAddr(); got != "demo.example.com:29000" {
479 - t.Fatalf("UDPAddr() = %q, want %q", got, "demo.example.com:29000")
480 - }
481 - if got := listener.QUICAddr(); got != "demo.example.com:4017" {
482 - t.Fatalf("QUICAddr() = %q, want %q", got, "demo.example.com:4017")
483 - }
484 -}
485 -
486 -func TestExposureDatagramReply(t *testing.T) {
487 - called := false
488 - dg := ExposureDatagram{
489 - FlowID: 7,
490 - Payload: []byte("hello"),
491 - reply: func(payload []byte) error {
492 - called = true
493 - if string(payload) != "world" {
494 - t.Fatalf("reply payload = %q, want %q", payload, "world")
495 - }
496 - return nil
497 - },
498 - }
499 -
500 - if err := dg.Reply([]byte("world")); err != nil {
501 - t.Fatalf("Reply() error = %v", err)
502 - }
503 - if !called {
504 - t.Fatal("Reply() did not invoke reply function")
476 + if got := listener.UDPAddr(); got != "demo.example.com:29900" {
477 + t.Fatalf("UDPAddr() = %q, want %q", got, "demo.example.com:29900")
478 }
479 }
480
types/api.go
+12 -10
@@ -6,12 +6,6 @@ import (
6 "time"
7 )
8
9 -const (
10 - TransportTCP = "tcp"
11 - TransportUDP = "udp"
12 - TransportBoth = "both"
13 -)
14 -
9 type APIEnvelope[T any] struct {
10 Data T `json:"data,omitempty"`
11 Error *APIError `json:"error,omitempty"`
@@ -64,8 +58,7 @@ type RegisterRequest struct {
58 ReverseToken string `json:"reverse_token"`
59 Metadata LeaseMetadata `json:"metadata"`
60 TTL int `json:"ttl,omitempty"`
67 - TLS bool `json:"tls"`
68 - Transport string `json:"transport,omitempty"`
61 + UDPEnabled bool `json:"udp_enabled,omitempty"`
62 }
63
64 type RegisterResponse struct {
@@ -75,8 +68,17 @@ type RegisterResponse struct {
68 Hostname string `json:"hostname"`
69 Metadata LeaseMetadata `json:"metadata"`
70 UDPAddr string `json:"udp_addr,omitempty"`
78 - QUICAddr string `json:"quic_addr,omitempty"`
79 - Transport string `json:"transport,omitempty"`
71 + UDPEnabled bool `json:"udp_enabled,omitempty"`
72 +}
73 +
74 +type QUICControlMessage struct {
75 + LeaseID string `json:"lease_id"`
76 + ReverseToken string `json:"reverse_token"`
77 +}
78 +
79 +type QUICControlResponse struct {
80 + OK bool `json:"ok"`
81 + Error string `json:"error,omitempty"`
82 }
83
84 type RenewRequest struct {
types/lease.go
+1 -1
@@ -29,7 +29,7 @@ type Lease struct {
29 BPS int64
30 ClientIP string
31 Hostname string
32 - Transport string
32 + UDPEnabled bool
33 Metadata LeaseMetadata
34 Ready int
35 UDPPort int
types/transport.go
-46
@@ -3,54 +3,8 @@ package types
3 import (
4 "encoding/binary"
5 "errors"
6 - "fmt"
7 - "strings"
6 )
7
10 -// LeaseCapabilities describes which data planes a lease exposes.
11 -// Stream maps to reverse TCP/TLS sessions; Datagram maps to QUIC/UDP.
12 -type LeaseCapabilities struct {
13 - Datagram bool
14 - Stream bool
15 -}
16 -
17 -// ParseLeaseCapabilities normalizes the public transport string into the
18 -// internal capability model shared by relay and SDK.
19 -func ParseLeaseCapabilities(raw string) (LeaseCapabilities, error) {
20 - switch strings.ToLower(strings.TrimSpace(raw)) {
21 - case "", TransportTCP:
22 - return LeaseCapabilities{Stream: true}, nil
23 - case TransportUDP:
24 - return LeaseCapabilities{Datagram: true}, nil
25 - case TransportBoth:
26 - return LeaseCapabilities{Datagram: true, Stream: true}, nil
27 - default:
28 - return LeaseCapabilities{}, fmt.Errorf("unsupported transport %q", strings.TrimSpace(raw))
29 - }
30 -}
31 -
32 -func (c LeaseCapabilities) SupportsDatagram() bool {
33 - return c.Datagram
34 -}
35 -
36 -func (c LeaseCapabilities) SupportsStream() bool {
37 - return c.Stream
38 -}
39 -
40 -// Transport returns the canonical public transport label for the capability set.
41 -func (c LeaseCapabilities) Transport() string {
42 - switch {
43 - case c.Stream && c.Datagram:
44 - return TransportBoth
45 - case c.Datagram:
46 - return TransportUDP
47 - case c.Stream:
48 - return TransportTCP
49 - default:
50 - return ""
51 - }
52 -}
53 -
8 // ErrDatagramTooSmall is returned when a datagram payload is too short to
9 // contain a valid flow ID varint.
10 var ErrDatagramTooSmall = errors.New("datagram too small to decode")
types/types.go
+1 -1
@@ -2,7 +2,7 @@ package types
2
3 const (
4 ReleaseVersion = "v2.0.6"
5 - SDKProtocolVersion = "1"
5 + SDKProtocolVersion = "2"
6 PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
7
8 HeaderReverseToken = "X-Portal-Token"