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),