feat: Add support for HTTPS DNS records across providers
- Implemented EnsureHTTPSRecord and DeleteHTTPSRecord methods in Cloudflare, GCloud, and Route53 providers to manage HTTPS DNS records. - Enhanced dnsRecord struct to include Data field for additional HTTPS record parameters. - Updated API server to handle ECH DNS records during lease registration and unregistration. - Introduced ECHConfigList in lease and hop route structures to support encrypted client hello configurations. - Added validation for ECHConfigList in lease registration to ensure proper hostname and hash requirements. - Refactored ECH key generation and configuration handling in keyless package.
Kim committed
May 6, 2026 at 18:59 UTC
24632447c23f65e72ef3935d6ba1165195efa489
20 files changed
+773
-132
.env.example
+2
-1
@@ -15,7 +15,8 @@ UDP_ENABLED=false
15
TCP_ENABLED=false
16
17
18
-# Supported managed values: cloudflare, gcloud, route53
18
+# Supported managed values: cloudflare, gcloud, route53.
19
+# Reused for ACME DNS-01, managed A records, ECH HTTPS records, and optional ENS DNS automation.
20
ACME_DNS_PROVIDER=
21
22
# Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
cmd/relay-server/main.go
+1
-1
@@ -88,7 +88,7 @@ func runServeCommand(args []string) error {
88
utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
89
utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
90
91
- utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
91
+ utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
92
utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
93
utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
94
utils.StringFlagEnv(fs, &cfg.GCPProjectID, "gcp-project-id", "", "Google Cloud project id for Cloud DNS automation; auto-detected from ADC or GCE metadata when omitted", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GCE_PROJECT")
docker-compose.yml
+1
-1
@@ -53,7 +53,7 @@ services:
53
PPROF_ENABLED: ${PPROF_ENABLED:-false}
54
PPROF_ADDR: ${PPROF_ADDR:-127.0.0.1:6060}
55
56
- # TLS/ACME materials
56
+ # TLS/ACME materials and DNS automation
57
ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
58
ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
59
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
docs/src/routes/configuration/+page.md
+2
-2
@@ -49,7 +49,7 @@ The relay server (`relay-server`) reads configuration from environment variables
49
50
| Variable | Default | Type | Description |
51
|----------|---------|------|-------------|
52
-| `ACME_DNS_PROVIDER` | `""` | string | ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `route53`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
52
+| `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `route53`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
53
| `ENS_GASLESS_ENABLED` | `false` | bool | Enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames |
54
55
### Admin
@@ -242,7 +242,7 @@ Relay admin settings are stored at `IDENTITY_PATH/admin_settings.json`.
242
243
## ACME DNS Provider Configuration
244
245
-Set `ACME_DNS_PROVIDER` (or `--acme-dns-provider`) to one of the values below to enable automated TLS certificate issuance via DNS-01 challenges.
245
+Set `ACME_DNS_PROVIDER` (or `--acme-dns-provider`) to one of the values below to enable DNS-backed automation. Portal uses the same provider for DNS-01 challenges, managed A records, ECH HTTPS records, and optional ENS gasless DNS records.
246
247
When this variable is empty the relay server falls back to manually supplied `fullchain.pem` and `privatekey.pem` files in `IDENTITY_PATH`.
248
docs/src/routes/deployment/+page.md
+3
-3
@@ -19,7 +19,7 @@ You need:
19
- A public domain, for example `example.com`
20
- A public Linux server with a static public IPv4
21
- Docker and Docker Compose
22
-- Optional for managed ACME DNS-01 automation or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare`, `gcloud`, or `route53`
22
+- Optional for managed ACME DNS-01 automation, Portal-managed ECH HTTPS records, or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare`, `gcloud`, or `route53`
23
- Open inbound ports:
24
- `443/tcp`
25
- `4017/tcp`
@@ -40,10 +40,10 @@ Choose one of these modes:
40
- Manual certificate + gasless mode
41
- Place `fullchain.pem` and `privatekey.pem` in `IDENTITY_PATH`.
42
- Set `ACME_DNS_PROVIDER`.
43
- - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for DNSSEC + ENS TXT automation.
43
+ - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for ECH HTTPS records and DNSSEC + ENS TXT automation.
44
- Managed ACME mode
45
- Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, or `route53`.
46
- - Portal manages root/wildcard A records and certificate renewal.
46
+ - Portal manages root/wildcard A records, ECH HTTPS records, and certificate renewal.
47
- If ENS gasless is enabled, Portal also manages DNSSEC.
48
49
If you only need a relay and do not need Portal-managed DNS or automatic renewal, manual certificate mode is the simplest option.
docs/src/routes/security-model/+page.md
+2
-2
@@ -33,11 +33,11 @@ Relay API TLS is separate from tenant TLS:
33
34
## Tunnel ECH
35
36
-For default stream leases, the SDK derives an opaque lease identity and an opaque route hostname from the tunnel identity private key. The relay stores the route hostname for ECH routing and a hash of the public fallback hostname for plaintext-SNI fallback. It does not need the real lease hostname in the new SDK registration path.
36
+For default stream leases, the SDK derives an opaque lease identity and an opaque route hostname from the tunnel identity private key. The relay stores the route hostname for ECH routing and a hash of the public fallback hostname for plaintext-SNI fallback. When DNS automation is enabled, the relay also keeps the public hostname needed to publish and delete its HTTPS `ech` record.
37
38
ECH-capable clients can use the opaque route hostname as the outer SNI while the real tenant SNI stays inside the ECH-protected ClientHello handled by the SDK. For multi-hop stream routes, the entry relay gets both matchers: a hostname hash for plaintext-SNI fallback and a hidden opaque route hostname for ECH. After the entry relay chooses the route, the remaining hops continue to use hop tokens and passthrough forwarding.
39
40
-This protects the packet-level tunnel SNI only for clients that actually offer ECH using the logged ECHConfigList. Operators must distribute that ECHConfigList through DNS HTTPS/SVCB or another ECH-capable bootstrap. Without that distribution, ordinary clients keep using the public hostname SNI and the relay routes them through the existing plaintext-SNI fallback.
40
+When `ACME_DNS_PROVIDER` is configured, Portal publishes DNS HTTPS records with the `ech` parameter for the relay root and stream lease public hostnames. Without a DNS provider, operators must distribute the logged ECHConfigList through DNS HTTPS/SVCB or another ECH-capable bootstrap. Without that distribution, ordinary clients keep using the public hostname SNI and the relay routes them through the existing plaintext-SNI fallback.
41
42
Legacy clients and raw TCP/UDP transports still use the legacy hostname registration path. On those paths the relay control plane receives the lease hostname and can expose it to admin views.
43
portal/acme/acme.go
+168
-1
@@ -7,11 +7,13 @@ import (
7
"crypto/elliptic"
8
"crypto/rand"
9
"crypto/x509"
10
+ "encoding/base64"
11
"encoding/pem"
12
"errors"
13
"fmt"
14
"os"
15
"path/filepath"
16
+ "strconv"
17
"strings"
18
"sync"
19
"time"
@@ -22,6 +24,7 @@ import (
24
"github.com/go-acme/lego/v4/registration"
25
"github.com/rs/zerolog/log"
26
27
+ "github.com/gosuda/portal-tunnel/v2/portal/keyless"
28
"github.com/gosuda/portal-tunnel/v2/utils"
29
)
30
@@ -66,6 +69,72 @@ type Manager struct {
69
dnssecLogOnce sync.Once
70
ensLogOnce sync.Once
71
trackedMu sync.Mutex
72
+ echMu sync.Mutex
73
+ echRecords map[string]HTTPSRecord
74
+}
75
+
76
+type HTTPSRecord struct {
77
+ Priority uint16
78
+ Target string
79
+ Port int
80
+ ECHConfigList []byte
81
+}
82
+
83
+func (r HTTPSRecord) Normalized() (HTTPSRecord, error) {
84
+ target := strings.TrimSpace(r.Target)
85
+ if target == "" {
86
+ target = "."
87
+ }
88
+ if target != "." {
89
+ target = strings.TrimSuffix(target, ".")
90
+ if target == "" {
91
+ target = "."
92
+ } else {
93
+ target += "."
94
+ }
95
+ }
96
+ priority := r.Priority
97
+ if priority == 0 {
98
+ priority = 1
99
+ }
100
+ if len(r.ECHConfigList) == 0 {
101
+ return HTTPSRecord{}, errors.New("ech config list is required")
102
+ }
103
+ if r.Port < 0 || r.Port > 65535 {
104
+ return HTTPSRecord{}, errors.New("https record port must be between 0 and 65535")
105
+ }
106
+ return HTTPSRecord{
107
+ Priority: priority,
108
+ Target: target,
109
+ Port: r.Port,
110
+ ECHConfigList: append([]byte(nil), r.ECHConfigList...),
111
+ }, nil
112
+}
113
+
114
+func (r HTTPSRecord) Content() (string, error) {
115
+ normalized, err := r.Normalized()
116
+ if err != nil {
117
+ return "", err
118
+ }
119
+ return strings.Join([]string{
120
+ strconv.Itoa(int(normalized.Priority)),
121
+ normalized.Target,
122
+ normalized.SvcParams(),
123
+ }, " "), nil
124
+}
125
+
126
+func (r HTTPSRecord) SvcParams() string {
127
+ normalized, err := r.Normalized()
128
+ if err != nil {
129
+ return ""
130
+ }
131
+ params := []string{
132
+ `ech="` + base64.StdEncoding.EncodeToString(normalized.ECHConfigList) + `"`,
133
+ }
134
+ if normalized.Port > 0 && normalized.Port != 443 {
135
+ params = append(params, "port="+strconv.Itoa(normalized.Port))
136
+ }
137
+ return strings.Join(params, " ")
138
}
139
140
type acmeUser struct {
@@ -330,7 +399,7 @@ func (m *Manager) maintenanceLoop(ctx context.Context) {
399
return
400
case <-dnsTicker.C:
401
syncCtx, cancel := context.WithTimeout(ctx, defaultSyncTimeout)
333
- err := m.syncDNS(syncCtx)
402
+ err := errors.Join(m.syncDNS(syncCtx), m.syncECHRecords(syncCtx))
403
cancel()
404
if err != nil {
405
log.Warn().Err(err).Str("base_domain", m.cfg.BaseDomain).Msg("sync dns records")
@@ -373,6 +442,104 @@ func (m *Manager) syncDNS(ctx context.Context) error {
442
return m.dns.EnsureARecords(ctx, m.cfg.BaseDomain, publicIP)
443
}
444
445
+func (m *Manager) SyncECHConfig(ctx context.Context, hostname string, echConfigList []byte, port int) error {
446
+ if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
447
+ return nil
448
+ }
449
+ if m.dns == nil {
450
+ return nil
451
+ }
452
+ hostname = utils.NormalizeHostname(hostname)
453
+ if hostname == "" {
454
+ return errors.New("hostname is required")
455
+ }
456
+ if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
457
+ return fmt.Errorf("hostname %q is outside acme base domain %q", hostname, m.cfg.BaseDomain)
458
+ }
459
+ echConfigList, err := keyless.NormalizeEncryptedClientHelloConfigList(echConfigList)
460
+ if err != nil {
461
+ return err
462
+ }
463
+ record := HTTPSRecord{
464
+ Priority: 1,
465
+ Target: ".",
466
+ Port: port,
467
+ ECHConfigList: echConfigList,
468
+ }
469
+ record, err = record.Normalized()
470
+ if err != nil {
471
+ return err
472
+ }
473
+ content, err := record.Content()
474
+ if err != nil {
475
+ return err
476
+ }
477
+ svcParams := record.SvcParams()
478
+
479
+ m.echMu.Lock()
480
+ if m.echRecords == nil {
481
+ m.echRecords = make(map[string]HTTPSRecord)
482
+ }
483
+ m.echRecords[hostname] = record
484
+ m.echMu.Unlock()
485
+
486
+ if err := m.dns.EnsureHTTPSRecord(ctx, hostname, record.Priority, record.Target, svcParams, content); err != nil {
487
+ return err
488
+ }
489
+ return nil
490
+}
491
+
492
+func (m *Manager) DeleteECHConfig(ctx context.Context, hostname string) error {
493
+ if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
494
+ return nil
495
+ }
496
+ if m.dns == nil {
497
+ return nil
498
+ }
499
+ hostname = utils.NormalizeHostname(hostname)
500
+ if hostname == "" {
501
+ return nil
502
+ }
503
+ if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
504
+ return nil
505
+ }
506
+
507
+ m.echMu.Lock()
508
+ delete(m.echRecords, hostname)
509
+ m.echMu.Unlock()
510
+
511
+ if err := m.dns.DeleteHTTPSRecord(ctx, hostname); err != nil {
512
+ return err
513
+ }
514
+ return nil
515
+}
516
+
517
+func (m *Manager) syncECHRecords(ctx context.Context) error {
518
+ if m == nil || m.dns == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
519
+ return nil
520
+ }
521
+
522
+ m.echMu.Lock()
523
+ records := make(map[string]HTTPSRecord, len(m.echRecords))
524
+ for hostname, record := range m.echRecords {
525
+ records[hostname] = record
526
+ }
527
+ m.echMu.Unlock()
528
+
529
+ var syncErr error
530
+ for hostname, record := range records {
531
+ content, err := record.Content()
532
+ if err != nil {
533
+ syncErr = errors.Join(syncErr, fmt.Errorf("build ECH HTTPS record for %s: %w", hostname, err))
534
+ continue
535
+ }
536
+ if err := m.dns.EnsureHTTPSRecord(ctx, hostname, record.Priority, record.Target, record.SvcParams(), content); err != nil {
537
+ syncErr = errors.Join(syncErr, fmt.Errorf("ensure ECH HTTPS record for %s: %w", hostname, err))
538
+ }
539
+ }
540
+ return syncErr
541
+}
542
+
543
func (m *Manager) syncENSGasless(ctx context.Context) error {
544
if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
545
return nil
portal/acme/cloudflare/provider.go
+151
-4
@@ -33,10 +33,17 @@ type zone struct {
33
}
34
35
type dnsRecord struct {
36
- ID string `json:"id"`
37
- Type string `json:"type"`
38
- Name string `json:"name"`
39
- Content string `json:"content"`
36
+ ID string `json:"id"`
37
+ Type string `json:"type"`
38
+ Name string `json:"name"`
39
+ Content string `json:"content"`
40
+ Data *dnsRecordData `json:"data,omitempty"`
41
+}
42
+
43
+type dnsRecordData struct {
44
+ Priority int `json:"priority,omitempty"`
45
+ Target string `json:"target,omitempty"`
46
+ Value string `json:"value,omitempty"`
47
}
48
49
type zonesResult struct {
@@ -243,6 +250,72 @@ func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix strin
250
return nil
251
}
252
253
+func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, priority uint16, target, svcParams, content string) error {
254
+ if p == nil {
255
+ return errors.New("cloudflare provider is nil")
256
+ }
257
+ name = utils.NormalizeHostname(name)
258
+ if name == "" {
259
+ return errors.New("record name is required")
260
+ }
261
+ if p.token == "" {
262
+ return errors.New("cloudflare token is required")
263
+ }
264
+ target = strings.TrimSpace(target)
265
+ if target == "" {
266
+ return errors.New("https record target is required")
267
+ }
268
+ svcParams = strings.TrimSpace(svcParams)
269
+ if svcParams == "" {
270
+ return errors.New("https record svc params are required")
271
+ }
272
+ content = strings.TrimSpace(content)
273
+ if content == "" {
274
+ return errors.New("https record content is required")
275
+ }
276
+
277
+ zoneID, err := findZoneID(ctx, p.token, name)
278
+ if err != nil {
279
+ return fmt.Errorf("find cloudflare zone: %w", err)
280
+ }
281
+ if err := ensureHTTPSRecord(ctx, p.token, zoneID, name, priority, target, svcParams, content); err != nil {
282
+ return fmt.Errorf("ensure HTTPS record for %s: %w", name, err)
283
+ }
284
+ return nil
285
+}
286
+
287
+func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
288
+ if p == nil {
289
+ return errors.New("cloudflare provider is nil")
290
+ }
291
+ name = utils.NormalizeHostname(name)
292
+ if name == "" {
293
+ return errors.New("record name is required")
294
+ }
295
+ if p.token == "" {
296
+ return errors.New("cloudflare token is required")
297
+ }
298
+
299
+ zoneID, err := findZoneID(ctx, p.token, name)
300
+ if err != nil {
301
+ return fmt.Errorf("find cloudflare zone: %w", err)
302
+ }
303
+
304
+ records, err := listDNSRecords(ctx, p.token, zoneID, name, "HTTPS")
305
+ if err != nil {
306
+ return err
307
+ }
308
+ for _, record := range records {
309
+ if !strings.EqualFold(record.Name, name) {
310
+ continue
311
+ }
312
+ if err := deleteDNSRecord(ctx, p.token, zoneID, record.ID); err != nil {
313
+ return fmt.Errorf("delete HTTPS record %s: %w", name, err)
314
+ }
315
+ }
316
+ return nil
317
+}
318
+
319
func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error) {
320
if p == nil {
321
return "", "", "", errors.New("cloudflare provider is nil")
@@ -337,6 +410,38 @@ func ensureTXTRecord(ctx context.Context, token, zoneID, name, value string) err
410
return createDNSRecord(ctx, token, zoneID, "TXT", name, value)
411
}
412
413
+func ensureHTTPSRecord(ctx context.Context, token, zoneID, name string, priority uint16, target, svcParams, content string) error {
414
+ records, err := listDNSRecords(ctx, token, zoneID, name, "HTTPS")
415
+ if err != nil {
416
+ return err
417
+ }
418
+
419
+ for _, existing := range records {
420
+ if !strings.EqualFold(existing.Name, name) {
421
+ continue
422
+ }
423
+ if sameHTTPSRecord(existing, priority, target, svcParams, content) {
424
+ return nil
425
+ }
426
+ return updateHTTPSRecord(ctx, token, zoneID, existing.ID, name, priority, target, svcParams, content)
427
+ }
428
+
429
+ return createHTTPSRecord(ctx, token, zoneID, name, priority, target, svcParams, content)
430
+}
431
+
432
+func sameHTTPSRecord(existing dnsRecord, priority uint16, target, svcParams, content string) bool {
433
+ if existing.Data != nil {
434
+ existingTarget := strings.TrimSpace(existing.Data.Target)
435
+ if existingTarget == "" {
436
+ existingTarget = "."
437
+ }
438
+ return existing.Data.Priority == int(priority) &&
439
+ existingTarget == target &&
440
+ strings.TrimSpace(existing.Data.Value) == svcParams
441
+ }
442
+ return strings.TrimSpace(existing.Content) == content
443
+}
444
+
445
func listZones(ctx context.Context, token, name string) ([]zone, error) {
446
u, _ := url.Parse(apiBase + "/zones")
447
q := u.Query()
@@ -421,6 +526,20 @@ func createDNSRecord(ctx context.Context, token, zoneID, recordType, name, conte
526
return nil
527
}
528
529
+func createHTTPSRecord(ctx context.Context, token, zoneID, name string, priority uint16, target, svcParams, content string) error {
530
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID)
531
+ body := httpsRecordBody("HTTPS", name, priority, target, svcParams, content)
532
+
533
+ var out recordResult
534
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodPost, endpoint, body, cloudflareHeaders(token), &out); err != nil {
535
+ return err
536
+ }
537
+ if !out.Success {
538
+ return wrapErrors(out.Errors)
539
+ }
540
+ return nil
541
+}
542
+
543
func updateDNSRecord(ctx context.Context, token, zoneID, recordID, recordType, name, content string) error {
544
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
545
body := map[string]any{
@@ -443,6 +562,34 @@ func updateDNSRecord(ctx context.Context, token, zoneID, recordID, recordType, n
562
return nil
563
}
564
565
+func updateHTTPSRecord(ctx context.Context, token, zoneID, recordID, name string, priority uint16, target, svcParams, content string) error {
566
+ endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
567
+ body := httpsRecordBody("HTTPS", name, priority, target, svcParams, content)
568
+
569
+ var out recordResult
570
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodPut, endpoint, body, cloudflareHeaders(token), &out); err != nil {
571
+ return err
572
+ }
573
+ if !out.Success {
574
+ return wrapErrors(out.Errors)
575
+ }
576
+ return nil
577
+}
578
+
579
+func httpsRecordBody(recordType, name string, priority uint16, target, svcParams, content string) map[string]any {
580
+ return map[string]any{
581
+ "type": recordType,
582
+ "name": name,
583
+ "content": content,
584
+ "data": map[string]any{
585
+ "priority": int(priority),
586
+ "target": target,
587
+ "value": svcParams,
588
+ },
589
+ "ttl": 1,
590
+ }
591
+}
592
+
593
func deleteDNSRecord(ctx context.Context, token, zoneID, recordID string) error {
594
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
595
portal/acme/gcloud/provider.go
+58
@@ -286,6 +286,64 @@ func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix strin
286
return nil
287
}
288
289
+func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
290
+ if p == nil {
291
+ return errors.New("gcloud provider is nil")
292
+ }
293
+ name = utils.NormalizeHostname(name)
294
+ if name == "" {
295
+ return errors.New("record name is required")
296
+ }
297
+ content = strings.TrimSpace(content)
298
+ if content == "" {
299
+ return errors.New("https record content is required")
300
+ }
301
+
302
+ service, runtimeCfg, zone, err := newService(ctx, p.cfg, name)
303
+ if err != nil {
304
+ return err
305
+ }
306
+
307
+ if err := ensureRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.ResourceRecordSet{
308
+ Name: fqdn(name),
309
+ Type: "HTTPS",
310
+ Ttl: defaultRecordTTL,
311
+ Rrdatas: []string{content},
312
+ }); err != nil {
313
+ return fmt.Errorf("upsert gcloud HTTPS record %s: %w", name, err)
314
+ }
315
+ return nil
316
+}
317
+
318
+func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
319
+ if p == nil {
320
+ return errors.New("gcloud provider is nil")
321
+ }
322
+ name = utils.NormalizeHostname(name)
323
+ if name == "" {
324
+ return errors.New("record name is required")
325
+ }
326
+
327
+ service, runtimeCfg, zone, err := newService(ctx, p.cfg, name)
328
+ if err != nil {
329
+ return err
330
+ }
331
+
332
+ existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "HTTPS")
333
+ if err != nil {
334
+ return fmt.Errorf("list gcloud HTTPS records %s: %w", name, err)
335
+ }
336
+ if len(existing) == 0 {
337
+ return nil
338
+ }
339
+ if err := applyChange(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.Change{
340
+ Deletions: existing,
341
+ }); err != nil {
342
+ return fmt.Errorf("delete gcloud HTTPS record %s: %w", name, err)
343
+ }
344
+ return nil
345
+}
346
+
347
func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error) {
348
if p == nil {
349
return "", "", "", errors.New("gcloud provider is nil")
portal/acme/provider.go
+2
@@ -26,6 +26,8 @@ type DNSProvider interface {
26
DeleteARecord(ctx context.Context, name string) error
27
EnsureTXTRecord(ctx context.Context, name, value string) error
28
DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error
29
+ EnsureHTTPSRecord(ctx context.Context, name string, priority uint16, target, svcParams, content string) error
30
+ DeleteHTTPSRecord(ctx context.Context, name string) error
31
EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error)
32
}
33
portal/acme/route53/provider.go
+59
@@ -220,6 +220,65 @@ func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix strin
220
return nil
221
}
222
223
+func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
224
+ if p == nil {
225
+ return errors.New("route53 provider is nil")
226
+ }
227
+ name = utils.NormalizeHostname(name)
228
+ if name == "" {
229
+ return errors.New("record name is required")
230
+ }
231
+ content = strings.TrimSpace(content)
232
+ if content == "" {
233
+ return errors.New("https record content is required")
234
+ }
235
+
236
+ client, err := newClient(ctx, p.cfg)
237
+ if err != nil {
238
+ return err
239
+ }
240
+
241
+ hostedZoneID, err := findHostedZoneID(ctx, client, name, p.cfg.HostedZoneID)
242
+ if err != nil {
243
+ return err
244
+ }
245
+ if err := upsertRecord(ctx, client, hostedZoneID, name, route53types.RRTypeHttps, []string{content}, "Managed by Portal ECH"); err != nil {
246
+ return fmt.Errorf("upsert route53 HTTPS record %s: %w", name, err)
247
+ }
248
+ return nil
249
+}
250
+
251
+func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
252
+ if p == nil {
253
+ return errors.New("route53 provider is nil")
254
+ }
255
+ name = utils.NormalizeHostname(name)
256
+ if name == "" {
257
+ return errors.New("record name is required")
258
+ }
259
+
260
+ client, err := newClient(ctx, p.cfg)
261
+ if err != nil {
262
+ return err
263
+ }
264
+
265
+ hostedZoneID, err := findHostedZoneID(ctx, client, name, p.cfg.HostedZoneID)
266
+ if err != nil {
267
+ return err
268
+ }
269
+ recordSet, err := getRecordSet(ctx, client, hostedZoneID, name, route53types.RRTypeHttps)
270
+ if err != nil {
271
+ return err
272
+ }
273
+ if recordSet == nil {
274
+ return nil
275
+ }
276
+ if err := deleteRecordSet(ctx, client, hostedZoneID, recordSet, "Managed by Portal ECH cleanup"); err != nil {
277
+ return fmt.Errorf("delete route53 HTTPS record %s: %w", name, err)
278
+ }
279
+ return nil
280
+}
281
+
282
func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error) {
283
if p == nil {
284
return "", "", "", errors.New("route53 provider is nil")
portal/api_server.go
+111
-18
@@ -1,7 +1,6 @@
1
package portal
2
3
import (
4
- "context"
4
"crypto/tls"
5
"errors"
6
"fmt"
@@ -279,15 +278,40 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
278
writeAPIErrorResponse(w, err)
279
return
280
}
282
- if err := s.syncENSGaslessHostname(context.Background(), record); err != nil {
283
- removed, _ := s.registry.Unregister(types.UnregisterRequest{AccessToken: resp.AccessToken})
284
- if removed == nil {
285
- record.Close()
286
- removed = record
281
+ if manager := s.acmeManager; manager != nil {
282
+ if record.hasENSGaslessDNSRecord() {
283
+ err := manager.SyncENSGaslessHostname(r.Context(), record.Hostname, record.Address)
284
+ if err != nil {
285
+ removed, _ := s.registry.Unregister(types.UnregisterRequest{AccessToken: resp.AccessToken})
286
+ if removed == nil {
287
+ record.Close()
288
+ removed = record
289
+ }
290
+ if removed.hasENSGaslessDNSRecord() {
291
+ deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
292
+ if deleteErr != nil {
293
+ log.Warn().
294
+ Err(deleteErr).
295
+ Str("hostname", removed.Hostname).
296
+ Str("address", removed.Address).
297
+ Msg("delete lease ens gasless hostname after sync failure")
298
+ }
299
+ }
300
+ writeAPIErrorResponse(w, err)
301
+ return
302
+ }
303
+ }
304
+ if record.hasECHDNSRecord() {
305
+ err := manager.SyncECHConfig(r.Context(), record.ECHDNSHostname, record.ECHConfigList, s.cfg.SNIPort)
306
+ if err != nil {
307
+ log.Warn().
308
+ Err(err).
309
+ Str("hostname", record.ECHDNSHostname).
310
+ Str("route_hostname", record.Hostname).
311
+ Str("address", record.Address).
312
+ Msg("publish lease ech dns record")
313
+ }
314
}
288
- s.deleteENSGaslessHostname(context.Background(), removed, "delete lease ens gasless hostname after sync failure")
289
- writeAPIErrorResponse(w, err)
290
- return
315
}
316
317
utils.WriteAPIData(w, http.StatusCreated, resp)
@@ -382,7 +406,29 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
406
writeAPIErrorResponse(w, err)
407
return
408
}
385
- s.deleteENSGaslessHostname(context.Background(), record, "delete lease ens gasless hostname")
409
+ if manager := s.acmeManager; manager != nil {
410
+ if record.hasENSGaslessDNSRecord() {
411
+ err := manager.DeleteENSGaslessHostname(r.Context(), record.Hostname)
412
+ if err != nil {
413
+ log.Warn().
414
+ Err(err).
415
+ Str("hostname", record.Hostname).
416
+ Str("address", record.Address).
417
+ Msg("delete lease ens gasless hostname")
418
+ }
419
+ }
420
+ if record.hasECHDNSRecord() {
421
+ err := manager.DeleteECHConfig(r.Context(), record.ECHDNSHostname)
422
+ if err != nil {
423
+ log.Warn().
424
+ Err(err).
425
+ Str("hostname", record.ECHDNSHostname).
426
+ Str("route_hostname", record.Hostname).
427
+ Str("address", record.Address).
428
+ Msg("delete lease ech dns record")
429
+ }
430
+ }
431
+ }
432
433
utils.WriteAPIData(w, http.StatusOK, map[string]any{})
434
}
@@ -421,7 +467,29 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
467
}
468
if r.Method == http.MethodDelete {
469
record := s.registry.DeleteHopRoute(&route)
424
- s.deleteENSGaslessHostname(context.Background(), record, "delete hop route ens gasless hostname")
470
+ if manager := s.acmeManager; manager != nil {
471
+ if record.hasENSGaslessDNSRecord() {
472
+ err := manager.DeleteENSGaslessHostname(r.Context(), record.Hostname)
473
+ if err != nil {
474
+ log.Warn().
475
+ Err(err).
476
+ Str("hostname", record.Hostname).
477
+ Str("address", record.Address).
478
+ Msg("delete hop route ens gasless hostname")
479
+ }
480
+ }
481
+ if record.hasECHDNSRecord() {
482
+ err := manager.DeleteECHConfig(r.Context(), record.ECHDNSHostname)
483
+ if err != nil {
484
+ log.Warn().
485
+ Err(err).
486
+ Str("hostname", record.ECHDNSHostname).
487
+ Str("route_hostname", record.Hostname).
488
+ Str("address", record.Address).
489
+ Msg("delete hop route ech dns record")
490
+ }
491
+ }
492
+ }
493
utils.WriteAPIData(w, http.StatusOK, map[string]any{})
494
return
495
}
@@ -454,14 +522,39 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
522
writeAPIErrorResponse(w, err)
523
return
524
}
457
- if err := s.syncENSGaslessHostname(context.Background(), record); err != nil {
458
- removed := s.registry.DeleteHopRoute(&route)
459
- if removed == nil {
460
- removed = record
525
+ if manager := s.acmeManager; manager != nil {
526
+ if record.hasENSGaslessDNSRecord() {
527
+ err := manager.SyncENSGaslessHostname(r.Context(), record.Hostname, record.Address)
528
+ if err != nil {
529
+ removed := s.registry.DeleteHopRoute(&route)
530
+ if removed == nil {
531
+ removed = record
532
+ }
533
+ if removed.hasENSGaslessDNSRecord() {
534
+ deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
535
+ if deleteErr != nil {
536
+ log.Warn().
537
+ Err(deleteErr).
538
+ Str("hostname", removed.Hostname).
539
+ Str("address", removed.Address).
540
+ Msg("delete hop route ens gasless hostname after sync failure")
541
+ }
542
+ }
543
+ writeAPIErrorResponse(w, err)
544
+ return
545
+ }
546
+ }
547
+ if record.hasECHDNSRecord() {
548
+ err := manager.SyncECHConfig(r.Context(), record.ECHDNSHostname, record.ECHConfigList, s.cfg.SNIPort)
549
+ if err != nil {
550
+ log.Warn().
551
+ Err(err).
552
+ Str("hostname", record.ECHDNSHostname).
553
+ Str("route_hostname", record.Hostname).
554
+ Str("address", record.Address).
555
+ Msg("publish hop route ech dns record")
556
+ }
557
}
462
- s.deleteENSGaslessHostname(context.Background(), removed, "delete hop route ens gasless hostname after sync failure")
463
- writeAPIErrorResponse(w, err)
464
- return
558
}
559
utils.WriteAPIData(w, http.StatusOK, map[string]any{})
560
}
portal/auth/hop_route.go
+1
@@ -77,6 +77,7 @@ func normalizeHopRoute(route types.HopRoute, requireOwner bool) (types.HopRoute,
77
78
route.OwnerPublicKey = ownerPublicKey
79
route.RelayURL = relayURL
80
+ route.PublicHostname = utils.NormalizeHostname(route.PublicHostname)
81
route.RouteHostname = utils.NormalizeHostname(route.RouteHostname)
82
route.HostnameHash = strings.TrimSpace(route.HostnameHash)
83
route.MatchToken = strings.TrimSpace(route.MatchToken)
portal/keyless/ech.go
+39
-26
@@ -20,35 +20,40 @@ const (
20
echKDFHKDFSHA256 = 0x0001
21
echAEADAES128GCM = 0x0001
22
echMaximumNameLength = 255
23
+ echMaxConfigListLength = 4096
24
echX25519PrivateLength = 32
25
echHKDFInfoPrefix = "portal relay ech v1:"
26
)
27
27
-func EncryptedClientHelloKeys(siwePrivateKey, seed, publicName string) ([]tls.EncryptedClientHelloKey, error) {
28
+// MinTLSVersion returns the minimum TLS version required when ECH is enabled.
29
+func MinTLSVersion(echEnabled bool) uint16 {
30
+ if echEnabled {
31
+ return tls.VersionTLS13
32
+ }
33
+ return tls.VersionTLS12
34
+}
35
+
36
+func EncryptedClientHelloMaterials(seed, publicName string) ([]tls.EncryptedClientHelloKey, []byte, error) {
37
publicName = utils.NormalizeHostname(publicName)
38
if publicName == "" {
30
- return nil, errors.New("ech public name is required")
31
- }
32
- signingKey, _, err := utils.ParseSecp256k1PrivateKeyHex(siwePrivateKey, true)
33
- if err != nil {
34
- return nil, fmt.Errorf("parse siwe private key: %w", err)
39
+ return nil, nil, errors.New("ech public name is required")
40
}
41
seed = strings.TrimSpace(seed)
42
if seed == "" {
38
- return nil, errors.New("ech seed is required")
43
+ return nil, nil, errors.New("ech seed is required")
44
}
45
46
if len(publicName) > echMaximumNameLength {
42
- return nil, errors.New("ech public name is too long")
47
+ return nil, nil, errors.New("ech public name is too long")
48
}
49
45
- privateKey, err := hkdf.Key(sha256.New, signingKey.Serialize(), []byte(seed), echHKDFInfoPrefix+publicName, echX25519PrivateLength)
50
+ privateKey, err := hkdf.Key(sha256.New, []byte(seed), nil, echHKDFInfoPrefix+publicName, echX25519PrivateLength)
51
if err != nil {
47
- return nil, fmt.Errorf("derive ech private key: %w", err)
52
+ return nil, nil, fmt.Errorf("derive ech private key: %w", err)
53
}
54
key, err := ecdh.X25519().NewPrivateKey(privateKey)
55
if err != nil {
51
- return nil, fmt.Errorf("parse ech private key: %w", err)
56
+ return nil, nil, fmt.Errorf("parse ech private key: %w", err)
57
}
58
publicKey := key.PublicKey().Bytes()
59
configID := sha256.Sum256(bytes.Join([][]byte{
@@ -86,26 +91,34 @@ func EncryptedClientHelloKeys(siwePrivateKey, seed, publicName string) ([]tls.En
91
writeUint16(&out, echConfigVersion)
92
writeUint16LengthPrefixed(&out, body.Bytes())
93
89
- return []tls.EncryptedClientHelloKey{{
94
+ keys := []tls.EncryptedClientHelloKey{{
95
Config: out.Bytes(),
96
PrivateKey: privateKey,
97
SendAsRetry: true,
93
- }}, nil
98
+ }}
99
+
100
+ var configList bytes.Buffer
101
+ var configListLength [2]byte
102
+ binary.BigEndian.PutUint16(configListLength[:], uint16(len(keys[0].Config)))
103
+ configList.Write(configListLength[:])
104
+ configList.Write(keys[0].Config)
105
+
106
+ return keys, configList.Bytes(), nil
107
}
108
96
-func EncryptedClientHelloConfigList(keys []tls.EncryptedClientHelloKey) []byte {
97
- var configs bytes.Buffer
98
- for _, key := range keys {
99
- configs.Write(key.Config)
109
+func NormalizeEncryptedClientHelloConfigList(raw []byte) ([]byte, error) {
110
+ if len(raw) == 0 {
111
+ return nil, errors.New("ech config list is required")
112
}
101
-
102
- var out bytes.Buffer
103
- writeUint16 := func(buf *bytes.Buffer, value uint16) {
104
- var encoded [2]byte
105
- binary.BigEndian.PutUint16(encoded[:], value)
106
- buf.Write(encoded[:])
113
+ if len(raw) > echMaxConfigListLength {
114
+ return nil, errors.New("ech config list is too large")
115
+ }
116
+ if len(raw) < 2 {
117
+ return nil, errors.New("ech config list is invalid")
118
+ }
119
+ listLength := int(binary.BigEndian.Uint16(raw[:2]))
120
+ if listLength != len(raw)-2 {
121
+ return nil, errors.New("ech config list length prefix is invalid")
122
}
108
- writeUint16(&out, uint16(configs.Len()))
109
- out.Write(configs.Bytes())
110
- return out.Bytes()
123
+ return append([]byte(nil), raw...), nil
124
}
portal/keyless/tls.go
-8
@@ -26,14 +26,6 @@ type RemoteSignerConfig struct {
26
RootCAPEM []byte
27
}
28
29
-// MinTLSVersion returns the minimum TLS version required by the keyless TLS mode.
30
-func MinTLSVersion(echEnabled bool) uint16 {
31
- if echEnabled {
32
- return tls.VersionTLS13
33
- }
34
- return tls.VersionTLS12
35
-}
36
-
29
func AttachToHTTPServer(server *http.Server, cfg TLSMaterialConfig) (io.Closer, error) {
30
if server == nil {
31
return nil, errors.New("http server is required")
portal/lease.go
+72
-19
@@ -11,6 +11,7 @@ import (
11
"time"
12
13
"github.com/gosuda/portal-tunnel/v2/portal/auth"
14
+ "github.com/gosuda/portal-tunnel/v2/portal/keyless"
15
"github.com/gosuda/portal-tunnel/v2/portal/policy"
16
"github.com/gosuda/portal-tunnel/v2/portal/transport"
17
"github.com/gosuda/portal-tunnel/v2/types"
@@ -162,6 +163,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
163
hopToken := strings.TrimSpace(req.HopToken)
164
routeHostname := utils.NormalizeHostname(req.RouteHostname)
165
hostnameHash := strings.TrimSpace(req.HostnameHash)
166
+ echConfigList := append([]byte(nil), req.ECHConfigList...)
167
if hopToken != "" && (req.UDPEnabled || req.TCPEnabled) {
168
return nil, types.RegisterResponse{}, errTransportMismatch
169
}
@@ -171,6 +173,9 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
173
if hostnameHash != "" && routeHostname == "" {
174
return nil, types.RegisterResponse{}, errors.New("hostname hash requires route hostname")
175
}
176
+ if len(echConfigList) > 0 && (routeHostname == "" || hostnameHash == "") {
177
+ return nil, types.RegisterResponse{}, errors.New("ech config list requires route hostname and hostname hash")
178
+ }
179
if routeHostname != "" {
180
routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
181
normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
@@ -178,6 +183,23 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
183
return nil, types.RegisterResponse{}, errors.New("route hostname must be a child of relay root hostname")
184
}
185
}
186
+ if len(echConfigList) > 0 {
187
+ echConfigList, err = keyless.NormalizeEncryptedClientHelloConfigList(echConfigList)
188
+ if err != nil {
189
+ return nil, types.RegisterResponse{}, err
190
+ }
191
+ }
192
+ echDNSHostname := ""
193
+ if len(echConfigList) > 0 {
194
+ publicHostname, err := utils.LeaseHostname(identity.Name, r.rootHostname)
195
+ if err != nil {
196
+ return nil, types.RegisterResponse{}, err
197
+ }
198
+ if utils.HostnameHash(publicHostname) != hostnameHash {
199
+ return nil, types.RegisterResponse{}, errors.New("hostname hash does not match ech dns hostname")
200
+ }
201
+ echDNSHostname = publicHostname
202
+ }
203
if req.UDPEnabled && !r.policy.IsUDPEnabled() {
204
return nil, types.RegisterResponse{}, errUDPDisabled
205
}
@@ -207,17 +229,19 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
229
230
stream := transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit)
231
record := &leaseRecord{
210
- Identity: identity,
211
- Hostname: hostname,
212
- HostnameHash: hostnameHash,
213
- Metadata: req.Metadata.Copy(),
214
- ExpiresAt: expiresAt,
215
- FirstSeenAt: issuedAt,
216
- LastSeenAt: issuedAt,
217
- ClientIP: clientIP,
218
- ReportedIP: utils.SanitizeReportedIP(reportedIP),
219
- hopToken: hopToken,
220
- stream: stream,
232
+ Identity: identity,
233
+ Hostname: hostname,
234
+ HostnameHash: hostnameHash,
235
+ ECHConfigList: echConfigList,
236
+ ECHDNSHostname: echDNSHostname,
237
+ Metadata: req.Metadata.Copy(),
238
+ ExpiresAt: expiresAt,
239
+ FirstSeenAt: issuedAt,
240
+ LastSeenAt: issuedAt,
241
+ ClientIP: clientIP,
242
+ ReportedIP: utils.SanitizeReportedIP(reportedIP),
243
+ hopToken: hopToken,
244
+ stream: stream,
245
}
246
247
if req.UDPEnabled {
@@ -456,6 +480,8 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
480
}
481
routeHostname := route.RouteHostname
482
hostnameHash := route.HostnameHash
483
+ echConfigList := append([]byte(nil), route.ECHConfigList...)
484
+ publicHostname := utils.NormalizeHostname(route.PublicHostname)
485
matchToken := route.MatchToken
486
overlayIPv4, overlayErr := utils.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey)
487
forwardToken := route.ForwardToken
@@ -483,6 +509,21 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
509
return nil, errors.New("route hostname must be a child of relay root hostname")
510
}
511
}
512
+ if len(echConfigList) > 0 {
513
+ if publicHostname == "" || routeHostname == "" || hostnameHash == "" {
514
+ return nil, errors.New("ech config list requires public hostname, route hostname, and hostname hash")
515
+ }
516
+ if !utils.HostnameMatchesBaseDomain(publicHostname, r.rootHostname) {
517
+ return nil, errors.New("public hostname must be a child of relay root hostname")
518
+ }
519
+ if utils.HostnameHash(publicHostname) != hostnameHash {
520
+ return nil, errors.New("hostname hash does not match ech dns hostname")
521
+ }
522
+ echConfigList, err = keyless.NormalizeEncryptedClientHelloConfigList(echConfigList)
523
+ if err != nil {
524
+ return nil, err
525
+ }
526
+ }
527
name := routeHostname
528
if label, _, ok := strings.Cut(name, "."); ok {
529
name = label
@@ -498,6 +539,8 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
539
},
540
Hostname: routeHostname,
541
HostnameHash: hostnameHash,
542
+ ECHConfigList: echConfigList,
543
+ ECHDNSHostname: publicHostname,
544
Metadata: route.Metadata.Copy(),
545
FirstSeenAt: route.FirstSeenAt.UTC(),
546
ExpiresAt: expiresAt,
@@ -813,14 +856,16 @@ func (r *leaseRegistry) publicLease(record *leaseRecord) types.Lease {
856
857
type leaseRecord struct {
858
types.Identity
816
- ExpiresAt time.Time
817
- FirstSeenAt time.Time
818
- LastSeenAt time.Time
819
- ClientIP string
820
- ReportedIP string
821
- Hostname string
822
- HostnameHash string
823
- Metadata types.LeaseMetadata
859
+ ExpiresAt time.Time
860
+ FirstSeenAt time.Time
861
+ LastSeenAt time.Time
862
+ ClientIP string
863
+ ReportedIP string
864
+ Hostname string
865
+ HostnameHash string
866
+ ECHConfigList []byte
867
+ ECHDNSHostname string
868
+ Metadata types.LeaseMetadata
869
870
hopToken string
871
hopNextOverlayIPv4 string
@@ -838,6 +883,14 @@ func (r *leaseRecord) isPublicEntry() bool {
883
return r != nil && r.hopToken == "" && r.Hostname != ""
884
}
885
886
+func (r *leaseRecord) hasENSGaslessDNSRecord() bool {
887
+ return r.isPublicEntry() && r.HostnameHash == ""
888
+}
889
+
890
+func (r *leaseRecord) hasECHDNSRecord() bool {
891
+ return r.isPublicEntry() && len(r.ECHConfigList) > 0 && r.ECHDNSHostname != ""
892
+}
893
+
894
func (r *leaseRecord) isHopMiddle() bool {
895
_, _, hasNextHop := r.nextHop()
896
return r != nil && r.Hostname == "" && r.hopToken != "" && hasNextHop
portal/server.go
+64
-32
@@ -387,8 +387,31 @@ func (s *Server) Shutdown(ctx context.Context) error {
387
s.cancel()
388
}
389
390
- for _, lease := range s.registry.CloseAll() {
391
- s.deleteENSGaslessHostname(ctx, lease, "delete lease ens gasless hostname during shutdown")
390
+ leases := s.registry.CloseAll()
391
+ if manager := s.acmeManager; manager != nil {
392
+ for _, lease := range leases {
393
+ if lease.hasENSGaslessDNSRecord() {
394
+ err := manager.DeleteENSGaslessHostname(ctx, lease.Hostname)
395
+ if err != nil {
396
+ log.Warn().
397
+ Err(err).
398
+ Str("hostname", lease.Hostname).
399
+ Str("address", lease.Address).
400
+ Msg("delete lease ens gasless hostname during shutdown")
401
+ }
402
+ }
403
+ if lease.hasECHDNSRecord() {
404
+ err := manager.DeleteECHConfig(ctx, lease.ECHDNSHostname)
405
+ if err != nil {
406
+ log.Warn().
407
+ Err(err).
408
+ Str("hostname", lease.ECHDNSHostname).
409
+ Str("route_hostname", lease.Hostname).
410
+ Str("address", lease.Address).
411
+ Msg("delete lease ech dns record during shutdown")
412
+ }
413
+ }
414
+ }
415
}
416
417
if s.quicBackhaul != nil {
@@ -454,17 +477,28 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
477
CertPEM: certPEM,
478
KeyPEM: keyPEM,
479
}
457
- echKeys, err := keyless.EncryptedClientHelloKeys(
458
- s.identity.PrivateKey,
480
+ echSeed, err := s.identity.DeriveToken(
481
+ "relay-ech",
482
s.identity.EncryptedClientHelloSeed,
483
s.identity.Name,
484
)
485
if err != nil {
486
manager.Stop()
464
- return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("prepare ech keys: %w", err)
487
+ return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("derive relay ech seed: %w", err)
488
+ }
489
+ echKeys, echConfigList, err := keyless.EncryptedClientHelloMaterials(echSeed, s.identity.Name)
490
+ if err != nil {
491
+ manager.Stop()
492
+ return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("prepare ech materials: %w", err)
493
}
494
if len(echKeys) > 0 {
495
apiTLS.EncryptedClientHelloKeys = echKeys
496
+ if err := manager.SyncECHConfig(ctx, s.identity.Name, echConfigList, s.cfg.SNIPort); err != nil {
497
+ log.Warn().
498
+ Err(err).
499
+ Str("hostname", s.identity.Name).
500
+ Msg("publish relay ech dns record")
501
+ }
502
}
503
504
return apiTLS, manager, nil
@@ -644,8 +678,31 @@ func (s *Server) runRegistryJanitor(ctx context.Context, interval time.Duration)
678
case <-ctx.Done():
679
return nil
680
case <-ticker.C:
647
- for _, lease := range s.registry.cleanupExpired(time.Now()) {
648
- s.deleteENSGaslessHostname(context.Background(), lease, "delete expired lease ens gasless hostname")
681
+ leases := s.registry.cleanupExpired(time.Now())
682
+ if manager := s.acmeManager; manager != nil {
683
+ for _, lease := range leases {
684
+ if lease.hasENSGaslessDNSRecord() {
685
+ err := manager.DeleteENSGaslessHostname(ctx, lease.Hostname)
686
+ if err != nil {
687
+ log.Warn().
688
+ Err(err).
689
+ Str("hostname", lease.Hostname).
690
+ Str("address", lease.Address).
691
+ Msg("delete expired lease ens gasless hostname")
692
+ }
693
+ }
694
+ if lease.hasECHDNSRecord() {
695
+ err := manager.DeleteECHConfig(ctx, lease.ECHDNSHostname)
696
+ if err != nil {
697
+ log.Warn().
698
+ Err(err).
699
+ Str("hostname", lease.ECHDNSHostname).
700
+ Str("route_hostname", lease.Hostname).
701
+ Str("address", lease.Address).
702
+ Msg("delete expired lease ech dns record")
703
+ }
704
+ }
705
+ }
706
}
707
}
708
}
@@ -805,28 +862,3 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error)
862
TCPBPS: s.proxy.currentTCPBPS(now),
863
}, s.identity.PrivateKey)
864
}
808
-
809
-func (s *Server) syncENSGaslessHostname(ctx context.Context, record *leaseRecord) error {
810
- if record == nil || !record.isPublicEntry() || record.HostnameHash != "" || s.acmeManager == nil {
811
- return nil
812
- }
813
- syncCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
814
- defer cancel()
815
- return s.acmeManager.SyncENSGaslessHostname(syncCtx, record.Hostname, record.Address)
816
-}
817
-
818
-func (s *Server) deleteENSGaslessHostname(ctx context.Context, record *leaseRecord, logMessage string) {
819
- if record == nil || !record.isPublicEntry() || record.HostnameHash != "" || s.acmeManager == nil {
820
- return
821
- }
822
- deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
823
- err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname)
824
- cancel()
825
- if err != nil {
826
- log.Warn().
827
- Err(err).
828
- Str("hostname", record.Hostname).
829
- Str("address", record.Address).
830
- Msg(logMessage)
831
- }
832
-}
sdk/api_client.go
+10
@@ -139,6 +139,13 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
139
return types.RegisterResponse{}, nil, "", "", err
140
}
141
}
142
+ var echConfigList []byte
143
+ if streamLease {
144
+ _, echConfigList, err = l.tenantECHMaterials(publicHostname, routeHostname)
145
+ if err != nil {
146
+ return types.RegisterResponse{}, nil, "", "", err
147
+ }
148
+ }
149
150
if len(l.multiHop) > 0 {
151
hopRoutes = make([]types.HopRoute, 0, len(hopPath)-1)
@@ -161,8 +168,10 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
168
ForwardToken: forwardToken,
169
}
170
if i == 0 {
171
+ route.PublicHostname = publicHostname
172
route.RouteHostname = routeHostname
173
route.HostnameHash = utils.HostnameHash(publicHostname)
174
+ route.ECHConfigList = append([]byte(nil), echConfigList...)
175
route.Metadata.Hide = true
176
hopRoutes = append(hopRoutes, route)
177
} else {
@@ -185,6 +194,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
194
if streamLease && len(l.multiHop) == 0 {
195
registerReq.RouteHostname = routeHostname
196
registerReq.HostnameHash = utils.HostnameHash(publicHostname)
197
+ registerReq.ECHConfigList = append([]byte(nil), echConfigList...)
198
}
199
200
var challenge types.RegisterChallengeResponse
sdk/listener.go
+19
-14
@@ -731,20 +731,10 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
731
publicURLBase = parsedKeylessURL
732
}
733
}
734
- var echKeys []tls.EncryptedClientHelloKey
735
- var echConfigList []byte
736
- if routeHostname != "" {
737
- echSeed, err := l.identity.DeriveToken("tenant-ech", publicHostname, routeHostname)
738
- if err != nil {
739
- _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
740
- return fmt.Errorf("derive tenant ech seed: %w", err)
741
- }
742
- echKeys, err = keyless.EncryptedClientHelloKeys(l.identity.PrivateKey, echSeed, routeHostname)
743
- if err != nil {
744
- _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
745
- return fmt.Errorf("prepare tenant ech keys: %w", err)
746
- }
747
- echConfigList = keyless.EncryptedClientHelloConfigList(echKeys)
734
+ echKeys, echConfigList, err := l.tenantECHMaterials(publicHostname, routeHostname)
735
+ if err != nil {
736
+ _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
737
+ return err
738
}
739
740
tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, publicHostname, echKeys)
@@ -800,6 +790,21 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
790
return nil
791
}
792
793
+func (l *listener) tenantECHMaterials(publicHostname, routeHostname string) ([]tls.EncryptedClientHelloKey, []byte, error) {
794
+ if routeHostname == "" {
795
+ return nil, nil, nil
796
+ }
797
+ echSeed, err := l.identity.DeriveToken("tenant-ech", publicHostname, routeHostname)
798
+ if err != nil {
799
+ return nil, nil, fmt.Errorf("derive tenant ech seed: %w", err)
800
+ }
801
+ echKeys, echConfigList, err := keyless.EncryptedClientHelloMaterials(echSeed, routeHostname)
802
+ if err != nil {
803
+ return nil, nil, fmt.Errorf("prepare tenant ech materials: %w", err)
804
+ }
805
+ return echKeys, echConfigList, nil
806
+}
807
+
808
func (l *listener) waitRetry(ctx context.Context, operation string, err error, retries, reverseSessionSlot int) bool {
809
if ctx.Err() != nil {
810
return false
types/api.go
+8
@@ -1,6 +1,7 @@
1
package types
2
3
import (
4
+ "encoding/base64"
5
"encoding/json"
6
"fmt"
7
"strings"
@@ -72,6 +73,7 @@ type RegisterChallengeRequest struct {
73
HopToken string `json:"hop_token,omitempty"`
74
RouteHostname string `json:"route_hostname,omitempty"`
75
HostnameHash string `json:"hostname_hash,omitempty"`
76
+ ECHConfigList []byte `json:"ech_config_list,omitempty"`
77
}
78
79
type RegisterChallengeResponse struct {
@@ -125,8 +127,10 @@ type UnregisterRequest struct {
127
type HopRoute struct {
128
OwnerPublicKey string `json:"owner_public_key,omitempty"`
129
RelayURL string `json:"relay_url"`
130
+ PublicHostname string `json:"public_hostname,omitempty"`
131
RouteHostname string `json:"route_hostname,omitempty"`
132
HostnameHash string `json:"hostname_hash,omitempty"`
133
+ ECHConfigList []byte `json:"ech_config_list,omitempty"`
134
MatchToken string `json:"match_token,omitempty"`
135
Metadata LeaseMetadata `json:"metadata,omitempty"`
136
ForwardRelay RelayDescriptor `json:"forward_relay"`
@@ -146,8 +150,10 @@ func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
150
Method string `json:"method"`
151
OwnerPublicKey string `json:"owner_public_key"`
152
RelayURL string `json:"relay_url"`
153
+ PublicHostname string `json:"public_hostname"`
154
RouteHostname string `json:"route_hostname"`
155
HostnameHash string `json:"hostname_hash"`
156
+ ECHConfigList string `json:"ech_config_list"`
157
MatchToken string `json:"match_token"`
158
ForwardRelay json.RawMessage `json:"forward_relay"`
159
ForwardToken string `json:"forward_token"`
@@ -158,8 +164,10 @@ func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
164
Method: strings.ToUpper(strings.TrimSpace(method)),
165
OwnerPublicKey: strings.TrimSpace(route.OwnerPublicKey),
166
RelayURL: strings.TrimSpace(route.RelayURL),
167
+ PublicHostname: strings.TrimSpace(route.PublicHostname),
168
RouteHostname: strings.TrimSpace(route.RouteHostname),
169
HostnameHash: strings.TrimSpace(route.HostnameHash),
170
+ ECHConfigList: base64.StdEncoding.EncodeToString(route.ECHConfigList),
171
MatchToken: strings.TrimSpace(route.MatchToken),
172
ForwardRelay: json.RawMessage(forwardRelay),
173
ForwardToken: strings.TrimSpace(route.ForwardToken),