refactor datagram

Kim committed Mar 18, 2026 at 11:44 UTC f28a68680c4319300c84454f6bc2408982a6de2a
27 files changed +1946 -1477
cmd/portal-tunnel/main.go
+39 -28
@@ -226,6 +226,11 @@ func runTunnel(
226 metadata types.LeaseMetadata,
227 ) error {
228 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)
236 if err != nil {
@@ -237,8 +242,21 @@ func runTunnel(
242 defer exposure.Close()
243
244 // UDP is best-effort — attach to existing lease, log and continue if it fails.
240 - if transport == types.TransportUDP || transport == types.TransportBoth {
241 - go runUDPBestEffort(ctx, exposure, target)
245 + if capabilities.SupportsDatagram() && !capabilities.SupportsStream() {
246 + runErr := runUDPBestEffort(ctx, exposure, target)
247 + closeErr := exposure.Close()
248 + if runErr != nil && stop != nil {
249 + stop()
250 + }
251 + return errors.Join(runErr, closeErr)
252 + }
253 +
254 + if capabilities.SupportsDatagram() {
255 + go func() {
256 + if err := runUDPBestEffort(ctx, exposure, target); err != nil && ctx.Err() == nil {
257 + logger.Warn().Err(err).Msg("udp transport disabled")
258 + }
259 + }()
260 }
261
262 logger.Info().
@@ -288,39 +306,32 @@ func runTunnel(
306 return errors.Join(waitErr, closeErr)
307 }
308
291 -// runUDPBestEffort attaches UDP listeners to the existing TCP exposure lease.
292 -// Failures are logged but do not bring down the tunnel.
293 -func runUDPBestEffort(ctx context.Context, exposure *sdk.Exposure, target string) {
309 +// runUDPBestEffort waits for the exposure datagram plane and proxies it to the
310 +// local UDP target.
311 +func runUDPBestEffort(ctx context.Context, exposure *sdk.Exposure, target string) error {
312 logger := log.With().Str("component", "portal-tunnel-udp").Logger()
313
296 - udpListeners, err := exposure.AttachUDP(ctx)
314 + udpAddrs, err := exposure.WaitDatagramReady(ctx)
315 if err != nil {
298 - logger.Warn().Err(err).Msg("udp transport disabled: attach failed")
299 - return
316 + if ctx.Err() != nil || errors.Is(err, context.Canceled) {
317 + return ctx.Err()
318 + }
319 + return fmt.Errorf("wait for udp readiness: %w", err)
320 }
301 - if len(udpListeners) == 0 {
302 - logger.Info().Msg("udp transport: no UDP addresses from relay")
303 - return
321 + if len(udpAddrs) == 0 {
322 + if ctx.Err() != nil {
323 + return ctx.Err()
324 + }
325 + return errors.New("relay did not expose any UDP listeners")
326 }
327
306 - var wg sync.WaitGroup
307 - for _, ul := range udpListeners {
308 - wg.Add(1)
309 - go func(l *sdk.UDPListener) {
310 - defer wg.Done()
311 - defer l.Close()
312 -
313 - logger.Info().
314 - Str("udp_addr", l.UDPAddr()).
315 - Str("lease_id", l.LeaseID()).
316 - Msg("UDP tunnel ready")
317 -
318 - if err := proxyUDPRelayConnections(ctx, l, target); err != nil {
319 - logger.Warn().Err(err).Msg("udp proxy ended")
320 - }
321 - }(ul)
328 + for _, udpAddr := range udpAddrs {
329 + logger.Info().
330 + Str("udp_addr", udpAddr).
331 + Msg("UDP tunnel ready")
332 }
323 - wg.Wait()
333 +
334 + return proxyExposureDatagrams(ctx, exposure, target)
335 }
336
337 func resolveRelayURLs(ctx context.Context, registryURL string, inputs []string, includeDefaultRelays bool) ([]string, error) {
cmd/portal-tunnel/relays.go
+72 -24
@@ -134,9 +134,9 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
134 return err
135 }
136
137 -// proxyUDPRelayConnections receives datagrams from the relay via the UDPListener
137 +// proxyExposureDatagrams receives datagrams from the exposure datagram plane
138 // and forwards them to the local UDP service, relaying responses back.
139 -func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener, localAddr string) error {
139 +func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAddr string) error {
140 logger := log.With().Str("component", "portal-tunnel-udp").Logger()
141
142 targetAddr, err := utils.NormalizeTargetAddr(localAddr)
@@ -149,15 +149,20 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
149 return fmt.Errorf("resolve udp addr %q: %w", targetAddr, err)
150 }
151
152 - // Per-flow local UDP connections: flowID → *net.UDPConn
152 + type flowKey struct {
153 + flowID uint32
154 + leaseID string
155 + relayURL string
156 + }
157 type flowEntry struct {
158 conn *net.UDPConn
159 lastSeen time.Time
160 + reply func([]byte) error
161 }
162 +
163 var mu sync.Mutex
158 - flows := make(map[uint32]*flowEntry)
164 + flows := make(map[flowKey]*flowEntry)
165
160 - // Cleanup idle flow connections.
166 go func() {
167 ticker := time.NewTicker(15 * time.Second)
168 defer ticker.Stop()
@@ -168,10 +173,10 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
173 case <-ticker.C:
174 mu.Lock()
175 now := time.Now()
171 - for id, f := range flows {
176 + for key, f := range flows {
177 if now.Sub(f.lastSeen) > 30*time.Second {
178 _ = f.conn.Close()
174 - delete(flows, id)
179 + delete(flows, key)
180 }
181 }
182 mu.Unlock()
@@ -179,11 +184,11 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
184 }
185 }()
186
182 - // getOrCreateFlow returns (or creates) a local UDP conn for a flow.
183 - getOrCreateFlow := func(flowID uint32) (*net.UDPConn, error) {
187 + getOrCreateFlow := func(key flowKey, reply func([]byte) error) (*net.UDPConn, error) {
188 mu.Lock()
185 - if f, ok := flows[flowID]; ok {
189 + if f, ok := flows[key]; ok {
190 f.lastSeen = time.Now()
191 + f.reply = reply
192 mu.Unlock()
193 return f.conn, nil
194 }
@@ -195,17 +200,16 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
200 }
201
202 mu.Lock()
198 - // Double-check after acquiring lock.
199 - if f, ok := flows[flowID]; ok {
203 + if f, ok := flows[key]; ok {
204 mu.Unlock()
205 _ = localConn.Close()
206 f.lastSeen = time.Now()
207 + f.reply = reply
208 return f.conn, nil
209 }
205 - flows[flowID] = &flowEntry{conn: localConn, lastSeen: time.Now()}
210 + flows[key] = &flowEntry{conn: localConn, lastSeen: time.Now(), reply: reply}
211 mu.Unlock()
212
208 - // Start reverse read loop: local service → relay.
213 go func() {
214 buf := make([]byte, 65535)
215 for {
@@ -214,11 +218,33 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
218 if ctx.Err() != nil {
219 return
220 }
217 - logger.Debug().Err(err).Uint32("flow_id", flowID).Msg("local read ended")
221 + logger.Debug().
222 + Err(err).
223 + Uint32("flow_id", key.flowID).
224 + Str("lease_id", key.leaseID).
225 + Str("relay_url", key.relayURL).
226 + Msg("local read ended")
227 return
228 }
220 - if sendErr := udpListener.SendDatagram(flowID, buf[:n]); sendErr != nil {
221 - logger.Debug().Err(sendErr).Uint32("flow_id", flowID).Msg("send datagram to relay failed")
229 +
230 + mu.Lock()
231 + entry := flows[key]
232 + if entry != nil {
233 + entry.lastSeen = time.Now()
234 + }
235 + replyFn := func([]byte) error { return net.ErrClosed }
236 + if entry != nil && entry.reply != nil {
237 + replyFn = entry.reply
238 + }
239 + mu.Unlock()
240 +
241 + if sendErr := replyFn(buf[:n]); sendErr != nil {
242 + logger.Debug().
243 + Err(sendErr).
244 + Uint32("flow_id", key.flowID).
245 + Str("lease_id", key.leaseID).
246 + Str("relay_url", key.relayURL).
247 + Msg("send datagram to relay failed")
248 return
249 }
250 }
@@ -227,13 +253,15 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
253 return localConn, nil
254 }
255
230 - // Main loop: relay → local service.
256 logger.Info().Str("target", targetAddr).Msg("udp proxy loop started, waiting for datagrams")
257 for {
233 - dg, err := udpListener.AcceptDatagram()
258 + dg, err := exposure.AcceptDatagram()
259 if err != nil {
235 - if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
236 - return nil
260 + if ctx.Err() != nil {
261 + return ctx.Err()
262 + }
263 + if errors.Is(err, net.ErrClosed) {
264 + break
265 }
266 return fmt.Errorf("accept datagram: %w", err)
267 }
@@ -241,17 +269,37 @@ func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener,
269 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).
275 Str("target", targetAddr).
276 Msg("datagram received from relay, forwarding to local")
277
247 - localConn, err := getOrCreateFlow(dg.FlowID)
278 + key := flowKey{
279 + flowID: dg.FlowID,
280 + leaseID: dg.LeaseID,
281 + relayURL: dg.RelayURL,
282 + }
283 + localConn, err := getOrCreateFlow(key, dg.Reply)
284 if err != nil {
249 - logger.Warn().Err(err).Uint32("flow_id", dg.FlowID).Msg("dial local udp failed")
285 + logger.Warn().
286 + Err(err).
287 + Uint32("flow_id", dg.FlowID).
288 + Str("lease_id", dg.LeaseID).
289 + Str("relay_url", dg.RelayURL).
290 + Msg("dial local udp failed")
291 continue
292 }
293
294 if _, err := localConn.Write(dg.Payload); err != nil {
254 - logger.Warn().Err(err).Uint32("flow_id", dg.FlowID).Msg("write to local udp failed")
295 + logger.Warn().
296 + Err(err).
297 + Uint32("flow_id", dg.FlowID).
298 + Str("lease_id", dg.LeaseID).
299 + Str("relay_url", dg.RelayURL).
300 + Msg("write to local udp failed")
301 }
302 }
303 +
304 + return nil
305 }
go.mod
+2 -1
@@ -9,7 +9,9 @@ require (
9 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
10 github.com/go-acme/lego/v4 v4.32.0
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
15 golang.org/x/net v0.50.0
16 golang.org/x/sync v0.19.0
17 )
@@ -31,7 +33,6 @@ require (
33 github.com/mattn/go-colorable v0.1.13 // indirect
34 github.com/mattn/go-isatty v0.0.20 // indirect
35 github.com/miekg/dns v1.1.72 // indirect
34 - 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
go.sum
+4
@@ -53,11 +53,15 @@ github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDETo
53 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
54 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
55 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
56 +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
57 +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
58 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
59 github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
60 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
61 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
62 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
63 +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
64 +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
65 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
66 golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
67 golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
portal/api_server.go
+40 -30
@@ -1,7 +1,6 @@
1 package portal
2
3 import (
4 - "context"
4 "crypto/tls"
5 "errors"
6 "fmt"
@@ -13,6 +12,7 @@ import (
12
13 "github.com/rs/zerolog/log"
14
15 + "github.com/gosuda/portal/v2/portal/datagram"
16 "github.com/gosuda/portal/v2/portal/keyless"
17 "github.com/gosuda/portal/v2/portal/policy"
18 "github.com/gosuda/portal/v2/types"
@@ -127,6 +127,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
127 if errors.Is(err, errIPBanned) {
128 status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
129 }
130 + if errors.Is(err, datagram.ErrPortExhausted) {
131 + status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted
132 + }
133 utils.WriteAPIError(w, status, code, err.Error())
134 return
135 }
@@ -229,6 +232,10 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
232 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, authErr.Error())
233 return
234 }
235 + if !lease.SupportsStream() {
236 + utils.WriteAPIError(w, http.StatusConflict, types.APIErrorCodeTransportMismatch, "lease does not support stream transport")
237 + return
238 + }
239
240 hijacker, ok := w.(http.Hijacker)
241 if !ok {
@@ -252,7 +259,13 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
259 }
260
261 session := newReverseSession(conn, s.cfg.IdleKeepaliveInterval)
255 - if err := lease.Broker.Offer(session); err != nil {
262 + streamBroker := lease.StreamBroker()
263 + if streamBroker == nil {
264 + _ = session.Close()
265 + return
266 + }
267 +
268 + if err := streamBroker.Offer(session); err != nil {
269 log.Warn().
270 Err(err).
271 Str("component", "relay-server").
@@ -270,7 +283,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
283 Str("lease_id", lease.ID).
284 Str("lease_name", lease.Name).
285 Str("remote_addr", session.RemoteAddr()).
273 - Int("ready", lease.Broker.ReadyCount()).
286 + Int("ready", streamBroker.ReadyCount()).
287 Msg("sdk reverse connected")
288 }
289
@@ -295,14 +308,27 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
308 ttl = time.Duration(req.TTL) * time.Second
309 }
310
298 - transport := strings.ToLower(strings.TrimSpace(req.Transport))
299 - if transport == "" {
300 - transport = types.TransportTCP
311 + capabilities, err := types.ParseLeaseCapabilities(req.Transport)
312 + if err != nil {
313 + return types.RegisterResponse{}, err
314 }
315 + transport := capabilities.Transport()
316
317 leaseID := utils.RandomID("lease_")
318 now := time.Now()
319 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 +
332 record := &leaseRecord{
333 Lease: types.Lease{
334 ID: leaseID,
@@ -314,38 +340,22 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
340 LastSeenAt: now,
341 ClientIP: clientIP,
342 Transport: transport,
343 + UDPPort: runtime.UDPPort(),
344 },
345 ReverseToken: req.ReverseToken,
319 - Broker: newLeaseBroker(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
346 + Runtime: runtime,
347 }
348
322 - if transport == types.TransportUDP || transport == types.TransportBoth {
323 - if s.ports == nil {
324 - return types.RegisterResponse{}, errors.New("udp port allocation not available")
325 - }
326 - udpPort, portErr := s.ports.Allocate(name)
327 - if portErr != nil {
328 - return types.RegisterResponse{}, fmt.Errorf("allocate udp port: %w", portErr)
329 - }
330 - record.UDPPort = udpPort
331 - record.QUICBroker = newQUICBroker(leaseID)
332 - record.UDPRelay = newUDPRelay(leaseID, udpPort, record.QUICBroker)
349 + if err := runtime.Start(); err != nil {
350 + runtime.Close(s.ports)
351 + return types.RegisterResponse{}, err
352 }
353
354 if err := s.registry.Register(record); err != nil {
336 - if record.QUICBroker != nil {
337 - record.QUICBroker.Stop()
338 - }
339 - if record.UDPPort > 0 && s.ports != nil {
340 - s.ports.Release(record.UDPPort)
341 - }
355 + runtime.Close(s.ports)
356 return types.RegisterResponse{}, err
357 }
358
345 - if record.UDPRelay != nil {
346 - go s.startUDPRelay(context.Background(), leaseID, record.UDPRelay)
347 - }
348 -
359 resp := types.RegisterResponse{
360 LeaseID: leaseID,
361 Hostname: hostname,
@@ -354,8 +364,8 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
364 ConnectURL: strings.TrimRight(s.cfg.PortalURL, "/") + types.PathSDKConnect,
365 Transport: transport,
366 }
357 - if record.UDPPort > 0 {
358 - resp.UDPAddr = fmt.Sprintf("%s:%d", s.rootHost, record.UDPPort)
367 + if record.SupportsDatagram() {
368 + resp.UDPAddr = fmt.Sprintf("%s:%d", s.rootHost, record.UDPPort())
369 resp.QUICAddr = s.quicPublicAddr()
370 }
371
portal/broker.go
+9 -9
@@ -18,7 +18,7 @@ var (
18 errBrokerFull = errors.New("broker ready queue full")
19 )
20
21 -type leaseBroker struct {
21 +type streamBroker struct {
22 notify chan struct{}
23 leaseID string
24 ready []*reverseSession
@@ -28,8 +28,8 @@ type leaseBroker struct {
28 mu sync.Mutex
29 }
30
31 -func newLeaseBroker(leaseID string, idleInterval time.Duration, readyLimit int) *leaseBroker {
32 - return &leaseBroker{
31 +func newStreamBroker(leaseID string, idleInterval time.Duration, readyLimit int) *streamBroker {
32 + return &streamBroker{
33 leaseID: leaseID,
34 idleInterval: idleInterval,
35 readyLimit: readyLimit,
@@ -37,7 +37,7 @@ func newLeaseBroker(leaseID string, idleInterval time.Duration, readyLimit int)
37 }
38 }
39
40 -func (b *leaseBroker) Offer(session *reverseSession) error {
40 +func (b *streamBroker) Offer(session *reverseSession) error {
41 if session == nil {
42 return errors.New("reverse session is required")
43 }
@@ -63,7 +63,7 @@ func (b *leaseBroker) Offer(session *reverseSession) error {
63 return nil
64 }
65
66 -func (b *leaseBroker) Claim(ctx context.Context) (*reverseSession, error) {
66 +func (b *streamBroker) Claim(ctx context.Context) (*reverseSession, error) {
67 for {
68 b.mu.Lock()
69 if b.closedErr != nil {
@@ -96,7 +96,7 @@ func (b *leaseBroker) Claim(ctx context.Context) (*reverseSession, error) {
96 }
97 }
98
99 -func (b *leaseBroker) Close() {
99 +func (b *streamBroker) Close() {
100 b.mu.Lock()
101 sessions := b.ready
102 b.ready = nil
@@ -111,13 +111,13 @@ func (b *leaseBroker) Close() {
111 }
112 }
113
114 -func (b *leaseBroker) ReadyCount() int {
114 +func (b *streamBroker) ReadyCount() int {
115 b.mu.Lock()
116 defer b.mu.Unlock()
117 return len(b.ready)
118 }
119
120 -func (b *leaseBroker) watchSession(session *reverseSession) {
120 +func (b *streamBroker) watchSession(session *reverseSession) {
121 <-session.Done()
122
123 var readyCount int
@@ -140,7 +140,7 @@ func (b *leaseBroker) watchSession(session *reverseSession) {
140 b.mu.Unlock()
141 }
142
143 -func (b *leaseBroker) signalLocked() {
143 +func (b *streamBroker) signalLocked() {
144 select {
145 case b.notify <- struct{}{}:
146 default:
portal/broker_test.go
+5 -5
@@ -22,7 +22,7 @@ func TestLeaseBrokerClaimActivatesTLSMarker(t *testing.T) {
22 _ = clientConn.Close()
23 })
24
25 - broker := newLeaseBroker("lease-test", time.Hour, 2)
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)
@@ -66,7 +66,7 @@ func TestLeaseBrokerCloseClosesIdleSessions(t *testing.T) {
66 t.Parallel()
67
68 serverConn, clientConn := net.Pipe()
69 - broker := newLeaseBroker("lease-test", time.Hour, 2)
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)
@@ -84,7 +84,7 @@ func TestLeaseBrokerCloseClosesIdleSessions(t *testing.T) {
84 func TestLeaseBrokerCloseUnblocksClaim(t *testing.T) {
85 t.Parallel()
86
87 - broker := newLeaseBroker("lease-test", time.Hour, 2)
87 + broker := newStreamBroker("lease-test", time.Hour, 2)
88 claimCtx, cancel := context.WithTimeout(context.Background(), brokerAsyncTestTimeout)
89 defer cancel()
90
@@ -118,7 +118,7 @@ func TestLeaseBrokerCloseUnblocksClaim(t *testing.T) {
118 func TestLeaseBrokerClaimWaitsForLateOffer(t *testing.T) {
119 t.Parallel()
120
121 - broker := newLeaseBroker("lease-test", time.Hour, 2)
121 + broker := newStreamBroker("lease-test", time.Hour, 2)
122
123 serverConn, clientConn := net.Pipe()
124 t.Cleanup(func() {
@@ -190,7 +190,7 @@ func TestLeaseBrokerClaimWaitsForLateOffer(t *testing.T) {
190 func TestLeaseBrokerClaimTimesOutWithoutSessions(t *testing.T) {
191 t.Parallel()
192
193 - broker := newLeaseBroker("lease-test", time.Hour, 2)
193 + broker := newStreamBroker("lease-test", time.Hour, 2)
194
195 claimCtx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
196 defer cancel()
portal/datagram/flow.go new
+193
@@ -0,0 +1,193 @@
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 new
+97
@@ -0,0 +1,97 @@
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 new
+119
@@ -0,0 +1,119 @@
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/session.go new
+168
@@ -0,0 +1,168 @@
1 +package datagram
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "sync"
7 +
8 + "github.com/quic-go/quic-go"
9 +
10 + "github.com/gosuda/portal/v2/types"
11 +)
12 +
13 +var (
14 + ErrNoConnection = errors.New("no quic connection registered")
15 + ErrSessionClosed = errors.New("quic datagram session closed")
16 +)
17 +
18 +// Session owns one active QUIC DATAGRAM connection and exposes decoded frames.
19 +type Session struct {
20 + incoming chan types.DatagramFrame
21 + dropIncoming bool
22 + onReceiveError func(error)
23 + done chan struct{}
24 +
25 + mu sync.Mutex
26 + conn *quic.Conn
27 + closed bool
28 +}
29 +
30 +func NewSession(bufferSize int, dropIncoming bool, onReceiveError func(error)) *Session {
31 + if bufferSize <= 0 {
32 + bufferSize = 256
33 + }
34 +
35 + return &Session{
36 + incoming: make(chan types.DatagramFrame, bufferSize),
37 + dropIncoming: dropIncoming,
38 + onReceiveError: onReceiveError,
39 + done: make(chan struct{}),
40 + }
41 +}
42 +
43 +// Bind installs a new active QUIC connection and starts the receive loop.
44 +// Any previously active connection is replaced and closed.
45 +func (s *Session) Bind(conn *quic.Conn) (<-chan struct{}, error) {
46 + if conn == nil {
47 + return nil, errors.New("quic connection is required")
48 + }
49 +
50 + s.mu.Lock()
51 + if s.closed {
52 + s.mu.Unlock()
53 + _ = conn.CloseWithError(0, "session closed")
54 + return nil, ErrSessionClosed
55 + }
56 + old := s.conn
57 + s.conn = conn
58 + s.mu.Unlock()
59 +
60 + if old != nil {
61 + _ = old.CloseWithError(0, "replaced")
62 + }
63 +
64 + recvDone := make(chan struct{})
65 + go s.receiveLoop(conn, recvDone)
66 + return recvDone, nil
67 +}
68 +
69 +func (s *Session) Incoming() <-chan types.DatagramFrame {
70 + return s.incoming
71 +}
72 +
73 +func (s *Session) Done() <-chan struct{} {
74 + return s.done
75 +}
76 +
77 +func (s *Session) HasConnection() bool {
78 + s.mu.Lock()
79 + defer s.mu.Unlock()
80 + return s.conn != nil && !s.closed
81 +}
82 +
83 +func (s *Session) Send(flowID uint32, payload []byte) error {
84 + s.mu.Lock()
85 + conn := s.conn
86 + closed := s.closed
87 + s.mu.Unlock()
88 +
89 + if closed {
90 + return ErrSessionClosed
91 + }
92 + if conn == nil {
93 + return ErrNoConnection
94 + }
95 + return conn.SendDatagram(types.EncodeDatagram(flowID, payload))
96 +}
97 +
98 +// Clear closes the active connection but keeps the session reusable.
99 +func (s *Session) Clear(reason string) {
100 + s.mu.Lock()
101 + conn := s.conn
102 + s.conn = nil
103 + s.mu.Unlock()
104 +
105 + if conn != nil {
106 + _ = conn.CloseWithError(0, reason)
107 + }
108 +}
109 +
110 +// Stop permanently closes the session and any active connection.
111 +func (s *Session) Stop(reason string) {
112 + s.mu.Lock()
113 + if s.closed {
114 + s.mu.Unlock()
115 + return
116 + }
117 + s.closed = true
118 + conn := s.conn
119 + s.conn = nil
120 + close(s.done)
121 + s.mu.Unlock()
122 +
123 + if conn != nil {
124 + _ = conn.CloseWithError(0, reason)
125 + }
126 +}
127 +
128 +func (s *Session) receiveLoop(conn *quic.Conn, recvDone chan struct{}) {
129 + defer close(recvDone)
130 +
131 + for {
132 + data, err := conn.ReceiveDatagram(context.Background())
133 + if err != nil {
134 + s.mu.Lock()
135 + isActive := s.conn == conn
136 + if isActive {
137 + s.conn = nil
138 + }
139 + closed := s.closed
140 + onReceiveError := s.onReceiveError
141 + s.mu.Unlock()
142 +
143 + if isActive && !closed && onReceiveError != nil {
144 + onReceiveError(err)
145 + }
146 + return
147 + }
148 +
149 + frame, err := types.DecodeDatagram(data)
150 + if err != nil {
151 + continue
152 + }
153 +
154 + if s.dropIncoming {
155 + select {
156 + case s.incoming <- frame:
157 + default:
158 + }
159 + continue
160 + }
161 +
162 + select {
163 + case s.incoming <- frame:
164 + case <-s.done:
165 + return
166 + }
167 + }
168 +}
portal/datagram/sni_parse.go renamed
+26 -208
@@ -1,4 +1,4 @@
1 -package portal
1 +package datagram
2
3 import (
4 "crypto"
@@ -7,15 +7,10 @@ import (
7 "encoding/binary"
8 "errors"
9 "fmt"
10 - "net"
11 - "sync"
10
11 "golang.org/x/crypto/hkdf"
14 -
15 - "github.com/gosuda/portal/v2/utils"
12 )
13
18 -// QUIC v1 constants (RFC 9001).
14 var quicV1InitialSalt = []byte{
15 0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
16 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
@@ -25,36 +20,27 @@ var quicV1InitialSalt = []byte{
20 var errNotQUICInitial = errors.New("not a quic initial packet")
21 var errSNINotFound = errors.New("sni not found in quic initial")
22
28 -// parseQUICInitialSNI extracts the TLS SNI from a QUIC Initial packet.
29 -// It decrypts the Initial packet header and payload using keys derived from
30 -// the Destination Connection ID per RFC 9001 Section 5.2, then parses the
31 -// CRYPTO frame to find the TLS ClientHello SNI extension.
32 -func parseQUICInitialSNI(packet []byte) (string, error) {
23 +func ParseQUICInitialSNI(packet []byte) (string, error) {
24 if len(packet) < 5 {
25 return "", errNotQUICInitial
26 }
27
37 - // Long header: first bit is 1, second bit is 1 (fixed), bits 4-5 are packet type.
28 firstByte := packet[0]
29 if firstByte&0x80 == 0 {
40 - return "", errNotQUICInitial // short header
30 + return "", errNotQUICInitial
31 }
32
43 - // Packet type: bits 4-5 of first byte. Initial = 0.
33 packetType := (firstByte & 0x30) >> 4
34 if packetType != 0 {
35 return "", errNotQUICInitial
36 }
37
49 - // Version (4 bytes).
38 version := binary.BigEndian.Uint32(packet[1:5])
39 if version == 0 {
52 - return "", errNotQUICInitial // version negotiation
40 + return "", errNotQUICInitial
41 }
42
43 offset := 5
56 -
57 - // Destination Connection ID length + DCID.
44 if offset >= len(packet) {
45 return "", errNotQUICInitial
46 }
@@ -66,7 +52,6 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
52 dcid := packet[offset : offset+dcidLen]
53 offset += dcidLen
54
69 - // Source Connection ID length + SCID.
55 if offset >= len(packet) {
56 return "", errNotQUICInitial
57 }
@@ -77,7 +62,6 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
62 return "", errNotQUICInitial
63 }
64
80 - // Token length (varint) + token.
65 tokenLen, n := readVarint(packet[offset:])
66 if n <= 0 {
67 return "", errNotQUICInitial
@@ -87,7 +71,6 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
71 return "", errNotQUICInitial
72 }
73
90 - // Payload length (varint).
74 payloadLen, n := readVarint(packet[offset:])
75 if n <= 0 {
76 return "", errNotQUICInitial
@@ -95,8 +78,6 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
78 offset += n
79 _ = payloadLen
80
98 - // The rest from offset is: packet number (1-4 bytes, encrypted) + encrypted payload.
99 - // We need to decrypt the header first to determine packet number length.
81 clientSecret, err := deriveInitialClientSecret(dcid, version)
82 if err != nil {
83 return "", fmt.Errorf("derive initial secret: %w", err)
@@ -117,7 +98,6 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
98 return "", fmt.Errorf("derive iv: %w", err)
99 }
100
120 - // Header protection: sample 16 bytes starting 4 bytes after packet number offset.
101 pnOffset := offset
102 sampleOffset := pnOffset + 4
103 if sampleOffset+16 > len(packet) {
@@ -125,7 +105,6 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
105 }
106 sample := packet[sampleOffset : sampleOffset+16]
107
128 - // Create AES-ECB cipher for HP mask.
108 block, err := aes.NewCipher(hp)
109 if err != nil {
110 return "", fmt.Errorf("aes cipher: %w", err)
@@ -133,11 +112,9 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
112 mask := make([]byte, aes.BlockSize)
113 block.Encrypt(mask, sample)
114
136 - // Unmask first byte.
137 - unmaskedFirst := packet[0] ^ (mask[0] & 0x0f) // long header: lower 4 bits
115 + unmaskedFirst := packet[0] ^ (mask[0] & 0x0f)
116 pnLength := int(unmaskedFirst&0x03) + 1
117
140 - // Unmask packet number.
118 pnBytes := make([]byte, pnLength)
119 for i := range pnLength {
120 pnBytes[i] = packet[pnOffset+i] ^ mask[1+i]
@@ -148,85 +125,57 @@ func parseQUICInitialSNI(packet []byte) (string, error) {
125 pn = (pn << 8) | uint32(b)
126 }
127
151 - // Build nonce for AEAD.
128 nonce := make([]byte, len(iv))
129 copy(nonce, iv)
154 - for i := range len(nonce) {
130 + for i := range nonce {
131 if i >= len(nonce)-4 {
132 nonce[i] ^= byte(pn >> (8 * (len(nonce) - 1 - i)))
133 }
134 }
135
160 - // Decrypt payload.
136 payloadOffset := pnOffset + pnLength
137 if payloadOffset >= len(packet) {
138 return "", errNotQUICInitial
139 }
140
166 - // AAD = entire header with unmasked first byte and unmasked PN.
141 aad := make([]byte, payloadOffset)
142 copy(aad, packet[:payloadOffset])
143 aad[0] = unmaskedFirst
144 copy(aad[pnOffset:], pnBytes)
145
172 - aead, err := cipher.NewGCM(block)
146 + aeadBlock, err := aes.NewCipher(key)
147 if err != nil {
174 - // Use the key for AEAD, not HP block.
175 - aeadBlock, err2 := aes.NewCipher(key)
176 - if err2 != nil {
177 - return "", fmt.Errorf("aead cipher: %w", err2)
178 - }
179 - aead, err = cipher.NewGCM(aeadBlock)
180 - if err != nil {
181 - return "", fmt.Errorf("gcm: %w", err)
182 - }
183 - } else {
184 - // We used the HP block for AEAD by mistake. Redo with key.
185 - aeadBlock, err2 := aes.NewCipher(key)
186 - if err2 != nil {
187 - return "", fmt.Errorf("aead cipher: %w", err2)
188 - }
189 - aead, err = cipher.NewGCM(aeadBlock)
190 - if err != nil {
191 - return "", fmt.Errorf("gcm: %w", err)
192 - }
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
195 - ciphertext := packet[payloadOffset:]
196 - plaintext, err := aead.Open(nil, nonce, ciphertext, aad)
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
201 - // Parse CRYPTO frames to find ClientHello.
160 return extractSNIFromCryptoFrames(plaintext)
161 }
162
205 -// extractSNIFromCryptoFrames parses QUIC frames looking for CRYPTO frames
206 -// containing a TLS ClientHello, and extracts the SNI server_name extension.
163 func extractSNIFromCryptoFrames(frames []byte) (string, error) {
164 offset := 0
165 for offset < len(frames) {
166 frameType := frames[offset]
167 offset++
168
213 - switch {
214 - case frameType == 0x00:
215 - // PADDING frame — skip.
169 + switch frameType {
170 + case 0x00, 0x01:
171 continue
217 - case frameType == 0x01:
218 - // PING frame — skip.
219 - continue
220 - case frameType == 0x06:
221 - // CRYPTO frame.
222 - // Offset field (varint).
172 + case 0x06:
173 _, n := readVarint(frames[offset:])
174 if n <= 0 {
175 return "", errSNINotFound
176 }
177 offset += n
178
229 - // Length field (varint).
179 dataLen, n := readVarint(frames[offset:])
180 if n <= 0 {
181 return "", errSNINotFound
@@ -244,20 +193,14 @@ func extractSNIFromCryptoFrames(frames []byte) (string, error) {
193 return sni, nil
194 }
195 default:
247 - // Unknown frame — can't continue parsing reliably.
196 return "", errSNINotFound
197 }
198 }
199 return "", errSNINotFound
200 }
201
254 -// parseTLSClientHelloSNI parses a raw TLS ClientHello message and extracts SNI.
202 func parseTLSClientHelloSNI(data []byte) (string, error) {
256 - // TLS handshake: type(1) + length(3) + ...
257 - if len(data) < 4 {
258 - return "", errSNINotFound
259 - }
260 - if data[0] != 0x01 { // ClientHello
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])
@@ -266,51 +209,46 @@ func parseTLSClientHelloSNI(data []byte) (string, error) {
209 }
210 body := data[4 : 4+msgLen]
211
269 - // ClientHello: version(2) + random(32) + session_id_len(1) + session_id + ...
212 if len(body) < 34 {
213 return "", errSNINotFound
214 }
273 - offset := 2 + 32 // skip version + random
215 + offset := 34
216
275 - // Session ID.
217 if offset >= len(body) {
218 return "", errSNINotFound
219 }
220 sessionIDLen := int(body[offset])
221 offset += 1 + sessionIDLen
222
282 - // Cipher suites.
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
289 - // Compression methods.
229 if offset >= len(body) {
230 return "", errSNINotFound
231 }
232 compMethodsLen := int(body[offset])
233 offset += 1 + compMethodsLen
234
296 - // Extensions.
235 if offset+2 > len(body) {
236 return "", errSNINotFound
237 }
238 extensionsLen := int(body[offset])<<8 | int(body[offset+1])
239 offset += 2
240
303 - extEnd := offset + extensionsLen
304 - if extEnd > len(body) {
305 - extEnd = len(body)
306 - }
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
313 - if extType == 0x0000 { // server_name
248 + if offset+extLen > extEnd {
249 + return "", errSNINotFound
250 + }
251 + if extType == 0x0000 {
252 return parseSNIExtension(body[offset : offset+extLen])
253 }
254 offset += extLen
@@ -323,19 +261,15 @@ func parseSNIExtension(data []byte) (string, error) {
261 if len(data) < 2 {
262 return "", errSNINotFound
263 }
326 - // Server name list length.
264 listLen := int(data[0])<<8 | int(data[1])
265 offset := 2
329 - end := offset + listLen
330 - if end > len(data) {
331 - end = len(data)
332 - }
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
338 - if nameType == 0x00 { // host_name
272 + if nameType == 0x00 {
273 if offset+nameLen > end {
274 return "", errSNINotFound
275 }
@@ -346,7 +280,6 @@ func parseSNIExtension(data []byte) (string, error) {
280 return "", errSNINotFound
281 }
282
349 -// QUIC Initial secret derivation (RFC 9001 Section 5.2).
283 func deriveInitialClientSecret(dcid []byte, version uint32) ([]byte, error) {
284 salt := quicV1InitialSalt
285
@@ -387,7 +320,6 @@ func deriveIV(secret []byte) ([]byte, error) {
320 return iv, nil
321 }
322
390 -// hkdfLabel builds a TLS 1.3 HkdfLabel structure for HKDF-Expand-Label.
323 func hkdfLabel(label []byte, length int) []byte {
324 fullLabel := append([]byte("tls13 "), label...)
325 out := make([]byte, 2+1+len(fullLabel)+1)
@@ -395,11 +327,10 @@ func hkdfLabel(label []byte, length int) []byte {
327 out[1] = byte(length)
328 out[2] = byte(len(fullLabel))
329 copy(out[3:], fullLabel)
398 - out[3+len(fullLabel)] = 0 // empty context
330 + out[3+len(fullLabel)] = 0
331 return out
332 }
333
402 -// readVarint reads a QUIC variable-length integer (RFC 9000 Section 16).
334 func readVarint(data []byte) (uint64, int) {
335 if len(data) == 0 {
336 return 0, -1
@@ -417,116 +348,3 @@ func readVarint(data []byte) (uint64, int) {
348 }
349 return val, length
350 }
420 -
421 -// quicSNIRouter listens on a raw UDP socket and routes QUIC connections
422 -// based on SNI extracted from Initial packets.
423 -type quicSNIRouter struct {
424 - conn net.PacketConn
425 - server *Server
426 - connTable map[string]string // "src_ip:port" → leaseID
427 - mu sync.RWMutex
428 - done chan struct{}
429 - closeOnce sync.Once
430 -}
431 -
432 -func newQUICSNIRouter(conn net.PacketConn, server *Server) *quicSNIRouter {
433 - return &quicSNIRouter{
434 - conn: conn,
435 - server: server,
436 - connTable: make(map[string]string),
437 - done: make(chan struct{}),
438 - }
439 -}
440 -
441 -func (r *quicSNIRouter) run() error {
442 - buf := make([]byte, 65535)
443 - for {
444 - n, addr, err := r.conn.ReadFrom(buf)
445 - if err != nil {
446 - select {
447 - case <-r.done:
448 - return nil
449 - default:
450 - }
451 - if errors.Is(err, net.ErrClosed) {
452 - return nil
453 - }
454 - return err
455 - }
456 -
457 - packet := make([]byte, n)
458 - copy(packet, buf[:n])
459 - go r.handlePacket(packet, addr)
460 - }
461 -}
462 -
463 -func (r *quicSNIRouter) handlePacket(packet []byte, srcAddr net.Addr) {
464 - key := srcAddr.String()
465 -
466 - // Check if we already have a mapping for this source.
467 - r.mu.RLock()
468 - leaseID, found := r.connTable[key]
469 - r.mu.RUnlock()
470 -
471 - if !found {
472 - // Try to parse SNI from Initial packet.
473 - sni, err := parseQUICInitialSNI(packet)
474 - if err != nil || sni == "" {
475 - return // drop non-Initial or unparseable packets from unknown sources
476 - }
477 -
478 - serverName := utils.NormalizeHostname(sni)
479 - record, ok := r.server.registry.Lookup(serverName)
480 - if !ok || record == nil {
481 - return // no route
482 - }
483 - leaseID = record.ID
484 -
485 - r.mu.Lock()
486 - r.connTable[key] = leaseID
487 - r.mu.Unlock()
488 - }
489 -
490 - // Forward packet to the tunnel via QUIC DATAGRAM.
491 - record, ok := r.server.registry.Get(leaseID)
492 -
493 - if !ok || record == nil || record.QUICBroker == nil || !record.QUICBroker.HasConnection() {
494 - return
495 - }
496 -
497 - udpAddr, ok := srcAddr.(*net.UDPAddr)
498 - if !ok {
499 - return
500 - }
501 -
502 - flowID := record.QUICBroker.AllocateFlow(udpAddr)
503 - _ = record.QUICBroker.SendDatagram(flowID, packet)
504 -}
505 -
506 -// writeBackLoop reads datagrams from each QUIC broker and writes raw UDP back
507 -// to the public QUIC clients via the SNI router's PacketConn.
508 -func (r *quicSNIRouter) writeBackLoop(leaseID string, broker *quicBroker) {
509 - for {
510 - select {
511 - case <-r.done:
512 - return
513 - case <-broker.Done():
514 - return
515 - case frame := <-broker.Incoming():
516 - addr, ok := broker.LookupFlowAddr(frame.FlowID)
517 - if !ok {
518 - continue
519 - }
520 - _, _ = r.conn.WriteTo(frame.Payload, addr)
521 - }
522 - }
523 -}
524 -
525 -func (r *quicSNIRouter) close() error {
526 - var closeErr error
527 - r.closeOnce.Do(func() {
528 - close(r.done)
529 - closeErr = r.conn.Close()
530 - })
531 - return closeErr
532 -}
portal/lease.go
+164 -4
@@ -8,6 +8,7 @@ import (
8 "sync"
9 "time"
10
11 + portaldatagram "github.com/gosuda/portal/v2/portal/datagram"
12 "github.com/gosuda/portal/v2/portal/policy"
13 "github.com/gosuda/portal/v2/types"
14 "github.com/gosuda/portal/v2/utils"
@@ -220,7 +221,8 @@ 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)
223 - snapshot.Ready = record.Broker.ReadyCount()
224 + snapshot.Ready = record.ReadyCount()
225 + snapshot.UDPPort = record.UDPPort()
226 snapshot.IsApproved = r.policy.EffectiveApproval(record.ID)
227 snapshot.IsBanned = r.policy.IsLeaseBanned(record.ID)
228 snapshot.IsDenied = r.policy.IsLeaseDenied(record.ID)
@@ -230,10 +232,168 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
232
233 type leaseRecord struct {
234 types.Lease
233 - Broker *leaseBroker
234 - QUICBroker *quicBroker
235 - UDPRelay *udpRelay
235 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
278 + startErr error
279 + 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
329 +}
330 +
331 +func (r *leaseRuntime) Start() error {
332 + if r == nil || r.datagram == nil || r.datagram.relay == nil {
333 + return nil
334 + }
335 +
336 + r.startOnce.Do(func() {
337 + r.startErr = r.datagram.relay.Start(context.Background())
338 + })
339 +
340 + return r.startErr
341 +}
342 +
343 +func (r *leaseRuntime) Close(ports *portaldatagram.PortAllocator) {
344 + if r == nil {
345 + return
346 + }
347 + if r.stream != nil && r.stream.broker != nil {
348 + r.stream.broker.Close()
349 + }
350 + 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)
359 + }
360 + }
361 +}
362 +
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
399 type routeTable struct {
portal/lease_test.go
+17 -5
@@ -10,6 +10,15 @@ import (
10 "github.com/gosuda/portal/v2/types"
11 )
12
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 +
22 func TestLeaseRegistryLifecycle(t *testing.T) {
23 t.Parallel()
24
@@ -22,6 +31,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
31 ExpiresAt: time.Now().Add(30 * time.Second),
32 },
33 ReverseToken: "tok_1",
34 + Runtime: newTestStreamLeaseRuntime("lease_1"),
35 }
36
37 if err := registry.Register(record); err != nil {
@@ -71,6 +81,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
81 ExpiresAt: time.Now().Add(30 * time.Second),
82 },
83 ReverseToken: "tok_wildcard",
84 + Runtime: newTestStreamLeaseRuntime("lease_wildcard"),
85 }
86 if err := registry.Register(wildcardLease); err != nil {
87 t.Fatalf("Register(wildcard) error = %v", err)
@@ -90,6 +101,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
101 ExpiresAt: time.Now().Add(30 * time.Second),
102 },
103 ReverseToken: "tok_conflict",
104 + Runtime: newTestStreamLeaseRuntime("lease_conflict"),
105 }
106 err := registry.Register(conflict)
107 if !errors.Is(err, errHostnameConflict) {
@@ -115,7 +127,7 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
127 ClientIP: "203.0.113.20",
128 },
129 ReverseToken: "tok_policy",
118 - Broker: newLeaseBroker("lease_policy", time.Minute, 1),
130 + Runtime: newTestStreamLeaseRuntime("lease_policy"),
131 }
132 if err := registry.Register(record); err != nil {
133 t.Fatalf("Register() error = %v", err)
@@ -149,7 +161,7 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
161
162 registry := newLeaseRegistry(policy.NewRuntime())
163 registry.onExpired = func(r *leaseRecord) {
152 - r.Broker.Close()
164 + r.Runtime.Close(nil)
165 }
166 record := &leaseRecord{
167 Lease: types.Lease{
@@ -158,20 +170,20 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
170 ExpiresAt: time.Now().Add(-time.Second),
171 },
172 ReverseToken: "tok_expired",
161 - Broker: newLeaseBroker("lease_expired", time.Minute, 1),
173 + Runtime: newTestStreamLeaseRuntime("lease_expired"),
174 }
175 if err := registry.Register(record); err != nil {
176 t.Fatalf("Register() error = %v", err)
177 }
178
179 for _, lease := range registry.removeExpired(time.Now()) {
168 - lease.Broker.Close()
180 + lease.Runtime.Close(nil)
181 }
182
183 if _, ok := registry.Lookup("expired.example.com"); ok {
184 t.Fatal("Lookup() after removeExpired() = true, want false")
185 }
174 - if _, err := record.Broker.Claim(context.Background()); !errors.Is(err, errBrokerClosed) {
186 + if _, err := record.StreamBroker().Claim(context.Background()); !errors.Is(err, errBrokerClosed) {
187 t.Fatalf("Claim() after removeExpired() error = %v, want %v", err, errBrokerClosed)
188 }
189 }
portal/quic.go deleted
-296
@@ -1,296 +0,0 @@
1 -package portal
2 -
3 -import (
4 - "context"
5 - "encoding/json"
6 - "errors"
7 - "net"
8 - "sync"
9 - "time"
10 -
11 - "github.com/quic-go/quic-go"
12 - "github.com/rs/zerolog/log"
13 -
14 - "github.com/gosuda/portal/v2/types"
15 -)
16 -
17 -var (
18 - errQUICNoConnection = errors.New("no quic connection registered")
19 - errQUICAlreadyClosed = errors.New("quic broker closed")
20 -)
21 -
22 -// quicBroker manages a single QUIC connection from a tunnel for one lease.
23 -// All UDP traffic for the lease is multiplexed over DATAGRAM frames on this
24 -// connection, identified by flow IDs.
25 -type quicBroker struct {
26 - leaseID string
27 -
28 - conn *quic.Conn
29 - flowTable map[uint32]*net.UDPAddr // flowID → client addr
30 - addrIndex map[string]uint32 // "ip:port" → flowID
31 - nextFlow uint32
32 -
33 - incoming chan types.DatagramFrame // frames received from tunnel
34 - done chan struct{}
35 -
36 - mu sync.Mutex
37 - closeOnce sync.Once
38 - closed bool
39 -}
40 -
41 -func newQUICBroker(leaseID string) *quicBroker {
42 - return &quicBroker{
43 - leaseID: leaseID,
44 - flowTable: make(map[uint32]*net.UDPAddr),
45 - addrIndex: make(map[string]uint32),
46 - nextFlow: 1,
47 - incoming: make(chan types.DatagramFrame, 256),
48 - done: make(chan struct{}),
49 - }
50 -}
51 -
52 -// Register stores the QUIC connection from the tunnel for this lease.
53 -// Replaces any existing connection.
54 -func (b *quicBroker) Register(conn *quic.Conn) {
55 - b.mu.Lock()
56 - old := b.conn
57 - b.conn = conn
58 - b.mu.Unlock()
59 -
60 - if old != nil {
61 - _ = old.CloseWithError(0, "replaced")
62 - }
63 -
64 - go b.receiveLoop(conn)
65 -
66 - log.Info().
67 - Str("component", "quic-broker").
68 - Str("lease_id", b.leaseID).
69 - Str("remote_addr", conn.RemoteAddr().String()).
70 - Msg("quic tunnel connection registered")
71 -}
72 -
73 -// HasConnection reports whether a tunnel QUIC connection is active.
74 -func (b *quicBroker) HasConnection() bool {
75 - b.mu.Lock()
76 - defer b.mu.Unlock()
77 - return b.conn != nil && !b.closed
78 -}
79 -
80 -// SendDatagram encodes a flow-framed datagram and sends it to the tunnel.
81 -func (b *quicBroker) SendDatagram(flowID uint32, payload []byte) error {
82 - b.mu.Lock()
83 - conn := b.conn
84 - b.mu.Unlock()
85 -
86 - if conn == nil {
87 - return errQUICNoConnection
88 - }
89 - return conn.SendDatagram(types.EncodeDatagram(flowID, payload))
90 -}
91 -
92 -// Incoming returns the channel that delivers datagrams received from the tunnel.
93 -func (b *quicBroker) Incoming() <-chan types.DatagramFrame {
94 - return b.incoming
95 -}
96 -
97 -// Done returns a channel closed when the broker shuts down.
98 -func (b *quicBroker) Done() <-chan struct{} {
99 - return b.done
100 -}
101 -
102 -// AllocateFlow assigns a flow ID for a client address. If the address already
103 -// has a flow, the existing ID is returned.
104 -func (b *quicBroker) AllocateFlow(addr *net.UDPAddr) uint32 {
105 - key := addr.String()
106 -
107 - b.mu.Lock()
108 - defer b.mu.Unlock()
109 -
110 - if id, ok := b.addrIndex[key]; ok {
111 - return id
112 - }
113 - id := b.nextFlow
114 - b.nextFlow++
115 - b.flowTable[id] = addr
116 - b.addrIndex[key] = id
117 - return id
118 -}
119 -
120 -// LookupFlowAddr returns the client address for a flow ID.
121 -func (b *quicBroker) LookupFlowAddr(flowID uint32) (*net.UDPAddr, bool) {
122 - b.mu.Lock()
123 - defer b.mu.Unlock()
124 - addr, ok := b.flowTable[flowID]
125 - return addr, ok
126 -}
127 -
128 -// Stop tears down the QUIC connection and signals done.
129 -func (b *quicBroker) Stop() {
130 - b.closeOnce.Do(func() {
131 - b.mu.Lock()
132 - b.closed = true
133 - conn := b.conn
134 - b.conn = nil
135 - b.mu.Unlock()
136 -
137 - if conn != nil {
138 - _ = conn.CloseWithError(0, "lease stopped")
139 - }
140 - close(b.done)
141 - })
142 -}
143 -
144 -func (b *quicBroker) receiveLoop(conn *quic.Conn) {
145 - for {
146 - data, err := conn.ReceiveDatagram(context.Background())
147 - if err != nil {
148 - b.mu.Lock()
149 - // Only clear if this is still the active connection.
150 - if b.conn == conn {
151 - b.conn = nil
152 - }
153 - b.mu.Unlock()
154 -
155 - if !b.isClosed() {
156 - log.Warn().
157 - Err(err).
158 - Str("component", "quic-broker").
159 - Str("lease_id", b.leaseID).
160 - Msg("quic receive loop ended")
161 - }
162 - return
163 - }
164 -
165 - frame, err := types.DecodeDatagram(data)
166 - if err != nil {
167 - continue
168 - }
169 -
170 - select {
171 - case b.incoming <- frame:
172 - default:
173 - // Drop if channel full — back-pressure on tunnel.
174 - }
175 - }
176 -}
177 -
178 -func (b *quicBroker) isClosed() bool {
179 - select {
180 - case <-b.done:
181 - return true
182 - default:
183 - return false
184 - }
185 -}
186 -
187 -// quicControlMessage is sent by the tunnel on the first QUIC stream after
188 -// connecting. The relay reads it to associate the connection with a lease.
189 -type quicControlMessage struct {
190 - LeaseID string `json:"lease_id"`
191 - ReverseToken string `json:"reverse_token"`
192 -}
193 -
194 -// quicTunnelListener manages the QUIC listener that accepts tunnel connections
195 -// on the relay API UDP port.
196 -type quicTunnelListener struct {
197 - listener *quic.Listener
198 - server *Server
199 -
200 - done chan struct{}
201 - closeOnce sync.Once
202 -}
203 -
204 -func newQUICTunnelListener(listener *quic.Listener, server *Server) *quicTunnelListener {
205 - return &quicTunnelListener{
206 - listener: listener,
207 - server: server,
208 - done: make(chan struct{}),
209 - }
210 -}
211 -
212 -func (l *quicTunnelListener) run() error {
213 - for {
214 - conn, err := l.listener.Accept(context.Background())
215 - if err != nil {
216 - select {
217 - case <-l.done:
218 - return nil
219 - default:
220 - }
221 - if errors.Is(err, quic.ErrServerClosed) {
222 - return nil
223 - }
224 - return err
225 - }
226 - go l.handleConnection(conn)
227 - }
228 -}
229 -
230 -func (l *quicTunnelListener) handleConnection(conn *quic.Conn) {
231 - stream, err := conn.AcceptStream(context.Background())
232 - if err != nil {
233 - _ = conn.CloseWithError(1, "stream accept failed")
234 - return
235 - }
236 -
237 - // Read control message with timeout.
238 - _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
239 - var msg quicControlMessage
240 - buf := make([]byte, 4096)
241 - n, err := stream.Read(buf)
242 - if err != nil {
243 - _ = conn.CloseWithError(1, "control read failed")
244 - return
245 - }
246 -
247 - // Simple JSON decode.
248 - if decErr := json.Unmarshal(buf[:n], &msg); decErr != nil {
249 - _ = conn.CloseWithError(1, "invalid control message")
250 - return
251 - }
252 - _ = stream.SetReadDeadline(time.Time{})
253 -
254 - lease, err := l.server.findLeaseByID(msg.LeaseID)
255 - if err != nil {
256 - _, _ = stream.Write([]byte(`{"ok":false,"error":"lease_not_found"}`))
257 - _ = conn.CloseWithError(1, "lease not found")
258 - return
259 - }
260 -
261 - if authErr := l.server.authorizeLeaseToken(lease, msg.ReverseToken); authErr != nil {
262 - _, _ = stream.Write([]byte(`{"ok":false,"error":"unauthorized"}`))
263 - _ = conn.CloseWithError(1, "unauthorized")
264 - return
265 - }
266 -
267 - if lease.QUICBroker == nil {
268 - _, _ = stream.Write([]byte(`{"ok":false,"error":"transport_mismatch"}`))
269 - _ = conn.CloseWithError(1, "lease does not support QUIC transport")
270 - return
271 - }
272 -
273 - // Success — register the QUIC connection with the broker.
274 - lease.QUICBroker.Register(conn)
275 -
276 - // Confirm registration to the tunnel.
277 - _, _ = stream.Write([]byte(`{"ok":true}`))
278 -
279 - l.server.registry.Touch(lease.ID, conn.RemoteAddr().String(), time.Now())
280 -
281 - log.Info().
282 - Str("component", "quic-tunnel-listener").
283 - Str("lease_id", lease.ID).
284 - Str("lease_name", lease.Name).
285 - Str("remote_addr", conn.RemoteAddr().String()).
286 - Msg("quic tunnel connected")
287 -}
288 -
289 -func (l *quicTunnelListener) close() error {
290 - var closeErr error
291 - l.closeOnce.Do(func() {
292 - close(l.done)
293 - closeErr = l.listener.Close()
294 - })
295 - return closeErr
296 -}
portal/server.go
+287 -60
@@ -2,6 +2,7 @@ package portal
2
3 import (
4 "context"
5 + "encoding/json"
6 "errors"
7 "fmt"
8 "io"
@@ -18,6 +19,7 @@ import (
19 "golang.org/x/sync/errgroup"
20
21 "github.com/gosuda/portal/v2/portal/acme"
22 + portaldatagram "github.com/gosuda/portal/v2/portal/datagram"
23 "github.com/gosuda/portal/v2/portal/keyless"
24 "github.com/gosuda/portal/v2/portal/policy"
25 "github.com/gosuda/portal/v2/types"
@@ -32,11 +34,18 @@ const (
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
42 )
43
44 +type quicSNIRoute struct {
45 + flowMux *portaldatagram.FlowMux
46 + lastSeen time.Time
47 +}
48 +
49 type ServerConfig struct {
50 PortalURL string
51 ACME acme.Config
@@ -55,20 +64,22 @@ type ServerConfig struct {
64 }
65
66 type Server struct {
58 - sniListener net.Listener
59 - apiListener net.Listener
60 - apiServer *http.Server
61 - apiTLSClose io.Closer
62 - acmeManager *acme.Manager
63 - quicTunnel *quicTunnelListener
64 - quicSNIConn net.PacketConn
65 - cancel context.CancelFunc
66 - group *errgroup.Group
67 - registry *leaseRegistry
68 - ports *portAllocator
69 - cfg ServerConfig
70 - rootHost string
71 - shutdownOnce sync.Once
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 *portaldatagram.PortAllocator
78 + cfg ServerConfig
79 + rootHost string
80 + shutdownOnce sync.Once
81 + quicSNIRoutes map[string]quicSNIRoute
82 + quicSNIMu sync.RWMutex
83 }
84
85 func NewServer(cfg ServerConfig) (*Server, error) {
@@ -94,13 +105,14 @@ func NewServer(cfg ServerConfig) (*Server, error) {
105 }
106
107 registry := newLeaseRegistry(policy.NewRuntime())
97 - ports := newPortAllocator(cfg.UDPPortMin, cfg.UDPPortMax, 5*time.Minute)
108 + ports := portaldatagram.NewPortAllocator(cfg.UDPPortMin, cfg.UDPPortMax, 5*time.Minute)
109
110 s := &Server{
100 - cfg: cfg,
101 - rootHost: rootHost,
102 - registry: registry,
103 - ports: ports,
111 + cfg: cfg,
112 + rootHost: rootHost,
113 + registry: registry,
114 + ports: ports,
115 + quicSNIRoutes: make(map[string]quicSNIRoute),
116 }
117
118 // Tear down all lease resources when leases expire via TTL janitor.
@@ -161,7 +173,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
173 group.Go(func() error { return s.watchContext(groupCtx) })
174 s.acmeManager.Start(serverCtx)
175
164 - if err := s.startQUICTunnelListener(serverCtx, apiTLS); err != nil {
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 {
@@ -190,10 +202,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
202 }
203
204 if s.quicTunnel != nil {
193 - _ = s.quicTunnel.close()
205 + _ = s.quicTunnel.Close()
206 }
195 - if s.quicSNIConn != nil {
196 - _ = s.quicSNIConn.Close()
207 + if s.quicSNI != nil {
208 + _ = s.quicSNI.Close()
209 }
210 if s.sniListener != nil {
211 if err := s.sniListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
@@ -237,10 +249,10 @@ func (s *Server) SNIAddr() string {
249 }
250
251 func (s *Server) QUICAddr() string {
240 - if s.quicTunnel == nil || s.quicTunnel.listener == nil {
252 + if s.quicTunnel == nil {
253 return ""
254 }
243 - return s.quicTunnel.listener.Addr().String()
255 + return s.quicTunnel.Addr().String()
256 }
257
258 func (s *Server) LeaseSnapshots() []types.Lease {
@@ -330,12 +342,8 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
342 return
343 }
344
333 - record, ok := s.registry.Lookup(serverName)
334 - if !ok || time.Now().After(record.ExpiresAt) {
335 - _ = wrappedConn.Close()
336 - return
337 - }
338 - if !s.registry.policy.IsLeaseRoutable(record.ID) {
345 + broker, err := s.resolveStreamBroker(serverName)
346 + if err != nil {
347 _ = wrappedConn.Close()
348 return
349 }
@@ -343,7 +351,7 @@ func (s *Server) handleSNIConn(ctx context.Context, conn net.Conn) {
351 claimCtx, cancel := context.WithTimeout(ctx, s.cfg.ClaimTimeout)
352 defer cancel()
353
346 - session, err := record.Broker.Claim(claimCtx)
354 + session, err := broker.Claim(claimCtx)
355 if err != nil {
356 _ = wrappedConn.Close()
357 return
@@ -367,6 +375,59 @@ func (s *Server) bridgeToAPI(ctx context.Context, conn net.Conn) {
375 BridgeConns(conn, upstream)
376 }
377
378 +func (s *Server) lookupRoutableLease(serverName string) (*leaseRecord, error) {
379 + record, ok := s.registry.Lookup(serverName)
380 + if !ok || record == nil {
381 + return nil, errors.New("no route")
382 + }
383 + if time.Now().After(record.ExpiresAt) {
384 + return nil, errors.New("lease expired")
385 + }
386 + if !s.registry.policy.IsLeaseRoutable(record.ID) {
387 + return nil, errors.New("not routable")
388 + }
389 + return record, nil
390 +}
391 +
392 +func (s *Server) resolveStreamBroker(serverName string) (*streamBroker, error) {
393 + record, err := s.lookupRoutableLease(serverName)
394 + if err != nil {
395 + return nil, err
396 + }
397 + if !record.SupportsStream() {
398 + return nil, errors.New("transport mismatch")
399 + }
400 + streamBroker := record.StreamBroker()
401 + if streamBroker == nil {
402 + return nil, errors.New("stream broker unavailable")
403 + }
404 + return streamBroker, nil
405 +}
406 +
407 +func (s *Server) resolveDatagramFlowMux(serverName string) (*portaldatagram.FlowMux, error) {
408 + if serverName == s.rootHost {
409 + return nil, errors.New("root host does not accept datagram routes")
410 + }
411 +
412 + record, err := s.lookupRoutableLease(serverName)
413 + if err != nil {
414 + return nil, err
415 + }
416 + if !record.SupportsDatagram() {
417 + return nil, errors.New("transport mismatch")
418 + }
419 + flowMux := record.DatagramFlowMux()
420 + if flowMux == nil {
421 + return nil, errors.New("flow mux unavailable")
422 + }
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)
@@ -401,7 +462,7 @@ func closeWrite(conn net.Conn) {
462 }
463 }
464
404 -func (s *Server) startQUICTunnelListener(ctx context.Context, apiTLS keyless.TLSMaterialConfig) error {
465 +func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error {
466 if len(apiTLS.KeyPEM) == 0 {
467 return fmt.Errorf("quic tunnel requires api tls key")
468 }
@@ -416,9 +477,9 @@ func (s *Server) startQUICTunnelListener(ctx context.Context, apiTLS keyless.TLS
477 MinVersion: tls.VersionTLS13,
478 }
479 quicConf := &quic.Config{
419 - EnableDatagrams: true,
420 - KeepAlivePeriod: 15 * time.Second,
421 - MaxIdleTimeout: 60 * time.Second,
480 + EnableDatagrams: true,
481 + KeepAlivePeriod: 15 * time.Second,
482 + MaxIdleTimeout: 60 * time.Second,
483 MaxIncomingStreams: 16,
484 }
485
@@ -427,9 +488,8 @@ func (s *Server) startQUICTunnelListener(ctx context.Context, apiTLS keyless.TLS
488 return fmt.Errorf("listen quic: %w", err)
489 }
490
430 - tunnel := newQUICTunnelListener(listener, s)
431 - s.quicTunnel = tunnel
432 - s.group.Go(tunnel.run)
491 + s.quicTunnel = listener
492 + s.group.Go(func() error { return s.runQUICTunnelListener(listener) })
493
494 log.Info().
495 Str("component", "relay-server").
@@ -439,14 +499,14 @@ func (s *Server) startQUICTunnelListener(ctx context.Context, apiTLS keyless.TLS
499 }
500
501 func (s *Server) startQUICSNIRouter() error {
442 - conn, err := net.ListenPacket("udp", s.cfg.SNIListenAddr)
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
447 - router := newQUICSNIRouter(conn, s)
448 - s.quicSNIConn = conn
449 - s.group.Go(router.run)
508 + s.quicSNI = conn
509 + s.group.Go(func() error { return s.runQUICSNIRouter(conn) })
510
511 log.Info().
512 Str("component", "relay-server").
@@ -455,31 +515,198 @@ func (s *Server) startQUICSNIRouter() error {
515 return nil
516 }
517
458 -func (s *Server) startUDPRelay(ctx context.Context, leaseID string, relay *udpRelay) {
459 - if relay == nil {
518 +func (s *Server) runQUICTunnelListener(listener *quic.Listener) error {
519 + for {
520 + conn, err := listener.Accept(context.Background())
521 + if err != nil {
522 + if errors.Is(err, quic.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
523 + return nil
524 + }
525 + return err
526 + }
527 + go s.handleQUICTunnelConn(conn)
528 + }
529 +}
530 +
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")
574 + return
575 + }
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")
585 +}
586 +
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 + }
608 +}
609 +
610 +func (s *Server) handleQUICSNIPacket(packet []byte, srcAddr net.Addr, now time.Time) {
611 + cacheKey := srcAddr.String()
612 + flowMux, ok := s.lookupQUICSNIRoute(cacheKey, now)
613 + if !ok {
614 + serverName, err := portaldatagram.ParseQUICInitialSNI(packet)
615 + if err != nil || serverName == "" {
616 + return
617 + }
618 +
619 + flowMux, err = s.resolveDatagramFlowMux(utils.NormalizeHostname(serverName))
620 + if err != nil || flowMux == nil {
621 + return
622 + }
623 + s.storeQUICSNIRoute(cacheKey, flowMux, now)
624 + }
625 +
626 + udpAddr, ok := srcAddr.(*net.UDPAddr)
627 + if !ok {
628 + return
629 + }
630 + if s.quicSNI == nil {
631 + return
632 + }
633 +
634 + flowID := flowMux.TouchFlow("quic:"+cacheKey, func(payload []byte) error {
635 + _, err := s.quicSNI.WriteTo(payload, udpAddr)
636 + return err
637 + })
638 + if err := flowMux.SendDatagram(flowID, packet); err != nil {
639 + s.deleteQUICSNIRoute(cacheKey)
640 + }
641 +}
642 +
643 +func (s *Server) lookupQUICSNIRoute(key string, now time.Time) (*portaldatagram.FlowMux, bool) {
644 + s.quicSNIMu.Lock()
645 + defer s.quicSNIMu.Unlock()
646 +
647 + route, ok := s.quicSNIRoutes[key]
648 + if !ok || route.flowMux == nil {
649 + delete(s.quicSNIRoutes, key)
650 + return nil, false
651 + }
652 + if now.Sub(route.lastSeen) > defaultQUICSNIRouteIdle || !route.flowMux.HasConnection() {
653 + delete(s.quicSNIRoutes, key)
654 + return nil, false
655 + }
656 +
657 + route.lastSeen = now
658 + s.quicSNIRoutes[key] = route
659 + return route.flowMux, true
660 +}
661 +
662 +func (s *Server) storeQUICSNIRoute(key string, flowMux *portaldatagram.FlowMux, now time.Time) {
663 + s.quicSNIMu.Lock()
664 + s.quicSNIRoutes[key] = quicSNIRoute{
665 + flowMux: flowMux,
666 + lastSeen: now,
667 + }
668 + s.quicSNIMu.Unlock()
669 +}
670 +
671 +func (s *Server) deleteQUICSNIRoute(key string) {
672 + s.quicSNIMu.Lock()
673 + delete(s.quicSNIRoutes, key)
674 + s.quicSNIMu.Unlock()
675 +}
676 +
677 +func (s *Server) cleanupQUICSNIRoutes(now time.Time) {
678 + s.quicSNIMu.Lock()
679 + defer s.quicSNIMu.Unlock()
680 +
681 + for key, route := range s.quicSNIRoutes {
682 + if route.flowMux == nil || now.Sub(route.lastSeen) > defaultQUICSNIRouteIdle || !route.flowMux.HasConnection() {
683 + delete(s.quicSNIRoutes, key)
684 + }
685 + }
686 +}
687 +
688 +func (s *Server) clearQUICSNIRoutesForFlowMux(flowMux *portaldatagram.FlowMux) {
689 + if flowMux == nil {
690 return
691 }
462 - if err := relay.Start(ctx); err != nil {
463 - log.Error().
464 - Err(err).
465 - Str("component", "relay-server").
466 - Str("lease_id", leaseID).
467 - Msg("failed to start udp relay")
692 +
693 + s.quicSNIMu.Lock()
694 + defer s.quicSNIMu.Unlock()
695 +
696 + for key, route := range s.quicSNIRoutes {
697 + if route.flowMux == flowMux {
698 + delete(s.quicSNIRoutes, key)
699 + }
700 }
701 }
702
703 // closeLease tears down all resources associated with a single lease record.
704 func (s *Server) closeLease(record *leaseRecord) {
473 - record.Broker.Close()
474 - if record.QUICBroker != nil {
475 - record.QUICBroker.Stop()
476 - }
477 - if record.UDPRelay != nil {
478 - record.UDPRelay.Stop()
479 - }
480 - if record.UDPPort > 0 && s.ports != nil {
481 - s.ports.Release(record.UDPPort)
705 + if record == nil || record.Runtime == nil {
706 + return
707 }
708 + s.clearQUICSNIRoutesForFlowMux(record.DatagramFlowMux())
709 + record.Runtime.Close(s.ports)
710 }
711
712 func (s *Server) quicPublicAddr() string {
portal/server_test.go
+46
@@ -151,3 +151,49 @@ func TestRegisterLeaseRejectsInvalidName(t *testing.T) {
151 t.Fatal("registerLease() error = nil, want invalid name error")
152 }
153 }
154 +
155 +func TestRegisterLeaseBuildsDatagramOnlyRuntime(t *testing.T) {
156 + t.Parallel()
157 +
158 + server, err := NewServer(ServerConfig{
159 + PortalURL: "https://portal.example.com",
160 + })
161 + if err != nil {
162 + t.Fatalf("NewServer() error = %v", err)
163 + }
164 +
165 + resp, err := server.registerLease(types.RegisterRequest{
166 + Name: "demo-udp",
167 + ReverseToken: "tok_udp",
168 + Transport: types.TransportUDP,
169 + }, "203.0.113.10")
170 + if err != nil {
171 + t.Fatalf("registerLease() error = %v", err)
172 + }
173 +
174 + record, ok := server.registry.Get(resp.LeaseID)
175 + if !ok {
176 + t.Fatal("registry.Get() = false, want registered lease")
177 + }
178 + if record.SupportsStream() {
179 + t.Fatal("SupportsStream() = true, want false")
180 + }
181 + if !record.SupportsDatagram() {
182 + t.Fatal("SupportsDatagram() = false, want true")
183 + }
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 {
191 + t.Fatal("UDPPort() = 0, want allocated port")
192 + }
193 + if resp.UDPAddr == "" {
194 + t.Fatal("RegisterResponse.UDPAddr = empty, want public udp address")
195 + }
196 + if resp.QUICAddr == "" {
197 + t.Fatal("RegisterResponse.QUICAddr = empty, want quic address")
198 + }
199 +}
portal/udp_port.go deleted
-119
@@ -1,119 +0,0 @@
1 -package portal
2 -
3 -import (
4 - "errors"
5 - "sort"
6 - "sync"
7 - "time"
8 -)
9 -
10 -var errPortExhausted = errors.New("no udp ports available")
11 -
12 -// portReservation holds a released port for a grace period so the same lease
13 -// name can reclaim it on rapid reconnect.
14 -type portReservation struct {
15 - port int
16 - expiresAt time.Time
17 -}
18 -
19 -// portAllocator manages a pool of UDP ports for dynamic per-lease allocation.
20 -//
21 -// Features:
22 -// - Sticky allocation: re-registering the same lease name within the grace
23 -// period returns the previously assigned port.
24 -// - Grace period: released ports are held in a reservation map for the
25 -// configured duration before returning to the free pool.
26 -// - Sorted reuse: free ports are kept in ascending order so the lowest
27 -// available port is always allocated first.
28 -type portAllocator struct {
29 - available []int // sorted ascending
30 - inUse map[int]string // port → lease name
31 - reserved map[string]portReservation // lease name → reservation
32 - grace time.Duration
33 - mu sync.Mutex
34 -}
35 -
36 -func newPortAllocator(min, max int, grace time.Duration) *portAllocator {
37 - available := make([]int, 0, max-min+1)
38 - for p := min; p <= max; p++ {
39 - available = append(available, p)
40 - }
41 - return &portAllocator{
42 - available: available,
43 - inUse: make(map[int]string),
44 - reserved: make(map[string]portReservation),
45 - grace: grace,
46 - }
47 -}
48 -
49 -// Allocate returns a UDP port for the given lease name.
50 -// If the name has a non-expired reservation the same port is returned.
51 -// Otherwise the lowest available port is allocated.
52 -func (a *portAllocator) Allocate(name string) (int, error) {
53 - a.mu.Lock()
54 - defer a.mu.Unlock()
55 -
56 - a.cleanupExpiredLocked(time.Now())
57 -
58 - // Reclaim reserved port for same name.
59 - if res, ok := a.reserved[name]; ok {
60 - delete(a.reserved, name)
61 - a.inUse[res.port] = name
62 - return res.port, nil
63 - }
64 -
65 - if len(a.available) == 0 {
66 - return 0, errPortExhausted
67 - }
68 -
69 - port := a.available[0]
70 - a.available = a.available[1:]
71 - a.inUse[port] = name
72 - return port, nil
73 -}
74 -
75 -// Release moves a port from in-use to reserved state. The port is held for
76 -// the grace period so the same lease name can reclaim it.
77 -func (a *portAllocator) Release(port int) {
78 - a.mu.Lock()
79 - defer a.mu.Unlock()
80 -
81 - name, ok := a.inUse[port]
82 - if !ok {
83 - return
84 - }
85 - delete(a.inUse, port)
86 -
87 - // If the name already has a different reservation (shouldn't happen in
88 - // normal flow), return that old port to the free pool first.
89 - if prev, exists := a.reserved[name]; exists {
90 - a.sortedInsertLocked(prev.port)
91 - }
92 -
93 - a.reserved[name] = portReservation{
94 - port: port,
95 - expiresAt: time.Now().Add(a.grace),
96 - }
97 -
98 - a.cleanupExpiredLocked(time.Now())
99 -}
100 -
101 -// cleanupExpiredLocked moves expired reservations back to the sorted available
102 -// pool. Caller must hold a.mu.
103 -func (a *portAllocator) cleanupExpiredLocked(now time.Time) {
104 - for name, res := range a.reserved {
105 - if now.After(res.expiresAt) {
106 - delete(a.reserved, name)
107 - a.sortedInsertLocked(res.port)
108 - }
109 - }
110 -}
111 -
112 -// sortedInsertLocked inserts port into a.available maintaining ascending order.
113 -// Caller must hold a.mu.
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 -}
portal/udp_relay.go deleted
-199
@@ -1,199 +0,0 @@
1 -package portal
2 -
3 -import (
4 - "context"
5 - "fmt"
6 - "net"
7 - "sync"
8 - "time"
9 -
10 - "github.com/rs/zerolog/log"
11 -)
12 -
13 -const (
14 - defaultUDPSessionTimeout = 30 * time.Second
15 - defaultMaxDatagramSize = 1350
16 -)
17 -
18 -// udpSession tracks one client endpoint sending to a per-lease UDP listener.
19 -type udpSession struct {
20 - FlowID uint32
21 - Addr *net.UDPAddr
22 - LastSeen time.Time
23 -}
24 -
25 -// udpRelay binds a UDP port for a lease and relays datagrams bidirectionally
26 -// between raw UDP clients and the tunnel's QUIC connection via the quicBroker.
27 -type udpRelay struct {
28 - leaseID string
29 - port int
30 - broker *quicBroker
31 - conn *net.UDPConn
32 -
33 - sessions map[string]*udpSession // "ip:port" → session
34 - mu sync.Mutex
35 -
36 - cancel context.CancelFunc
37 - done chan struct{}
38 - closeOnce sync.Once
39 -}
40 -
41 -func newUDPRelay(leaseID string, port int, broker *quicBroker) *udpRelay {
42 - return &udpRelay{
43 - leaseID: leaseID,
44 - port: port,
45 - broker: broker,
46 - sessions: make(map[string]*udpSession),
47 - done: make(chan struct{}),
48 - }
49 -}
50 -
51 -// Start binds the UDP port and launches read/write relay goroutines.
52 -func (r *udpRelay) Start(ctx context.Context) error {
53 - addr := &net.UDPAddr{Port: r.port}
54 - conn, err := net.ListenUDP("udp", addr)
55 - if err != nil {
56 - return fmt.Errorf("listen udp :%d: %w", r.port, err)
57 - }
58 - r.conn = conn
59 -
60 - relayCtx, cancel := context.WithCancel(ctx)
61 - r.cancel = cancel
62 -
63 - go r.readLoop(relayCtx)
64 - go r.writeLoop(relayCtx)
65 - go r.sessionCleanup(relayCtx)
66 -
67 - log.Info().
68 - Str("component", "udp-relay").
69 - Str("lease_id", r.leaseID).
70 - Int("port", r.port).
71 - Msg("udp relay started")
72 -
73 - return nil
74 -}
75 -
76 -// Stop closes the UDP socket and cancels the relay context.
77 -func (r *udpRelay) Stop() {
78 - r.closeOnce.Do(func() {
79 - if r.cancel != nil {
80 - r.cancel()
81 - }
82 - if r.conn != nil {
83 - _ = r.conn.Close()
84 - }
85 - close(r.done)
86 - log.Info().
87 - Str("component", "udp-relay").
88 - Str("lease_id", r.leaseID).
89 - Int("port", r.port).
90 - Msg("udp relay stopped")
91 - })
92 -}
93 -
94 -// readLoop reads raw UDP from public clients and forwards via QUIC DATAGRAM.
95 -func (r *udpRelay) readLoop(ctx context.Context) {
96 - buf := make([]byte, defaultMaxDatagramSize)
97 - for {
98 - select {
99 - case <-ctx.Done():
100 - return
101 - default:
102 - }
103 -
104 - _ = r.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
105 - n, clientAddr, err := r.conn.ReadFromUDP(buf)
106 - if err != nil {
107 - if ctx.Err() != nil {
108 - return
109 - }
110 - if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
111 - continue
112 - }
113 - log.Warn().
114 - Str("component", "udp-relay").
115 - Str("lease_id", r.leaseID).
116 - Err(err).
117 - Msg("readLoop exiting: unexpected read error")
118 - return
119 - }
120 -
121 - flowID := r.getOrCreateFlow(clientAddr)
122 - payload := make([]byte, n)
123 - copy(payload, buf[:n])
124 -
125 - if err := r.broker.SendDatagram(flowID, payload); err != nil {
126 - log.Warn().
127 - Str("component", "udp-relay").
128 - Str("lease_id", r.leaseID).
129 - Err(err).
130 - Uint32("flow_id", flowID).
131 - Int("bytes", n).
132 - Msg("send datagram to tunnel failed, dropping packet")
133 - continue
134 - }
135 - }
136 -}
137 -
138 -// writeLoop receives QUIC DATAGRAM frames from the tunnel and sends raw UDP back.
139 -func (r *udpRelay) writeLoop(ctx context.Context) {
140 - for {
141 - select {
142 - case <-ctx.Done():
143 - return
144 - case frame := <-r.broker.Incoming():
145 - addr, ok := r.broker.LookupFlowAddr(frame.FlowID)
146 - if !ok {
147 - log.Debug().
148 - Str("component", "udp-relay").
149 - Str("lease_id", r.leaseID).
150 - Uint32("flow_id", frame.FlowID).
151 - Msg("write loop: unknown flow id, dropping")
152 - continue
153 - }
154 - _, _ = r.conn.WriteToUDP(frame.Payload, addr)
155 - }
156 - }
157 -}
158 -
159 -func (r *udpRelay) getOrCreateFlow(addr *net.UDPAddr) uint32 {
160 - key := addr.String()
161 -
162 - r.mu.Lock()
163 - defer r.mu.Unlock()
164 -
165 - if s, ok := r.sessions[key]; ok {
166 - s.LastSeen = time.Now()
167 - return s.FlowID
168 - }
169 -
170 - flowID := r.broker.AllocateFlow(addr)
171 - r.sessions[key] = &udpSession{
172 - FlowID: flowID,
173 - Addr: addr,
174 - LastSeen: time.Now(),
175 - }
176 - return flowID
177 -}
178 -
179 -// sessionCleanup periodically removes idle UDP sessions.
180 -func (r *udpRelay) sessionCleanup(ctx context.Context) {
181 - ticker := time.NewTicker(defaultUDPSessionTimeout)
182 - defer ticker.Stop()
183 -
184 - for {
185 - select {
186 - case <-ctx.Done():
187 - return
188 - case <-ticker.C:
189 - now := time.Now()
190 - r.mu.Lock()
191 - for key, s := range r.sessions {
192 - if now.Sub(s.LastSeen) > defaultUDPSessionTimeout {
193 - delete(r.sessions, key)
194 - }
195 - }
196 - r.mu.Unlock()
197 - }
198 - }
199 -}
sdk/expose.go
+206 -60
@@ -20,18 +20,43 @@ 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 - listener net.Listener
24 - listeners []*Listener
25 - done chan struct{}
23 + capabilities types.LeaseCapabilities
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 {
36 + FlowID uint32
37 + LeaseID string
38 + Payload []byte
39 + RelayURL string
40 + UDPAddr string
41 +
42 + reply func([]byte) error
43 +}
44 +
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)
50 +}
51 +
52 // Expose creates relay listeners for each normalized relay URL and exposes a
53 // merged listener for accepting traffic from all of them. Empty relay input
54 // 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 +
60 relayURLs, err := utils.NormalizeRelayURLs(relayUrls)
61 if err != nil {
62 return nil, err
@@ -39,6 +64,11 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
64 if len(relayURLs) == 0 {
65 return nil, nil
66 }
67 + capabilities, err := types.ParseLeaseCapabilities(transport)
68 + if err != nil {
69 + return nil, err
70 + }
71 +
72 listeners := make([]*Listener, 0, len(relayURLs))
73 cleanup := func() error {
74 var closeErr error
@@ -53,7 +83,7 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
83 for _, relayURL := range relayURLs {
84 listener, err := NewListener(ctx, relayURL, ListenerConfig{
85 Name: name,
56 - Transport: transport,
86 + Transport: capabilities.Transport(),
87 Metadata: metadata,
88 })
89 if err != nil {
@@ -63,22 +93,30 @@ func Expose(ctx context.Context, relayUrls []string, name string, transport stri
93 listeners = append(listeners, listener)
94 }
95
66 - mergedListeners := make([]net.Listener, 0, len(listeners))
67 - for _, listener := range listeners {
68 - mergedListeners = append(mergedListeners, listener)
69 - }
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
71 - merged, err := mergeListeners(mergedListeners...)
72 - if err != nil {
73 - return nil, errors.Join(fmt.Errorf("merge listeners: %w", err), cleanup())
103 + merged, err = mergeListeners(mergedListeners...)
104 + if err != nil {
105 + return nil, errors.Join(fmt.Errorf("merge listeners: %w", err), cleanup())
106 + }
107 }
108
109 exposure := &Exposure{
77 - listener: merged,
78 - listeners: listeners,
79 - done: make(chan struct{}),
110 + capabilities: capabilities,
111 + listener: merged,
112 + listeners: listeners,
113 + datagrams: make(chan ExposureDatagram, max(len(listeners)*32, 1)),
114 + done: make(chan struct{}),
115 }
116 go exposure.monitorStartupCounts(ctx)
117 + if exposure.SupportsDatagram() {
118 + exposure.attachDatagramPlanes(ctx)
119 + }
120
121 log.Info().
122 Str("release_version", types.ReleaseVersion).
@@ -129,6 +167,21 @@ func (e *Exposure) Addr() net.Addr {
167 return e.listener.Addr()
168 }
169
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 {
@@ -145,6 +198,38 @@ func (e *Exposure) RelayURLs() []string {
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.
234 func (e *Exposure) PublicURLs() []string {
235 if e == nil || len(e.listeners) == 0 {
@@ -178,70 +263,105 @@ func (e *Exposure) PublicURLs() []string {
263 // local-only serving.
264 func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr string) error {
265 var relayListener net.Listener
181 - if e != nil {
266 + if e != nil && e.listener != nil {
267 relayListener = e
268 }
269 return RunHTTP(ctx, relayListener, handler, localAddr)
270 }
271
187 -// AttachUDP creates UDPListeners for each underlying relay listener that has
188 -// UDP transport enabled. Listeners without UDP support are silently skipped.
189 -func (e *Exposure) AttachUDP(ctx context.Context) ([]*UDPListener, error) {
190 - if e == nil || len(e.listeners) == 0 {
191 - return nil, nil
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
194 - // Attach concurrently so a slow/failing relay does not block the rest.
195 - // Return collected results as soon as timeout fires or all complete.
196 - waitCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
197 - defer cancel()
282 + ticker := time.NewTicker(50 * time.Millisecond)
283 + defer ticker.Stop()
284
199 - results := make(chan *UDPListener, len(e.listeners))
285 + for {
286 + if addrs := e.UDPAddrs(); len(addrs) > 0 {
287 + return addrs, nil
288 + }
289
201 - count := 0
202 - for _, relay := range e.listeners {
203 - if relay == nil {
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 }
206 - count++
207 - go func(l *Listener) {
208 - udpL, err := l.AttachUDP(waitCtx)
209 - if err != nil {
210 - results <- nil
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 }
213 - results <- udpL
214 - }(relay)
215 - }
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
217 - var out []*UDPListener
218 - var graceTimer *time.Timer
219 - defer func() {
220 - if graceTimer != nil {
221 - graceTimer.Stop()
347 + flowID := dg.FlowID
348 + reply := func(payload []byte) error {
349 + return listener.SendDatagram(flowID, payload)
350 }
223 - }()
224 - var grace <-chan time.Time
225 - collected := 0
226 - for collected < count {
351 +
352 select {
228 - case l := <-results:
229 - collected++
230 - if l != nil {
231 - out = append(out, l)
232 - if graceTimer == nil {
233 - // After first success, give 3 more seconds for remaining relays.
234 - graceTimer = time.NewTimer(3 * time.Second)
235 - grace = graceTimer.C
236 - }
237 - }
238 - case <-grace:
239 - return out, nil
240 - case <-waitCtx.Done():
241 - return out, nil
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 }
244 - return out, nil
365 }
366
367 // Close closes the merged listener and all underlying relay listeners.
@@ -258,6 +378,11 @@ func (e *Exposure) Close() error {
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)).
@@ -273,6 +398,27 @@ func (e *Exposure) Close() error {
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
419 + }
420 +}
421 +
422 // RunHTTP serves one handler on relayListener and, when localAddr is set, on
423 // the provided local HTTP address for app-local access.
424 func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
sdk/listener.go
+217 -13
@@ -14,6 +14,7 @@ import (
14
15 "github.com/rs/zerolog/log"
16
17 + "github.com/gosuda/portal/v2/portal/datagram"
18 "github.com/gosuda/portal/v2/portal/keyless"
19 "github.com/gosuda/portal/v2/types"
20 "github.com/gosuda/portal/v2/utils"
@@ -42,6 +43,12 @@ const (
43 listenerStatusReady listenerStatus = "ready"
44 )
45
46 +type datagramState struct {
47 + leaseID string
48 + reverseToken string
49 + quicAddr string
50 +}
51 +
52 type Listener struct {
53 tlsCloser io.Closer
54 tlsConfig *tls.Config
@@ -55,6 +62,7 @@ type Listener 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
@@ -64,6 +72,7 @@ type Listener struct {
72 udpAddr string
73 quicAddr string
74 metadata types.LeaseMetadata
75 + datagram *datagram.Session
76
77 registered chan struct{} // closed after first successful registration
78 closeOnce sync.Once
@@ -91,13 +100,20 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
100 return nil, err
101 }
102
94 - transport := strings.ToLower(strings.TrimSpace(cfg.Transport))
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
111 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,
@@ -109,7 +125,19 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
125 renewBefore: renewBefore,
126 handshakeTimeout: handshakeTimeout,
127 }
128 + if capabilities.SupportsDatagram() {
129 + l.datagram = datagram.NewSession(256, false, func(err error) {
130 + log.Warn().
131 + Err(err).
132 + Str("component", "sdk-datagram-plane").
133 + Str("lease_id", l.LeaseID()).
134 + Msg("quic receive loop ended")
135 + })
136 + }
137
138 + if l.datagram != nil {
139 + go l.runDatagramLoop(listenerCtx)
140 + }
141 go l.runStartup(listenerCtx)
142 return l, nil
143 }
@@ -121,16 +149,22 @@ func (l *Listener) runStartup(ctx context.Context) {
149 err := l.registerAndConfigure(ctx)
150 switch {
151 case err == nil:
124 - for i := 0; i < l.readyTarget; i++ {
125 - go l.runSessionLoop(ctx)
152 + if l.SupportsStream() {
153 + for i := 0; i < l.readyTarget; i++ {
154 + go l.runSessionLoop(ctx)
155 + }
156 + } else {
157 + l.setStartupStatus(listenerStatusReady)
158 }
159 go l.runRenewLoop(ctx)
160 publicURL := l.PublicURL()
129 - log.Info().
161 + event := log.Info().
162 Str("relay_url", l.relayURL).
131 - Str("lease_id", l.LeaseID()).
132 - Str("public_url", publicURL).
133 - Msg("service is available at this URL")
163 + Str("lease_id", l.LeaseID())
164 + if publicURL != "" {
165 + event = event.Str("public_url", publicURL)
166 + }
167 + event.Msg("relay listener registered")
168 return
169 case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
170 return
@@ -144,6 +178,9 @@ func (l *Listener) runStartup(ctx context.Context) {
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
@@ -162,6 +199,7 @@ func (l *Listener) Close() error {
199 l.mu.Lock()
200 leaseID := l.leaseID
201 tlsCloser := l.tlsCloser
202 + datagram := l.datagram
203 api := l.api
204 l.leaseID = ""
205 l.hostname = ""
@@ -170,6 +208,9 @@ func (l *Listener) Close() error {
208 l.mu.Unlock()
209
210 l.drainAccepted()
211 + if datagram != nil {
212 + datagram.Stop("listener closed")
213 + }
214
215 if api != nil && leaseID != "" {
216 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -214,6 +255,10 @@ func (l *Listener) Metadata() types.LeaseMetadata {
255 }
256
257 func (l *Listener) PublicURL() string {
258 + if !l.SupportsStream() {
259 + return ""
260 + }
261 +
262 l.mu.Lock()
263 hostname := l.hostname
264 relayURL := l.relayURL
@@ -265,6 +310,73 @@ func (l *Listener) runSessionLoop(ctx context.Context) {
310 }
311 }
312
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 + }
321 +
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 + }
330 +
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 + }
344 +
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")
350 +
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
365 + }
366 +
367 + select {
368 + case <-ctx.Done():
369 + l.datagram.Stop("listener context closed")
370 + return
371 + case <-recvDone:
372 + }
373 +
374 + if !utils.SleepOrDone(ctx, time.Second) {
375 + return
376 + }
377 + }
378 +}
379 +
380 func (l *Listener) runRenewLoop(ctx context.Context) {
381 interval := l.leaseTTL / 2
382 if interval <= 0 {
@@ -389,15 +501,23 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
501 return err
502 }
503
392 - tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(l.api.baseURL.String(), []string{resp.Hostname})
393 - if err != nil {
394 - _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
395 - return err
504 + var (
505 + tlsConf *tls.Config
506 + tlsCloser io.Closer
507 + )
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 + }
514 }
515
516 if ctx.Err() != nil {
517 _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
400 - _ = tlsCloser.Close()
518 + if tlsCloser != nil {
519 + _ = tlsCloser.Close()
520 + }
521 return ctx.Err()
522 }
523
@@ -405,10 +525,13 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
525 if ctx.Err() != nil {
526 l.mu.Unlock()
527 _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
408 - _ = tlsCloser.Close()
528 + if tlsCloser != nil {
529 + _ = tlsCloser.Close()
530 + }
531 return ctx.Err()
532 }
533 oldCloser := l.tlsCloser
534 + datagram := l.datagram
535 l.leaseID = resp.LeaseID
536 l.hostname = resp.Hostname
537 l.udpAddr = resp.UDPAddr
@@ -421,12 +544,78 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
544 if oldCloser != nil {
545 _ = oldCloser.Close()
546 }
547 + if datagram != nil {
548 + datagram.Clear("lease updated")
549 + }
550 l.registerOnce.Do(func() { close(l.registered) })
551 return nil
552 }
553
554 +func (l *Listener) SupportsDatagram() bool {
555 + return l != nil && l.capabilities.SupportsDatagram()
556 +}
557 +
558 +func (l *Listener) SupportsStream() bool {
559 + return l != nil && l.capabilities.SupportsStream()
560 +}
561 +
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
611 +}
612 +
613 // WaitRegistered blocks until the first successful lease registration or context cancellation.
614 func (l *Listener) WaitRegistered(ctx context.Context) error {
615 + if ctx == nil {
616 + ctx = context.Background()
617 + }
618 +
619 select {
620 case <-l.registered:
621 return nil
@@ -437,6 +626,21 @@ func (l *Listener) WaitRegistered(ctx context.Context) error {
626 }
627 }
628
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 +
644 func (l *Listener) reregister(ctx context.Context) error {
645 requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
646 defer cancel()
sdk/sdk_test.go
+149
@@ -154,6 +154,9 @@ 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)
159 + }
160 if registerReq.Name != "demo-app" {
161 t.Fatalf("register request Name = %q, want %q", registerReq.Name, "demo-app")
162 }
@@ -356,6 +359,152 @@ func TestExposeNoRelayInputs(t *testing.T) {
359 }
360 }
361
362 +func TestNewListenerTransportUDPDoesNotOpenReverseSessions(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 {
366 + case types.PathSDKDomain:
367 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
368 + OK: true,
369 + Data: types.DomainResponse{
370 + Version: types.SDKProtocolVersion,
371 + },
372 + })
373 + case types.PathSDKRegister:
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,
382 + },
383 + })
384 + case types.PathSDKConnect:
385 + connectCount.Add(1)
386 + writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
387 + OK: false,
388 + Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "stream should be disabled"},
389 + })
390 + case types.PathSDKRenew:
391 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
392 + OK: true,
393 + Data: types.RenewResponse{LeaseID: "lease-udp"},
394 + })
395 + case types.PathSDKUnregister:
396 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
397 + default:
398 + http.NotFound(w, r)
399 + }
400 + }))
401 + defer server.Close()
402 +
403 + listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
404 + Name: "demo",
405 + Transport: types.TransportUDP,
406 + LeaseTTL: 100 * time.Millisecond,
407 + })
408 + if err != nil {
409 + t.Fatalf("NewListener() error = %v", err)
410 + }
411 + defer listener.Close()
412 +
413 + waitForSDKTest(t, func() bool {
414 + return listener.LeaseID() == "lease-udp"
415 + })
416 + time.Sleep(150 * time.Millisecond)
417 +
418 + if connectCount.Load() != 0 {
419 + t.Fatalf("connect count = %d, want 0", connectCount.Load())
420 + }
421 + if got := listener.PublicURL(); got != "" {
422 + t.Fatalf("PublicURL() = %q, want empty", got)
423 + }
424 + if !listener.SupportsDatagram() {
425 + t.Fatal("SupportsDatagram() = false, want true")
426 + }
427 + if listener.SupportsStream() {
428 + t.Fatal("SupportsStream() = true, want false")
429 + }
430 +}
431 +
432 +func TestListenerWaitDatagramReadyPublishesRelayAddresses(t *testing.T) {
433 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
434 + switch r.URL.Path {
435 + case types.PathSDKDomain:
436 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
437 + OK: true,
438 + Data: types.DomainResponse{
439 + Version: types.SDKProtocolVersion,
440 + },
441 + })
442 + case types.PathSDKRegister:
443 + writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
444 + OK: true,
445 + 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,
451 + },
452 + })
453 + case types.PathSDKRenew:
454 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
455 + OK: true,
456 + Data: types.RenewResponse{LeaseID: "lease-udp"},
457 + })
458 + case types.PathSDKUnregister:
459 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
460 + default:
461 + http.NotFound(w, r)
462 + }
463 + }))
464 + defer server.Close()
465 +
466 + listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
467 + Name: "demo",
468 + Transport: types.TransportUDP,
469 + })
470 + if err != nil {
471 + t.Fatalf("NewListener() error = %v", err)
472 + }
473 + defer listener.Close()
474 +
475 + if err := listener.WaitDatagramReady(context.Background()); err != nil {
476 + t.Fatalf("WaitDatagramReady() error = %v", err)
477 + }
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")
505 + }
506 +}
507 +
508 func writeSDKTestEnvelope[T any](w http.ResponseWriter, status int, envelope types.APIEnvelope[T]) {
509 w.Header().Set("Content-Type", "application/json")
510 w.WriteHeader(status)
sdk/udp_listener.go deleted
-371
@@ -1,371 +0,0 @@
1 -package sdk
2 -
3 -import (
4 - "context"
5 - "errors"
6 - "fmt"
7 - "net"
8 - "strings"
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 - "github.com/gosuda/portal/v2/utils"
17 -)
18 -
19 -// UDPListenerConfig configures a standalone UDP listener that registers its own lease.
20 -type UDPListenerConfig struct {
21 - Name string
22 - ReverseToken string
23 - Metadata types.LeaseMetadata
24 - Transport string // "udp" or "both", defaults to "udp"
25 - LeaseTTL time.Duration
26 - RootCAPEM []byte
27 - DialTimeout time.Duration
28 - RequestTimeout time.Duration
29 -}
30 -
31 -// UDPListener manages a QUIC connection to the relay for a UDP-transport lease.
32 -// It receives DATAGRAM frames from the relay and delivers decoded datagrams via
33 -// the AcceptDatagram method.
34 -type UDPListener struct {
35 - api *apiClient
36 - baseContext func() context.Context
37 - ctxDone <-chan struct{}
38 - cancel context.CancelFunc
39 -
40 - name string
41 - leaseID string
42 - reverseToken string
43 - udpAddr string
44 - quicAddr string
45 - hostname string
46 - metadata types.LeaseMetadata
47 - leaseTTL time.Duration
48 -
49 - conn *quic.Conn
50 - datagrams chan UDPDatagram
51 - done chan struct{}
52 -
53 - ownsLease bool
54 - closeOnce sync.Once
55 - mu sync.Mutex
56 -}
57 -
58 -// UDPDatagram represents a single datagram received from a public client
59 -// through the relay.
60 -type UDPDatagram struct {
61 - FlowID uint32
62 - Payload []byte
63 -}
64 -
65 -// AcceptDatagram blocks until a datagram is available or the listener is closed.
66 -func (l *UDPListener) AcceptDatagram() (UDPDatagram, error) {
67 - select {
68 - case <-l.ctxDone:
69 - return UDPDatagram{}, net.ErrClosed
70 - case dg, ok := <-l.datagrams:
71 - if !ok {
72 - return UDPDatagram{}, net.ErrClosed
73 - }
74 - return dg, nil
75 - }
76 -}
77 -
78 -// SendDatagram sends a response datagram back to a client via the relay.
79 -func (l *UDPListener) SendDatagram(flowID uint32, payload []byte) error {
80 - l.mu.Lock()
81 - conn := l.conn
82 - l.mu.Unlock()
83 -
84 - if conn == nil {
85 - return errors.New("quic connection not established")
86 - }
87 - return conn.SendDatagram(types.EncodeDatagram(flowID, payload))
88 -}
89 -
90 -// UDPAddr returns the public UDP address allocated by the relay.
91 -func (l *UDPListener) UDPAddr() string {
92 - return l.udpAddr
93 -}
94 -
95 -// LeaseID returns the current lease ID.
96 -func (l *UDPListener) LeaseID() string {
97 - l.mu.Lock()
98 - defer l.mu.Unlock()
99 - return l.leaseID
100 -}
101 -
102 -// Hostname returns the hostname registered for this lease.
103 -func (l *UDPListener) Hostname() string {
104 - l.mu.Lock()
105 - defer l.mu.Unlock()
106 - return l.hostname
107 -}
108 -
109 -// Close tears down the QUIC connection. If this listener owns the lease
110 -// (created via NewUDPListener), it also unregisters the lease. Attached listeners
111 -// (created via Listener.AttachUDP) leave lease lifecycle to the TCP Listener.
112 -func (l *UDPListener) Close() error {
113 - var closeErr error
114 - l.closeOnce.Do(func() {
115 - l.cancel()
116 -
117 - l.mu.Lock()
118 - conn := l.conn
119 - leaseID := l.leaseID
120 - l.mu.Unlock()
121 -
122 - if conn != nil {
123 - _ = conn.CloseWithError(0, "listener closed")
124 - }
125 -
126 - if l.ownsLease && l.api != nil && leaseID != "" {
127 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
128 - defer cancel()
129 - closeErr = l.api.unregisterLease(ctx, leaseID)
130 - }
131 - })
132 - return closeErr
133 -}
134 -
135 -// NewUDPListener registers a UDP-transport lease and returns a UDPListener.
136 -func NewUDPListener(ctx context.Context, relayURL string, cfg UDPListenerConfig) (*UDPListener, error) {
137 - if strings.TrimSpace(cfg.Name) == "" {
138 - return nil, errors.New("listener name is required")
139 - }
140 - if ctx == nil {
141 - ctx = context.Background()
142 - }
143 -
144 - reverseToken := strings.TrimSpace(cfg.ReverseToken)
145 - if reverseToken == "" {
146 - reverseToken = utils.RandomID("tok_")
147 - }
148 - leaseTTL := utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
149 -
150 - transport := strings.ToLower(strings.TrimSpace(cfg.Transport))
151 - if transport == "" {
152 - transport = types.TransportUDP
153 - }
154 -
155 - api, err := newApiClient(relayURL, ListenerConfig{
156 - Name: cfg.Name,
157 - ReverseToken: reverseToken,
158 - Metadata: cfg.Metadata,
159 - RootCAPEM: cfg.RootCAPEM,
160 - DialTimeout: cfg.DialTimeout,
161 - RequestTimeout: cfg.RequestTimeout,
162 - })
163 - if err != nil {
164 - return nil, err
165 - }
166 -
167 - if err := api.ensureReady(ctx); err != nil {
168 - api.close()
169 - return nil, err
170 - }
171 -
172 - registerResp, err := api.registerLease(ctx, leaseTTL, transport)
173 - if err != nil {
174 - api.close()
175 - return nil, err
176 - }
177 -
178 - listenerCtx, cancel := context.WithCancel(ctx)
179 - listener := &UDPListener{
180 - api: api,
181 - baseContext: func() context.Context { return listenerCtx },
182 - ctxDone: listenerCtx.Done(),
183 - cancel: cancel,
184 - name: strings.TrimSpace(cfg.Name),
185 - leaseID: registerResp.LeaseID,
186 - reverseToken: reverseToken,
187 - udpAddr: registerResp.UDPAddr,
188 - quicAddr: registerResp.QUICAddr,
189 - hostname: registerResp.Hostname,
190 - metadata: registerResp.Metadata,
191 - leaseTTL: leaseTTL,
192 - datagrams: make(chan UDPDatagram, 256),
193 - done: make(chan struct{}),
194 - ownsLease: true,
195 - }
196 -
197 - go listener.runSupervisor()
198 - go listener.runRenewLoop()
199 -
200 - return listener, nil
201 -}
202 -
203 -// AttachUDP creates a UDPListener that connects to an existing Listener's
204 -// QUIC broker without registering a new lease or running a renew loop.
205 -// The caller (the TCP Listener) owns the lease lifecycle.
206 -// The Listener must have been registered with transport "udp" or "both".
207 -func (l *Listener) AttachUDP(ctx context.Context) (*UDPListener, error) {
208 - // Wait for lease registration to complete before reading UDP addresses.
209 - if err := l.WaitRegistered(ctx); err != nil {
210 - return nil, fmt.Errorf("wait for registration: %w", err)
211 - }
212 -
213 - l.mu.Lock()
214 - leaseID := l.leaseID
215 - api := l.api
216 - udpAddr := l.udpAddr
217 - quicAddr := l.quicAddr
218 - l.mu.Unlock()
219 -
220 - if leaseID == "" {
221 - return nil, errors.New("lease not registered yet")
222 - }
223 - if api == nil {
224 - return nil, errors.New("api client not available")
225 - }
226 - if udpAddr == "" && quicAddr == "" {
227 - return nil, errors.New("lease does not have UDP transport enabled")
228 - }
229 -
230 - // Use context.Background — the caller's ctx may be a short-lived timeout
231 - // context (e.g. the 15-second waitCtx from Exposure.AttachUDP). The
232 - // UDPListener's lifecycle is managed by Close(), not context cancellation.
233 - listenerCtx, cancel := context.WithCancel(context.Background())
234 - udpL := &UDPListener{
235 - api: api,
236 - baseContext: func() context.Context { return listenerCtx },
237 - ctxDone: listenerCtx.Done(),
238 - cancel: cancel,
239 - leaseID: leaseID,
240 - reverseToken: api.reverseToken,
241 - udpAddr: udpAddr,
242 - quicAddr: quicAddr,
243 - datagrams: make(chan UDPDatagram, 256),
244 - done: make(chan struct{}),
245 - ownsLease: false,
246 - }
247 -
248 - go udpL.runSupervisor()
249 - return udpL, nil
250 -}
251 -
252 -func (l *UDPListener) runSupervisor() {
253 - for {
254 - select {
255 - case <-l.ctxDone:
256 - return
257 - default:
258 - }
259 -
260 - conn, err := l.api.openQUICSession(l.context(), l.quicAddr, l.leaseID, l.reverseToken)
261 - if err != nil {
262 - log.Warn().
263 - Err(err).
264 - Str("component", "sdk-udp-listener").
265 - Str("lease_id", l.leaseID).
266 - Msg("quic session open failed, retrying")
267 - utils.SleepOrDone(l.context(), 2*time.Second)
268 - continue
269 - }
270 -
271 - l.mu.Lock()
272 - l.conn = conn
273 - l.mu.Unlock()
274 -
275 - log.Info().
276 - Str("component", "sdk-udp-listener").
277 - Str("lease_id", l.leaseID).
278 - Str("remote_addr", conn.RemoteAddr().String()).
279 - Msg("quic tunnel connected")
280 -
281 - l.receiveLoop(conn)
282 -
283 - l.mu.Lock()
284 - if l.conn == conn {
285 - l.conn = nil
286 - }
287 - l.mu.Unlock()
288 -
289 - if l.isClosed() {
290 - return
291 - }
292 - utils.SleepOrDone(l.context(), time.Second)
293 - }
294 -}
295 -
296 -func (l *UDPListener) receiveLoop(conn *quic.Conn) {
297 - for {
298 - data, err := conn.ReceiveDatagram(l.context())
299 - if err != nil {
300 - if !l.isClosed() {
301 - log.Warn().
302 - Err(err).
303 - Str("component", "sdk-udp-listener").
304 - Str("lease_id", l.leaseID).
305 - Msg("quic receive loop ended")
306 - }
307 - return
308 - }
309 -
310 - frame, err := types.DecodeDatagram(data)
311 - if err != nil {
312 - continue
313 - }
314 -
315 - select {
316 - case l.datagrams <- UDPDatagram{FlowID: frame.FlowID, Payload: frame.Payload}:
317 - case <-l.ctxDone:
318 - return
319 - }
320 - }
321 -}
322 -
323 -func (l *UDPListener) runRenewLoop() {
324 - interval := l.leaseTTL / 2
325 - if interval <= 0 {
326 - interval = 30 * time.Second
327 - }
328 -
329 - ticker := time.NewTicker(interval)
330 - defer ticker.Stop()
331 -
332 - for {
333 - select {
334 - case <-l.ctxDone:
335 - return
336 - case <-ticker.C:
337 - ctx, cancel := context.WithTimeout(l.context(), 10*time.Second)
338 - err := l.api.renewLease(ctx, l.leaseID, l.leaseTTL)
339 - cancel()
340 - if err != nil {
341 - log.Warn().
342 - Err(err).
343 - Str("component", "sdk-udp-listener").
344 - Str("lease_id", l.leaseID).
345 - Msg("lease renewal failed")
346 - }
347 - }
348 - }
349 -}
350 -
351 -func (l *UDPListener) context() context.Context {
352 - if l.baseContext != nil {
353 - if ctx := l.baseContext(); ctx != nil {
354 - return ctx
355 - }
356 - }
357 - return context.Background()
358 -}
359 -
360 -func (l *UDPListener) isClosed() bool {
361 - if l.ctxDone == nil {
362 - return false
363 - }
364 - select {
365 - case <-l.ctxDone:
366 - return true
367 - default:
368 - return false
369 - }
370 -}
371 -
types/api.go
-4
@@ -6,10 +6,6 @@ import (
6 "time"
7 )
8
9 -const (
10 - MarkerQUICReady = byte(0x03)
11 -)
12 -
9 const (
10 TransportTCP = "tcp"
11 TransportUDP = "udp"
types/datagram.go deleted
-39
@@ -1,39 +0,0 @@
1 -package types
2 -
3 -import (
4 - "encoding/binary"
5 - "errors"
6 -)
7 -
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")
11 -
12 -// DatagramFrame is the wire format for QUIC DATAGRAM payloads.
13 -// Layout: [flowID varint][payload bytes]
14 -type DatagramFrame struct {
15 - FlowID uint32
16 - Payload []byte
17 -}
18 -
19 -// EncodeDatagram serialises a flow-framed datagram for transmission.
20 -func EncodeDatagram(flowID uint32, payload []byte) []byte {
21 - var buf [binary.MaxVarintLen32]byte
22 - n := binary.PutUvarint(buf[:], uint64(flowID))
23 - out := make([]byte, n+len(payload))
24 - copy(out, buf[:n])
25 - copy(out[n:], payload)
26 - return out
27 -}
28 -
29 -// DecodeDatagram deserialises a flow-framed datagram.
30 -func DecodeDatagram(data []byte) (DatagramFrame, error) {
31 - flowID, n := binary.Uvarint(data)
32 - if n <= 0 {
33 - return DatagramFrame{}, ErrDatagramTooSmall
34 - }
35 - return DatagramFrame{
36 - FlowID: uint32(flowID),
37 - Payload: data[n:],
38 - }, nil
39 -}
types/paths.go
+1 -2
@@ -26,6 +26,5 @@ const (
26 PathSDKRegister = "/sdk/register"
27 PathSDKRenew = "/sdk/renew"
28 PathSDKUnregister = "/sdk/unregister"
29 - PathSDKConnect = "/sdk/connect"
30 - PathSDKQUICConnect = "/sdk/quic-connect"
29 + PathSDKConnect = "/sdk/connect"
30 )
types/transport.go new
+85
@@ -0,0 +1,85 @@
1 +package types
2 +
3 +import (
4 + "encoding/binary"
5 + "errors"
6 + "fmt"
7 + "strings"
8 +)
9 +
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 +
54 +// ErrDatagramTooSmall is returned when a datagram payload is too short to
55 +// contain a valid flow ID varint.
56 +var ErrDatagramTooSmall = errors.New("datagram too small to decode")
57 +
58 +// DatagramFrame is the wire format for QUIC DATAGRAM payloads.
59 +// Layout: [flowID varint][payload bytes]
60 +type DatagramFrame struct {
61 + FlowID uint32
62 + Payload []byte
63 +}
64 +
65 +// EncodeDatagram serialises a flow-framed datagram for transmission.
66 +func EncodeDatagram(flowID uint32, payload []byte) []byte {
67 + var buf [binary.MaxVarintLen32]byte
68 + n := binary.PutUvarint(buf[:], uint64(flowID))
69 + out := make([]byte, n+len(payload))
70 + copy(out, buf[:n])
71 + copy(out[n:], payload)
72 + return out
73 +}
74 +
75 +// DecodeDatagram deserialises a flow-framed datagram.
76 +func DecodeDatagram(data []byte) (DatagramFrame, error) {
77 + flowID, n := binary.Uvarint(data)
78 + if n <= 0 {
79 + return DatagramFrame{}, ErrDatagramTooSmall
80 + }
81 + return DatagramFrame{
82 + FlowID: uint32(flowID),
83 + Payload: data[n:],
84 + }, nil
85 +}