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