refact: promote address to first-class identity and remove lease IDs

Kim committed Apr 1, 2026 at 18:30 UTC 8120ca37cd3dea2604ea20d6fc40fd988d9dd785
55 files changed +1487 -907
.env.example
+3 -3
@@ -1,8 +1,8 @@
1 -# Public routing and discovery
1 +# Public routing, discovery, and relay identity persistence
2 PORTAL_URL=https://localhost:4017
3 BOOTSTRAPS=https://localhost:4017
4 DISCOVERY=true
5 -OWNER_PRIVATE_KEY=
5 +IDENTITY_PATH=/portal-certs/identity.json
6 WIREGUARD_ENDPOINT=
7 WIREGUARD_PRIVATE_KEY=
8
@@ -14,7 +14,7 @@ SNI_PORT=443
14 UDP_PORT_COUNT=0
15
16 # TLS/ACME and keyless materials
17 -KEYLESS_DIR=./.portal-certs
17 +KEYLESS_DIR=/portal-certs
18 # Supported values: cloudflare, route53
19 ACME_DNS_PROVIDER=cloudflare
20
Dockerfile
+4 -1
@@ -39,10 +39,13 @@ FROM gcr.io/distroless/static-debian12:nonroot
39
40 COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
41
42 +WORKDIR /tmp
43 +
44 ENV PORTAL_URL=https://localhost:4017
45 +ENV IDENTITY_PATH=/tmp/.portal-certs/identity.json
46 ENV ADMIN_SECRET_KEY=
47 ENV SNI_PORT=:443
45 -ENV KEYLESS_DIR=
48 +ENV KEYLESS_DIR=/tmp/.portal-certs
49 ENV CLOUDFLARE_TOKEN=
50 ENV TZ=UTC
51
README.md
+3 -1
@@ -49,10 +49,12 @@ Portal also checks that the relay is preserving TLS passthrough. The Portal clie
49
50 ```bash
51 git clone https://github.com/gosuda/portal
52 -cd portal
52 +cd portal && cp .env.example .env
53 docker compose up
54 ```
55
56 +The Docker setup persists both the relay identity JSON and relay certificates under `./.portal-certs`. Keep that directory on persistent storage if you want a stable relay address and ACME state across restarts.
57 +
58 For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
59
60 ### Expose Local Service via Tunnel
cmd/demo-app/main.go
+41 -12
@@ -31,16 +31,17 @@ func main() {
31 }
32
33 type demoConfig struct {
34 - relayURLs string
35 - discovery bool
36 - banMITM bool
37 - addr string
38 - name string
39 - desc string
40 - tags string
41 - owner string
42 - hide bool
43 - thumbnail string
34 + relayURLs string
35 + discovery bool
36 + banMITM bool
37 + identityPath string
38 + addr string
39 + name string
40 + desc string
41 + tags string
42 + owner string
43 + hide bool
44 + thumbnail string
45 }
46
47 func runTCPCommand(args []string) error {
@@ -50,6 +51,7 @@ func runTCPCommand(args []string) error {
51 utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
52 utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
53 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
54 + utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
55 utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
56 utils.StringFlag(fs, &cfg.name, "name", "demo-app", "public hostname prefix (single DNS label)")
57 utils.StringFlag(fs, &cfg.desc, "description", "Portal demo connectivity app", "lease description")
@@ -68,6 +70,11 @@ func runTCPCommand(args []string) error {
70 printTCPUsage(os.Stderr)
71 return err
72 }
73 + normalizedName, err := utils.NormalizeDNSLabel(cfg.name)
74 + if err != nil {
75 + return fmt.Errorf("invalid --name value: %w", err)
76 + }
77 + cfg.name = normalizedName
78
79 ctx, stop := utils.SignalContext()
80 defer stop()
@@ -82,6 +89,7 @@ func runUDPCommand(args []string) error {
89 utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
90 utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
91 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
92 + utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
93 utils.StringFlag(fs, &cfg.name, "name", "demo-udp", "public hostname prefix (single DNS label)")
94 utils.StringFlag(fs, &cfg.desc, "description", "Portal demo UDP echo service", "lease description")
95 utils.StringFlag(fs, &cfg.tags, "tags", "demo,udp,echo", "comma-separated lease tags")
@@ -99,6 +107,11 @@ func runUDPCommand(args []string) error {
107 printUDPUsage(os.Stderr)
108 return err
109 }
110 + normalizedName, err := utils.NormalizeDNSLabel(cfg.name)
111 + if err != nil {
112 + return fmt.Errorf("invalid --name value: %w", err)
113 + }
114 + cfg.name = normalizedName
115
116 ctx, stop := utils.SignalContext()
117 defer stop()
@@ -133,9 +146,17 @@ func runHelpCommand(args []string) error {
146 }
147
148 func runTCPDemo(ctx context.Context, cfg demoConfig) error {
149 + identity, createdIdentity, err := utils.LoadOrCreateIdentity(cfg.identityPath, types.Identity{Name: cfg.name})
150 + if err != nil {
151 + return fmt.Errorf("load demo identity: %w", err)
152 + }
153 + if createdIdentity {
154 + log.Info().Str("identity_path", cfg.identityPath).Str("address", identity.Address).Msg("generated demo identity and saved it to disk")
155 + }
156 +
157 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
158 RelayURLs: utils.SplitCSV(cfg.relayURLs),
138 - Name: cfg.name,
159 + Identity: identity,
160 BanMITM: cfg.banMITM,
161 Discovery: cfg.discovery,
162 Metadata: types.LeaseMetadata{
@@ -173,9 +194,17 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
194 }
195
196 func runUDPDemo(ctx context.Context, cfg demoConfig) error {
197 + identity, createdIdentity, err := utils.LoadOrCreateIdentity(cfg.identityPath, types.Identity{Name: cfg.name})
198 + if err != nil {
199 + return fmt.Errorf("load demo identity: %w", err)
200 + }
201 + if createdIdentity {
202 + log.Info().Str("identity_path", cfg.identityPath).Str("address", identity.Address).Msg("generated demo identity and saved it to disk")
203 + }
204 +
205 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
206 RelayURLs: utils.SplitCSV(cfg.relayURLs),
178 - Name: cfg.name,
207 + Identity: identity,
208 UDPEnabled: true,
209 BanMITM: cfg.banMITM,
210 Discovery: cfg.discovery,
cmd/portal-tunnel/README.md
+5 -1
@@ -39,6 +39,7 @@ Custom relay and metadata example:
39 ```text
40 portal expose localhost:8080 \
41 --name myapp \
42 + --identity-path ~/.config/portal/myapp.identity.json \
43 --relays https://portal.example.com \
44 --description "Service description" \
45 --tags tag1,tag2 \
@@ -74,6 +75,7 @@ Flags:
75 --relays Portal relay API URLs (comma-separated, https only)
76 --discovery Include public registry relays and discover additional relay bootstraps
77 --ban-mitm Ban relay when the MITM self-probe detects TLS termination
78 +--identity-path Identity JSON file path; created automatically when missing
79 --name Public hostname prefix (single DNS label); auto-generated when omitted
80 --description Service description metadata
81 --tags Service tags metadata (comma-separated)
@@ -105,7 +107,9 @@ Legacy execution compatibility has been removed:
107
108 ## Notes
109
108 -- Multiple relay URLs are registered independently. Each relay gets its own lease ID and public URLs.
110 +- `portal expose` loads or creates the signing identity at `identity.json` by default. Reusing the same `--identity-path` keeps the same address across runs.
111 +- Use different `--identity-path` values when you want separate local identities.
112 +- Multiple relay URLs are registered independently. Each relay gets its own lease registration and public URLs.
113 - Relay publishes each service at `<name>.<portal root host>`.
114 - The tunnel consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
115 - Relay startup and reconnect failures are retried independently in the background. A relay that is down does not stop healthy relays from continuing to serve traffic.
cmd/portal-tunnel/main.go
+34 -18
@@ -32,20 +32,20 @@ func main() {
32 }
33
34 type exposeFlags struct {
35 - relayCSV string
36 - discovery bool
37 - banMITM bool
38 - privateKey string
39 - name string
40 - desc string
41 - tags string
42 - owner string
43 - thumbnail string
44 - hide bool
45 - targetAddr string
46 - httpRoutes []string
47 - udp bool
48 - udpAddr string
35 + relayCSV string
36 + discovery bool
37 + banMITM bool
38 + identityPath string
39 + name string
40 + desc string
41 + tags string
42 + owner string
43 + thumbnail string
44 + hide bool
45 + targetAddr string
46 + httpRoutes []string
47 + udp bool
48 + udpAddr string
49 }
50
51 func runExposeCommand(args []string) error {
@@ -55,7 +55,7 @@ func runExposeCommand(args []string) error {
55 utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
56 utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include public registry relays and discover additional relay bootstraps")
57 utils.BoolFlagEnv(fs, &flags.banMITM, "ban-mitm", true, "Ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
58 - utils.StringFlag(fs, &flags.privateKey, "private-key", "", "Owner private key used to derive a discovery address")
58 + utils.StringFlagEnv(fs, &flags.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
59 utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
60 utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata")
61 utils.StringFlag(fs, &flags.tags, "tags", "", "Service tags metadata (comma-separated)")
@@ -90,6 +90,12 @@ func runExposeCommand(args []string) error {
90 printExposeUsage(os.Stderr)
91 return errors.New("--udp cannot be combined with --http-route")
92 }
93 + if flags.name == "" {
94 + storedIdentity, err := utils.LoadIdentity(flags.identityPath)
95 + if err == nil && strings.TrimSpace(storedIdentity.Name) != "" {
96 + flags.name = storedIdentity.Name
97 + }
98 + }
99 if flags.name == "" {
100 defaultTarget := flags.targetAddr
101 if defaultTarget == "" && len(flags.httpRoutes) > 0 {
@@ -100,13 +106,24 @@ func runExposeCommand(args []string) error {
106 return fmt.Errorf("derive service name: %w", err)
107 }
108 }
109 + flags.name, err = utils.NormalizeDNSLabel(flags.name)
110 + if err != nil {
111 + return fmt.Errorf("invalid service name: %w", err)
112 + }
113 + identity, createdIdentity, err := utils.LoadOrCreateIdentity(flags.identityPath, types.Identity{Name: flags.name})
114 + if err != nil {
115 + return fmt.Errorf("load tunnel identity: %w", err)
116 + }
117 + if createdIdentity {
118 + log.Info().Str("identity_path", flags.identityPath).Str("address", identity.Address).Msg("generated tunnel identity and saved it to disk")
119 + }
120
121 ctx, stop := utils.SignalContext()
122 defer stop()
123
124 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
125 RelayURLs: utils.SplitCSV(flags.relayCSV),
109 - Name: flags.name,
126 + Identity: identity,
127 TargetAddr: flags.targetAddr,
128 UDPAddr: flags.udpAddr,
129 UDPEnabled: flags.udp,
@@ -119,7 +136,6 @@ func runExposeCommand(args []string) error {
136 Thumbnail: flags.thumbnail,
137 Hide: flags.hide,
138 },
122 - OwnerPrivateKey: flags.privateKey,
139 })
140 if err != nil {
141 return fmt.Errorf("service %s: failed to start relays: %w", flags.name, err)
@@ -133,7 +149,7 @@ func runExposeCommand(args []string) error {
149 defer exposure.Close()
150 return exposure.RunHTTP(ctx, handler, "")
151 }
136 - return proxyExposure(ctx, exposure, flags.name)
152 + return proxyExposure(ctx, exposure)
153 }
154
155 type listFlags struct {
cmd/portal-tunnel/relays.go
+12 -11
@@ -16,12 +16,13 @@ import (
16 "github.com/gosuda/portal/v2/types"
17 )
18
19 -func proxyExposure(ctx context.Context, exposure *sdk.Exposure, serviceName string) error {
19 +func proxyExposure(ctx context.Context, exposure *sdk.Exposure) error {
20 defer exposure.Close()
21 if len(exposure.ActiveRelayURLs()) == 0 {
22 return errors.New("no relay URLs provided")
23 }
24
25 + identity := exposure.Identity()
26 tcpTarget := exposure.TargetAddr
27 udpTarget := exposure.UDPAddr
28 udpEnabled := udpTarget != ""
@@ -29,13 +30,13 @@ func proxyExposure(ctx context.Context, exposure *sdk.Exposure, serviceName stri
30 log.Info().
31 Str("release_version", types.ReleaseVersion).
32 Str("tcp_target", tcpTarget).
32 - Str("service_name", serviceName).
33 + Str("service_name", identity.Name).
34 Strs("relays", exposure.ActiveRelayURLs()).
35 Msg("starting portal tunnel; public URLs will be logged as relays become ready")
36 if udpEnabled {
37 log.Info().
38 Str("udp_target", udpTarget).
38 - Str("service_name", serviceName).
39 + Str("service_name", identity.Name).
40 Msg("udp relay enabled")
41 }
42
@@ -249,7 +250,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
250
251 type flowKey struct {
252 flowID uint32
252 - leaseID string
253 + address string
254 relayURL string
255 }
256 type flowEntry struct {
@@ -285,7 +286,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
286 getOrCreateFlow := func(frame types.DatagramFrame) (*net.UDPConn, error) {
287 key := flowKey{
288 flowID: frame.FlowID,
288 - leaseID: frame.LeaseID,
289 + address: frame.Address,
290 relayURL: frame.RelayURL,
291 }
292
@@ -314,7 +315,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
315 lastSeen: time.Now(),
316 frame: types.DatagramFrame{
317 FlowID: frame.FlowID,
317 - LeaseID: frame.LeaseID,
318 + Address: frame.Address,
319 RelayURL: frame.RelayURL,
320 UDPAddr: frame.UDPAddr,
321 },
@@ -332,7 +333,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
333 log.Debug().
334 Err(err).
335 Uint32("flow_id", key.flowID).
335 - Str("lease_id", key.leaseID).
336 + Str("address", key.address).
337 Str("relay_url", key.relayURL).
338 Msg("local read ended")
339 return
@@ -354,7 +355,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
355 log.Debug().
356 Err(sendErr).
357 Uint32("flow_id", key.flowID).
357 - Str("lease_id", key.leaseID).
358 + Str("address", key.address).
359 Str("relay_url", key.relayURL).
360 Msg("send datagram to relay failed")
361 return
@@ -381,7 +382,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
382 log.Debug().
383 Uint32("flow_id", frame.FlowID).
384 Int("bytes", len(frame.Payload)).
384 - Str("lease_id", frame.LeaseID).
385 + Str("address", frame.Address).
386 Str("relay_url", frame.RelayURL).
387 Str("udp_addr", frame.UDPAddr).
388 Str("target", localAddr).
@@ -392,7 +393,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
393 log.Warn().
394 Err(err).
395 Uint32("flow_id", frame.FlowID).
395 - Str("lease_id", frame.LeaseID).
396 + Str("address", frame.Address).
397 Str("relay_url", frame.RelayURL).
398 Msg("dial local udp failed")
399 continue
@@ -402,7 +403,7 @@ func proxyExposureDatagrams(ctx context.Context, exposure *sdk.Exposure, localAd
403 log.Warn().
404 Err(err).
405 Uint32("flow_id", frame.FlowID).
405 - Str("lease_id", frame.LeaseID).
406 + Str("address", frame.Address).
407 Str("relay_url", frame.RelayURL).
408 Msg("write to local udp failed")
409 }
cmd/relay-server/admin.go
+51 -34
@@ -251,24 +251,38 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
251 case strings.HasPrefix(path, types.PathAdminLeasesPrefix):
252 rest := strings.TrimPrefix(path, types.PathAdminLeasesPrefix)
253 parts := strings.Split(rest, "/")
254 - if len(parts) != 2 {
254 + if len(parts) != 3 {
255 http.NotFound(w, r)
256 return
257 }
258
259 - leaseID, err := utils.DecodeBase64URLString(parts[0])
259 + name, err := utils.DecodeBase64URLString(parts[0])
260 if err != nil {
261 - utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidLeaseID, "invalid lease ID")
261 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
262 return
263 }
264 + address, err := utils.DecodeBase64URLString(parts[1])
265 + if err != nil {
266 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidAddress, "invalid address")
267 + return
268 + }
269 + identity, err := utils.NormalizeIdentity(types.Identity{
270 + Name: name,
271 + Address: address,
272 + })
273 + if err != nil {
274 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
275 + return
276 + }
277 + identityKey := identity.Key()
278
265 - switch parts[1] {
279 + switch parts[2] {
280 case "ban":
281 switch r.Method {
282 case http.MethodPost:
269 - runtime.BanLease(leaseID)
283 + runtime.BanIdentity(identityKey)
284 case http.MethodDelete:
271 - runtime.UnbanLease(leaseID)
285 + runtime.UnbanIdentity(identityKey)
286 default:
287 methodNotAllowed.Write(w)
288 return
@@ -286,9 +300,9 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
300 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "bps must be greater than zero")
301 return
302 }
289 - runtime.BPSManager().SetLeaseBPS(leaseID, req.BPS)
303 + runtime.BPSManager().SetIdentityBPS(identityKey, req.BPS)
304 case http.MethodDelete:
291 - runtime.BPSManager().DeleteLeaseBPS(leaseID)
305 + runtime.BPSManager().DeleteIdentityBPS(identityKey)
306 default:
307 methodNotAllowed.Write(w)
308 return
@@ -299,10 +313,10 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
313 approver := runtime.Approver()
314 switch r.Method {
315 case http.MethodPost:
302 - approver.Approve(leaseID)
303 - approver.Undeny(leaseID)
316 + approver.Approve(identityKey)
317 + approver.Undeny(identityKey)
318 case http.MethodDelete:
305 - approver.Revoke(leaseID)
319 + approver.Revoke(identityKey)
320 default:
321 methodNotAllowed.Write(w)
322 return
@@ -313,9 +327,9 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
327 approver := runtime.Approver()
328 switch r.Method {
329 case http.MethodPost:
316 - approver.Deny(leaseID)
330 + approver.Deny(identityKey)
331 case http.MethodDelete:
318 - approver.Undeny(leaseID)
332 + approver.Undeny(identityKey)
333 default:
334 methodNotAllowed.Write(w)
335 return
@@ -425,15 +439,15 @@ func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled boo
439 }
440
441 type persistedAdminState struct {
428 - ApprovalMode string `json:"approval_mode"`
429 - ApprovedLeases []string `json:"approved_leases,omitempty"`
430 - DeniedLeases []string `json:"denied_leases,omitempty"`
431 - BannedLeases []string `json:"banned_leases,omitempty"`
432 - BannedIPs []string `json:"banned_ips,omitempty"`
433 - LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
434 - UDPEnabled *bool `json:"udp_enabled,omitempty"`
435 - UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
436 - LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
442 + ApprovalMode string `json:"approval_mode"`
443 + ApprovedIdentityKeys []string `json:"approved_identity_keys,omitempty"`
444 + DeniedIdentityKeys []string `json:"denied_identity_keys,omitempty"`
445 + BannedIdentityKeys []string `json:"banned_identity_keys,omitempty"`
446 + BannedIPs []string `json:"banned_ips,omitempty"`
447 + IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
448 + UDPEnabled *bool `json:"udp_enabled,omitempty"`
449 + UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
450 + LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
451 }
452
453 func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool) persistedAdminState {
@@ -441,15 +455,15 @@ func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool)
455 udpEnabled := runtime.IsUDPEnabled()
456 udpMaxLeases := runtime.UDPMaxLeases()
457 return persistedAdminState{
444 - ApprovalMode: string(approver.Mode()),
445 - ApprovedLeases: approver.ApprovedLeases(),
446 - DeniedLeases: approver.DeniedLeases(),
447 - BannedLeases: runtime.BannedLeases(),
448 - BannedIPs: runtime.IPFilter().BannedIPs(),
449 - LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
450 - UDPEnabled: &udpEnabled,
451 - UDPMaxLeases: &udpMaxLeases,
452 - LandingPageEnabled: &landingPageEnabled,
458 + ApprovalMode: string(approver.Mode()),
459 + ApprovedIdentityKeys: approver.ApprovedKeys(),
460 + DeniedIdentityKeys: approver.DeniedKeys(),
461 + BannedIdentityKeys: runtime.BannedIdentityKeys(),
462 + BannedIPs: runtime.IPFilter().BannedIPs(),
463 + IdentityBPS: runtime.BPSManager().IdentityBPSLimits(),
464 + UDPEnabled: &udpEnabled,
465 + UDPMaxLeases: &udpMaxLeases,
466 + LandingPageEnabled: &landingPageEnabled,
467 }
468 }
469
@@ -469,10 +483,13 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
483 return err
484 }
485 }
472 - runtime.Approver().SetDecisions(s.ApprovedLeases, s.DeniedLeases)
473 - runtime.SetBannedLeases(s.BannedLeases)
486 + runtime.Approver().SetDecisions(
487 + utils.NormalizeIdentityKeys(s.ApprovedIdentityKeys),
488 + utils.NormalizeIdentityKeys(s.DeniedIdentityKeys),
489 + )
490 + runtime.SetBannedIdentityKeys(utils.NormalizeIdentityKeys(s.BannedIdentityKeys))
491 runtime.IPFilter().SetBannedIPs(s.BannedIPs)
475 - runtime.BPSManager().SetLeaseBPSLimits(s.LeaseBPS)
492 + runtime.BPSManager().SetIdentityBPSLimits(utils.NormalizeIdentityKeyBPS(s.IdentityBPS))
493 switch {
494 case s.UDPEnabled != nil && s.UDPMaxLeases != nil:
495 runtime.SetUDPPolicy(*s.UDPEnabled, *s.UDPMaxLeases)
cmd/relay-server/frontend.go
+1
@@ -297,6 +297,7 @@ func (f *Frontend) publicLeaseSnapshots() []types.Lease {
297 }
298
299 snapshot.ClientIP = ""
300 + snapshot.Address = ""
301 snapshot.BPS = 0
302 snapshot.IsApproved = false
303 snapshot.IsBanned = false
cmd/relay-server/main.go
+4 -3
@@ -38,7 +38,7 @@ type relayServerConfig struct {
38 LandingPageEnabled bool
39 Bootstraps string
40 DiscoveryEnabled bool
41 - OwnerPrivateKey string
41 + IdentityPath string
42 WireGuardPrivateKey string
43 DiscoveryPort int
44 WireGuardEndpoint string
@@ -67,7 +67,7 @@ func runServeCommand(args []string) error {
67 utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
68 utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
69 utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
70 - utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
70 + utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "identity.json", "relay identity json file path", "IDENTITY_PATH")
71 utils.StringFlagEnv(fs, &cfg.WireGuardPrivateKey, "wireguard-private-key", "", "wireguard private key for relay peer overlay", "WIREGUARD_PRIVATE_KEY")
72 utils.IntFlagEnv(fs, &cfg.DiscoveryPort, "discovery-port", 0, utils.ParsePortNumber, "public UDP listen port advertised for relay-peer discovery overlay (defaults to 51820 when wireguard is enabled)", "DISCOVERY_PORT")
73 utils.StringFlagEnv(fs, &cfg.WireGuardEndpoint, "wireguard-endpoint", "", "explicit public WireGuard endpoint advertised for relay peer overlay (host:port or ip:port); defaults to PORTAL_URL host + DISCOVERY_PORT when empty", "WIREGUARD_ENDPOINT")
@@ -99,6 +99,7 @@ func runServeCommand(args []string) error {
99 log.Info().
100 Str("release_version", types.ReleaseVersion).
101 Str("portal_url", cfg.PortalURL).
102 + Str("identity_path", cfg.IdentityPath).
103 Str("admin_settings_path", cfg.AdminSettingsPath).
104 Bool("landing_page_enabled", cfg.LandingPageEnabled).
105 Bool("discovery_enabled", cfg.DiscoveryEnabled).
@@ -120,7 +121,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
121
122 server, err := portal.NewServer(portal.ServerConfig{
123 PortalURL: cfg.PortalURL,
123 - OwnerPrivateKey: cfg.OwnerPrivateKey,
124 + IdentityPath: cfg.IdentityPath,
125 Bootstraps: bootstraps,
126 WireGuardPrivateKey: cfg.WireGuardPrivateKey,
127 DiscoveryPort: cfg.DiscoveryPort,
docker-compose.yml
+5 -5
@@ -8,16 +8,16 @@ services:
8 ports:
9 - "${API_PORT:-4017}:${API_PORT:-4017}"
10 - "${SNI_PORT:-443}:${SNI_PORT:-443}"
11 - - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
11 + # - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
12 # Uncomment below when enabling UDP transport (UDP_PORT_COUNT > 0):
13 # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
14 # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
15 environment:
16 - # Public routing and discovery
16 + # Public routing, discovery, and relay identity persistence
17 PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
18 BOOTSTRAPS: ${BOOTSTRAPS:-}
19 DISCOVERY: ${DISCOVERY:-true}
20 - OWNER_PRIVATE_KEY: ${OWNER_PRIVATE_KEY:-}
20 + IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
21 WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
22 DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
23 WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
@@ -36,7 +36,7 @@ services:
36 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
37
38 # TLS/ACME and keyless materials
39 - KEYLESS_DIR: /.portal-certs
39 + KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
40 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
41 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
42 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
@@ -46,5 +46,5 @@ services:
46 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
47 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
48 volumes:
49 - - ${KEYLESS_DIR:-./.portal-certs}:/.portal-certs
49 + - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
50 restart: unless-stopped
docs/architecture.md
+19 -18
@@ -39,7 +39,7 @@ UDP client
39 - SDK/tunnel endpoints terminate tenant TLS locally with a keyless-backed signer that calls the relay.
40 - In keyless TLS, the relay performs certificate private-key signing through `/v1/sign`, but the SDK/tunnel endpoint still runs the TLS server handshake and derives tenant TLS session keys locally.
41 - `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` are authorized by lease existence plus a relay-issued lease access token.
42 -- `/sdk/register` is authenticated by a SIWE challenge/response flow using the SDK owner secp256k1 key. On success, the relay issues a lease-scoped ES256K JWT access token signed by the relay owner key and used for the rest of the lease lifecycle.
42 +- `/sdk/register` is authenticated by a SIWE challenge/response flow using the SDK identity secp256k1 key. On success, the relay issues a lease-scoped ES256K JWT access token signed by the relay identity key and used for the rest of the lease lifecycle.
43 - Relay URLs must use `https://`.
44 - HTTP/2 stays disabled on the admin/API TLS listener because `/sdk/connect` depends on HTTP/1.1 hijacking semantics.
45 - WireGuard, when enabled, is relay-to-relay overlay transport only. It is not used for tenant stream TLS, public UDP ingress, or `/sdk/*` control-plane traffic.
@@ -64,6 +64,7 @@ UDP client
64 ### Operational Constraints
65
66 - For non-localhost deployments, ACME management supports only `cloudflare` and `route53`.
67 +- Relay, tunnel, and demo-app identities are persisted as JSON at `IDENTITY_PATH` / `--identity-path`. Missing files are generated automatically and stored with `name`, `address`, `public_key`, and `private_key`.
68 - Non-localhost ACME keeps both root and wildcard DNS A records in sync.
69 - Relay certificate material lives under `KEYLESS_DIR` as `fullchain.pem` and `privatekey.pem`.
70 - Localhost uses the development certificate path instead of DNS-provider-managed ACME.
@@ -79,13 +80,13 @@ Portal has three distinct network roles:
80 - `POST /sdk/unregister`
81 - `GET /sdk/domain`
82 - **Reverse session connection**
82 - - `GET /sdk/connect?lease_id=...`
83 + - `GET /sdk/connect?name=...&address=...`
84 - HTTP/1.1 only
85 - hijacked into a long-lived raw TCP session
86 - starts idle in the per-lease stream ready queue, then becomes the tenant data path when claimed
87 - **Internal datagram tunnel**
88 - QUIC to the relay URL host:port with ALPN `portal-tunnel`
88 - - authenticated by a first-stream control message carrying `lease_id` + `access_token`
89 + - authenticated by a first-stream control message carrying `identity` + `access_token`
90 - carries relay-to-SDK/tunnel datagram traffic only
91
92 That distinction matters because `/sdk/connect` stops being ordinary HTTP once hijacked, while the UDP backhaul is a separate internal QUIC carrier.
@@ -143,6 +144,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
144 ### Tunnel (`cmd/portal-tunnel`)
145
146 - Builds the `portal` CLI and exposes subcommands such as `portal expose` and `portal list`
147 +- Loads or creates the local signing identity from `--identity-path` before starting the SDK exposure
148 - Creates one SDK listener per relay through the SDK and consumes one aggregate listener
149 - Accepts claimed tenant connections from the relay
150 - Proxies raw TCP to a local target passed to `portal expose`
@@ -160,7 +162,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
162 ### Raw reverse transport (TLS only)
163
164 1. SDK/tunnel registers one lease per relay through `POST /sdk/register/challenge` followed by `POST /sdk/register`.
163 -2. SDK opens one or more reverse sessions per registered lease with `GET /sdk/connect?lease_id=...`.
165 +2. SDK opens one or more reverse sessions per registered lease with `GET /sdk/connect?name=...&address=...`.
166 3. Each relay hijacks `/sdk/connect` requests and places the connection in the per-lease stream ready queue.
167 4. While idle, the relay writes `0x00` keepalive markers.
168 5. A stream client connects to the relay SNI listener.
@@ -188,7 +190,7 @@ Result: this is a detect-only signal by default. It raises the cost of adaptive
190 2. Relay validates that the datagram plane is enabled (server has `UDP_PORT_COUNT > 0` and admin has enabled UDP), allocates a UDP port via `PortAllocator`, and creates a `transport.RelayDatagram` for the lease.
191 3. Registration response includes `udp_addr` (public UDP endpoint) and `access_token`. There is no separate `quic_addr`; the SDK dials QUIC to the relay URL host:port.
192 4. SDK `transport.ClientDatagram` opens a QUIC connection with ALPN `portal-tunnel` and QUIC DATAGRAM support enabled.
191 -5. Authentication: SDK sends `{lease_id, access_token}` JSON on the first QUIC stream; the relay validates that lease access token before calling `RelayDatagram.Register(conn)`.
193 +5. Authentication: SDK sends `{identity, access_token}` JSON on the first QUIC stream; the relay validates that lease access token before calling `RelayDatagram.Register(conn)`.
194 6. External UDP client sends a packet to `udp_addr` -> `RelayDatagram.readLoop` -> `TouchFlow` (assigns flow ID) -> `SendDatagram` -> QUIC DATAGRAM frame.
195 7. SDK-side `datagramSession.receiveLoop` decodes frames -> `Listener.AcceptDatagram()` -> `Exposure.AcceptDatagram()` -> `proxyExposureDatagrams` -> local UDP target.
196 8. Return path: local response -> `Exposure.SendDatagram()` -> `Listener.SendDatagram()` -> `ClientDatagram.Send()` -> QUIC DATAGRAM -> `RelayDatagram.dispatch()` -> `conn.WriteToUDP` to the original client.
@@ -228,32 +230,31 @@ Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
230 - `POST /sdk/register`
231 - JSON envelope response
232 - Challenge request fields:
231 - - `name`
233 + - `identity`
234 - `metadata`
233 - - `owner_address`
235 - `ttl`
236 - `udp_enabled`
237 - Challenge response fields:
238 - `challenge_id`
239 - `expires_at`
240 - `siwe_message`
240 -- Caller signs the returned SIWE message with the owner Ethereum private key (`personal_sign`) and then submits:
241 +- Caller signs the returned SIWE message with the identity Ethereum private key (`personal_sign`) and then submits:
242 - `challenge_id`
243 - `siwe_message`
244 - `siwe_signature`
245 - `name` must be a valid single DNS label and relay publishes the lease at `<name>.<root host>`
246 - Registration reserves the hostname and publishes the route immediately; if no reverse session is ready yet, inbound SNI claims wait up to `ClaimTimeout`
247 - When registration succeeds, the response includes:
247 - - `lease_id`
248 + - `identity`
249 - `hostname`
250 - `expires_at`
251 - `access_token`
252 - optional `udp_addr`
252 -- `access_token` is a relay-issued ES256K JWT signed by the relay owner key and validated with:
253 +- `access_token` is a relay-issued ES256K JWT signed by the relay identity key and validated with:
254 - `iss = PORTAL_URL`
255 - `aud = portal-sdk`
255 - - `sub = owner_address`
256 - - `lease_id`
256 + - `sub = identity.key()`
257 + - `identity`
258 - `iat`, `nbf`, `exp`
259 - `jti`
260 - UDP registration requires two conditions: server must have `UDP_PORT_COUNT > 0` and admin must enable UDP in the admin panel
@@ -264,25 +265,25 @@ Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
265
266 ### 2. Reverse Connect
267
267 -- `GET /sdk/connect?lease_id=...`
268 +- `GET /sdk/connect?name=...&address=...`
269 - Requires HTTP/1.1
270 - Requires `X-Portal-Access-Token` header with the lease access token
271 - Relay validates:
272 - lease exists and is not expired
272 - - the lease access token signature, issuer, audience, lease ID, and expiry are valid
273 + - the lease access token signature, issuer, audience, identity, and expiry are valid
274 - After claim, relay writes `0x02` before switching the session into tenant TLS passthrough
275 - After hijack, the connection becomes a broker-managed reverse session
276
277 ### 3. Renew
278
279 - `POST /sdk/renew`
279 -- Requires `lease_id` + `access_token`
280 +- Requires `identity` + `access_token`
281 - Extends lease TTL and returns a refreshed `access_token`
282
283 ### 4. Unregister
284
285 - `POST /sdk/unregister`
285 -- Requires `lease_id` + `access_token`
286 +- Requires `identity` + `access_token`
287 - Removes the lease, routes, and ready reverse sessions
288
289 ## Routing Behavior
@@ -364,11 +365,11 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
365 - SNI-based routing with root-host fallback
366 - End-to-end tenant TLS with relay-backed keyless signing
367 - Traffic-triggered detect-only MITM self-probing for probable relay-side TLS termination
367 -- SIWE owner proof for registration plus relay-issued ES256K JWT access tokens for the lease lifecycle
368 +- SIWE identity proof for registration plus relay-issued ES256K JWT access tokens for the lease lifecycle
369 - Lease-local stream and datagram ownership through per-lease transport runtimes
370 - Optional QUIC/UDP datagram transport coexisting with TCP on the same lease
371 - Per-lease UDP port allocation with sticky name-based reservation
371 -- QUIC tunnel authentication via control stream (lease ID + lease access token)
372 +- QUIC tunnel authentication via control stream (`identity` + lease access token)
373
374 ## ADRs
375
docs/deployment.md
+17 -3
@@ -105,10 +105,11 @@ Example:
105 PORTAL_URL=https://example.com
106 BOOTSTRAPS=
107 DISCOVERY=true
108 +IDENTITY_PATH=/portal-certs/identity.json
109 WIREGUARD_ENDPOINT=
110 SNI_PORT=443
111 ADMIN_SECRET_KEY=your-admin-secret
111 -KEYLESS_DIR=./.portal-certs
112 +KEYLESS_DIR=/portal-certs
113 ACME_DNS_PROVIDER=cloudflare
114 CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
115 ```
@@ -116,7 +117,8 @@ CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
117 Route53 example:
118
119 ```bash
119 -KEYLESS_DIR=./.portal-certs
120 +IDENTITY_PATH=/portal-certs/identity.json
121 +KEYLESS_DIR=/portal-certs
122 ACME_DNS_PROVIDER=route53
123 AWS_ACCESS_KEY_ID=AKIA...
124 AWS_SECRET_ACCESS_KEY=...
@@ -132,7 +134,9 @@ Notes:
134 - Portal uses the `PORTAL_URL` host for public lease hostnames
135 - `WIREGUARD_ENDPOINT` is optional. When empty, Portal advertises `PORTAL_URL` host with `DISCOVERY_PORT`
136 - Set `WIREGUARD_ENDPOINT` explicitly only when relay-peer discovery UDP is exposed on a different address than `PORTAL_URL`
135 -- `KEYLESS_DIR` stores relay certificate material
137 +- `IDENTITY_PATH` stores the relay identity JSON inside the container
138 +- `KEYLESS_DIR` stores relay certificate material inside the container
139 +- The Docker Compose stack stores both the relay identity JSON and ACME state under `./.portal-certs` on the host
140
141 If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
142
@@ -144,6 +148,16 @@ If your proxy source addresses are public or you want a stricter allowlist, also
148
149 ### 3.2 Start Relay
150
151 +When using the published Docker image, create the bind-mount directory first and make it writable by UID `65532` (`nonroot` in the distroless image):
152 +
153 +```bash
154 +mkdir -p ./.portal-certs
155 +sudo chown 65532:65532 ./.portal-certs
156 +chmod 755 ./.portal-certs
157 +```
158 +
159 +Then start the stack:
160 +
161 ```bash
162 docker compose up -d
163 ```
docs/examples/nginx-proxy-multi-service/.env.example
+6 -3
@@ -1,7 +1,7 @@
1 # Portal + nginx multi-service deployment configuration
2 # Copy this file to .env and fill in the values.
3
4 -# Public routing (no port — nginx handles :443 externally)
4 +# Public routing (no port; nginx handles :443 externally)
5 PORTAL_URL=https://portal.example.com
6
7 # Internal listener ports
@@ -13,8 +13,11 @@ SNI_PORT=4443
13 # ACME DNS provider (cloudflare or route53)
14 ACME_DNS_PROVIDER=cloudflare
15
16 +# Relay identity persistence
17 +IDENTITY_PATH=/portal-certs/identity.json
18 +
19 # UDP transport (0 = disabled). Allocates ports starting from 50000.
17 -# e.g., count=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
20 +# e.g., count=10 -> ports 50000-50009. Also requires enabling UDP in the admin panel.
21 UDP_PORT_COUNT=0
22
23 # Admin secret for the /admin UI
@@ -23,7 +26,7 @@ ADMIN_SECRET_KEY=
26 # Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
27 CLOUDFLARE_TOKEN=
28
26 -# Portal's own keyless TLS certificate directory
29 +# Portal state directories
30 KEYLESS_DIR=/portal-certs
31
32 # Trust forwarded headers from nginx (required behind reverse proxy)
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+6 -6
@@ -16,10 +16,10 @@
16 # 2. Place TLS certificates in ./certs/:
17 # - Portal: managed by portal itself (ACME via KEYLESS_DIR)
18 # - Other services: app_a_fullchain.pem, app_a_privkey.pem, etc.
19 -# 3. Create the portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
20 -# mkdir -p ./portal-certs
21 -# sudo chown 65532:65532 ./portal-certs
22 -# chmod 755 ./portal-certs
19 +# 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
20 +# mkdir -p ./.portal-certs
21 +# sudo chown 65532:65532 ./.portal-certs
22 +# chmod 755 ./.portal-certs
23 # 4. Start all services:
24 # docker compose up -d
25
@@ -70,7 +70,7 @@ services:
70 PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
71 API_PORT: ${API_PORT:-4017}
72 SNI_PORT: ${SNI_PORT:-4443}
73 - OWNER_PRIVATE_KEY: ${OWNER_PRIVATE_KEY:-}
73 + IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
74 WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
75 DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
76 WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
@@ -82,7 +82,7 @@ services:
82 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
83 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
84 volumes:
85 - - ./portal-certs:/portal-certs
85 + - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
86 restart: unless-stopped
87
88 # ─── App A: backend ─────────────────────────────────────────────────────────
docs/examples/nginx-proxy/.env.example
+5 -2
@@ -11,8 +11,11 @@ SNI_PORT=443
11 # ACME DNS provider (cloudflare or route53)
12 ACME_DNS_PROVIDER=cloudflare
13
14 +# Relay identity persistence
15 +IDENTITY_PATH=/portal-certs/identity.json
16 +
17 # UDP transport (0 = disabled). Allocates ports starting from 50000.
15 -# e.g., count=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
18 +# e.g., count=10 -> ports 50000-50009. Also requires enabling UDP in the admin panel.
19 UDP_PORT_COUNT=0
20
21 # Admin secret for the /admin UI
@@ -21,7 +24,7 @@ ADMIN_SECRET_KEY=
24 # Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
25 CLOUDFLARE_TOKEN=
26
24 -# Portal's own keyless TLS certificate directory
27 +# Portal state directories
28 KEYLESS_DIR=/portal-certs
29
30 # Trust forwarded headers from nginx (required behind reverse proxy)
docs/examples/nginx-proxy/docker-compose.yaml
+6 -6
@@ -16,10 +16,10 @@
16 # 2. Place your TLS certificate files in ./certs/:
17 # ./certs/fullchain.pem
18 # ./certs/privkey.pem
19 -# 3. Create the portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
20 -# mkdir -p ./portal-certs
21 -# sudo chown 65532:65532 ./portal-certs
22 -# chmod 755 ./portal-certs
19 +# 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
20 +# mkdir -p ./.portal-certs
21 +# sudo chown 65532:65532 ./.portal-certs
22 +# chmod 755 ./.portal-certs
23 # 4. Start all services:
24 # docker compose up -d
25
@@ -64,7 +64,7 @@ services:
64 API_PORT: ${API_PORT:-4017}
65 # Use a non-443 port to avoid conflict with nginx on the host.
66 SNI_PORT: ${SNI_PORT:-4443}
67 - OWNER_PRIVATE_KEY: ${OWNER_PRIVATE_KEY:-}
67 + IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
68 WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
69 DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
70 WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
@@ -80,5 +80,5 @@ services:
80 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
81 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
82 volumes:
83 - - ./portal-certs:/portal-certs
83 + - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
84 restart: unless-stopped
frontend/README.md
+4 -4
@@ -130,11 +130,11 @@ Relay server exposes:
130
131 Admin endpoints use a JSON envelope contract (`{ ok, data, error }`) and reject malformed or non-JSON responses with explicit API client errors.
132
133 -Admin lease ID contract:
133 +Admin address contract:
134
135 -- `/admin/snapshot` returns `leases`, `banned_leases`, and `approval_mode` in one envelope payload.
136 -- `leases` rows inside the snapshot return plain lease IDs in `Peer`.
137 -- Frontend only Base64URL-encodes lease IDs when constructing admin action routes (`/admin/leases/{encodedLeaseID}/{action}`).
135 +- `/admin/snapshot` returns `leases` and `approval_mode` in one envelope payload.
136 +- `leases` rows inside the admin snapshot include the normalized identity `address`; public SSR snapshots omit it.
137 +- Frontend only Base64URL-encodes addresses when constructing admin action routes (`/admin/leases/{encodedAddress}/{action}`).
138
139 ### SDK-Related Runtime Contract
140
frontend/src/components/ServerCard.tsx
+24 -26
@@ -26,7 +26,8 @@ interface ServerCardProps {
26 isFavorite?: boolean;
27 onToggleFavorite?: (serverId: number) => void;
28 showAdminControls?: boolean;
29 - leaseId?: string;
29 + identityKey?: string;
30 + address?: string;
31 isBanned?: boolean;
32 isApproved?: boolean;
33 isDenied?: boolean;
@@ -35,19 +36,18 @@ interface ServerCardProps {
36 displayIP?: string;
37 isIPBanned?: boolean;
38 onBanStatusChange?: (
38 - leaseId: string,
39 + identityKey: string,
40 isBan: boolean
41 ) => void | Promise<void>;
41 - onBPSChange?: (leaseId: string, bps: number) => void | Promise<void>;
42 + onBPSChange?: (identityKey: string, bps: number) => void | Promise<void>;
43 onApproveStatusChange?: (
43 - leaseId: string,
44 + identityKey: string,
45 approve: boolean
46 ) => void | Promise<void>;
46 - onDenyStatusChange?: (leaseId: string, deny: boolean) => void | Promise<void>;
47 + onDenyStatusChange?: (identityKey: string, deny: boolean) => void | Promise<void>;
48 onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>;
48 - transport?: string;
49 isSelected?: boolean;
50 - onToggleSelect?: (leaseId: string) => void;
50 + onToggleSelect?: (identityKey: string) => void;
51 }
52
53 export function ServerCard({
@@ -65,7 +65,8 @@ export function ServerCard({
65 isFavorite = false,
66 onToggleFavorite,
67 showAdminControls = false,
68 - leaseId,
68 + identityKey,
69 + address,
70 isBanned = false,
71 isApproved = false,
72 isDenied = false,
@@ -78,7 +79,6 @@ export function ServerCard({
79 onApproveStatusChange,
80 onDenyStatusChange,
81 onIPBanStatusChange,
81 - transport = "tcp",
82 isSelected = false,
83 onToggleSelect,
84 }: ServerCardProps) {
@@ -131,32 +131,32 @@ export function ServerCard({
131 const handleSelectClick = (event: React.MouseEvent) => {
132 event.preventDefault();
133 event.stopPropagation();
134 - if (leaseId && onToggleSelect) {
135 - onToggleSelect(leaseId);
134 + if (identityKey && onToggleSelect) {
135 + onToggleSelect(identityKey);
136 }
137 };
138
139 const handleBanClick = (event: React.MouseEvent) => {
140 event.preventDefault();
141 event.stopPropagation();
142 - if (leaseId) {
143 - runAsyncAdminAction(() => onBanStatusChange?.(leaseId, !isBanned));
142 + if (identityKey) {
143 + runAsyncAdminAction(() => onBanStatusChange?.(identityKey, !isBanned));
144 }
145 };
146
147 const handleApproveClick = (event: React.MouseEvent) => {
148 event.preventDefault();
149 event.stopPropagation();
150 - if (leaseId) {
151 - runAsyncAdminAction(() => onApproveStatusChange?.(leaseId, !isApproved));
150 + if (identityKey) {
151 + runAsyncAdminAction(() => onApproveStatusChange?.(identityKey, !isApproved));
152 }
153 };
154
155 const handleDenyClick = (event: React.MouseEvent) => {
156 event.preventDefault();
157 event.stopPropagation();
158 - if (leaseId) {
159 - runAsyncAdminAction(() => onDenyStatusChange?.(leaseId, !isDenied));
158 + if (identityKey) {
159 + runAsyncAdminAction(() => onDenyStatusChange?.(identityKey, !isDenied));
160 }
161 };
162
@@ -177,9 +177,9 @@ export function ServerCard({
177 };
178
179 const handleBPSSave = () => {
180 - if (leaseId) {
180 + if (identityKey) {
181 const newBps = parseInt(bpsInput, 10) || 0;
182 - runAsyncAdminAction(() => onBPSChange?.(leaseId, newBps));
182 + runAsyncAdminAction(() => onBPSChange?.(identityKey, newBps));
183 }
184 setShowBPSModal(false);
185 };
@@ -262,12 +262,6 @@ export function ServerCard({
262 {formattedDuration && online && ` · ${formattedDuration}`}
263 </span>
264 </div>
265 - {showAdminControls && transport !== "tcp" && (
266 - <span className="rounded-full bg-black/40 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-primary backdrop-blur-sm border border-primary/30">
267 - {transport}
268 - </span>
269 - )}
270 -
265 {showAdminControls ? (
266 <button
267 onClick={handleSelectClick}
@@ -360,8 +354,12 @@ export function ServerCard({
354 </div>
355 </div>
356
363 - {showAdminControls && leaseId && (
357 + {showAdminControls && address && (
358 <div className="flex flex-col gap-3 rounded-3xl border border-border bg-secondary/70 p-4">
359 + <div className="text-[11px] text-text-muted">
360 + Address: <span className="font-mono text-foreground">{address}</span>
361 + </div>
362 +
363 {onBPSChange && (
364 <div className="flex items-center justify-between gap-4">
365 <span className="text-xs text-text-muted">
frontend/src/components/ServerListView.tsx
+38 -38
@@ -151,31 +151,31 @@ interface ServerListViewProps {
151 landingPageEnabled?: boolean;
152 onBanFilterChange?: (value: BanFilter) => void;
153 onBanStatusChange?: (
154 - leaseId: string,
154 + identityKey: string,
155 isBan: boolean
156 ) => void | Promise<void>;
157 - onBPSChange?: (leaseId: string, bps: number) => void | Promise<void>;
157 + onBPSChange?: (identityKey: string, bps: number) => void | Promise<void>;
158 onApprovalModeChange?: (mode: ApprovalMode) => void;
159 onLandingPageEnabledChange?: (enabled: boolean) => void | Promise<void>;
160 udpSettings?: UDPSettings;
161 onUDPSettingsChange?: (settings: UDPSettings) => void | Promise<void>;
162 onApproveStatusChange?: (
163 - leaseId: string,
163 + identityKey: string,
164 approve: boolean
165 ) => void | Promise<void>;
166 onDenyStatusChange?: (
167 - leaseId: string,
167 + identityKey: string,
168 deny: boolean
169 ) => void | Promise<void>;
170 onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>;
171 - onBulkApprove?: (leaseIds: string[]) => void | Promise<void>;
172 - onBulkDeny?: (leaseIds: string[]) => void | Promise<void>;
173 - onBulkBan?: (leaseIds: string[]) => void | Promise<void>;
171 + onBulkApprove?: (identityKeys: string[]) => void | Promise<void>;
172 + onBulkDeny?: (identityKeys: string[]) => void | Promise<void>;
173 + onBulkBan?: (identityKeys: string[]) => void | Promise<void>;
174 onLogout?: () => void;
175 }
176
177 function isAdminServer(server: ListServer): server is AdminServer {
178 - return "peerId" in server;
178 + return "address" in server;
179 }
180
181 function toAdminServer(server: ListServer): AdminServer | undefined {
@@ -219,27 +219,27 @@ export function ServerListView({
219 const [officialRegistryRelays, setOfficialRegistryRelays] = useState<
220 OfficialRegistryRelay[] | null
221 >(null);
222 - const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
222 + const [selectedIdentityKeys, setSelectedIdentityKeys] = useState<Set<string>>(
223 new Set()
224 );
225 const serverItems = filteredServers as ListServer[];
226 const favoriteIds = useMemo(() => new Set(favorites), [favorites]);
227 const showLandingHero = !isAdmin && landingPageEnabled;
228
229 - const handleToggleSelect = (leaseId: string) => {
230 - setSelectedLeaseIds((prev) => {
229 + const handleToggleSelect = (identityKey: string) => {
230 + setSelectedIdentityKeys((prev) => {
231 const next = new Set(prev);
232 - if (next.has(leaseId)) {
233 - next.delete(leaseId);
232 + if (next.has(identityKey)) {
233 + next.delete(identityKey);
234 } else {
235 - next.add(leaseId);
235 + next.add(identityKey);
236 }
237 return next;
238 });
239 };
240
241 const handleClearSelection = () => {
242 - setSelectedLeaseIds(new Set());
242 + setSelectedIdentityKeys(new Set());
243 };
244
245 const serverRows = useMemo(
@@ -251,14 +251,14 @@ export function ServerListView({
251 [serverItems]
252 );
253
254 - const allLeaseIds = useMemo(
254 + const allIdentityKeys = useMemo(
255 () => [
256 ...new Set(
257 serverRows
258 - .map(({ adminServer }) => adminServer?.peerId)
258 + .map(({ adminServer }) => adminServer?.identityKey)
259 .filter(
260 - (leaseId): leaseId is string =>
261 - typeof leaseId === "string" && leaseId.trim().length > 0
260 + (identityKey): identityKey is string =>
261 + typeof identityKey === "string" && identityKey.trim().length > 0
262 )
263 ),
264 ],
@@ -266,16 +266,16 @@ export function ServerListView({
266 );
267
268 useEffect(() => {
269 - const validLeaseIDs = new Set(allLeaseIds);
270 - setSelectedLeaseIds((prev) => {
269 + const validIdentityKeys = new Set(allIdentityKeys);
270 + setSelectedIdentityKeys((prev) => {
271 if (prev.size === 0) {
272 return prev;
273 }
274
275 const next = new Set<string>();
276 - prev.forEach((leaseId) => {
277 - if (validLeaseIDs.has(leaseId)) {
278 - next.add(leaseId);
276 + prev.forEach((identityKey) => {
277 + if (validIdentityKeys.has(identityKey)) {
278 + next.add(identityKey);
279 }
280 });
281
@@ -285,13 +285,13 @@ export function ServerListView({
285
286 return next;
287 });
288 - }, [allLeaseIds]);
288 + }, [allIdentityKeys]);
289
290 useEffect(() => {
291 if (isAdmin) {
292 return;
293 }
294 - setSelectedLeaseIds((prev) => (prev.size === 0 ? prev : new Set()));
294 + setSelectedIdentityKeys((prev) => (prev.size === 0 ? prev : new Set()));
295 }, [isAdmin]);
296
297 useEffect(() => {
@@ -366,27 +366,27 @@ export function ServerListView({
366
367 const officialRegistryList = officialRegistryRelays ?? [];
368 const isAllSelected =
369 - allLeaseIds.length > 0 &&
370 - allLeaseIds.every((id) => selectedLeaseIds.has(id));
369 + allIdentityKeys.length > 0 &&
370 + allIdentityKeys.every((identityKey) => selectedIdentityKeys.has(identityKey));
371 const officialRegistryAvailable = officialRegistryList.length > 0;
372
373 const handleSelectAll = () => {
374 if (isAllSelected) {
375 - setSelectedLeaseIds(new Set());
375 + setSelectedIdentityKeys(new Set());
376 } else {
377 - setSelectedLeaseIds(new Set(allLeaseIds));
377 + setSelectedIdentityKeys(new Set(allIdentityKeys));
378 }
379 };
380
381 const runBulkAction = async (
382 - handler?: (leaseIds: string[]) => void | Promise<void>
382 + handler?: (identityKeys: string[]) => void | Promise<void>
383 ) => {
384 - if (!handler || selectedLeaseIds.size === 0) {
384 + if (!handler || selectedIdentityKeys.size === 0) {
385 return;
386 }
387
388 try {
389 - await handler(Array.from(selectedLeaseIds));
389 + await handler(Array.from(selectedIdentityKeys));
390 handleClearSelection();
391 } catch (err) {
392 console.error("Failed bulk admin action", err);
@@ -542,7 +542,7 @@ export function ServerListView({
542 adminServer?: AdminServer;
543 }) => {
544 const isSelected = adminServer
545 - ? selectedLeaseIds.has(adminServer.peerId)
545 + ? selectedIdentityKeys.has(adminServer.identityKey)
546 : false;
547
548 return (
@@ -571,7 +571,8 @@ export function ServerListView({
571 isFavorite={favoriteIds.has(server.id)}
572 onToggleFavorite={onToggleFavorite}
573 showAdminControls={isAdmin && !!adminServer}
574 - leaseId={adminServer?.peerId}
574 + identityKey={adminServer?.identityKey}
575 + address={adminServer?.address}
576 isBanned={adminServer?.isBanned}
577 isApproved={adminServer?.isApproved}
578 isDenied={adminServer?.isDenied}
@@ -579,7 +580,6 @@ export function ServerListView({
580 ip={adminServer?.ip}
581 displayIP={adminServer?.displayIP}
582 isIPBanned={adminServer?.isIPBanned}
582 - transport={adminServer?.transport}
583 onBanStatusChange={onBanStatusChange}
584 onBPSChange={onBPSChange}
585 onApproveStatusChange={onApproveStatusChange}
@@ -910,8 +910,8 @@ export function ServerListView({
910
911 {isAdmin && (
912 <FloatingActionBar
913 - selectedCount={selectedLeaseIds.size}
914 - totalCount={allLeaseIds.length}
913 + selectedCount={selectedIdentityKeys.size}
914 + totalCount={allIdentityKeys.length}
915 isAllSelected={isAllSelected}
916 onSelectAll={handleSelectAll}
917 onApprove={handleBulkApprove}
frontend/src/hooks/useAdmin.test.ts
+51 -24
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
3
4 import type { ServerData } from "@/hooks/useSSRData";
5 import { useAdmin } from "@/hooks/useAdmin";
6 -import { API_PATHS, adminLeasePath, encodeLeaseID } from "@/lib/apiPaths";
6 +import { API_PATHS, adminLeasePath } from "@/lib/apiPaths";
7 import { APIClientError, apiClient } from "@/lib/apiClient";
8
9 type DeferredAdminSnapshot = {
@@ -43,13 +43,13 @@ vi.mock("@/lib/apiClient", async () => {
43 };
44 });
45
46 -function buildLease(peer: string): ServerData {
46 +function buildLease(address: string, name: string = "relay-1"): ServerData {
47 return {
48 ExpiresAt: "2026-03-03T01:00:00Z",
49 FirstSeenAt: "2026-03-02T00:00:00Z",
50 LastSeenAt: "2026-03-03T00:00:00Z",
51 - ID: peer,
52 - Name: "relay-1",
51 + address,
52 + name,
53 BPS: 1024,
54 ClientIP: "203.0.113.10",
55 Hostname: "relay.example.com",
@@ -62,7 +62,7 @@ function buildLease(peer: string): ServerData {
62 },
63 Ready: 1,
64 IsApproved: true,
65 - IsBanned: peer === "peer-a",
65 + IsBanned: address === "0x00000000000000000000000000000000000000A1",
66 IsDenied: false,
67 IsIPBanned: false,
68 };
@@ -85,7 +85,7 @@ describe("useAdmin", () => {
85 mockGet.mockImplementation(async (path: string) => {
86 if (path === API_PATHS.admin.snapshot) {
87 return {
88 - leases: [buildLease("peer-a")],
88 + leases: [buildLease("0x00000000000000000000000000000000000000A1")],
89 approval_mode: "not-a-mode",
90 } as never;
91 }
@@ -103,7 +103,7 @@ describe("useAdmin", () => {
103
104 expect(result.current.error).toBe("");
105 expect(result.current.approvalMode).toBe("auto");
106 - expect(result.current.servers[0]?.peerId).toBe("peer-a");
106 + expect(result.current.servers[0]?.address).toBe("0x00000000000000000000000000000000000000A1");
107 expect(result.current.servers[0]?.isBanned).toBe(true);
108 expect(result.current.servers[0]?.bps).toBe(1024);
109 });
@@ -158,18 +158,22 @@ describe("useAdmin", () => {
158 });
159 });
160
161 - it("encodes peer IDs for action routes", async () => {
161 + it("encodes addresses for action routes", async () => {
162 const { result } = renderHook(() => useAdmin());
163 await waitForLoaded(result);
164 - const plainLeaseID = "deadbeefcafebabe";
164 + const identityKey = "relay-1:0x00000000000000000000000000000000000000a1";
165
166 await act(async () => {
167 - await result.current.handleApproveStatus(plainLeaseID, true);
167 + await result.current.handleApproveStatus(identityKey, true);
168 });
169
170 const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
171 expect(calledPaths).toContain(
172 - adminLeasePath(encodeLeaseID(plainLeaseID), "approve"),
172 + adminLeasePath(
173 + "relay-1",
174 + "0x00000000000000000000000000000000000000A1",
175 + "approve"
176 + ),
177 );
178 });
179
@@ -178,11 +182,18 @@ describe("useAdmin", () => {
182 await waitForLoaded(result);
183
184 await act(async () => {
181 - await result.current.handleBPSChange("peer-a", 4096);
185 + await result.current.handleBPSChange(
186 + "relay-1:0x00000000000000000000000000000000000000a1",
187 + 4096
188 + );
189 });
190
191 expect(mockPost).toHaveBeenCalledWith(
185 - adminLeasePath(encodeLeaseID("peer-a"), "bps"),
192 + adminLeasePath(
193 + "relay-1",
194 + "0x00000000000000000000000000000000000000A1",
195 + "bps"
196 + ),
197 { bps: 4096 },
198 );
199 });
@@ -200,7 +211,7 @@ describe("useAdmin", () => {
211 getCalls++;
212 if (getCalls === 1) {
213 return Promise.resolve({
203 - leases: [buildLease("peer-a")],
214 + leases: [buildLease("0x00000000000000000000000000000000000000A1")],
215 approval_mode: "auto",
216 } as never);
217 }
@@ -214,11 +225,14 @@ describe("useAdmin", () => {
225
226 let pending: Promise<void> | undefined;
227 await act(async () => {
217 - pending = result.current.handleBPSChange("peer-a", 2048);
228 + pending = result.current.handleBPSChange(
229 + "relay-1:0x00000000000000000000000000000000000000a1",
230 + 2048
231 + );
232 await Promise.resolve();
233 expect(result.current.loading).toBe(false);
234 resolveRefresh?.({
221 - leases: [{ ...buildLease("peer-a"), BPS: 2048 }],
235 + leases: [{ ...buildLease("0x00000000000000000000000000000000000000A1"), BPS: 2048 }],
236 approval_mode: "auto",
237 });
238 await pending;
@@ -227,25 +241,38 @@ describe("useAdmin", () => {
241 expect(result.current.servers[0]?.bps).toBe(2048);
242 });
243
230 - it("bulk deny posts deduped lease IDs to action routes", async () => {
244 + it("bulk deny posts deduped addresses to action routes", async () => {
245 + mockGet.mockImplementation(async (path: string) => {
246 + if (path === API_PATHS.admin.snapshot) {
247 + return {
248 + leases: [
249 + buildLease("0x00000000000000000000000000000000000000A1", "relay-1"),
250 + buildLease("0x00000000000000000000000000000000000000B2", "relay-2"),
251 + ],
252 + approval_mode: "auto",
253 + } as never;
254 + }
255 + throw new Error(`Unexpected GET path: ${path}`);
256 + });
257 +
258 const { result } = renderHook(() => useAdmin());
259 await waitForLoaded(result);
233 - const normalizedPeerA = encodeLeaseID("peer-a");
234 - const normalizedPeerB = encodeLeaseID("peer-b");
260 + const addressA = "0x00000000000000000000000000000000000000A1";
261 + const addressB = "0x00000000000000000000000000000000000000B2";
262
263 await act(async () => {
264 await result.current.handleBulkDeny([
238 - "peer-a",
239 - "peer-a",
240 - "peer-b",
265 + "relay-1:0x00000000000000000000000000000000000000a1",
266 + "relay-1:0x00000000000000000000000000000000000000a1",
267 + "relay-2:0x00000000000000000000000000000000000000b2",
268 ]);
269 });
270
271 const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
272 expect(calledPaths).toEqual(
273 expect.arrayContaining([
247 - adminLeasePath(normalizedPeerA, "deny"),
248 - adminLeasePath(normalizedPeerB, "deny"),
274 + adminLeasePath("relay-1", addressA, "deny"),
275 + adminLeasePath("relay-2", addressB, "deny"),
276 ]),
277 );
278
frontend/src/hooks/useAdmin.ts
+78 -43
@@ -6,7 +6,6 @@ import {
6 API_PATHS,
7 adminIPBanPath,
8 adminLeasePath,
9 - encodeLeaseID,
9 } from "@/lib/apiPaths";
10 import { APIClientError, apiClient } from "@/lib/apiClient";
11 import { parseLeaseMetadata } from "@/lib/metadata";
@@ -33,7 +32,8 @@ type AdminSnapshotResponse = {
32 type LeaseActionResult = ApprovalModeResponse;
33
34 export interface AdminServer extends BaseServer {
36 - peerId: string;
35 + identityKey: string;
36 + address: string;
37 isBanned: boolean;
38 bps: number;
39 isApproved: boolean;
@@ -41,8 +41,6 @@ export interface AdminServer extends BaseServer {
41 ip: string;
42 displayIP: string;
43 isIPBanned: boolean;
44 - transport: string;
45 - udpPort: number;
44 }
45
46 export interface UDPSettings {
@@ -52,7 +50,8 @@ export interface UDPSettings {
50
51 const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
52 invalid_mode: "Invalid approval mode. Choose auto or manual and retry.",
55 - invalid_lease_id: "Selected lease identifier is invalid. Refresh and try again.",
53 + invalid_address: "Selected address is invalid. Refresh and try again.",
54 + invalid_request: "Selected lease is invalid. Refresh and try again.",
55 lease_rejected: "Request was rejected by policy. Review conflicts and retry.",
56 ip_banned: "Request denied because the source IP is banned.",
57 unauthorized: "Admin authorization failed. Sign in again and retry.",
@@ -85,16 +84,41 @@ function toAdminErrorMessage(error: unknown, fallback: string): string {
84 return fallback;
85 }
86
87 +function buildIdentityKey(name: string, address: string): string {
88 + return `${name.trim().toLowerCase()}:${address.trim().toLowerCase()}`;
89 +}
90 +
91 +function resolveLeaseIdentity(
92 + rows: ServerData[],
93 + identityKey: string
94 +): { name: string; address: string } {
95 + const match = rows.find((row) => {
96 + const name = (row.name || "").trim();
97 + const address = (row.address || "").trim();
98 + return buildIdentityKey(name, address) === identityKey;
99 + });
100 + if (!match) {
101 + throw new Error("Missing lease identity");
102 + }
103 +
104 + return {
105 + name: (match.name || "").trim(),
106 + address: (match.address || "").trim(),
107 + };
108 +}
109 +
110 function toAdminServer(
111 row: ServerData,
112 index: number
113 ): AdminServer {
114 const metadata = parseLeaseMetadata(row.Metadata);
115 const hostname = row.Hostname || "";
116 + const serviceName = row.name || "";
117 + const address = (row.address || "").trim();
118
119 return {
120 id: index + 1,
97 - name: row.Name || hostname || "(unnamed)",
121 + name: serviceName || hostname || "(unnamed)",
122 description: metadata.description,
123 tags: metadata.tags,
124 thumbnail: metadata.thumbnail,
@@ -104,7 +128,8 @@ function toAdminServer(
128 link: hostname ? `https://${hostname}/` : "",
129 lastUpdated: row.LastSeenAt || undefined,
130 firstSeen: row.FirstSeenAt || undefined,
107 - peerId: row.ID,
131 + identityKey: buildIdentityKey(serviceName, address),
132 + address,
133 isBanned: row.IsBanned || false,
134 bps: row.BPS || 0,
135 isApproved: row.IsApproved || false,
@@ -112,8 +137,6 @@ function toAdminServer(
137 ip: row.ClientIP || "",
138 displayIP: row.ReportedIP || row.ClientIP || "",
139 isIPBanned: row.IsIPBanned || false,
115 - transport: row.Transport || "tcp",
116 - udpPort: row.UDPPort || 0,
140 };
141 }
142
@@ -249,37 +272,43 @@ export function useAdmin() {
272 };
273
274 const updateLeaseAction = async (
252 - peerId: string,
275 + identityKey: string,
276 action: LeaseAction,
277 enabled: boolean
278 ) => {
256 - if (!peerId) {
257 - throw new Error("Missing lease ID");
258 - }
259 - const encodedLeaseID = encodeLeaseID(peerId);
279 + const identity = resolveLeaseIdentity(serverData, identityKey);
280 const method = enabled ? apiClient.post : apiClient.delete;
261 - await method<LeaseActionResult>(adminLeasePath(encodedLeaseID, action));
281 + await method<LeaseActionResult>(
282 + adminLeasePath(identity.name, identity.address, action)
283 + );
284 };
285
286 const handleBanFilterChange = (value: BanFilter) => {
287 setBanFilter(value);
288 };
289
268 - const handleBanStatus = (peerId: string, isBan: boolean) =>
269 - runAdminAction(() => updateLeaseAction(peerId, "ban", isBan));
290 + const handleBanStatus = (identityKey: string, isBan: boolean) =>
291 + runAdminAction(() => updateLeaseAction(identityKey, "ban", isBan));
292
271 - const handleBPSChange = async (peerId: string, bps: number) => {
272 - if (!peerId) {
273 - throw new Error("Missing lease ID");
293 + const handleBPSChange = async (identityKey: string, bps: number) => {
294 + if (!identityKey) {
295 + throw new Error("Missing lease identity");
296 }
297
276 - const encodedLeaseID = encodeLeaseID(peerId);
298 + const identity = resolveLeaseIdentity(serverData, identityKey);
299 const normalizedBPS = Math.max(0, Math.trunc(bps));
278 - const previousBPS = serverData.find((row) => row.ID === peerId)?.BPS ?? 0;
300 + const previousBPS =
301 + serverData.find((row) =>
302 + buildIdentityKey((row.name || "").trim(), (row.address || "").trim()) ===
303 + identityKey
304 + )?.BPS ?? 0;
305
306 setServerData((prev) =>
307 prev.map((row) =>
282 - row.ID === peerId ? { ...row, BPS: normalizedBPS } : row
308 + buildIdentityKey((row.name || "").trim(), (row.address || "").trim()) ===
309 + identityKey
310 + ? { ...row, BPS: normalizedBPS }
311 + : row
312 )
313 );
314
@@ -287,19 +316,22 @@ export function useAdmin() {
316 await runAdminAction(async () => {
317 if (!Number.isFinite(normalizedBPS) || normalizedBPS <= 0) {
318 await apiClient.delete<LeaseActionResult>(
290 - adminLeasePath(encodedLeaseID, "bps")
319 + adminLeasePath(identity.name, identity.address, "bps")
320 );
321 return;
322 }
323 await apiClient.post<LeaseActionResult>(
295 - adminLeasePath(encodedLeaseID, "bps"),
324 + adminLeasePath(identity.name, identity.address, "bps"),
325 { bps: normalizedBPS }
326 );
327 });
328 } catch (err) {
329 setServerData((prev) =>
330 prev.map((row) =>
302 - row.ID === peerId ? { ...row, BPS: previousBPS } : row
331 + buildIdentityKey((row.name || "").trim(), (row.address || "").trim()) ===
332 + identityKey
333 + ? { ...row, BPS: previousBPS }
334 + : row
335 )
336 );
337 throw err;
@@ -340,11 +372,11 @@ export function useAdmin() {
372 });
373 };
374
343 - const handleApproveStatus = (peerId: string, approve: boolean) =>
344 - runAdminAction(() => updateLeaseAction(peerId, "approve", approve));
375 + const handleApproveStatus = (identityKey: string, approve: boolean) =>
376 + runAdminAction(() => updateLeaseAction(identityKey, "approve", approve));
377
346 - const handleDenyStatus = (peerId: string, deny: boolean) =>
347 - runAdminAction(() => updateLeaseAction(peerId, "deny", deny));
378 + const handleDenyStatus = (identityKey: string, deny: boolean) =>
379 + runAdminAction(() => updateLeaseAction(identityKey, "deny", deny));
380
381 const handleIPBanStatus = (ip: string, isBan: boolean) =>
382 runAdminAction(async () => {
@@ -359,18 +391,21 @@ export function useAdmin() {
391 await apiClient.delete<LeaseActionResult>(adminIPBanPath(normalizedIP));
392 });
393
362 - const runBulkLeaseAction = async (peerIds: string[], action: LeaseAction) => {
363 - const normalizedPeerIDs = dedupeStrings(peerIds.filter((peerId) => peerId.length > 0));
364 - if (normalizedPeerIDs.length === 0) {
394 + const runBulkLeaseAction = async (identityKeys: string[], action: LeaseAction) => {
395 + const normalizedIdentityKeys = dedupeStrings(
396 + identityKeys.filter((identityKey) => identityKey.length > 0)
397 + );
398 + if (normalizedIdentityKeys.length === 0) {
399 throw new Error("No valid leases selected");
400 }
401
402 const results = await Promise.allSettled(
369 - normalizedPeerIDs.map((peerId) =>
370 - apiClient.post<LeaseActionResult>(
371 - adminLeasePath(encodeLeaseID(peerId), action)
372 - )
373 - )
403 + normalizedIdentityKeys.map((identityKey) => {
404 + const identity = resolveLeaseIdentity(serverData, identityKey);
405 + return apiClient.post<LeaseActionResult>(
406 + adminLeasePath(identity.name, identity.address, action)
407 + );
408 + })
409 );
410
411 const failed = results.find(
@@ -386,14 +421,14 @@ export function useAdmin() {
421 }
422 };
423
389 - const handleBulkAction = (peerIds: string[], action: LeaseAction) =>
390 - runAdminAction(() => runBulkLeaseAction(peerIds, action));
424 + const handleBulkAction = (identityKeys: string[], action: LeaseAction) =>
425 + runAdminAction(() => runBulkLeaseAction(identityKeys, action));
426
392 - const handleBulkApprove = (peerIds: string[]) => handleBulkAction(peerIds, "approve");
427 + const handleBulkApprove = (identityKeys: string[]) => handleBulkAction(identityKeys, "approve");
428
394 - const handleBulkDeny = (peerIds: string[]) => handleBulkAction(peerIds, "deny");
429 + const handleBulkDeny = (identityKeys: string[]) => handleBulkAction(identityKeys, "deny");
430
396 - const handleBulkBan = (peerIds: string[]) => handleBulkAction(peerIds, "ban");
431 + const handleBulkBan = (identityKeys: string[]) => handleBulkAction(identityKeys, "ban");
432
433 return {
434 servers,
frontend/src/hooks/useSSRData.ts
+2 -4
@@ -12,16 +12,14 @@ export interface ServerData {
12 ExpiresAt: string;
13 FirstSeenAt: string;
14 LastSeenAt: string;
15 - ID: string;
16 - Name: string;
15 + name?: string;
16 + address?: string;
17 BPS?: number;
18 ClientIP: string;
19 ReportedIP?: string;
20 Hostname: string;
21 Metadata: unknown;
22 Ready: number;
23 - Transport?: string;
24 - UDPPort?: number;
23 IsApproved?: boolean;
24 IsBanned?: boolean;
25 IsDenied?: boolean;
frontend/src/hooks/useServerList.ts
+2 -1
@@ -10,10 +10,11 @@ function convertSSRDataToServers(ssrData: ServerData[]): ClientServer[] {
10 return ssrData.map((row, index) => {
11 const metadata = parseLeaseMetadata(row.Metadata);
12 const hostname = row.Hostname || "";
13 + const serviceName = row.name || "";
14
15 return {
16 id: index + 1,
16 - name: row.Name || hostname || "(unnamed)",
17 + name: serviceName || hostname || "(unnamed)",
18 description: metadata.description || "",
19 tags: metadata.tags,
20 thumbnail: metadata.thumbnail || "",
frontend/src/lib/apiPaths.test.ts
+17 -9
@@ -1,6 +1,6 @@
1 import { describe, expect, it } from "vitest";
2
3 -import { API_PATHS, adminLeasePath, encodeLeaseID } from "@/lib/apiPaths";
3 +import { API_PATHS, adminLeasePath, encodePathPart } from "@/lib/apiPaths";
4
5 describe("API_PATHS contract alignment", () => {
6 it("keeps admin snapshot path aligned", () => {
@@ -19,19 +19,27 @@ describe("API_PATHS contract alignment", () => {
19 });
20 });
21
22 - it("encodes lease IDs as base64url path segments", () => {
23 - const leaseId = "peer:legacy/123";
24 - const expected = Buffer.from(leaseId)
22 + it("encodes lease identities as base64url path segments", () => {
23 + const name = "relay-1";
24 + const address = "0x00000000000000000000000000000000000000A1";
25 + const expectedName = Buffer.from(name)
26 .toString("base64")
27 .replace(/\+/g, "-")
28 .replace(/\//g, "_")
29 .replace(/=+$/, "");
29 - const encoded = encodeLeaseID(leaseId);
30 + const expectedAddress = Buffer.from(address)
31 + .toString("base64")
32 + .replace(/\+/g, "-")
33 + .replace(/\//g, "_")
34 + .replace(/=+$/, "");
35 + const encodedName = encodePathPart(name);
36 + const encodedAddress = encodePathPart(address);
37
31 - expect(encoded).toBe(expected);
32 - expect(encoded).not.toContain("=");
33 - expect(adminLeasePath(encoded, "approve")).toBe(
34 - `${API_PATHS.admin.leases}/${encodeURIComponent(encoded)}/approve`
38 + expect(encodedName).toBe(expectedName);
39 + expect(encodedAddress).toBe(expectedAddress);
40 + expect(encodedAddress).not.toContain("=");
41 + expect(adminLeasePath(name, address, "approve")).toBe(
42 + `${API_PATHS.admin.leases}/${encodeURIComponent(encodedName)}/${encodeURIComponent(encodedAddress)}/approve`
43 );
44 });
45
frontend/src/lib/apiPaths.ts
+7 -4
@@ -37,15 +37,18 @@ export const ROUTE_PATHS = {
37 adminLogin: "/admin/login",
38 } as const;
39
40 -export function encodeLeaseID(leaseID: string): string {
41 - return btoa(leaseID).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40 +export function encodePathPart(value: string): string {
41 + return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
42 }
43
44 export function adminLeasePath(
45 - encodedLeaseID: string,
45 + name: string,
46 + address: string,
47 action: "ban" | "bps" | "approve" | "deny"
48 ): string {
48 - return `${API_PATHS.admin.leases}/${encodeURIComponent(encodedLeaseID)}/${action}`;
49 + const encodedName = encodePathPart(name);
50 + const encodedAddress = encodePathPart(address);
51 + return `${API_PATHS.admin.leases}/${encodeURIComponent(encodedName)}/${encodeURIComponent(encodedAddress)}/${action}`;
52 }
53
54 export function adminIPBanPath(ip: string): string {
portal/api_server.go
+45 -49
@@ -104,7 +104,7 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
104 func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
105 utils.WriteAPIData(w, http.StatusOK, map[string]any{
106 "service": "portal-relay",
107 - "root": s.rootHost,
107 + "root": s.identity.Name,
108 })
109 }
110
@@ -138,7 +138,7 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
138 }
139
140 now := time.Now().UTC()
141 - ingressAddr := s.rootHost
141 + ingressAddr := s.identity.Name
142 if s.cfg.SNIPort != 0 && s.cfg.SNIPort != 443 {
143 ingressAddr = fmt.Sprintf("%s:%d", ingressAddr, s.cfg.SNIPort)
144 }
@@ -148,6 +148,7 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
148 s.wgConfig.OverlayIPv4 != ""
149
150 self, err := discovery.NormalizeDescriptor(types.RelayDescriptor{
151 + Identity: s.identity.Copy(),
152 RelayID: s.cfg.PortalURL,
153 Sequence: uint64(now.UnixMilli()),
154 Version: 1,
@@ -263,7 +264,7 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
264 }
265 domain := strings.TrimSpace(r.Host)
266 if domain == "" {
266 - domain = s.rootHost
267 + domain = s.identity.Name
268 }
269 registerURI := (&url.URL{
270 Scheme: scheme,
@@ -309,7 +310,7 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
310 return
311 }
312
312 - claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.ownerIdentity.PublicKey, s.cfg.PortalURL, strings.TrimSpace(req.LeaseID), time.Now().UTC())
313 + claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.identity.PublicKey, s.cfg.PortalURL, req.Identity, time.Now().UTC())
314 if err != nil {
315 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, errUnauthorized.Error())
316 return
@@ -319,19 +320,19 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
320 if req.TTL > 0 {
321 ttl = time.Duration(req.TTL) * time.Second
322 }
322 - record, err := s.registry.Renew(strings.TrimSpace(req.LeaseID), ttl, clientIP, utils.SanitizeReportedIP(req.ReportedIP))
323 + record, err := s.registry.Renew(claims.Identity, ttl, clientIP, utils.SanitizeReportedIP(req.ReportedIP))
324 if err != nil {
325 leaseLookupError(err).Write(w)
326 return
327 }
327 - nextAccessToken, _, err := auth.IssueLeaseAccessToken(s.ownerIdentity.PrivateKey, s.ownerIdentity.Address, s.cfg.PortalURL, claims.Subject, record.ID, ttl)
328 + nextAccessToken, _, err := auth.IssueLeaseAccessToken(s.identity.PrivateKey, s.identity.Address, s.cfg.PortalURL, record.Copy(), ttl)
329 if err != nil {
330 utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
331 return
332 }
333
334 utils.WriteAPIData(w, http.StatusOK, types.RenewResponse{
334 - LeaseID: record.ID,
335 + Identity: record.Copy(),
336 ExpiresAt: record.ExpiresAt,
337 AccessToken: nextAccessToken,
338 })
@@ -346,12 +347,13 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
347 if !ok {
348 return
349 }
349 - if _, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.ownerIdentity.PublicKey, s.cfg.PortalURL, strings.TrimSpace(req.LeaseID), time.Now().UTC()); err != nil {
350 + claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.identity.PublicKey, s.cfg.PortalURL, req.Identity, time.Now().UTC())
351 + if err != nil {
352 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, errUnauthorized.Error())
353 return
354 }
355
354 - record, err := s.registry.Unregister(strings.TrimSpace(req.LeaseID))
356 + record, err := s.registry.Unregister(claims.Identity)
357 if err != nil {
358 leaseLookupError(err).Write(w)
359 return
@@ -372,14 +374,17 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
374 return
375 }
376
375 - leaseID := strings.TrimSpace(r.URL.Query().Get("lease_id"))
377 + identity := types.Identity{
378 + Name: r.URL.Query().Get("name"),
379 + Address: r.URL.Query().Get("address"),
380 + }
381 token := strings.TrimSpace(r.Header.Get(types.HeaderAccessToken))
382 clientIP, ok := s.extractAllowedClientIP(w, r)
383 if !ok {
384 return
385 }
386
382 - lease, err := s.admitLeaseByID(leaseID, token, false)
387 + lease, err := s.admitLeaseByIdentity(identity, token, false)
388 if err != nil {
389 switch {
390 case errors.Is(err, errLeaseNotFound):
@@ -424,16 +429,16 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
429 if err := lease.stream.OfferConn(conn); err != nil {
430 log.Warn().
431 Err(err).
427 - Str("lease_id", lease.ID).
432 + Str("address", lease.Address).
433 Str("lease_name", lease.Name).
434 Str("remote_addr", remoteAddr).
435 Msg("sdk reverse rejected")
436 return
437 }
438
434 - s.registry.Touch(lease.ID, clientIP, time.Now())
439 + s.registry.Touch(lease.Copy(), clientIP, time.Now())
440 log.Info().
436 - Str("lease_id", lease.ID).
441 + Str("address", lease.Address).
442 Str("lease_name", lease.Name).
443 Str("remote_addr", remoteAddr).
444 Int("ready", lease.stream.ReadyCount()).
@@ -454,13 +459,13 @@ func (s *Server) handleQUICTunnelConn(conn *quic.Conn) {
459 return
460 }
461 _ = stream.SetReadDeadline(time.Time{})
457 - if msg.LeaseID == "" || msg.AccessToken == "" {
462 + if msg.Identity.Key() == "" || msg.AccessToken == "" {
463 _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: "invalid_control_message"})
464 _ = conn.CloseWithError(1, "invalid control message")
465 return
466 }
467
463 - lease, err := s.admitLeaseByID(msg.LeaseID, msg.AccessToken, true)
468 + lease, err := s.admitLeaseByIdentity(msg.Identity, msg.AccessToken, true)
469 switch {
470 case err == nil:
471 case errors.Is(err, errLeaseNotFound):
@@ -492,24 +497,24 @@ func (s *Server) handleQUICTunnelConn(conn *quic.Conn) {
497 }
498
499 _ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: true})
495 - s.registry.Touch(lease.ID, conn.RemoteAddr().String(), time.Now())
500 + s.registry.Touch(lease.Copy(), conn.RemoteAddr().String(), time.Now())
501 log.Info().
502 Str("component", "quic-tunnel-listener").
498 - Str("lease_id", lease.ID).
503 + Str("address", lease.Address).
504 Str("lease_name", lease.Name).
505 Str("remote_addr", conn.RemoteAddr().String()).
506 Msg("quic tunnel connected")
507 }
508
504 -func (s *Server) admitLeaseByID(leaseID, token string, requireDatagram bool) (*leaseRecord, error) {
505 - lease, err := s.registry.FindByID(leaseID)
509 +func (s *Server) admitLeaseByIdentity(identity types.Identity, token string, requireDatagram bool) (*leaseRecord, error) {
510 + lease, err := s.registry.Find(identity)
511 if err != nil {
512 return nil, err
513 }
509 - if !s.registry.policy.IsLeaseRoutable(lease.ID) {
514 + if !s.registry.policy.IsIdentityRoutable(lease.Key()) {
515 return nil, errLeaseRejected
516 }
512 - if _, err := auth.VerifyLeaseAccessToken(token, s.ownerIdentity.PublicKey, s.cfg.PortalURL, leaseID, time.Now().UTC()); err != nil {
517 + if _, err := auth.VerifyLeaseAccessToken(token, s.identity.PublicKey, s.cfg.PortalURL, lease.Copy(), time.Now().UTC()); err != nil {
518 return nil, errUnauthorized
519 }
520 if lease.stream == nil || (requireDatagram && lease.datagram == nil) {
@@ -519,14 +524,14 @@ func (s *Server) admitLeaseByID(leaseID, token string, requireDatagram bool) (*l
524 }
525
526 func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, reportedIP string) (types.RegisterResponse, error) {
522 - name, err := utils.NormalizeDNSLabel(req.Name)
527 + identity, err := utils.NormalizeIdentity(req.Identity)
528 if err != nil {
529 return types.RegisterResponse{}, err
530 }
531 if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
532 return types.RegisterResponse{}, errIPBanned
533 }
529 - hostname, err := utils.LeaseHostname(name, s.rootHost)
534 + hostname, err := utils.LeaseHostname(identity.Name, s.identity.Name)
535 if err != nil {
536 return types.RegisterResponse{}, err
537 }
@@ -535,13 +540,6 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
540 if req.TTL > 0 {
541 ttl = time.Duration(req.TTL) * time.Second
542 }
538 - ownerAddress := strings.TrimSpace(req.OwnerAddress)
539 - if ownerAddress != "" {
540 - ownerAddress, err = utils.NormalizeEVMAddress(ownerAddress)
541 - if err != nil {
542 - return types.RegisterResponse{}, fmt.Errorf("normalize owner address: %w", err)
543 - }
544 - }
543
544 if req.UDPEnabled {
545 if s.cfg.UDPPortCount <= 0 || s.group != nil && s.quicTunnel == nil {
@@ -554,38 +552,36 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
552 return types.RegisterResponse{}, errUDPCapacityExceeded
553 }
554 }
557 - leaseID := utils.RandomID("lease_")
558 - accessToken, claims, err := auth.IssueLeaseAccessToken(s.ownerIdentity.PrivateKey, s.ownerIdentity.Address, s.cfg.PortalURL, ownerAddress, leaseID, ttl)
555 + accessToken, claims, err := auth.IssueLeaseAccessToken(s.identity.PrivateKey, s.identity.Address, s.cfg.PortalURL, identity, ttl)
556 if err != nil {
557 return types.RegisterResponse{}, err
558 }
559 issuedAt := claims.IssuedAt.Time().UTC()
560 expiresAt := claims.Expiry.Time().UTC()
561 + identityKey := identity.Key()
562 record := &leaseRecord{
563 Lease: types.Lease{
566 - ID: leaseID,
567 - Name: name,
568 - Hostname: hostname,
569 - Metadata: req.Metadata,
570 - OwnerAddress: ownerAddress,
571 - ExpiresAt: expiresAt,
572 - FirstSeenAt: issuedAt,
573 - LastSeenAt: issuedAt,
574 - ClientIP: clientIP,
575 - ReportedIP: utils.SanitizeReportedIP(reportedIP),
576 - UDPEnabled: req.UDPEnabled,
564 + Identity: identity,
565 + Hostname: hostname,
566 + Metadata: req.Metadata,
567 + ExpiresAt: expiresAt,
568 + FirstSeenAt: issuedAt,
569 + LastSeenAt: issuedAt,
570 + ClientIP: clientIP,
571 + ReportedIP: utils.SanitizeReportedIP(reportedIP),
572 + UDPEnabled: req.UDPEnabled,
573 },
578 - stream: transport.NewRelayStream(leaseID, defaultIdleKeepalive, defaultReadyQueueLimit),
574 + stream: transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit),
575 }
576 if req.UDPEnabled {
577 if s.ports == nil {
578 return types.RegisterResponse{}, errors.New("udp port allocation not available")
579 }
584 - port, err := s.ports.Allocate(name)
580 + port, err := s.ports.Allocate(identity.Name)
581 if err != nil {
582 return types.RegisterResponse{}, err
583 }
588 - record.datagram = transport.NewRelayDatagram(leaseID, port)
584 + record.datagram = transport.NewRelayDatagram(identityKey, port)
585 record.ports = s.ports
586 }
587
@@ -600,7 +596,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
596 }
597
598 resp := types.RegisterResponse{
603 - LeaseID: leaseID,
599 + Identity: record.Copy(),
600 Hostname: hostname,
601 Metadata: record.Metadata,
602 ExpiresAt: expiresAt,
@@ -608,7 +604,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
604 UDPEnabled: record.UDPEnabled,
605 }
606 if record.datagram != nil {
611 - resp.UDPAddr = fmt.Sprintf("%s:%d", s.rootHost, record.datagram.UDPPort())
607 + resp.UDPAddr = fmt.Sprintf("%s:%d", s.identity.Name, record.datagram.UDPPort())
608 }
609
610 return resp, nil
portal/auth/auth.go
+34 -25
@@ -33,8 +33,8 @@ var (
33 const leaseTokenAlgorithm = jose.SignatureAlgorithm("ES256K")
34
35 type LeaseAccessTokenClaims struct {
36 - LeaseID string `json:"lease_id"`
36 jwt.Claims
37 + Identity types.Identity `json:"identity"`
38 }
39
40 type es256kOpaqueSigner struct {
@@ -116,42 +116,38 @@ type RegisterChallenge struct {
116 }
117
118 func NewRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string, now time.Time, ttl time.Duration) (*RegisterChallenge, error) {
119 - name, err := utils.NormalizeDNSLabel(req.Name)
119 + normalizedIdentity, err := utils.NormalizeIdentity(req.Identity)
120 if err != nil {
121 return nil, err
122 }
123
124 - ownerAddress, err := utils.NormalizeEVMAddress(req.OwnerAddress)
125 - if err != nil {
126 - return nil, fmt.Errorf("normalize owner address: %w", err)
127 - }
128 -
124 challengeID := utils.RandomID("rch_")
125 nonce := siwe.GenerateNonce()
126 expiresAt := now.UTC().Add(ttl)
132 - siweMessage, err := BuildRegisterChallengeMessage(domain, ownerAddress, uri, challengeID, nonce, now.UTC(), expiresAt)
127 + siweMessage, err := BuildRegisterChallengeMessage(domain, normalizedIdentity.Address, uri, challengeID, nonce, now.UTC(), expiresAt)
128 if err != nil {
129 return nil, err
130 }
131
132 + normalizedRequest := types.RegisterChallengeRequest{
133 + Identity: normalizedIdentity,
134 + Metadata: req.Metadata.Copy(),
135 + TTL: req.TTL,
136 + UDPEnabled: req.UDPEnabled,
137 + }
138 +
139 return &RegisterChallenge{
140 ChallengeID: challengeID,
141 ExpiresAt: expiresAt,
140 - Request: types.RegisterChallengeRequest{
141 - Name: name,
142 - Metadata: req.Metadata.Copy(),
143 - OwnerAddress: ownerAddress,
144 - TTL: req.TTL,
145 - UDPEnabled: req.UDPEnabled,
146 - },
142 + Request: normalizedRequest,
143 SIWEMessage: siweMessage,
144 domain: strings.TrimSpace(domain),
145 nonce: nonce,
146 }, nil
147 }
148
153 -func BuildRegisterChallengeMessage(domain, ownerAddress, uri, challengeID, nonce string, issuedAt, expiresAt time.Time) (string, error) {
154 - message, err := siwe.InitMessage(domain, ownerAddress, uri, nonce, map[string]interface{}{
149 +func BuildRegisterChallengeMessage(domain, address, uri, challengeID, nonce string, issuedAt, expiresAt time.Time) (string, error) {
150 + message, err := siwe.InitMessage(domain, address, uri, nonce, map[string]interface{}{
151 "statement": registerStatement,
152 "chainId": 1,
153 "issuedAt": issuedAt.UTC().Format(time.RFC3339),
@@ -196,12 +192,12 @@ func VerifyRegisterChallengeMessage(messageText, signature, domain, nonce string
192 return err
193 }
194
199 -func IssueLeaseAccessToken(privateKeyHex, keyID, issuer, ownerAddress, leaseID string, ttl time.Duration) (string, LeaseAccessTokenClaims, error) {
195 +func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.Identity, ttl time.Duration) (string, LeaseAccessTokenClaims, error) {
196 privateKeyBytes, err := decodePrivateKeyHex(privateKeyHex)
197 if err != nil {
198 return "", LeaseAccessTokenClaims{}, err
199 }
204 - normalizedOwnerAddress, err := utils.NormalizeEVMAddress(ownerAddress)
200 + normalizedIdentity, err := utils.NormalizeIdentity(identity)
201 if err != nil {
202 return "", LeaseAccessTokenClaims{}, err
203 }
@@ -220,16 +216,16 @@ func IssueLeaseAccessToken(privateKeyHex, keyID, issuer, ownerAddress, leaseID s
216 now := time.Now().UTC()
217 expiresAt := now.Add(ttl)
218 claims := LeaseAccessTokenClaims{
223 - LeaseID: strings.TrimSpace(leaseID),
219 Claims: jwt.Claims{
220 Issuer: strings.TrimSpace(issuer),
226 - Subject: normalizedOwnerAddress,
221 + Subject: normalizedIdentity.Key(),
222 Audience: jwt.Audience{leaseAccessTokenAudience},
223 ID: utils.RandomID("tok_"),
224 IssuedAt: jwt.NewNumericDate(now),
225 NotBefore: jwt.NewNumericDate(now),
226 Expiry: jwt.NewNumericDate(expiresAt),
227 },
228 + Identity: normalizedIdentity,
229 }
230
231 token, err := jwt.Signed(signer).Claims(claims).Serialize()
@@ -239,7 +235,7 @@ func IssueLeaseAccessToken(privateKeyHex, keyID, issuer, ownerAddress, leaseID s
235 return token, claims, nil
236 }
237
242 -func VerifyLeaseAccessToken(token, publicKeyHex, issuer, leaseID string, now time.Time) (LeaseAccessTokenClaims, error) {
238 +func VerifyLeaseAccessToken(token, publicKeyHex, issuer string, identity types.Identity, now time.Time) (LeaseAccessTokenClaims, error) {
239 pubKeyText := strings.TrimSpace(publicKeyHex)
240 if pubKeyText == "" {
241 return LeaseAccessTokenClaims{}, errors.New("public key is required")
@@ -264,9 +260,22 @@ func VerifyLeaseAccessToken(token, publicKeyHex, issuer, leaseID string, now tim
260 if err := parsed.Claims(&es256kOpaqueVerifier{publicKey: publicKey}, &claims); err != nil {
261 return LeaseAccessTokenClaims{}, err
262 }
267 - requestedLeaseID := strings.TrimSpace(leaseID)
268 - if requestedLeaseID != "" && claims.LeaseID != requestedLeaseID {
269 - return LeaseAccessTokenClaims{}, errors.New("lease access token lease id does not match request")
263 + normalizedClaimsIdentity, err := utils.NormalizeIdentity(claims.Identity)
264 + if err != nil {
265 + return LeaseAccessTokenClaims{}, err
266 + }
267 + if normalizedClaimsIdentity.Key() != claims.Subject {
268 + return LeaseAccessTokenClaims{}, errors.New("lease access token identity does not match subject")
269 + }
270 + claims.Identity = normalizedClaimsIdentity
271 + if identity.Key() != "" {
272 + normalizedIdentity, err := utils.NormalizeIdentity(identity)
273 + if err != nil {
274 + return LeaseAccessTokenClaims{}, err
275 + }
276 + if claims.Subject != normalizedIdentity.Key() {
277 + return LeaseAccessTokenClaims{}, errors.New("lease access token identity does not match request")
278 + }
279 }
280 if err := claims.ValidateWithLeeway(jwt.Expected{
281 Issuer: strings.TrimSpace(issuer),
portal/discovery/discovery.go
+35 -7
@@ -17,6 +17,8 @@ import (
17 const defaultRequestTimeout = 15 * time.Second
18
19 func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, error) {
20 + desc.Name = utils.NormalizeHostname(desc.Name)
21 + desc.Address = strings.TrimSpace(desc.Address)
22 desc.RelayID = strings.TrimSpace(desc.RelayID)
23 desc.APIHTTPSAddr = strings.TrimSpace(desc.APIHTTPSAddr)
24 desc.WireGuardPublicKey = strings.TrimSpace(desc.WireGuardPublicKey)
@@ -39,6 +41,13 @@ func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, err
41 desc.RelayID = normalized
42 }
43 }
44 + if desc.Address != "" {
45 + normalized, err := utils.NormalizeEVMAddress(desc.Address)
46 + if err != nil {
47 + return types.RelayDescriptor{}, fmt.Errorf("normalize address: %w", err)
48 + }
49 + desc.Address = normalized
50 + }
51 if len(desc.OverlayCIDRs) > 0 {
52 normalized, err := utils.NormalizeOverlayCIDRs(desc.OverlayCIDRs)
53 if err != nil {
@@ -66,6 +75,8 @@ func ValidateDescriptor(desc types.RelayDescriptor, now time.Time) (types.RelayD
75 now = now.UTC()
76
77 switch {
78 + case normalized.Name == "":
79 + return types.RelayDescriptor{}, errors.New("identity.name is required")
80 case normalized.RelayID == "":
81 return types.RelayDescriptor{}, errors.New("relay_id is required")
82 case normalized.APIHTTPSAddr == "":
@@ -108,7 +119,7 @@ func ValidateRelayDiscoveryResponse(resp types.DiscoveryResponse, now time.Time)
119 return types.RelayDescriptor{}, nil, err
120 }
121
111 - seen := map[string]struct{}{self.RelayID: {}}
122 + seen := map[string]struct{}{self.Key(): {}}
123 relays := make([]types.RelayDescriptor, 0, len(resp.Relays))
124 var validateErr error
125 for _, descriptor := range resp.Relays {
@@ -119,25 +130,39 @@ func ValidateRelayDiscoveryResponse(resp types.DiscoveryResponse, now time.Time)
130 }
131 continue
132 }
122 - if _, ok := seen[verified.RelayID]; ok {
133 + identityKey := verified.Key()
134 + if _, ok := seen[identityKey]; ok {
135 continue
136 }
125 - seen[verified.RelayID] = struct{}{}
137 + seen[identityKey] = struct{}{}
138 relays = append(relays, verified)
139 }
140 return self, relays, validateErr
141 }
142
143 // ValidateDescriptorTarget checks if a descriptor matches expected target identity.
132 -func ValidateDescriptorTarget(desc types.RelayDescriptor, targetRelayID, targetURL string) error {
144 +func ValidateDescriptorTarget(desc types.RelayDescriptor, targetIdentity types.Identity, targetURL string) error {
145 normalized, err := NormalizeDescriptor(desc)
146 if err != nil {
147 return err
148 }
149
138 - relayID := normalized.RelayID
139 - if targetRelayID != "" && relayID != targetRelayID {
140 - return errors.New("descriptor relay_id does not match target relay")
150 + targetName := strings.TrimSpace(targetIdentity.Name)
151 + if targetName != "" {
152 + normalizedTargetName := utils.NormalizeHostname(targetName)
153 + if normalized.Name != normalizedTargetName {
154 + return errors.New("descriptor name does not match target relay")
155 + }
156 + }
157 + targetAddress := strings.TrimSpace(targetIdentity.Address)
158 + if targetAddress != "" {
159 + normalizedTargetAddress, err := utils.NormalizeEVMAddress(targetAddress)
160 + if err != nil {
161 + return err
162 + }
163 + if normalized.Address != normalizedTargetAddress {
164 + return errors.New("descriptor address does not match target relay")
165 + }
166 }
167
168 if targetURL != "" {
@@ -196,6 +221,9 @@ func SeedDescriptor(apiURL string) (types.RelayDescriptor, error) {
221 return types.RelayDescriptor{}, err
222 }
223 return types.RelayDescriptor{
224 + Identity: types.Identity{
225 + Name: utils.PortalRootHost(normalized),
226 + },
227 RelayID: normalized,
228 APIHTTPSAddr: normalized,
229 Version: 1,
portal/discovery/relayset.go
+41 -50
@@ -53,7 +53,7 @@ type RelaySummary struct {
53 type RelaySet struct {
54 mu sync.RWMutex
55 knownRelayURLs []string
56 - relayIDsByURL map[string]string
56 + relayKeysByURL map[string]string
57 relays map[string]RelayView
58 localByURL map[string]RelayLocalState
59 lastStatusReachable map[string]bool
@@ -63,9 +63,9 @@ type RelaySet struct {
63
64 func NewRelaySet() *RelaySet {
65 return &RelaySet{
66 - relayIDsByURL: make(map[string]string),
67 - relays: make(map[string]RelayView),
68 - localByURL: make(map[string]RelayLocalState),
66 + relayKeysByURL: make(map[string]string),
67 + relays: make(map[string]RelayView),
68 + localByURL: make(map[string]RelayLocalState),
69 }
70 }
71
@@ -152,8 +152,8 @@ func (s *RelaySet) logStatusChange() {
152 for _, relayURL := range trackedRelayURLs {
153 summary.Known++
154 state := s.localByURL[relayURL]
155 - relayID := s.relayIDsByURL[relayURL]
156 - view, ok := s.relays[relayID]
155 + relayKey := s.relayKeysByURL[relayURL]
156 + view, ok := s.relays[relayKey]
157 expired := ok && relayExpiredAt(view, state, now) || !ok && state.Expired
158 if state.Banned {
159 summary.Banned++
@@ -236,13 +236,16 @@ func (s *RelaySet) BootstrapDescriptors() []types.RelayDescriptor {
236 if !ok || !state.Bootstrap {
237 continue
238 }
239 - if relayID, ok := s.relayIDsByURL[relayURL]; ok {
240 - if view, ok := s.relays[relayID]; ok && view.Descriptor.APIHTTPSAddr != "" {
239 + if relayKey, ok := s.relayKeysByURL[relayURL]; ok {
240 + if view, ok := s.relays[relayKey]; ok && view.Descriptor.APIHTTPSAddr != "" {
241 out = append(out, view.Descriptor)
242 continue
243 }
244 }
245 out = append(out, types.RelayDescriptor{
246 + Identity: types.Identity{
247 + Name: utils.PortalRootHost(relayURL),
248 + },
249 RelayID: relayURL,
250 APIHTTPSAddr: relayURL,
251 Version: 1,
@@ -442,9 +445,9 @@ func (s *RelaySet) Snapshot() map[string]types.RelayState {
445
446 now := time.Now().UTC()
447 snapshot := make(map[string]types.RelayState, len(s.relays))
445 - for relayID, view := range s.relays {
448 + for relayKey, view := range s.relays {
449 localState := s.localByURL[view.Descriptor.APIHTTPSAddr]
447 - snapshot[relayID] = types.RelayState{
450 + snapshot[relayKey] = types.RelayState{
451 Descriptor: view.Descriptor,
452 Bootstrap: localState.Bootstrap,
453 Advertised: localState.Advertised,
@@ -492,34 +495,35 @@ func (s *RelaySet) registerDescriptor(desc types.RelayDescriptor, now time.Time)
495 if err != nil {
496 return "", false, false, err
497 }
495 - if current, ok := s.relays[normalized.RelayID]; ok {
496 - currentURL := current.Descriptor.APIHTTPSAddr
497 - if currentURL != "" && currentURL != normalized.APIHTTPSAddr {
498 - return "", false, false, errors.New("descriptor api_https_addr does not match known relay url")
499 - }
498 + relayKey := normalized.Key()
499 + if relayKey == "" {
500 + return "", false, false, errors.New("descriptor identity is required")
501 }
501 - if knownRelayID, ok := s.relayIDsByURL[normalized.APIHTTPSAddr]; ok && knownRelayID != normalized.RelayID {
502 - return "", false, false, errors.New("descriptor relay_id does not match known relay")
502 + if knownRelayKey, ok := s.relayKeysByURL[normalized.APIHTTPSAddr]; ok && knownRelayKey != relayKey {
503 + return "", false, false, errors.New("descriptor identity does not match known relay url")
504 }
505
506 if now.IsZero() {
507 now = time.Now().UTC()
508 }
509
509 - relayID := normalized.RelayID
510 - view, ok := s.relays[relayID]
510 + view, ok := s.relays[relayKey]
511 added := !ok
512 if !ok {
513 view.FirstSeenAt = now
514 }
515 + previousURL := view.Descriptor.APIHTTPSAddr
516 previousDescriptor := view.Descriptor
517 view.Descriptor = normalized
518 view.LastSeenAt = now
518 - s.relays[relayID] = view
519 - s.relayIDsByURL[normalized.APIHTTPSAddr] = relayID
519 + s.relays[relayKey] = view
520 + s.relayKeysByURL[normalized.APIHTTPSAddr] = relayKey
521 + if previousURL != "" && previousURL != normalized.APIHTTPSAddr {
522 + delete(s.relayKeysByURL, previousURL)
523 + }
524
525 changed := added || !reflect.DeepEqual(previousDescriptor, normalized)
522 - return relayID, added, changed, nil
526 + return relayKey, added, changed, nil
527 }
528
529 func relayDiscoveryURLs(selfDescriptor types.RelayDescriptor, relayDescriptors []types.RelayDescriptor) []string {
@@ -538,17 +542,17 @@ func relayDiscoveryURLs(selfDescriptor types.RelayDescriptor, relayDescriptors [
542 return relayURLs
543 }
544
541 -func (s *RelaySet) applyDiscoveryDescriptors(targetRelayID, targetURL string, selfDescriptor types.RelayDescriptor, relayDescriptors []types.RelayDescriptor, now time.Time) (relaySetChanged bool, addedRelayCount int, err error) {
545 +func (s *RelaySet) applyDiscoveryDescriptors(targetIdentity types.Identity, targetURL string, selfDescriptor types.RelayDescriptor, relayDescriptors []types.RelayDescriptor, now time.Time) (relaySetChanged bool, addedRelayCount int, err error) {
546 if s == nil {
547 return false, 0, nil
548 }
545 - if strings.TrimSpace(targetRelayID) == "" {
546 - return false, 0, errors.New("target relay id is required")
549 + if strings.TrimSpace(targetIdentity.Name) == "" && strings.TrimSpace(targetIdentity.Address) == "" {
550 + return false, 0, errors.New("target relay identity is required")
551 }
552 if now.IsZero() {
553 now = time.Now().UTC()
554 }
551 - if err := ValidateDescriptorTarget(selfDescriptor, targetRelayID, targetURL); err != nil {
555 + if err := ValidateDescriptorTarget(selfDescriptor, targetIdentity, targetURL); err != nil {
556 return false, 0, err
557 }
558
@@ -593,17 +597,14 @@ func (s *RelaySet) applyDiscoveryDescriptors(targetRelayID, targetURL string, se
597 return relaySetChanged, addedRelayCount, nil
598 }
599
596 -func (s *RelaySet) ApplyRelayDiscoveryResponse(targetRelayID, targetURL string, resp types.DiscoveryResponse, now time.Time) (relayURLs []string, relaySetChanged bool, addedRelayCount int, warnErr error, err error) {
600 +func (s *RelaySet) ApplyRelayDiscoveryResponse(targetIdentity types.Identity, targetURL string, resp types.DiscoveryResponse, now time.Time) (relayURLs []string, relaySetChanged bool, addedRelayCount int, warnErr error, err error) {
601 selfDescriptor, relayDescriptors, validateErr := ValidateRelayDiscoveryResponse(resp, now)
602 warnErr = validateErr
599 - if selfDescriptor.RelayID == "" {
603 + if selfDescriptor.Key() == "" {
604 return nil, false, 0, warnErr, validateErr
605 }
602 - if selfDescriptor.RelayID != strings.TrimSpace(targetRelayID) {
603 - return nil, false, 0, warnErr, errors.New("relay discovery response relay_id mismatch")
604 - }
606 s.mu.Lock()
606 - relaySetChanged, addedRelayCount, err = s.applyDiscoveryDescriptors(targetRelayID, targetURL, selfDescriptor, relayDescriptors, now)
607 + relaySetChanged, addedRelayCount, err = s.applyDiscoveryDescriptors(targetIdentity, targetURL, selfDescriptor, relayDescriptors, now)
608 s.mu.Unlock()
609 if err != nil {
610 return nil, false, 0, warnErr, err
@@ -611,15 +612,12 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetRelayID, targetURL string,
612 return relayDiscoveryURLs(selfDescriptor, relayDescriptors), relaySetChanged, addedRelayCount, warnErr, nil
613 }
614
614 -func (s *RelaySet) ApplyOverlayRelayDiscoveryResponse(targetRelayID, targetURL string, resp types.DiscoveryResponse, now time.Time) (relayURLs []string, relaySetChanged bool, addedRelayCount int, warnErr error, err error) {
615 +func (s *RelaySet) ApplyOverlayRelayDiscoveryResponse(targetIdentity types.Identity, targetURL string, resp types.DiscoveryResponse, now time.Time) (relayURLs []string, relaySetChanged bool, addedRelayCount int, warnErr error, err error) {
616 selfDescriptor, relayDescriptors, validateErr := ValidateRelayDiscoveryResponse(resp, now)
617 warnErr = validateErr
617 - if selfDescriptor.RelayID == "" {
618 + if selfDescriptor.Key() == "" {
619 return nil, false, 0, warnErr, validateErr
620 }
620 - if selfDescriptor.RelayID != strings.TrimSpace(targetRelayID) {
621 - return nil, false, 0, warnErr, errors.New("relay discovery response relay_id mismatch")
622 - }
621 if err := RequireOverlayRelayDescriptor(selfDescriptor); err != nil {
622 return nil, false, 0, warnErr, err
623 }
@@ -636,7 +634,7 @@ func (s *RelaySet) ApplyOverlayRelayDiscoveryResponse(targetRelayID, targetURL s
634 }
635
636 s.mu.Lock()
639 - relaySetChanged, addedRelayCount, err = s.applyDiscoveryDescriptors(targetRelayID, targetURL, selfDescriptor, filteredRelayDescriptors, now)
637 + relaySetChanged, addedRelayCount, err = s.applyDiscoveryDescriptors(targetIdentity, targetURL, selfDescriptor, filteredRelayDescriptors, now)
638 s.mu.Unlock()
639 if err != nil {
640 return nil, false, 0, warnErr, err
@@ -644,7 +642,7 @@ func (s *RelaySet) ApplyOverlayRelayDiscoveryResponse(targetRelayID, targetURL s
642 return relayDiscoveryURLs(selfDescriptor, filteredRelayDescriptors), relaySetChanged, addedRelayCount, warnErr, nil
643 }
644
647 -func (s *RelaySet) RegisterBootstrapRelayURLs(inputs []string, now time.Time) ([]string, error) {
645 +func (s *RelaySet) RegisterBootstrapRelayURLs(inputs []string) ([]string, error) {
646 if s == nil || len(inputs) == 0 {
647 return nil, nil
648 }
@@ -660,10 +658,6 @@ func (s *RelaySet) RegisterBootstrapRelayURLs(inputs []string, now time.Time) ([
658 if len(normalized) == 0 {
659 return nil, nil
660 }
663 - if now.IsZero() {
664 - now = time.Now().UTC()
665 - }
666 -
661 s.mu.Lock()
662 defer s.mu.Unlock()
663
@@ -685,9 +679,6 @@ func (s *RelaySet) RegisterBootstrapRelayURLs(inputs []string, now time.Time) ([
679 state.Bootstrap = true
680 state.Reachable = false
681 s.localByURL[relayURL] = state
688 - if descriptor, err := SeedDescriptor(relayURL); err == nil {
689 - _, _, _, _ = s.registerDescriptor(descriptor, now)
690 - }
682 }
683 s.logStatusChange()
684 if len(added) == 0 {
@@ -696,12 +687,12 @@ func (s *RelaySet) RegisterBootstrapRelayURLs(inputs []string, now time.Time) ([
687 return added, nil
688 }
689
699 -func (s *RelaySet) RecordDiscoveryFailure(relayID, relayURL string, err error, recoveryFailures int, now time.Time) (expired bool, expireReason string, consecutiveFailures int) {
690 +func (s *RelaySet) RecordDiscoveryFailure(identity types.Identity, relayURL string, err error, recoveryFailures int, now time.Time) (expired bool, expireReason string, consecutiveFailures int) {
691 if s == nil {
692 return false, "", 0
693 }
703 - relayID = strings.TrimSpace(relayID)
704 - if relayID == "" {
694 + relayKey := identity.Key()
695 + if relayKey == "" {
696 return false, "", 0
697 }
698 relayURL = strings.TrimSpace(relayURL)
@@ -715,7 +706,7 @@ func (s *RelaySet) RecordDiscoveryFailure(relayID, relayURL string, err error, r
706 s.mu.Lock()
707 defer s.mu.Unlock()
708
718 - view, ok := s.relays[relayID]
709 + view, ok := s.relays[relayKey]
710 if !ok {
711 return false, "", 0
712 }
portal/lease.go
+57 -48
@@ -18,7 +18,7 @@ const defaultRegisterChallengeTTL = 2 * time.Minute
18
19 type leaseRegistry struct {
20 routes *routeTable
21 - leaseByID map[string]*leaseRecord
21 + leasesByKey map[string]*leaseRecord
22 registerChallenges map[string]*auth.RegisterChallenge
23 policy *policy.Runtime
24 mu sync.RWMutex
@@ -31,7 +31,7 @@ func newLeaseRegistry(runtime *policy.Runtime) *leaseRegistry {
31
32 return &leaseRegistry{
33 routes: newRouteTable(),
34 - leaseByID: make(map[string]*leaseRecord),
34 + leasesByKey: make(map[string]*leaseRecord),
35 registerChallenges: make(map[string]*auth.RegisterChallenge),
36 policy: runtime,
37 }
@@ -41,13 +41,13 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
41 r.mu.Lock()
42 defer r.mu.Unlock()
43
44 - out := make([]*leaseRecord, 0, len(r.leaseByID))
45 - for _, record := range r.leaseByID {
44 + out := make([]*leaseRecord, 0, len(r.leasesByKey))
45 + for _, record := range r.leasesByKey {
46 out = append(out, record)
47 - r.policy.ForgetLease(record.ID)
47 + r.policy.ForgetIdentity(record.Key())
48 }
49 r.routes = newRouteTable()
50 - r.leaseByID = make(map[string]*leaseRecord)
50 + r.leasesByKey = make(map[string]*leaseRecord)
51 r.registerChallenges = make(map[string]*auth.RegisterChallenge)
52 return out
53 }
@@ -79,11 +79,11 @@ func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
79 r.mu.RLock()
80 defer r.mu.RUnlock()
81
82 - leaseID, ok := r.routes.Lookup(host)
82 + key, ok := r.routes.Lookup(host)
83 if !ok {
84 return nil, false
85 }
86 - record, ok := r.leaseByID[leaseID]
86 + record, ok := r.leasesByKey[key]
87 return record, ok && record != nil
88 }
89
@@ -92,9 +92,9 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
92 return errors.New("lease record is required")
93 }
94
95 - leaseID := record.ID
96 - if leaseID == "" {
97 - return errors.New("lease id is required")
95 + key := record.Key()
96 + if key == "" {
97 + return errors.New("lease identity is required")
98 }
99 hostname := utils.NormalizeHostname(record.Hostname)
100 if hostname == "" {
@@ -102,27 +102,37 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
102 }
103
104 r.mu.Lock()
105 - defer r.mu.Unlock()
105
107 - if ownerLeaseID, ok := r.routes.LookupExact(hostname); ok && ownerLeaseID != leaseID {
106 + if existingKey, ok := r.routes.LookupExact(hostname); ok && existingKey != key {
107 + r.mu.Unlock()
108 return errHostnameConflict
109 }
110
111 - record.ID = leaseID
111 + var replaced *leaseRecord
112 + if existing, ok := r.leasesByKey[key]; ok && existing != nil {
113 + replaced = existing
114 + r.routes.Delete(existing.Hostname)
115 + r.policy.ForgetIdentity(existing.Key())
116 + }
117 record.Hostname = hostname
113 - r.leaseByID[leaseID] = record
114 - r.routes.Set(hostname, leaseID)
118 + r.leasesByKey[key] = record
119 + r.routes.Set(hostname, key)
120 if strings.TrimSpace(record.ClientIP) != "" {
116 - r.policy.IPFilter().RegisterLeaseIP(leaseID, record.ClientIP)
121 + r.policy.IPFilter().RegisterIdentityIP(key, record.ClientIP)
122 + }
123 + r.mu.Unlock()
124 +
125 + if replaced != nil && replaced != record {
126 + replaced.Close()
127 }
128 return nil
129 }
130
121 -func (r *leaseRegistry) Renew(leaseID string, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
131 +func (r *leaseRegistry) Renew(identity types.Identity, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
132 r.mu.Lock()
133 defer r.mu.Unlock()
134
125 - record, ok := r.leaseByID[leaseID]
135 + record, ok := r.leasesByKey[identity.Key()]
136 if !ok {
137 return nil, errLeaseNotFound
138 }
@@ -132,7 +142,7 @@ func (r *leaseRegistry) Renew(leaseID string, ttl time.Duration, clientIP, repor
142 record.LastSeenAt = now
143 if strings.TrimSpace(clientIP) != "" {
144 record.ClientIP = clientIP
135 - r.policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
145 + r.policy.IPFilter().RegisterIdentityIP(record.Key(), clientIP)
146 }
147 if strings.TrimSpace(reportedIP) != "" {
148 record.ReportedIP = reportedIP
@@ -140,26 +150,27 @@ func (r *leaseRegistry) Renew(leaseID string, ttl time.Duration, clientIP, repor
150 return record, nil
151 }
152
143 -func (r *leaseRegistry) Unregister(leaseID string) (*leaseRecord, error) {
153 +func (r *leaseRegistry) Unregister(identity types.Identity) (*leaseRecord, error) {
154 r.mu.Lock()
155 defer r.mu.Unlock()
156
147 - record, ok := r.leaseByID[leaseID]
157 + key := identity.Key()
158 + record, ok := r.leasesByKey[key]
159 if !ok {
160 return nil, errLeaseNotFound
161 }
162
152 - delete(r.leaseByID, record.ID)
163 + delete(r.leasesByKey, key)
164 r.routes.Delete(record.Hostname)
154 - r.policy.ForgetLease(record.ID)
165 + r.policy.ForgetIdentity(key)
166 return record, nil
167 }
168
158 -func (r *leaseRegistry) FindByID(leaseID string) (*leaseRecord, error) {
169 +func (r *leaseRegistry) Find(identity types.Identity) (*leaseRecord, error) {
170 r.mu.RLock()
171 defer r.mu.RUnlock()
172
162 - record, ok := r.leaseByID[leaseID]
173 + record, ok := r.leasesByKey[identity.Key()]
174 if !ok || time.Now().After(record.ExpiresAt) {
175 return nil, errLeaseNotFound
176 }
@@ -219,18 +230,18 @@ func (r *leaseRegistry) consumeVerifiedRegisterChallenge(req types.RegisterReque
230 return challenge, nil
231 }
232
222 -func (r *leaseRegistry) Touch(leaseID, clientIP string, now time.Time) *leaseRecord {
233 +func (r *leaseRegistry) Touch(identity types.Identity, clientIP string, now time.Time) *leaseRecord {
234 r.mu.Lock()
235 defer r.mu.Unlock()
236
226 - record, ok := r.leaseByID[leaseID]
237 + record, ok := r.leasesByKey[identity.Key()]
238 if !ok {
239 return nil
240 }
241 record.LastSeenAt = now
242 if strings.TrimSpace(clientIP) != "" {
243 record.ClientIP = clientIP
233 - r.policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
244 + r.policy.IPFilter().RegisterIdentityIP(record.Key(), clientIP)
245 }
246 return record
247 }
@@ -254,12 +265,12 @@ func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
265 defer r.mu.Unlock()
266
267 expired := make([]*leaseRecord, 0)
257 - for leaseID, record := range r.leaseByID {
268 + for key, record := range r.leasesByKey {
269 if now.After(record.ExpiresAt) {
270 expired = append(expired, record)
260 - delete(r.leaseByID, leaseID)
271 + delete(r.leasesByKey, key)
272 r.routes.Delete(record.Hostname)
262 - r.policy.ForgetLease(record.ID)
273 + r.policy.ForgetIdentity(key)
274 }
275 }
276 return expired
@@ -270,7 +281,7 @@ func (r *leaseRegistry) CountDatagramLeases() int {
281 defer r.mu.RUnlock()
282 now := time.Now()
283 count := 0
273 - for _, record := range r.leaseByID {
284 + for _, record := range r.leasesByKey {
285 if record.datagram != nil && now.Before(record.ExpiresAt) {
286 count++
287 }
@@ -286,16 +297,14 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
297 snapshot := record.Lease
298 snapshot.Metadata = snapshot.Metadata.Copy()
299 clientIP := record.ClientIP
289 - snapshot.BPS = r.policy.BPSManager().LeaseBPS(record.ID)
300 + identityKey := record.Key()
301 + snapshot.BPS = r.policy.BPSManager().IdentityBPS(identityKey)
302 if record.stream != nil {
303 snapshot.Ready = record.stream.ReadyCount()
304 }
293 - if record.datagram != nil {
294 - snapshot.UDPPort = record.datagram.UDPPort()
295 - }
296 - snapshot.IsApproved = r.policy.EffectiveApproval(record.ID)
297 - snapshot.IsBanned = r.policy.IsLeaseBanned(record.ID)
298 - snapshot.IsDenied = r.policy.IsLeaseDenied(record.ID)
305 + snapshot.IsApproved = r.policy.EffectiveApproval(identityKey)
306 + snapshot.IsBanned = r.policy.IsIdentityBanned(identityKey)
307 + snapshot.IsDenied = r.policy.IsIdentityDenied(identityKey)
308 snapshot.IsIPBanned = r.policy.IPFilter().IsIPBanned(clientIP)
309 return snapshot
310 }
@@ -344,12 +353,12 @@ func newRouteTable() *routeTable {
353 return &routeTable{exact: make(map[string]string)}
354 }
355
347 -func (t *routeTable) Set(host, leaseID string) {
356 +func (t *routeTable) Set(host, identityKey string) {
357 host = utils.NormalizeHostname(host)
358 if host == "" {
359 return
360 }
352 - t.exact[host] = leaseID
361 + t.exact[host] = identityKey
362 }
363
364 func (t *routeTable) Delete(host string) {
@@ -361,8 +370,8 @@ func (t *routeTable) LookupExact(host string) (string, bool) {
370 if host == "" {
371 return "", false
372 }
364 - leaseID, ok := t.exact[host]
365 - return leaseID, ok
373 + identityKey, ok := t.exact[host]
374 + return identityKey, ok
375 }
376
377 func (t *routeTable) Lookup(host string) (string, bool) {
@@ -371,8 +380,8 @@ func (t *routeTable) Lookup(host string) (string, bool) {
380 return "", false
381 }
382
374 - if leaseID, ok := t.exact[host]; ok {
375 - return leaseID, true
383 + if identityKey, ok := t.exact[host]; ok {
384 + return identityKey, true
385 }
386
387 parts := strings.Split(host, ".")
@@ -380,6 +389,6 @@ func (t *routeTable) Lookup(host string) (string, bool) {
389 return "", false
390 }
391 wildcard := "*." + strings.Join(parts[1:], ".")
383 - leaseID, ok := t.exact[wildcard]
384 - return leaseID, ok
392 + identityKey, ok := t.exact[wildcard]
393 + return identityKey, ok
394 }
portal/lease_test.go
+35 -21
@@ -19,11 +19,14 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
19 registry := newLeaseRegistry(runtime)
20 record := &leaseRecord{
21 Lease: types.Lease{
22 - ID: "lease_1",
22 + Identity: types.Identity{
23 + Name: "demo",
24 + Address: "addr-1",
25 + },
26 Hostname: "demo.example.com",
27 ExpiresAt: time.Now().Add(30 * time.Second),
28 },
26 - stream: transport.NewRelayStream("lease_1", time.Minute, 1),
29 + stream: transport.NewRelayStream("addr-1", time.Minute, 1),
30 }
31
32 if err := registry.Register(record); err != nil {
@@ -35,18 +38,18 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
38 t.Fatalf("Lookup() = %v, %v, want registered lease", lookedUp, ok)
39 }
40
38 - renewed, err := registry.Renew(record.ID, time.Minute, "203.0.113.10", "")
41 + renewed, err := registry.Renew(record.Copy(), time.Minute, "203.0.113.10", "")
42 if err != nil {
43 t.Fatalf("Renew() error = %v", err)
44 }
45 if renewed.ClientIP != "203.0.113.10" {
46 t.Fatalf("Renew() client ip = %q, want %q", renewed.ClientIP, "203.0.113.10")
47 }
45 - if got := runtime.IPFilter().LeaseIP(record.ID); got != "203.0.113.10" {
48 + if got := runtime.IPFilter().IdentityIP(record.Key()); got != "203.0.113.10" {
49 t.Fatalf("Renew() did not register client IP for lease")
50 }
51
49 - removed, err := registry.Unregister(record.ID)
52 + removed, err := registry.Unregister(record.Copy())
53 if err != nil {
54 t.Fatalf("Unregister() error = %v", err)
55 }
@@ -57,7 +60,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
60 if _, ok := registry.Lookup("demo.example.com"); ok {
61 t.Fatal("Lookup() after Unregister() = true, want false")
62 }
60 - if got := runtime.IPFilter().LeaseIP(record.ID); got != "" {
63 + if got := runtime.IPFilter().IdentityIP(record.Key()); got != "" {
64 t.Fatalf("Unregister() lease IP = %q, want empty", got)
65 }
66 }
@@ -68,11 +71,14 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
71 registry := newLeaseRegistry(policy.NewRuntime())
72 wildcardLease := &leaseRecord{
73 Lease: types.Lease{
71 - ID: "lease_wildcard",
74 + Identity: types.Identity{
75 + Name: "wildcard",
76 + Address: "addr-wildcard",
77 + },
78 Hostname: "*.example.com",
79 ExpiresAt: time.Now().Add(30 * time.Second),
80 },
75 - stream: transport.NewRelayStream("lease_wildcard", time.Minute, 1),
81 + stream: transport.NewRelayStream("addr-wildcard", time.Minute, 1),
82 }
83 if err := registry.Register(wildcardLease); err != nil {
84 t.Fatalf("Register(wildcard) error = %v", err)
@@ -87,11 +93,14 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
93
94 conflict := &leaseRecord{
95 Lease: types.Lease{
90 - ID: "lease_conflict",
96 + Identity: types.Identity{
97 + Name: "conflict",
98 + Address: "addr-conflict",
99 + },
100 Hostname: "*.example.com",
101 ExpiresAt: time.Now().Add(30 * time.Second),
102 },
94 - stream: transport.NewRelayStream("lease_conflict", time.Minute, 1),
103 + stream: transport.NewRelayStream("addr-conflict", time.Minute, 1),
104 }
105 err := registry.Register(conflict)
106 if !errors.Is(err, errHostnameConflict) {
@@ -110,33 +119,35 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
119 registry := newLeaseRegistry(runtime)
120 record := &leaseRecord{
121 Lease: types.Lease{
113 - ID: "lease_policy",
114 - Name: "demo",
122 + Identity: types.Identity{
123 + Name: "demo",
124 + Address: "addr-policy",
125 + },
126 Hostname: "demo.example.com",
127 ExpiresAt: time.Now().Add(30 * time.Second),
128 ClientIP: "203.0.113.20",
129 },
119 - stream: transport.NewRelayStream("lease_policy", time.Minute, 1),
130 + stream: transport.NewRelayStream("addr-policy", time.Minute, 1),
131 }
132 if err := registry.Register(record); err != nil {
133 t.Fatalf("Register() error = %v", err)
134 }
135
125 - if registry.policy.IsLeaseRoutable(record.ID) {
126 - t.Fatal("policy.IsLeaseRoutable() = true, want false before approval")
136 + if registry.policy.IsIdentityRoutable(record.Key()) {
137 + t.Fatal("policy.IsIdentityRoutable() = true, want false before approval")
138 }
139
140 snapshot := registry.Snapshot(record)
141 if snapshot.IsApproved {
142 t.Fatal("Snapshot().IsApproved = true, want false before approval")
143 }
133 - if got := runtime.IPFilter().LeaseIP(record.ID); got != "203.0.113.20" {
144 + if got := runtime.IPFilter().IdentityIP(record.Key()); got != "203.0.113.20" {
145 t.Fatalf("Register() lease IP = %q, want %q", got, "203.0.113.20")
146 }
147
137 - runtime.Approver().Approve(record.ID)
138 - if !registry.policy.IsLeaseRoutable(record.ID) {
139 - t.Fatal("policy.IsLeaseRoutable() = false, want true after approval")
148 + runtime.Approver().Approve(record.Key())
149 + if !registry.policy.IsIdentityRoutable(record.Key()) {
150 + t.Fatal("policy.IsIdentityRoutable() = false, want true after approval")
151 }
152
153 snapshot = registry.Snapshot(record)
@@ -151,11 +162,14 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
162 registry := newLeaseRegistry(policy.NewRuntime())
163 record := &leaseRecord{
164 Lease: types.Lease{
154 - ID: "lease_expired",
165 + Identity: types.Identity{
166 + Name: "expired",
167 + Address: "addr-expired",
168 + },
169 Hostname: "expired.example.com",
170 ExpiresAt: time.Now().Add(-time.Second),
171 },
158 - stream: transport.NewRelayStream("lease_expired", time.Minute, 1),
172 + stream: transport.NewRelayStream("addr-expired", time.Minute, 1),
173 }
174 if err := registry.Register(record); err != nil {
175 t.Fatalf("Register() error = %v", err)
portal/policy/approver.go
+41 -41
@@ -13,17 +13,17 @@ const (
13 )
14
15 type Approver struct {
16 - approvedLeases map[string]struct{}
17 - deniedLeases map[string]struct{}
18 - approvalMode Mode
19 - mu sync.RWMutex
16 + approvedKeys map[string]struct{}
17 + deniedKeys map[string]struct{}
18 + approvalMode Mode
19 + mu sync.RWMutex
20 }
21
22 func NewApprover() *Approver {
23 return &Approver{
24 - approvalMode: ModeAuto,
25 - approvedLeases: make(map[string]struct{}),
26 - deniedLeases: make(map[string]struct{}),
24 + approvalMode: ModeAuto,
25 + approvedKeys: make(map[string]struct{}),
26 + deniedKeys: make(map[string]struct{}),
27 }
28 }
29
@@ -43,90 +43,90 @@ func (a *Approver) SetMode(mode Mode) error {
43 return nil
44 }
45
46 -func (a *Approver) IsApproved(leaseID string) bool {
46 +func (a *Approver) IsApproved(key string) bool {
47 a.mu.RLock()
48 defer a.mu.RUnlock()
49 - _, ok := a.approvedLeases[leaseID]
49 + _, ok := a.approvedKeys[key]
50 return ok
51 }
52
53 -func (a *Approver) Approve(leaseID string) {
53 +func (a *Approver) Approve(key string) {
54 a.mu.Lock()
55 defer a.mu.Unlock()
56 - a.approvedLeases[leaseID] = struct{}{}
57 - delete(a.deniedLeases, leaseID)
56 + a.approvedKeys[key] = struct{}{}
57 + delete(a.deniedKeys, key)
58 }
59
60 -func (a *Approver) Revoke(leaseID string) {
60 +func (a *Approver) Revoke(key string) {
61 a.mu.Lock()
62 defer a.mu.Unlock()
63 - delete(a.approvedLeases, leaseID)
63 + delete(a.approvedKeys, key)
64 }
65
66 -func (a *Approver) ApprovedLeases() []string {
66 +func (a *Approver) ApprovedKeys() []string {
67 a.mu.RLock()
68 defer a.mu.RUnlock()
69 - out := make([]string, 0, len(a.approvedLeases))
70 - for leaseID := range a.approvedLeases {
71 - out = append(out, leaseID)
69 + out := make([]string, 0, len(a.approvedKeys))
70 + for key := range a.approvedKeys {
71 + out = append(out, key)
72 }
73 return out
74 }
75
76 -func (a *Approver) IsDenied(leaseID string) bool {
76 +func (a *Approver) IsDenied(key string) bool {
77 a.mu.RLock()
78 defer a.mu.RUnlock()
79 - _, ok := a.deniedLeases[leaseID]
79 + _, ok := a.deniedKeys[key]
80 return ok
81 }
82
83 -func (a *Approver) Deny(leaseID string) {
83 +func (a *Approver) Deny(key string) {
84 a.mu.Lock()
85 defer a.mu.Unlock()
86 - a.deniedLeases[leaseID] = struct{}{}
87 - delete(a.approvedLeases, leaseID)
86 + a.deniedKeys[key] = struct{}{}
87 + delete(a.approvedKeys, key)
88 }
89
90 -func (a *Approver) Undeny(leaseID string) {
90 +func (a *Approver) Undeny(key string) {
91 a.mu.Lock()
92 defer a.mu.Unlock()
93 - delete(a.deniedLeases, leaseID)
93 + delete(a.deniedKeys, key)
94 }
95
96 -func (a *Approver) DeniedLeases() []string {
96 +func (a *Approver) DeniedKeys() []string {
97 a.mu.RLock()
98 defer a.mu.RUnlock()
99 - out := make([]string, 0, len(a.deniedLeases))
100 - for leaseID := range a.deniedLeases {
101 - out = append(out, leaseID)
99 + out := make([]string, 0, len(a.deniedKeys))
100 + for key := range a.deniedKeys {
101 + out = append(out, key)
102 }
103 return out
104 }
105
106 -func (a *Approver) SetDecisions(approvedLeases, deniedLeases []string) {
106 +func (a *Approver) SetDecisions(approvedKeys, deniedKeys []string) {
107 if a == nil {
108 return
109 }
110
111 - approved := make(map[string]struct{}, len(approvedLeases))
112 - for _, leaseID := range approvedLeases {
113 - if leaseID == "" {
111 + approved := make(map[string]struct{}, len(approvedKeys))
112 + for _, key := range approvedKeys {
113 + if key == "" {
114 continue
115 }
116 - approved[leaseID] = struct{}{}
116 + approved[key] = struct{}{}
117 }
118
119 - denied := make(map[string]struct{}, len(deniedLeases))
120 - for _, leaseID := range deniedLeases {
121 - if leaseID == "" {
119 + denied := make(map[string]struct{}, len(deniedKeys))
120 + for _, key := range deniedKeys {
121 + if key == "" {
122 continue
123 }
124 - delete(approved, leaseID)
125 - denied[leaseID] = struct{}{}
124 + delete(approved, key)
125 + denied[key] = struct{}{}
126 }
127
128 a.mu.Lock()
129 - a.approvedLeases = approved
130 - a.deniedLeases = denied
129 + a.approvedKeys = approved
130 + a.deniedKeys = denied
131 a.mu.Unlock()
132 }
portal/policy/bps_manager.go
+21 -21
@@ -6,51 +6,51 @@ import (
6 )
7
8 type BPSManager struct {
9 - leaseBPS map[string]int64
10 - mu sync.RWMutex
9 + identityBPS map[string]int64
10 + mu sync.RWMutex
11 }
12
13 func NewBPSManager() *BPSManager {
14 return &BPSManager{
15 - leaseBPS: make(map[string]int64),
15 + identityBPS: make(map[string]int64),
16 }
17 }
18
19 -func (m *BPSManager) LeaseBPS(leaseID string) int64 {
20 - if m == nil || leaseID == "" {
19 +func (m *BPSManager) IdentityBPS(key string) int64 {
20 + if m == nil || key == "" {
21 return 0
22 }
23
24 m.mu.RLock()
25 defer m.mu.RUnlock()
26 - return m.leaseBPS[leaseID]
26 + return m.identityBPS[key]
27 }
28
29 -func (m *BPSManager) SetLeaseBPS(leaseID string, bps int64) {
30 - if m == nil || leaseID == "" {
29 +func (m *BPSManager) SetIdentityBPS(key string, bps int64) {
30 + if m == nil || key == "" {
31 return
32 }
33
34 m.mu.Lock()
35 defer m.mu.Unlock()
36 if bps <= 0 {
37 - delete(m.leaseBPS, leaseID)
37 + delete(m.identityBPS, key)
38 return
39 }
40 - m.leaseBPS[leaseID] = bps
40 + m.identityBPS[key] = bps
41 }
42
43 -func (m *BPSManager) DeleteLeaseBPS(leaseID string) {
44 - if m == nil || leaseID == "" {
43 +func (m *BPSManager) DeleteIdentityBPS(key string) {
44 + if m == nil || key == "" {
45 return
46 }
47
48 m.mu.Lock()
49 defer m.mu.Unlock()
50 - delete(m.leaseBPS, leaseID)
50 + delete(m.identityBPS, key)
51 }
52
53 -func (m *BPSManager) LeaseBPSLimits() map[string]int64 {
53 +func (m *BPSManager) IdentityBPSLimits() map[string]int64 {
54 if m == nil {
55 return nil
56 }
@@ -58,25 +58,25 @@ func (m *BPSManager) LeaseBPSLimits() map[string]int64 {
58 m.mu.RLock()
59 defer m.mu.RUnlock()
60
61 - out := make(map[string]int64, len(m.leaseBPS))
62 - maps.Copy(out, m.leaseBPS)
61 + out := make(map[string]int64, len(m.identityBPS))
62 + maps.Copy(out, m.identityBPS)
63 return out
64 }
65
66 -func (m *BPSManager) SetLeaseBPSLimits(limits map[string]int64) {
66 +func (m *BPSManager) SetIdentityBPSLimits(limits map[string]int64) {
67 if m == nil {
68 return
69 }
70
71 next := make(map[string]int64, len(limits))
72 - for leaseID, bps := range limits {
73 - if leaseID == "" || bps <= 0 {
72 + for key, bps := range limits {
73 + if key == "" || bps <= 0 {
74 continue
75 }
76 - next[leaseID] = bps
76 + next[key] = bps
77 }
78
79 m.mu.Lock()
80 - m.leaseBPS = next
80 + m.identityBPS = next
81 m.mu.Unlock()
82 }
portal/policy/ip_filter.go
+30 -30
@@ -7,17 +7,17 @@ import (
7 )
8
9 type IPFilter struct {
10 - bannedIPs map[string]struct{}
11 - leaseToIP map[string]string
12 - ipToLeases map[string][]string
13 - mu sync.RWMutex
10 + bannedIPs map[string]struct{}
11 + identityToIP map[string]string
12 + ipToIdentities map[string][]string
13 + mu sync.RWMutex
14 }
15
16 func NewIPFilter() *IPFilter {
17 return &IPFilter{
18 - bannedIPs: make(map[string]struct{}),
19 - leaseToIP: make(map[string]string),
20 - ipToLeases: make(map[string][]string),
18 + bannedIPs: make(map[string]struct{}),
19 + identityToIP: make(map[string]string),
20 + ipToIdentities: make(map[string][]string),
21 }
22 }
23
@@ -63,61 +63,61 @@ func (f *IPFilter) SetBannedIPs(ips []string) {
63 }
64 }
65
66 -func (f *IPFilter) RegisterLeaseIP(leaseID, ip string) {
66 +func (f *IPFilter) RegisterIdentityIP(key, ip string) {
67 f.mu.Lock()
68 defer f.mu.Unlock()
69 - if leaseID == "" || ip == "" {
69 + if key == "" || ip == "" {
70 return
71 }
72
73 - if oldIP, ok := f.leaseToIP[leaseID]; ok {
73 + if oldIP, ok := f.identityToIP[key]; ok {
74 if oldIP == ip {
75 return
76 }
77 - f.removeLeaseFromIPLocked(leaseID, oldIP)
77 + f.removeIdentityFromIPLocked(key, oldIP)
78 }
79 - if slices.Contains(f.ipToLeases[ip], leaseID) {
80 - f.leaseToIP[leaseID] = ip
79 + if slices.Contains(f.ipToIdentities[ip], key) {
80 + f.identityToIP[key] = ip
81 return
82 }
83
84 - f.leaseToIP[leaseID] = ip
85 - f.ipToLeases[ip] = append(f.ipToLeases[ip], leaseID)
84 + f.identityToIP[key] = ip
85 + f.ipToIdentities[ip] = append(f.ipToIdentities[ip], key)
86 }
87
88 -func (f *IPFilter) LeaseIP(leaseID string) string {
88 +func (f *IPFilter) IdentityIP(key string) string {
89 f.mu.RLock()
90 defer f.mu.RUnlock()
91 - if leaseID == "" {
91 + if key == "" {
92 return ""
93 }
94 - return f.leaseToIP[leaseID]
94 + return f.identityToIP[key]
95 }
96
97 -func (f *IPFilter) RemoveLeaseIP(leaseID string) {
97 +func (f *IPFilter) RemoveIdentityIP(key string) {
98 f.mu.Lock()
99 defer f.mu.Unlock()
100
101 - if leaseID == "" {
101 + if key == "" {
102 return
103 }
104 - ip, ok := f.leaseToIP[leaseID]
104 + ip, ok := f.identityToIP[key]
105 if !ok {
106 return
107 }
108 - delete(f.leaseToIP, leaseID)
109 - f.removeLeaseFromIPLocked(leaseID, ip)
108 + delete(f.identityToIP, key)
109 + f.removeIdentityFromIPLocked(key, ip)
110 }
111
112 -func (f *IPFilter) removeLeaseFromIPLocked(leaseID, ip string) {
113 - leases := f.ipToLeases[ip]
114 - for i, candidate := range leases {
115 - if candidate == leaseID {
116 - f.ipToLeases[ip] = append(leases[:i], leases[i+1:]...)
112 +func (f *IPFilter) removeIdentityFromIPLocked(key, ip string) {
113 + identities := f.ipToIdentities[ip]
114 + for i, candidate := range identities {
115 + if candidate == key {
116 + f.ipToIdentities[ip] = append(identities[:i], identities[i+1:]...)
117 break
118 }
119 }
120 - if len(f.ipToLeases[ip]) == 0 {
121 - delete(f.ipToLeases, ip)
120 + if len(f.ipToIdentities[ip]) == 0 {
121 + delete(f.ipToIdentities, ip)
122 }
123 }
portal/policy/runtime.go
+42 -42
@@ -5,21 +5,21 @@ import (
5 )
6
7 type Runtime struct {
8 - approver *Approver
9 - bpsManager *BPSManager
10 - ipFilter *IPFilter
11 - bannedLeases map[string]struct{}
12 - udpEnabled bool
13 - udpMaxLeases int
14 - mu sync.RWMutex
8 + approver *Approver
9 + bpsManager *BPSManager
10 + ipFilter *IPFilter
11 + bannedIdentityKeys map[string]struct{}
12 + udpEnabled bool
13 + udpMaxLeases int
14 + mu sync.RWMutex
15 }
16
17 func NewRuntime() *Runtime {
18 return &Runtime{
19 - approver: NewApprover(),
20 - bpsManager: NewBPSManager(),
21 - ipFilter: NewIPFilter(),
22 - bannedLeases: make(map[string]struct{}),
19 + approver: NewApprover(),
20 + bpsManager: NewBPSManager(),
21 + ipFilter: NewIPFilter(),
22 + bannedIdentityKeys: make(map[string]struct{}),
23 }
24 }
25
@@ -44,90 +44,90 @@ func (r *Runtime) BPSManager() *BPSManager {
44 return r.bpsManager
45 }
46
47 -func (r *Runtime) BanLease(leaseID string) {
48 - if r == nil || leaseID == "" {
47 +func (r *Runtime) BanIdentity(key string) {
48 + if r == nil || key == "" {
49 return
50 }
51 r.mu.Lock()
52 defer r.mu.Unlock()
53 - r.bannedLeases[leaseID] = struct{}{}
53 + r.bannedIdentityKeys[key] = struct{}{}
54 }
55
56 -func (r *Runtime) UnbanLease(leaseID string) {
57 - if r == nil || leaseID == "" {
56 +func (r *Runtime) UnbanIdentity(key string) {
57 + if r == nil || key == "" {
58 return
59 }
60 r.mu.Lock()
61 defer r.mu.Unlock()
62 - delete(r.bannedLeases, leaseID)
62 + delete(r.bannedIdentityKeys, key)
63 }
64
65 -func (r *Runtime) IsLeaseBanned(leaseID string) bool {
66 - if r == nil || leaseID == "" {
65 +func (r *Runtime) IsIdentityBanned(key string) bool {
66 + if r == nil || key == "" {
67 return false
68 }
69 r.mu.RLock()
70 defer r.mu.RUnlock()
71 - _, ok := r.bannedLeases[leaseID]
71 + _, ok := r.bannedIdentityKeys[key]
72 return ok
73 }
74
75 -func (r *Runtime) BannedLeases() []string {
75 +func (r *Runtime) BannedIdentityKeys() []string {
76 if r == nil {
77 return nil
78 }
79 r.mu.RLock()
80 defer r.mu.RUnlock()
81 - out := make([]string, 0, len(r.bannedLeases))
82 - for leaseID := range r.bannedLeases {
83 - out = append(out, leaseID)
81 + out := make([]string, 0, len(r.bannedIdentityKeys))
82 + for key := range r.bannedIdentityKeys {
83 + out = append(out, key)
84 }
85 return out
86 }
87
88 -func (r *Runtime) SetBannedLeases(leaseIDs []string) {
88 +func (r *Runtime) SetBannedIdentityKeys(keys []string) {
89 if r == nil {
90 return
91 }
92
93 - bannedLeases := make(map[string]struct{}, len(leaseIDs))
94 - for _, leaseID := range leaseIDs {
95 - if leaseID == "" {
93 + bannedIdentityKeys := make(map[string]struct{}, len(keys))
94 + for _, key := range keys {
95 + if key == "" {
96 continue
97 }
98 - bannedLeases[leaseID] = struct{}{}
98 + bannedIdentityKeys[key] = struct{}{}
99 }
100
101 r.mu.Lock()
102 - r.bannedLeases = bannedLeases
102 + r.bannedIdentityKeys = bannedIdentityKeys
103 r.mu.Unlock()
104 }
105
106 -func (r *Runtime) EffectiveApproval(leaseID string) bool {
107 - if r == nil || r.approver == nil || leaseID == "" {
106 +func (r *Runtime) EffectiveApproval(key string) bool {
107 + if r == nil || r.approver == nil || key == "" {
108 return true
109 }
110 if r.approver.Mode() == ModeAuto {
111 return true
112 }
113 - return r.approver.IsApproved(leaseID)
113 + return r.approver.IsApproved(key)
114 }
115
116 -func (r *Runtime) IsLeaseDenied(leaseID string) bool {
117 - if r == nil || r.approver == nil || leaseID == "" {
116 +func (r *Runtime) IsIdentityDenied(key string) bool {
117 + if r == nil || r.approver == nil || key == "" {
118 return false
119 }
120 - return r.approver.IsDenied(leaseID)
120 + return r.approver.IsDenied(key)
121 }
122
123 -func (r *Runtime) IsLeaseRoutable(leaseID string) bool {
123 +func (r *Runtime) IsIdentityRoutable(key string) bool {
124 if r == nil {
125 return true
126 }
127 - if r.IsLeaseBanned(leaseID) || r.IsLeaseDenied(leaseID) {
127 + if r.IsIdentityBanned(key) || r.IsIdentityDenied(key) {
128 return false
129 }
130 - return r.EffectiveApproval(leaseID)
130 + return r.EffectiveApproval(key)
131 }
132
133 func (r *Runtime) SetUDPPolicy(enabled bool, maxLeases int) {
@@ -158,14 +158,14 @@ func (r *Runtime) UDPMaxLeases() int {
158 return r.udpMaxLeases
159 }
160
161 -func (r *Runtime) ForgetLease(leaseID string) {
161 +func (r *Runtime) ForgetIdentity(key string) {
162 if r == nil {
163 return
164 }
165 if r.ipFilter != nil {
166 - r.ipFilter.RemoveLeaseIP(leaseID)
166 + r.ipFilter.RemoveIdentityIP(key)
167 }
168 if r.bpsManager != nil {
169 - r.bpsManager.DeleteLeaseBPS(leaseID)
169 + r.bpsManager.DeleteIdentityBPS(key)
170 }
171 }
portal/server.go
+29 -29
@@ -40,7 +40,7 @@ const (
40
41 type ServerConfig struct {
42 PortalURL string
43 - OwnerPrivateKey string
43 + IdentityPath string
44 Bootstraps []string
45 WireGuardPrivateKey string
46 DiscoveryPort int
@@ -72,10 +72,9 @@ type Server struct {
72 group *errgroup.Group
73 registry *leaseRegistry
74 ports *transport.PortAllocator
75 - ownerIdentity utils.Secp256k1Identity
75 + identity types.Identity
76 wgConfig wireguard.Config
77 cfg ServerConfig
78 - rootHost string
78 trustedProxyCIDRs []*net.IPNet
79 relaySet *discovery.RelaySet
80 shutdownOnce sync.Once
@@ -133,19 +132,15 @@ func NewServer(cfg ServerConfig) (*Server, error) {
132 portMax = defaultUDPPortBase + cfg.UDPPortCount - 1
133 }
134
136 - ownerPrivateKey := strings.TrimSpace(cfg.OwnerPrivateKey)
137 - ownerIdentity, err := utils.ResolveSecp256k1Identity(ownerPrivateKey)
135 + identity, generatedIdentity, err := utils.LoadOrCreateIdentity(cfg.IdentityPath, types.Identity{Name: rootHost})
136 if err != nil {
139 - if ownerPrivateKey == "" {
140 - return nil, fmt.Errorf("generate relay owner private key: %w", err)
141 - }
142 - return nil, fmt.Errorf("resolve owner identity: %w", err)
137 + return nil, fmt.Errorf("load relay identity: %w", err)
138 }
144 - if ownerPrivateKey == "" {
139 + if generatedIdentity {
140 log.Warn().
146 - Str("owner_address", ownerIdentity.Address).
147 - Str("owner_private_key", ownerIdentity.PrivateKey).
148 - Msg("generated relay owner private key; set OWNER_PRIVATE_KEY unique identity")
141 + Str("identity_path", cfg.IdentityPath).
142 + Str("address", identity.Address).
143 + Msg("generated relay identity and saved it to disk")
144 }
145
146 policy := policy.NewRuntime()
@@ -155,17 +150,16 @@ func NewServer(cfg ServerConfig) (*Server, error) {
150
151 s := &Server{
152 cfg: cfg,
158 - rootHost: rootHost,
153 registry: registry,
154 ports: ports,
161 - ownerIdentity: ownerIdentity,
155 + identity: identity,
156 wgConfig: wgConfig,
157 trustedProxyCIDRs: trustedProxyCIDRs,
158 }
159
160 if cfg.DiscoveryEnabled {
161 s.relaySet = discovery.NewRelaySet()
168 - _, err = s.relaySet.RegisterBootstrapRelayURLs(cfg.Bootstraps, time.Now().UTC())
162 + _, err = s.relaySet.RegisterBootstrapRelayURLs(cfg.Bootstraps)
163 if err != nil {
164 return nil, err
165 }
@@ -173,7 +167,6 @@ func NewServer(cfg ServerConfig) (*Server, error) {
167
168 return s, nil
169 }
176 -
170 func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
171 if s.group != nil {
172 return errors.New("server already started")
@@ -255,12 +248,12 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
248 logEvent := log.Info().
249 Str("api_addr", utils.HostPortOrLoopback(s.apiListener.Addr().String())).
250 Str("sni_addr", s.sniListener.Addr().String()).
258 - Str("root_host", s.rootHost).
251 + Str("root_host", s.identity.Name).
252 Str("acme_dns_provider", s.cfg.ACME.DNSProvider).
253 Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
254 Bool("wireguard_enabled", s.wgConfig.PrivateKey != "").
255 Bool("udp_enabled", s.cfg.UDPPortCount > 0).
263 - Bool("acme_enabled", !strings.HasSuffix(s.rootHost, "localhost") && s.rootHost != "127.0.0.1" && s.rootHost != "::1")
256 + Bool("acme_enabled", !strings.HasSuffix(s.identity.Name, "localhost") && s.identity.Name != "127.0.0.1" && s.identity.Name != "::1")
257 if s.quicTunnel != nil {
258 logEvent = logEvent.Str("internal_quic_tunnel_addr", s.quicTunnel.Addr().String())
259 }
@@ -276,6 +269,13 @@ func (s *Server) Wait() error {
269 return s.group.Wait()
270 }
271
272 +func (s *Server) Identity() types.Identity {
273 + if s == nil {
274 + return types.Identity{}
275 + }
276 + return s.identity.Copy()
277 +}
278 +
279 func (s *Server) Shutdown(ctx context.Context) error {
280 var shutdownErr error
281 s.shutdownOnce.Do(func() {
@@ -335,8 +335,8 @@ func (s *Server) LeaseSnapshots() []types.Lease {
335 s.registry.mu.RLock()
336 defer s.registry.mu.RUnlock()
337
338 - records := make([]*leaseRecord, 0, len(s.registry.leaseByID))
339 - for _, record := range s.registry.leaseByID {
338 + records := make([]*leaseRecord, 0, len(s.registry.leasesByKey))
339 + for _, record := range s.registry.leasesByKey {
340 records = append(records, record)
341 }
342 snapshots := make([]types.Lease, 0, len(records))
@@ -360,10 +360,10 @@ func (s *Server) LeaseSnapshotByHostname(hostname string) (types.Lease, bool) {
360
361 func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig, *acme.Manager, error) {
362 acmeCfg := s.cfg.ACME
363 - if baseDomain := utils.NormalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.rootHost {
364 - return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("acme base domain %q does not match portal root host %q", acmeCfg.BaseDomain, s.rootHost)
363 + if baseDomain := utils.NormalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.identity.Name {
364 + return keyless.TLSMaterialConfig{}, nil, fmt.Errorf("acme base domain %q does not match portal root host %q", acmeCfg.BaseDomain, s.identity.Name)
365 }
366 - acmeCfg.BaseDomain = s.rootHost
366 + acmeCfg.BaseDomain = s.identity.Name
367
368 manager, err := acme.NewManager(acmeCfg)
369 if err != nil {
@@ -414,7 +414,7 @@ func (s *Server) runSNIListener(ctx context.Context) error {
414 return
415 }
416
417 - if serverName == s.rootHost {
417 + if serverName == s.identity.Name {
418 if s.apiListener == nil {
419 _ = wrappedConn.Close()
420 return
@@ -430,7 +430,7 @@ func (s *Server) runSNIListener(ctx context.Context) error {
430 }
431
432 record, ok := s.registry.Lookup(serverName)
433 - if !ok || record == nil || time.Now().After(record.ExpiresAt) || !s.registry.policy.IsLeaseRoutable(record.ID) || record.stream == nil {
433 + if !ok || record == nil || time.Now().After(record.ExpiresAt) || !s.registry.policy.IsIdentityRoutable(record.Key()) || record.stream == nil {
434 _ = wrappedConn.Close()
435 return
436 }
@@ -548,7 +548,7 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
548 now := time.Now().UTC()
549 var relaySetChanged bool
550 var warnErr error
551 - _, relaySetChanged, _, warnErr, err = s.relaySet.ApplyRelayDiscoveryResponse(bootstrap.RelayID, bootstrap.APIHTTPSAddr, resp, now)
551 + _, relaySetChanged, _, warnErr, err = s.relaySet.ApplyRelayDiscoveryResponse(bootstrap.Identity, bootstrap.APIHTTPSAddr, resp, now)
552 if relaySetChanged && s.overlay != nil {
553 if syncErr := s.overlay.Sync(s.cfg.PortalURL, s.relaySet.Snapshot()); syncErr != nil {
554 if warnErr == nil {
@@ -598,7 +598,7 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
598 var relaySetChanged bool
599 var warnErr error
600 var snapshot map[string]types.RelayState
601 - _, relaySetChanged, _, warnErr, err = s.relaySet.ApplyOverlayRelayDiscoveryResponse(relay.RelayID, relay.APIHTTPSAddr, resp, now)
601 + _, relaySetChanged, _, warnErr, err = s.relaySet.ApplyOverlayRelayDiscoveryResponse(relay.Identity, relay.APIHTTPSAddr, resp, now)
602 if relaySetChanged {
603 snapshot = s.relaySet.Snapshot()
604 if syncErr := s.overlay.Sync(s.cfg.PortalURL, snapshot); syncErr != nil {
@@ -622,7 +622,7 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
622 }
623 }
624 }
625 - expired, expireReason, consecutiveFailures := s.relaySet.RecordDiscoveryFailure(relay.RelayID, relay.APIHTTPSAddr, failureErr, defaultWGRecoveryFailures, time.Now().UTC())
625 + expired, expireReason, consecutiveFailures := s.relaySet.RecordDiscoveryFailure(relay.Identity, relay.APIHTTPSAddr, failureErr, defaultWGRecoveryFailures, time.Now().UTC())
626 if expired {
627 if syncErr := s.overlay.Sync(s.cfg.PortalURL, s.relaySet.Snapshot()); syncErr != nil && failureErr == nil {
628 failureErr = syncErr
portal/server_test.go
+49 -19
@@ -7,6 +7,7 @@ import (
7 "io"
8 "net"
9 "net/http"
10 + "path/filepath"
11 "reflect"
12 "sort"
13 "strings"
@@ -36,6 +37,9 @@ func mustRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
37 t.Fatalf("DeriveWireGuardOverlayIPv4() error = %v", err)
38 }
39 desc, err := discovery.NormalizeDescriptor(types.RelayDescriptor{
40 + Identity: types.Identity{
41 + Name: utils.PortalRootHost(relayURL),
42 + },
43 RelayID: relayURL,
44 Sequence: uint64(now.UnixMilli()),
45 Version: 1,
@@ -54,11 +58,17 @@ func mustRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
58 return desc
59 }
60
61 +func tempIdentityPath(t *testing.T) string {
62 + t.Helper()
63 + return filepath.Join(t.TempDir(), "relay_identity.json")
64 +}
65 +
66 func TestNewServerGeneratesWireGuardWhenDiscoveryEnabled(t *testing.T) {
67 t.Parallel()
68
69 server, err := NewServer(ServerConfig{
70 PortalURL: "https://portal.example.com",
71 + IdentityPath: tempIdentityPath(t),
72 DiscoveryEnabled: true,
73 })
74 if err != nil {
@@ -83,6 +93,7 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
93
94 server, err := NewServer(ServerConfig{
95 PortalURL: "https://localhost:4017",
96 + IdentityPath: tempIdentityPath(t),
97 ACME: acme.Config{KeyDir: t.TempDir()},
98 APIListenAddr: "127.0.0.1:0",
99 SNIListenAddr: "127.0.0.1:0",
@@ -141,11 +152,12 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
152 }
153 }
154
144 -func TestServerStartDiscoveryOmitsOwnerIdentityFields(t *testing.T) {
155 +func TestServerStartDiscoveryIncludesIdentityAndOmitsSignerFields(t *testing.T) {
156 t.Parallel()
157
158 server, err := NewServer(ServerConfig{
159 PortalURL: "https://localhost:4017",
160 + IdentityPath: tempIdentityPath(t),
161 ACME: acme.Config{KeyDir: t.TempDir()},
162 APIListenAddr: "127.0.0.1:0",
163 SNIListenAddr: "127.0.0.1:0",
@@ -189,7 +201,13 @@ func TestServerStartDiscoveryOmitsOwnerIdentityFields(t *testing.T) {
201 if err != nil {
202 t.Fatalf("read /discovery response: %v", err)
203 }
192 - for _, key := range []string{"owner_address", "signer_public_key", "descriptor_signature"} {
204 + for _, key := range []string{"\"address\"", "\"name\"", "signer_public_key", "descriptor_signature"} {
205 + if key == "\"address\"" || key == "\"name\"" {
206 + if !strings.Contains(string(body), key) {
207 + t.Fatalf("/discovery body = %q, want %q present", string(body), key)
208 + }
209 + continue
210 + }
211 if strings.Contains(string(body), key) {
212 t.Fatalf("/discovery body = %q, want %q omitted", string(body), key)
213 }
@@ -201,6 +219,7 @@ func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
219
220 server, err := NewServer(ServerConfig{
221 PortalURL: "https://portal.example.com",
222 + IdentityPath: tempIdentityPath(t),
223 ACME: acme.Config{BaseDomain: "other.example.com", KeyDir: t.TempDir()},
224 APIListenAddr: "127.0.0.1:0",
225 SNIListenAddr: "127.0.0.1:0",
@@ -224,6 +243,7 @@ func TestNewServerDerivesWireGuardConfigFromPrivateKey(t *testing.T) {
243
244 server, err := NewServer(ServerConfig{
245 PortalURL: "https://portal.example.com",
246 + IdentityPath: tempIdentityPath(t),
247 WireGuardPrivateKey: strings.Repeat("33", 32),
248 DiscoveryPort: 41011,
249 })
@@ -264,6 +284,7 @@ func TestNewServerIgnoresDiscoveryPortWithoutWireGuardKey(t *testing.T) {
284
285 server, err := NewServer(ServerConfig{
286 PortalURL: "https://portal.example.com",
287 + IdentityPath: tempIdentityPath(t),
288 DiscoveryPort: 51820,
289 })
290 if err != nil {
@@ -279,6 +300,7 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
300
301 server, err := NewServer(ServerConfig{
302 PortalURL: "https://portal.example.com",
303 + IdentityPath: tempIdentityPath(t),
304 UDPPortCount: 1,
305 })
306 if err != nil {
@@ -286,8 +308,10 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
308 }
309
310 resp, err := server.registerLease(types.RegisterChallengeRequest{
289 - Name: "Demo-App",
290 - OwnerAddress: server.ownerIdentity.Address,
311 + Identity: types.Identity{
312 + Name: "Demo-App",
313 + Address: server.identity.Address,
314 + },
315 }, "203.0.113.10", "")
316 if err != nil {
317 t.Fatalf("registerLease() error = %v", err)
@@ -298,9 +322,9 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
322 t.Fatalf("registerLease() hostname = %q, want %q", resp.Hostname, wantHostname)
323 }
324
301 - record, err := server.registry.FindByID(resp.LeaseID)
325 + record, err := server.registry.Find(resp.Identity)
326 if err != nil {
303 - t.Fatalf("registry.FindByID() error = %v, want registered lease", err)
327 + t.Fatalf("registry.Find() error = %v, want registered lease", err)
328 }
329 snapshot := server.registry.Snapshot(record)
330 if snapshot.Name != "demo-app" {
@@ -316,6 +340,7 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
340
341 server, err := NewServer(ServerConfig{
342 PortalURL: "https://portal.example.com",
343 + IdentityPath: tempIdentityPath(t),
344 UDPPortCount: 10,
345 })
346 if err != nil {
@@ -324,22 +349,24 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
349 server.registry.policy.SetUDPPolicy(true, 0)
350
351 resp, err := server.registerLease(types.RegisterChallengeRequest{
327 - Name: "demo-udp",
328 - OwnerAddress: server.ownerIdentity.Address,
329 - UDPEnabled: true,
352 + Identity: types.Identity{
353 + Name: "demo-udp",
354 + Address: server.identity.Address,
355 + },
356 + UDPEnabled: true,
357 }, "203.0.113.10", "")
358 if err != nil {
359 t.Fatalf("registerLease() error = %v", err)
360 }
361 t.Cleanup(func() {
335 - if record, err := server.registry.FindByID(resp.LeaseID); err == nil {
362 + if record, err := server.registry.Find(resp.Identity); err == nil {
363 record.Close()
364 }
365 })
366
340 - record, err := server.registry.FindByID(resp.LeaseID)
367 + record, err := server.registry.Find(resp.Identity)
368 if err != nil {
342 - t.Fatalf("registry.FindByID() error = %v, want registered lease", err)
369 + t.Fatalf("registry.Find() error = %v, want registered lease", err)
370 }
371 if record.stream == nil {
372 t.Fatal("stream = nil, want stream runtime")
@@ -360,6 +387,7 @@ func TestServerUpsertDiscoverySeedURLsSkipsLocalRelayHosts(t *testing.T) {
387
388 server, err := NewServer(ServerConfig{
389 PortalURL: "https://portal.example.com",
390 + IdentityPath: tempIdentityPath(t),
391 Bootstraps: []string{"https://bootstrap.example.com"},
392 WireGuardPrivateKey: strings.Repeat("23", 32),
393 DiscoveryPort: 41022,
@@ -373,7 +401,7 @@ func TestServerUpsertDiscoverySeedURLsSkipsLocalRelayHosts(t *testing.T) {
401 "https://localhost:4017",
402 "https://relay-a.example.com",
403 "https://127.0.0.1:4017",
376 - }, time.Now().UTC())
404 + })
405 if err != nil {
406 t.Fatalf("UpsertSeedURLs() error = %v", err)
407 }
@@ -416,6 +444,7 @@ func TestServerRecordVerifiedDiscoveryPeerRequiresDirectConfirmation(t *testing.
444
445 server, err := NewServer(ServerConfig{
446 PortalURL: "https://portal.example.com",
447 + IdentityPath: tempIdentityPath(t),
448 Bootstraps: []string{"https://bootstrap.example.com"},
449 WireGuardPrivateKey: strings.Repeat("24", 32),
450 DiscoveryPort: 41023,
@@ -428,18 +457,18 @@ func TestServerRecordVerifiedDiscoveryPeerRequiresDirectConfirmation(t *testing.
457 bootstrapDesc := mustRelayDescriptor(t, "https://bootstrap.example.com")
458 relayADesc := mustRelayDescriptor(t, "https://relay-a.example.com")
459
431 - applyDiscovery := func(targetRelayID, targetURL string, resp types.DiscoveryResponse, requireSelfOverlay bool) (bool, int, error, error) {
460 + applyDiscovery := func(targetIdentity types.Identity, targetURL string, resp types.DiscoveryResponse, requireSelfOverlay bool) (bool, int, error, error) {
461 now := time.Now().UTC()
462 if requireSelfOverlay {
434 - _, updated, added, warnErr, err := server.relaySet.ApplyOverlayRelayDiscoveryResponse(targetRelayID, targetURL, resp, now)
463 + _, updated, added, warnErr, err := server.relaySet.ApplyOverlayRelayDiscoveryResponse(targetIdentity, targetURL, resp, now)
464 return updated, added, warnErr, err
465 }
437 - _, updated, added, warnErr, err := server.relaySet.ApplyRelayDiscoveryResponse(targetRelayID, targetURL, resp, now)
466 + _, updated, added, warnErr, err := server.relaySet.ApplyRelayDiscoveryResponse(targetIdentity, targetURL, resp, now)
467 return updated, added, warnErr, err
468 }
469
470 resultUpdated, resultAdded, warnErr, err := applyDiscovery(
442 - bootstrapDesc.RelayID,
471 + bootstrapDesc.Identity,
472 bootstrapDesc.APIHTTPSAddr,
473 types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: bootstrapDesc},
474 false,
@@ -458,7 +487,7 @@ func TestServerRecordVerifiedDiscoveryPeerRequiresDirectConfirmation(t *testing.
487 }
488
489 resultUpdated, resultAdded, warnErr, err = applyDiscovery(
461 - bootstrapDesc.RelayID,
490 + bootstrapDesc.Identity,
491 bootstrapDesc.APIHTTPSAddr,
492 types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: bootstrapDesc, Relays: []types.RelayDescriptor{relayADesc}},
493 false,
@@ -529,7 +558,7 @@ func TestServerRecordVerifiedDiscoveryPeerRequiresDirectConfirmation(t *testing.
558 }
559
560 resultUpdated, resultAdded, warnErr, err = applyDiscovery(
532 - relayADesc.RelayID,
561 + relayADesc.Identity,
562 relayADesc.APIHTTPSAddr,
563 types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: relayADesc},
564 true,
@@ -569,6 +598,7 @@ func TestServerStartHidesDiscoveryRoutesWhenDisabled(t *testing.T) {
598
599 server, err := NewServer(ServerConfig{
600 PortalURL: "https://localhost:4017",
601 + IdentityPath: tempIdentityPath(t),
602 ACME: acme.Config{KeyDir: t.TempDir()},
603 APIListenAddr: "127.0.0.1:0",
604 SNIListenAddr: "127.0.0.1:0",
portal/transport/datagram_client.go
+4 -4
@@ -13,7 +13,7 @@ import (
13 )
14
15 type ClientDatagramState struct {
16 - LeaseID string
16 + Identity types.Identity
17 AccessToken string
18 }
19
@@ -54,7 +54,7 @@ func (d *ClientDatagram) RunLoop(
54 log.Info().
55 Err(err).
56 Str("component", "sdk-datagram-plane").
57 - Str("lease_id", state.LeaseID).
57 + Str("address", state.Identity.Address).
58 Msg("quic datagram plane unavailable; retrying")
59 if !utils.SleepOrDone(ctx, 2*time.Second) {
60 d.session.Stop("listener context closed")
@@ -65,7 +65,7 @@ func (d *ClientDatagram) RunLoop(
65
66 log.Info().
67 Str("component", "sdk-datagram-plane").
68 - Str("lease_id", state.LeaseID).
68 + Str("address", state.Identity.Address).
69 Str("remote_addr", conn.RemoteAddr().String()).
70 Msg("quic tunnel connected")
71
@@ -77,7 +77,7 @@ func (d *ClientDatagram) RunLoop(
77 log.Info().
78 Err(err).
79 Str("component", "sdk-datagram-plane").
80 - Str("lease_id", state.LeaseID).
80 + Str("address", state.Identity.Address).
81 Msg("quic datagram plane did not bind cleanly; retrying")
82 if !utils.SleepOrDone(ctx, time.Second) {
83 return
portal/transport/datagram_relay.go
+16 -16
@@ -128,12 +128,12 @@ func (a *PortAllocator) sortedInsertLocked(port int) {
128
129 // Datagram owns the UDP and QUIC datagram runtime for one lease.
130 type RelayDatagram struct {
131 - leaseID string
132 - port int
133 - session *datagramSession
134 - flowTable map[uint32]*flowState
135 - addrIndex map[string]uint32
136 - nextFlow uint32
131 + identityKey string
132 + port int
133 + session *datagramSession
134 + flowTable map[uint32]*flowState
135 + addrIndex map[string]uint32
136 + nextFlow uint32
137
138 conn *net.UDPConn
139
@@ -142,15 +142,15 @@ type RelayDatagram struct {
142 mu sync.Mutex
143 }
144
145 -func NewRelayDatagram(leaseID string, port int) *RelayDatagram {
145 +func NewRelayDatagram(identityKey string, port int) *RelayDatagram {
146 d := &RelayDatagram{
147 - leaseID: leaseID,
148 - port: port,
147 + identityKey: identityKey,
148 + port: port,
149 session: newDatagramSession(256, true, func(err error) {
150 log.Warn().
151 Err(err).
152 Str("component", "quic-flow-mux").
153 - Str("lease_id", leaseID).
153 + Str("identity_key", identityKey).
154 Msg("quic receive loop ended")
155 }),
156 flowTable: make(map[uint32]*flowState),
@@ -180,7 +180,7 @@ func (d *RelayDatagram) Start(ctx context.Context) error {
180
181 log.Info().
182 Str("component", "udp-relay").
183 - Str("lease_id", d.leaseID).
183 + Str("identity_key", d.identityKey).
184 Int("port", d.port).
185 Msg("udp relay started")
186
@@ -202,7 +202,7 @@ func (d *RelayDatagram) Close() {
202 }
203 log.Info().
204 Str("component", "udp-relay").
205 - Str("lease_id", d.leaseID).
205 + Str("identity_key", d.identityKey).
206 Int("port", d.port).
207 Msg("udp relay stopped")
208 })
@@ -215,7 +215,7 @@ func (d *RelayDatagram) Register(conn *quic.Conn) error {
215
216 log.Info().
217 Str("component", "quic-flow-mux").
218 - Str("lease_id", d.leaseID).
218 + Str("identity_key", d.identityKey).
219 Str("remote_addr", conn.RemoteAddr().String()).
220 Msg("quic tunnel connection registered")
221 return nil
@@ -290,7 +290,7 @@ func (d *RelayDatagram) dispatch(frame types.DatagramFrame) {
290 log.Warn().
291 Err(err).
292 Str("component", "quic-flow-mux").
293 - Str("lease_id", d.leaseID).
293 + Str("identity_key", d.identityKey).
294 Uint32("flow_id", frame.FlowID).
295 Msg("flow writeback failed")
296 d.forgetFlow(frame.FlowID)
@@ -360,7 +360,7 @@ func (d *RelayDatagram) readLoop(ctx context.Context) {
360 }
361 log.Warn().
362 Str("component", "udp-relay").
363 - Str("lease_id", d.leaseID).
363 + Str("identity_key", d.identityKey).
364 Err(err).
365 Msg("readLoop exiting: unexpected read error")
366 return
@@ -376,7 +376,7 @@ func (d *RelayDatagram) readLoop(ctx context.Context) {
376 if err := d.SendDatagram(flowID, payload); err != nil {
377 log.Warn().
378 Str("component", "udp-relay").
379 - Str("lease_id", d.leaseID).
379 + Str("identity_key", d.identityKey).
380 Err(err).
381 Uint32("flow_id", flowID).
382 Int("bytes", n).
portal/transport/stream_relay.go
+4 -4
@@ -19,7 +19,7 @@ var errStreamFull = errors.New("stream ready queue full")
19
20 type RelayStream struct {
21 notify chan struct{}
22 - leaseID string
22 + identityKey string
23 ready []*relaySession
24 idleInterval time.Duration
25 readyLimit int
@@ -27,9 +27,9 @@ type RelayStream struct {
27 mu sync.Mutex
28 }
29
30 -func NewRelayStream(leaseID string, idleInterval time.Duration, readyLimit int) *RelayStream {
30 +func NewRelayStream(identityKey string, idleInterval time.Duration, readyLimit int) *RelayStream {
31 return &RelayStream{
32 - leaseID: leaseID,
32 + identityKey: identityKey,
33 idleInterval: idleInterval,
34 readyLimit: readyLimit,
35 notify: make(chan struct{}, 1),
@@ -133,7 +133,7 @@ func (b *RelayStream) watchSession(session *relaySession) {
133 }
134 readyCount = len(b.ready)
135 log.Info().
136 - Str("lease_id", b.leaseID).
136 + Str("identity_key", b.identityKey).
137 Str("remote_addr", session.remoteAddrString()).
138 Int("ready", readyCount).
139 Msg("sdk reverse disconnected")
sdk/api_client.go
+41 -34
@@ -44,23 +44,16 @@ type apiClient struct {
44 dialTimeout time.Duration
45 requestTimeout time.Duration
46 rootCAPEM []byte
47 - name string
47 + identity types.Identity
48 accessToken string
49 metadata types.LeaseMetadata
50 - ownerPrivateKey string
51 - ownerAddress string
50 resolvedPublicIP string
51 }
52
53 func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
56 - name, err := utils.NormalizeDNSLabel(cfg.Name)
54 + identity, err := utils.ResolveLeaseIdentity(cfg.Identity)
55 if err != nil {
58 - return nil, err
59 - }
60 -
61 - identity, err := utils.ResolveSecp256k1Identity(cfg.OwnerPrivateKey)
62 - if err != nil {
63 - return nil, fmt.Errorf("resolve owner identity: %w", err)
56 + return nil, fmt.Errorf("resolve identity: %w", err)
57 }
58
59 normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
@@ -77,14 +70,12 @@ func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
70 requestTimeout := utils.DurationOrDefault(cfg.RequestTimeout, defaultRequestTimeout)
71
72 return &apiClient{
80 - baseURL: baseURL,
81 - dialTimeout: dialTimeout,
82 - requestTimeout: requestTimeout,
83 - rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
84 - name: name,
85 - metadata: cfg.Metadata.Copy(),
86 - ownerPrivateKey: identity.PrivateKey,
87 - ownerAddress: identity.Address,
73 + baseURL: baseURL,
74 + dialTimeout: dialTimeout,
75 + requestTimeout: requestTimeout,
76 + rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
77 + identity: identity,
78 + metadata: cfg.Metadata.Copy(),
79 }, nil
80 }
81
@@ -103,17 +94,17 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
94 }
95
96 var challenge types.RegisterChallengeResponse
106 - if err := utils.HTTPDoAPIPath(ctx, a.httpClient, a.baseURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
107 - Name: a.name,
108 - Metadata: a.metadata.Copy(),
109 - OwnerAddress: a.ownerAddress,
110 - TTL: int(ttl / time.Second),
111 - UDPEnabled: udpEnabled,
112 - }, nil, &challenge); err != nil {
97 + challengeReq := types.RegisterChallengeRequest{
98 + Identity: a.identity.Copy(),
99 + Metadata: a.metadata.Copy(),
100 + TTL: int(ttl / time.Second),
101 + UDPEnabled: udpEnabled,
102 + }
103 + if err := utils.HTTPDoAPIPath(ctx, a.httpClient, a.baseURL, http.MethodPost, types.PathSDKRegisterChallenge, challengeReq, nil, &challenge); err != nil {
104 return types.RegisterResponse{}, err
105 }
106
116 - signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, a.ownerPrivateKey)
107 + signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, a.identity.PrivateKey)
108 if err != nil {
109 return types.RegisterResponse{}, err
110 }
@@ -131,6 +122,14 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
122 if resp.AccessToken == "" {
123 return types.RegisterResponse{}, errors.New("relay did not return access token")
124 }
125 + registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
126 + if err != nil {
127 + return types.RegisterResponse{}, err
128 + }
129 + if registeredIdentity.Key() != a.identity.Key() {
130 + return types.RegisterResponse{}, errors.New("relay returned mismatched lease identity")
131 + }
132 + resp.Identity = registeredIdentity
133 a.mu.Lock()
134 a.accessToken = resp.AccessToken
135 a.mu.Unlock()
@@ -192,7 +191,7 @@ func (a *apiClient) ensureCompatible(ctx context.Context, httpClient *http.Clien
191 return nil
192 }
193
195 -func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Duration) error {
194 +func (a *apiClient) renewLease(ctx context.Context, identity types.Identity, ttl time.Duration) error {
195 if err := a.ensureHTTPClient(ctx); err != nil {
196 return err
197 }
@@ -206,7 +205,7 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
205
206 var resp types.RenewResponse
207 if err := utils.HTTPDoAPIPath(ctx, a.httpClient, a.baseURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
209 - LeaseID: leaseID,
208 + Identity: identity.Copy(),
209 AccessToken: accessToken,
210 TTL: int(ttl / time.Second),
211 ReportedIP: a.reportedIP(ctx),
@@ -217,6 +216,13 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
216 if resp.AccessToken == "" {
217 return errors.New("relay did not return renewed access token")
218 }
219 + renewedIdentity, err := utils.NormalizeIdentity(resp.Identity)
220 + if err != nil {
221 + return err
222 + }
223 + if renewedIdentity.Key() != a.identity.Key() {
224 + return errors.New("relay returned mismatched renewed lease identity")
225 + }
226
227 a.mu.Lock()
228 if a.accessToken == accessToken {
@@ -226,17 +232,17 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
232 return nil
233 }
234
229 -func (a *apiClient) unregisterLease(ctx context.Context, leaseID string) error {
235 +func (a *apiClient) unregisterLease(ctx context.Context, identity types.Identity) error {
236 a.mu.RLock()
237 accessToken := a.accessToken
238 a.mu.RUnlock()
239 return utils.HTTPDoAPIPath(ctx, a.httpClient, a.baseURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
234 - LeaseID: leaseID,
240 + Identity: identity.Copy(),
241 AccessToken: accessToken,
242 }, nil, nil)
243 }
244
239 -func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net.Conn, error) {
245 +func (a *apiClient) openReverseSession(ctx context.Context, identity types.Identity) (net.Conn, error) {
246 if err := a.ensureHTTPClient(ctx); err != nil {
247 return nil, err
248 }
@@ -253,7 +259,8 @@ func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net
259
260 connectURL := utils.ResolveAPIURL(a.baseURL, types.PathSDKConnect)
261 query := connectURL.Query()
256 - query.Set("lease_id", leaseID)
262 + query.Set("name", identity.Name)
263 + query.Set("address", identity.Address)
264 connectURL.RawQuery = query.Encode()
265
266 req := &http.Request{
@@ -314,7 +321,7 @@ func (c *bufferedConn) Read(p []byte) (int, error) {
321 }
322
323 // openQUICSession opens a QUIC connection to the relay for datagram transport.
317 -func (a *apiClient) openQUICSession(ctx context.Context, leaseID, accessToken string) (*quic.Conn, error) {
324 +func (a *apiClient) openQUICSession(ctx context.Context, identity types.Identity, accessToken string) (*quic.Conn, error) {
325 if err := a.ensureHTTPClient(ctx); err != nil {
326 return nil, err
327 }
@@ -341,7 +348,7 @@ func (a *apiClient) openQUICSession(ctx context.Context, leaseID, accessToken st
348 }
349
350 controlMsg := types.QUICControlMessage{
344 - LeaseID: leaseID,
351 + Identity: identity.Copy(),
352 AccessToken: accessToken,
353 }
354 if err := json.NewEncoder(stream).Encode(controlMsg); err != nil {
sdk/expose.go
+32 -25
@@ -24,13 +24,12 @@ type Exposure struct {
24 cancel context.CancelFunc
25 done <-chan struct{}
26
27 - name string
27 + identity types.Identity
28 TargetAddr string
29 UDPAddr string
30 udpEnabled bool
31 banMITM bool
32 metadata types.LeaseMetadata
33 - ownerPrivateKey string
33 rootCAPEM []byte
34 discoveryEnabled bool
35
@@ -46,16 +45,15 @@ type Exposure struct {
45 }
46
47 type ExposeConfig struct {
49 - RelayURLs []string
50 - Name string
51 - TargetAddr string
52 - UDPAddr string
53 - UDPEnabled bool
54 - BanMITM bool
55 - Discovery bool
56 - Metadata types.LeaseMetadata
57 - OwnerPrivateKey string
58 - RootCAPEM []byte
48 + RelayURLs []string
49 + Identity types.Identity
50 + TargetAddr string
51 + UDPAddr string
52 + UDPEnabled bool
53 + BanMITM bool
54 + Discovery bool
55 + Metadata types.LeaseMetadata
56 + RootCAPEM []byte
57 }
58
59 // Expose creates relay listeners for each normalized relay URL and exposes a
@@ -66,9 +64,9 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
64 return nil, err
65 }
66
69 - identity, err := utils.ResolveSecp256k1Identity(cfg.OwnerPrivateKey)
67 + identity, err := utils.ResolveLeaseIdentity(cfg.Identity)
68 if err != nil {
71 - return nil, fmt.Errorf("resolve owner identity: %w", err)
69 + return nil, fmt.Errorf("resolve identity: %w", err)
70 }
71 targetAddr, err := utils.NormalizeLoopbackTarget(cfg.TargetAddr)
72 if err != nil {
@@ -86,13 +84,12 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
84 exposure := &Exposure{
85 cancel: cancel,
86 done: exposureCtx.Done(),
89 - name: cfg.Name,
87 + identity: identity,
88 TargetAddr: targetAddr,
89 UDPAddr: udpAddr,
90 udpEnabled: cfg.UDPEnabled,
91 banMITM: cfg.BanMITM,
92 metadata: cfg.Metadata.Copy(),
95 - ownerPrivateKey: identity.PrivateKey,
93 rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
94 discoveryEnabled: cfg.Discovery,
95 accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
@@ -134,6 +131,13 @@ func (e *Exposure) Addr() net.Addr {
131 return listenerAddr("portal:exposure")
132 }
133
134 +func (e *Exposure) Identity() types.Identity {
135 + if e == nil {
136 + return types.Identity{}
137 + }
138 + return e.identity.Copy()
139 +}
140 +
141 type exposureConn struct {
142 net.Conn
143 id uint64
@@ -243,8 +247,12 @@ func (e *Exposure) runRelayDiscoveryLoop(ctx context.Context) {
247 }
248
249 now := time.Now().UTC()
250 + targetDescriptor, err := discovery.SeedDescriptor(relayURL)
251 + if err != nil {
252 + continue
253 + }
254 var descriptorRelayURLs []string
247 - descriptorRelayURLs, _, _, _, err = e.relaySet.ApplyRelayDiscoveryResponse(relayURL, relayURL, resp, now)
255 + descriptorRelayURLs, _, _, _, err = e.relaySet.ApplyRelayDiscoveryResponse(targetDescriptor.Identity, relayURL, resp, now)
256 if err != nil {
257 continue
258 }
@@ -304,13 +312,12 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
312 }
313 for _, relayURL := range missingRelayURLs {
314 listener, err := NewListener(context.Background(), relayURL, ListenerConfig{
307 - Name: e.name,
308 - OwnerPrivateKey: e.ownerPrivateKey,
309 - UDPEnabled: e.udpEnabled,
310 - BanMITM: e.banMITM,
311 - Metadata: e.metadata.Copy(),
312 - RootCAPEM: append([]byte(nil), e.rootCAPEM...),
313 - relaySet: e.relaySet,
315 + Identity: e.identity.Copy(),
316 + UDPEnabled: e.udpEnabled,
317 + BanMITM: e.banMITM,
318 + Metadata: e.metadata.Copy(),
319 + RootCAPEM: append([]byte(nil), e.rootCAPEM...),
320 + relaySet: e.relaySet,
321 })
322 if err != nil {
323 if failOnError {
@@ -367,7 +374,7 @@ func (e *Exposure) runListenerAcceptLoop(listener *Listener) {
374 log.Warn().
375 Err(err).
376 Str("relay_url", relayURL).
370 - Str("lease_id", listener.LeaseID()).
377 + Str("address", listener.Address()).
378 Msg("datagram accept failed")
379 return
380 }
sdk/expose_test.go
+8 -5
@@ -14,6 +14,9 @@ func mustRelayDescriptor(t *testing.T, relayID, relayURL string) types.RelayDesc
14
15 now := time.Now().UTC()
16 desc, err := discovery.NormalizeDescriptor(types.RelayDescriptor{
17 + Identity: types.Identity{
18 + Name: relayID,
19 + },
20 RelayID: relayID,
21 Sequence: uint64(now.UnixMilli()),
22 Version: 1,
@@ -160,17 +163,17 @@ func TestExposureSetRelayURLsRemovesStaleListener(t *testing.T) {
163 }
164 }
165
163 -func TestExposurePinDiscoveredDescriptorRejectsURLChange(t *testing.T) {
166 +func TestExposurePinDiscoveredDescriptorAllowsURLChangeForSameIdentity(t *testing.T) {
167 exposure := &Exposure{relaySet: discovery.NewRelaySet()}
168 desc := mustRelayDescriptor(t, "relay-a", "https://relay-a.example")
169
167 - if _, _, _, _, err := exposure.relaySet.ApplyRelayDiscoveryResponse(desc.RelayID, desc.APIHTTPSAddr, types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: desc}, time.Now().UTC()); err != nil {
170 + if _, _, _, _, err := exposure.relaySet.ApplyRelayDiscoveryResponse(desc.Identity, desc.APIHTTPSAddr, types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: desc}, time.Now().UTC()); err != nil {
171 t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
172 }
173
174 changedURL := mustRelayDescriptor(t, desc.RelayID, "https://relay-b.example")
172 - _, _, _, _, err := exposure.relaySet.ApplyRelayDiscoveryResponse(desc.RelayID, "", types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: changedURL}, time.Now().UTC())
173 - if err == nil {
174 - t.Fatal("ApplyRelayDiscoveryResponse() error = nil, want pinned relay url mismatch")
175 + _, _, _, _, err := exposure.relaySet.ApplyRelayDiscoveryResponse(desc.Identity, "", types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: changedURL}, time.Now().UTC())
176 + if err != nil {
177 + t.Fatalf("ApplyRelayDiscoveryResponse() error = %v, want nil for same relay identity", err)
178 }
179 }
sdk/listener.go
+39 -38
@@ -22,8 +22,7 @@ import (
22 )
23
24 type ListenerConfig struct {
25 - Name string
26 - OwnerPrivateKey string
25 + Identity types.Identity
26 UDPEnabled bool
27 BanMITM bool
28 Metadata types.LeaseMetadata
@@ -58,9 +57,9 @@ type Listener struct {
57 registerOnce sync.Once
58
59 banMITM bool
60 + identity types.Identity
61 relaySet *discovery.RelaySet
62 mu sync.Mutex
63 - leaseID string
63 hostname string
64 udpAddr string
65 metadata types.LeaseMetadata
@@ -93,6 +92,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
92 retryWait: retryWait,
93 leaseTTL: leaseTTL,
94 renewBefore: renewBefore,
95 + identity: api.identity.Copy(),
96 metadata: cfg.Metadata.Copy(),
97 banMITM: cfg.BanMITM,
98 relaySet: cfg.relaySet,
@@ -104,11 +104,11 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
104 log.Info().
105 Err(err).
106 Str("component", "sdk-datagram-plane").
107 - Str("lease_id", l.LeaseID()).
107 + Str("address", l.Address()).
108 Msg("quic datagram plane disconnected; waiting to reconnect")
109 })
110 go l.datagram.RunLoop(listenerCtx, l.currentDatagramState, func(ctx context.Context, state transport.ClientDatagramState) (*quic.Conn, error) {
111 - return l.api.openQUICSession(ctx, state.LeaseID, state.AccessToken)
111 + return l.api.openQUICSession(ctx, state.Identity, state.AccessToken)
112 })
113 }
114
@@ -127,10 +127,7 @@ func (l *Listener) runStartup(ctx context.Context, readyTarget int) {
127 go l.stream.RunLoop(
128 ctx,
129 func(ctx context.Context) (net.Conn, error) {
130 - l.mu.Lock()
131 - leaseID := l.leaseID
132 - l.mu.Unlock()
133 - return l.api.openReverseSession(ctx, leaseID)
130 + return l.api.openReverseSession(ctx, l.identity.Copy())
131 },
132 func() *tls.Config {
133 l.mu.Lock()
@@ -144,7 +141,7 @@ func (l *Listener) runStartup(ctx context.Context, readyTarget int) {
141 }
142 go l.runRenewLoop(ctx)
143 publicURL := l.PublicURL()
147 - event := log.Info().Str("lease_id", l.LeaseID())
144 + event := log.Info().Str("address", l.Address())
145 if publicURL != "" {
146 event.
147 Msg("service ready at " + publicURL)
@@ -163,7 +160,7 @@ func (l *Listener) runStartup(ctx context.Context, readyTarget int) {
160 log.Error().
161 Err(err).
162 Str("relay_url", l.api.baseURL.String()).
166 - Str("lease_id", l.LeaseID()).
163 + Str("address", l.Address()).
164 Msg("lease registration failed; closing listener")
165 _ = l.Close()
166 return
@@ -184,12 +181,12 @@ func (l *Listener) Close() error {
181 }
182
183 l.mu.Lock()
187 - leaseID := l.leaseID
184 + identity := l.identity.Copy()
185 + registered := l.hostname != ""
186 tlsCloser := l.tlsCloser
187 stream := l.stream
188 datagram := l.datagram
189 api := l.api
192 - l.leaseID = ""
190 l.hostname = ""
191 l.udpAddr = ""
192 l.tlsConfig = nil
@@ -207,9 +204,9 @@ func (l *Listener) Close() error {
204 datagram.Close()
205 }
206
210 - if api != nil && leaseID != "" {
207 + if api != nil && registered && identity.Key() != "" {
208 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
212 - closeErr = errors.Join(closeErr, api.unregisterLease(ctx, leaseID))
209 + closeErr = errors.Join(closeErr, api.unregisterLease(ctx, identity))
210 cancel()
211 }
212 if tlsCloser != nil {
@@ -237,7 +234,7 @@ func (l *Listener) Accept() (net.Conn, error) {
234 log.Debug().
235 Err(handleErr).
236 Str("relay_url", l.api.baseURL.String()).
240 - Str("lease_id", l.LeaseID()).
237 + Str("address", l.Address()).
238 Msg("mitm self-probe handling failed")
239 }
240 if handled {
@@ -259,7 +256,7 @@ func (l *Listener) AcceptDatagram() (types.DatagramFrame, error) {
256
257 frame.Payload = append([]byte(nil), frame.Payload...)
258 l.mu.Lock()
262 - frame.LeaseID = l.leaseID
259 + frame.Address = l.identity.Address
260 frame.UDPAddr = l.udpAddr
261 if l.api != nil && l.api.baseURL != nil {
262 frame.RelayURL = l.api.baseURL.String()
@@ -274,15 +271,15 @@ func (l *Listener) SendDatagram(frame types.DatagramFrame) error {
271 }
272
273 l.mu.Lock()
277 - leaseID := l.leaseID
274 datagram := l.datagram
275 + address := l.identity.Address
276 l.mu.Unlock()
277
281 - if leaseID == "" || datagram == nil {
278 + if address == "" || datagram == nil {
279 return net.ErrClosed
280 }
284 - if frameLeaseID := strings.TrimSpace(frame.LeaseID); frameLeaseID != "" && frameLeaseID != leaseID {
285 - return errors.New("datagram frame targets stale lease")
281 + if frameAddress := strings.TrimSpace(frame.Address); frameAddress != "" && frameAddress != address {
282 + return errors.New("datagram frame targets stale address")
283 }
284 return datagram.Send(frame.FlowID, frame.Payload)
285 }
@@ -309,16 +306,16 @@ func (l *Listener) DatagramReady() (string, bool, bool) {
306 func (l *Listener) Addr() net.Addr {
307 l.mu.Lock()
308 defer l.mu.Unlock()
312 - if l.leaseID == "" {
309 + if l.identity.Address == "" {
310 return listenerAddr("portal:closed")
311 }
315 - return listenerAddr("portal:" + l.leaseID)
312 + return listenerAddr("portal:" + l.identity.Address)
313 }
314
318 -func (l *Listener) LeaseID() string {
315 +func (l *Listener) Address() string {
316 l.mu.Lock()
317 defer l.mu.Unlock()
321 - return l.leaseID
318 + return l.identity.Address
319 }
320
321 func (l *Listener) Hostname() string {
@@ -333,6 +330,13 @@ func (l *Listener) Metadata() types.LeaseMetadata {
330 return l.metadata.Copy()
331 }
332
333 +func (l *Listener) Identity() types.Identity {
334 + if l == nil {
335 + return types.Identity{}
336 + }
337 + return l.identity.Copy()
338 +}
339 +
340 func (l *Listener) PublicURL() string {
341 if l == nil || l.api == nil || l.api.baseURL == nil {
342 return ""
@@ -369,7 +373,7 @@ func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool)
373 l.mu.Lock()
374 defer l.mu.Unlock()
375
372 - if l.api == nil || l.leaseID == "" || l.udpAddr == "" {
376 + if l.api == nil || l.identity.Key() == "" || l.udpAddr == "" {
377 return transport.ClientDatagramState{}, false
378 }
379 l.api.mu.RLock()
@@ -377,7 +381,7 @@ func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool)
381 l.api.mu.RUnlock()
382
383 return transport.ClientDatagramState{
380 - LeaseID: l.leaseID,
384 + Identity: l.identity.Copy(),
385 AccessToken: accessToken,
386 }, true
387 }
@@ -418,12 +422,8 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
422 }
423
424 func (l *Listener) renewLease(ctx context.Context) error {
421 - l.mu.Lock()
422 - leaseID := l.leaseID
423 - l.mu.Unlock()
424 -
425 requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
426 - err := l.api.renewLease(requestCtx, leaseID, l.leaseTTL)
426 + err := l.api.renewLease(requestCtx, l.identity.Copy(), l.leaseTTL)
427 cancel()
428 if err == nil {
429 return nil
@@ -446,7 +446,7 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
446 return err
447 }
448 if l.datagram != nil && !resp.UDPEnabled {
449 - _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
449 + _ = l.api.unregisterLease(context.Background(), resp.Identity)
450 return &types.APIRequestError{
451 Code: types.APIErrorCodeFeatureUnavailable,
452 Message: "relay did not enable required udp support",
@@ -454,12 +454,12 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
454 }
455 tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(l.api.baseURL.String(), []string{resp.Hostname})
456 if err != nil {
457 - _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
457 + _ = l.api.unregisterLease(context.Background(), resp.Identity)
458 return err
459 }
460
461 if ctx.Err() != nil {
462 - _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
462 + _ = l.api.unregisterLease(context.Background(), resp.Identity)
463 if tlsCloser != nil {
464 _ = tlsCloser.Close()
465 }
@@ -469,7 +469,7 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
469 l.mu.Lock()
470 if ctx.Err() != nil {
471 l.mu.Unlock()
472 - _ = l.api.unregisterLease(context.Background(), resp.LeaseID)
472 + _ = l.api.unregisterLease(context.Background(), resp.Identity)
473 if tlsCloser != nil {
474 _ = tlsCloser.Close()
475 }
@@ -477,7 +477,8 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
477 }
478 oldCloser := l.tlsCloser
479 datagram := l.datagram
480 - l.leaseID = resp.LeaseID
480 + l.identity.Name = resp.Identity.Name
481 + l.identity.Address = resp.Identity.Address
482 l.hostname = resp.Hostname
483 l.udpAddr = resp.UDPAddr
484 l.metadata = resp.Metadata.Copy()
@@ -503,7 +504,7 @@ func (l *Listener) retryOrClose(ctx context.Context, operation string, err error
504 logger := log.With().
505 Str("relay_url", l.api.baseURL.String()).
506 Str("operation", operation).
506 - Str("lease_id", l.LeaseID()).
507 + Str("address", l.Address()).
508 Logger()
509
510 if operation == "lease registration" {
sdk/mitm.go
+6 -6
@@ -34,7 +34,7 @@ const (
34 type MITMProbeReport struct {
35 RelayURL string
36 PublicURL string
37 - LeaseID string
37 + Address string
38 Detected bool
39 Reason string
40 }
@@ -94,7 +94,7 @@ func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport,
94 report := MITMProbeReport{
95 RelayURL: l.api.baseURL.String(),
96 PublicURL: publicURL,
97 - LeaseID: l.LeaseID(),
97 + Address: l.Address(),
98 }
99
100 probeCtx, cancel := context.WithTimeout(ctx, defaultMITMProbeTimeout)
@@ -232,13 +232,13 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
232 log.Warn().
233 Err(err).
234 Str("relay_url", l.api.baseURL.String()).
235 - Str("lease_id", l.LeaseID()).
235 + Str("address", l.Address()).
236 Msg("tls passthrough self-probe failed")
237 case report.Reason == types.MITMProbeReasonProbeTimeout:
238 log.Warn().
239 Str("relay_url", report.RelayURL).
240 Str("public_url", report.PublicURL).
241 - Str("lease_id", report.LeaseID).
241 + Str("address", report.Address).
242 Msg("tls self-probe timed out before passthrough could be verified")
243 case report.Detected:
244 event := log.Warn().
@@ -246,7 +246,7 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
246 Str("reason", report.Reason).
247 Str("relay_url", report.RelayURL).
248 Str("public_url", report.PublicURL).
249 - Str("lease_id", report.LeaseID)
249 + Str("address", report.Address)
250 if l.BanMITM() {
251 event.Msg("tls termination suspected by self-probe; banning relay")
252 l.ban()
@@ -257,7 +257,7 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
257 log.Debug().
258 Str("relay_url", report.RelayURL).
259 Str("public_url", report.PublicURL).
260 - Str("lease_id", report.LeaseID).
260 + Str("address", report.Address).
261 Msg("tls passthrough self-probe passed")
262 }
263 }
sdk/sdk_test.go
+48 -38
@@ -16,6 +16,8 @@ import (
16 )
17
18 func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
19 + const address = "0x00000000000000000000000000000000000000A1"
20 +
21 challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
22 registerReqCh := make(chan types.RegisterRequest, 1)
23 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -41,7 +43,7 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
43 Data: types.RegisterChallengeResponse{
44 ChallengeID: "challenge-1",
45 ExpiresAt: time.Now().Add(time.Minute).UTC(),
44 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.OwnerAddress, "challenge-1"),
46 + SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
47 },
48 })
49 case types.PathSDKRegister:
@@ -56,9 +58,9 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
58 writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
59 OK: true,
60 Data: types.RegisterResponse{
59 - LeaseID: "lease-1",
61 + Identity: types.Identity{Name: "demo-app", Address: address},
62 Hostname: "127.0.0.1",
61 - Metadata: types.LeaseMetadata{Owner: "alice"},
63 + Metadata: types.LeaseMetadata{Description: "demo"},
64 AccessToken: "jwt-register-1",
65 },
66 })
@@ -70,7 +72,7 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
72 case types.PathSDKRenew:
73 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
74 OK: true,
73 - Data: types.RenewResponse{LeaseID: "lease-1", AccessToken: "jwt-renew-1"},
75 + Data: types.RenewResponse{Identity: types.Identity{Name: "demo-app", Address: address}, AccessToken: "jwt-renew-1"},
76 })
77 case types.PathSDKUnregister:
78 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -81,8 +83,8 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
83 defer server.Close()
84
85 listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
84 - Name: "Demo-App",
85 - Metadata: types.LeaseMetadata{Owner: "alice"},
86 + Identity: types.Identity{Name: "Demo-App"},
87 + Metadata: types.LeaseMetadata{Description: "demo"},
88 LeaseTTL: 42 * time.Second,
89 })
90 if err != nil {
@@ -110,7 +112,7 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
112 }
113 })
114 waitForSDKTest(t, func() bool {
113 - return listener.LeaseID() == "lease-1"
115 + return listener.Address() == address
116 })
117
118 if challengeReq.TTL != 42 {
@@ -119,11 +121,11 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
121 if challengeReq.UDPEnabled {
122 t.Fatal("register challenge UDPEnabled = true, want false")
123 }
122 - if challengeReq.Name != "demo-app" {
123 - t.Fatalf("register challenge Name = %q, want %q", challengeReq.Name, "demo-app")
124 + if challengeReq.Identity.Name != "demo-app" {
125 + t.Fatalf("register challenge Identity.Name = %q, want %q", challengeReq.Identity.Name, "demo-app")
126 }
125 - if challengeReq.OwnerAddress == "" {
126 - t.Fatal("register challenge OwnerAddress = empty, want derived address")
127 + if challengeReq.Identity.Address == "" {
128 + t.Fatal("register challenge Identity.Address = empty, want derived address")
129 }
130 if registerReq.ChallengeID != "challenge-1" {
131 t.Fatalf("register request ChallengeID = %q, want %q", registerReq.ChallengeID, "challenge-1")
@@ -134,8 +136,8 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
136 if registerReq.SIWESignature == "" {
137 t.Fatal("register request SIWESignature = empty, want signature")
138 }
137 - if listener.LeaseID() != "lease-1" {
138 - t.Fatalf("LeaseID() = %q, want %q", listener.LeaseID(), "lease-1")
139 + if listener.Address() != address {
140 + t.Fatalf("Address() = %q, want %q", listener.Address(), address)
141 }
142 if got := listener.Hostname(); got != "127.0.0.1" {
143 t.Fatalf("Hostname() = %q, want %q", got, "127.0.0.1")
@@ -143,13 +145,13 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
145 if got := listener.PublicURL(); got != server.URL {
146 t.Fatalf("PublicURL() = %q, want %q", got, server.URL)
147 }
146 - if got := listener.Metadata(); got.Owner != "alice" {
147 - t.Fatalf("Metadata().Owner = %q, want %q", got.Owner, "alice")
148 + if got := listener.Metadata(); got.Description != "demo" {
149 + t.Fatalf("Metadata().Description = %q, want %q", got.Description, "demo")
150 }
151 }
152
153 func TestExposeNoRelayInputs(t *testing.T) {
152 - exposure, err := Expose(context.Background(), ExposeConfig{Name: "demo"})
154 + exposure, err := Expose(context.Background(), ExposeConfig{Identity: types.Identity{Name: "demo"}})
155 if err != nil {
156 t.Fatalf("Expose() error = %v", err)
157 }
@@ -162,9 +164,11 @@ func TestExposeNoRelayInputs(t *testing.T) {
164 }
165 }
166
165 -func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
166 - ownerPrivateKey := strings.Repeat("11", 32)
167 - identity, err := utils.ResolveSecp256k1Identity(ownerPrivateKey)
167 +func TestExposeResolvesPrivateKey(t *testing.T) {
168 + const address = "0x00000000000000000000000000000000000000A2"
169 +
170 + privateKey := strings.Repeat("11", 32)
171 + identity, err := utils.ResolveSecp256k1Identity(privateKey)
172 if err != nil {
173 t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
174 }
@@ -193,7 +197,7 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
197 Data: types.RegisterChallengeResponse{
198 ChallengeID: "challenge-1",
199 ExpiresAt: time.Now().Add(time.Minute).UTC(),
196 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.OwnerAddress, "challenge-1"),
200 + SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
201 },
202 })
203 case types.PathSDKRegister:
@@ -204,7 +208,7 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
208 writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
209 OK: true,
210 Data: types.RegisterResponse{
207 - LeaseID: "lease-1",
211 + Identity: types.Identity{Name: "demo", Address: address},
212 Hostname: "127.0.0.1",
213 AccessToken: "jwt-register-2",
214 },
@@ -217,7 +221,7 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
221 case types.PathSDKRenew:
222 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
223 OK: true,
220 - Data: types.RenewResponse{LeaseID: "lease-1", AccessToken: "jwt-renew-2"},
224 + Data: types.RenewResponse{Identity: types.Identity{Name: "demo", Address: address}, AccessToken: "jwt-renew-2"},
225 })
226 case types.PathSDKUnregister:
227 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -228,9 +232,11 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
232 defer server.Close()
233
234 exposure, err := Expose(context.Background(), ExposeConfig{
231 - RelayURLs: []string{server.URL},
232 - Name: "demo",
233 - OwnerPrivateKey: ownerPrivateKey,
235 + RelayURLs: []string{server.URL},
236 + Identity: types.Identity{
237 + Name: "demo",
238 + PrivateKey: privateKey,
239 + },
240 })
241 if err != nil {
242 t.Fatalf("Expose() error = %v", err)
@@ -247,12 +253,14 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
253 }
254 })
255
250 - if challengeReq.OwnerAddress != identity.Address {
251 - t.Fatalf("register challenge OwnerAddress = %q, want %q", challengeReq.OwnerAddress, identity.Address)
256 + if challengeReq.Identity.Address != identity.Address {
257 + t.Fatalf("register challenge Identity.Address = %q, want %q", challengeReq.Identity.Address, identity.Address)
258 }
259 }
260
255 -func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
261 +func TestExposeGeneratesAddressWithoutPrivateKey(t *testing.T) {
262 + const address = "0x00000000000000000000000000000000000000A3"
263 +
264 challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
265 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
266 switch r.URL.Path {
@@ -277,7 +285,7 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
285 Data: types.RegisterChallengeResponse{
286 ChallengeID: "challenge-1",
287 ExpiresAt: time.Now().Add(time.Minute).UTC(),
280 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.OwnerAddress, "challenge-1"),
288 + SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
289 },
290 })
291 case types.PathSDKRegister:
@@ -288,7 +296,7 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
296 writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
297 OK: true,
298 Data: types.RegisterResponse{
291 - LeaseID: "lease-1",
299 + Identity: types.Identity{Name: "demo", Address: address},
300 Hostname: "127.0.0.1",
301 AccessToken: "jwt-register-3",
302 },
@@ -301,7 +309,7 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
309 case types.PathSDKRenew:
310 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
311 OK: true,
304 - Data: types.RenewResponse{LeaseID: "lease-1", AccessToken: "jwt-renew-3"},
312 + Data: types.RenewResponse{Identity: types.Identity{Name: "demo", Address: address}, AccessToken: "jwt-renew-3"},
313 })
314 case types.PathSDKUnregister:
315 writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -313,7 +321,9 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
321
322 exposure, err := Expose(context.Background(), ExposeConfig{
323 RelayURLs: []string{server.URL},
316 - Name: "demo",
324 + Identity: types.Identity{
325 + Name: "demo",
326 + },
327 })
328 if err != nil {
329 t.Fatalf("Expose() error = %v", err)
@@ -330,22 +340,22 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
340 }
341 })
342
333 - if challengeReq.OwnerAddress == "" {
334 - t.Fatal("register challenge OwnerAddress = empty, want generated address")
343 + if challengeReq.Identity.Address == "" {
344 + t.Fatal("register challenge Identity.Address = empty, want generated address")
345 }
336 - if _, err := utils.NormalizeEVMAddress(challengeReq.OwnerAddress); err != nil {
337 - t.Fatalf("register challenge OwnerAddress = %q, want valid EVM address: %v", challengeReq.OwnerAddress, err)
346 + if _, err := utils.NormalizeEVMAddress(challengeReq.Identity.Address); err != nil {
347 + t.Fatalf("register challenge Identity.Address = %q, want valid EVM address: %v", challengeReq.Identity.Address, err)
348 }
349 }
350
341 -func mustSDKTestSIWEMessage(t *testing.T, r *http.Request, ownerAddress, challengeID string) string {
351 +func mustSDKTestSIWEMessage(t *testing.T, r *http.Request, address, challengeID string) string {
352 t.Helper()
353
354 scheme := "https"
355 if r.TLS == nil {
356 scheme = "http"
357 }
348 - message, err := siwe.InitMessage(r.Host, ownerAddress, scheme+"://"+r.Host+types.PathSDKRegister, "testnonce123", map[string]interface{}{
358 + message, err := siwe.InitMessage(r.Host, address, scheme+"://"+r.Host+types.PathSDKRegister, "testnonce123", map[string]interface{}{
359 "statement": "Register a portal lease",
360 "chainId": 1,
361 "issuedAt": time.Now().UTC().Format(time.RFC3339),
types/api.go
+14 -15
@@ -63,11 +63,10 @@ type RegisterRequest struct {
63 }
64
65 type RegisterChallengeRequest struct {
66 - Name string `json:"name"`
67 - Metadata LeaseMetadata `json:"metadata"`
68 - OwnerAddress string `json:"owner_address"`
69 - TTL int `json:"ttl,omitempty"`
70 - UDPEnabled bool `json:"udp_enabled,omitempty"`
66 + Identity Identity `json:"identity"`
67 + Metadata LeaseMetadata `json:"metadata"`
68 + TTL int `json:"ttl,omitempty"`
69 + UDPEnabled bool `json:"udp_enabled,omitempty"`
70 }
71
72 type RegisterChallengeResponse struct {
@@ -77,8 +76,8 @@ type RegisterChallengeResponse struct {
76 }
77
78 type RegisterResponse struct {
79 + Identity Identity `json:"identity"`
80 ExpiresAt time.Time `json:"expires_at"`
81 - LeaseID string `json:"lease_id"`
81 Hostname string `json:"hostname"`
82 Metadata LeaseMetadata `json:"metadata"`
83 AccessToken string `json:"access_token"`
@@ -94,8 +93,8 @@ type DiscoveryResponse struct {
93 }
94
95 type QUICControlMessage struct {
97 - LeaseID string `json:"lease_id"`
98 - AccessToken string `json:"access_token"`
96 + Identity Identity `json:"identity"`
97 + AccessToken string `json:"access_token"`
98 }
99
100 type QUICControlResponse struct {
@@ -104,21 +103,21 @@ type QUICControlResponse struct {
103 }
104
105 type RenewRequest struct {
107 - LeaseID string `json:"lease_id"`
108 - AccessToken string `json:"access_token"`
109 - TTL int `json:"ttl,omitempty"`
110 - ReportedIP string `json:"reported_ip,omitempty"`
106 + Identity Identity `json:"identity"`
107 + AccessToken string `json:"access_token"`
108 + TTL int `json:"ttl,omitempty"`
109 + ReportedIP string `json:"reported_ip,omitempty"`
110 }
111
112 type RenewResponse struct {
113 + Identity Identity `json:"identity"`
114 ExpiresAt time.Time `json:"expires_at"`
115 - LeaseID string `json:"lease_id"`
115 AccessToken string `json:"access_token"`
116 }
117
118 type UnregisterRequest struct {
120 - LeaseID string `json:"lease_id"`
121 - AccessToken string `json:"access_token"`
119 + Identity Identity `json:"identity"`
120 + AccessToken string `json:"access_token"`
121 }
122
123 type DomainResponse struct {
types/error.go
+1 -1
@@ -7,10 +7,10 @@ const (
7 APIErrorCodeHijackUnsupported = "hijack_unsupported"
8 APIErrorCodeHostnameConflict = "hostname_conflict"
9 APIErrorCodeHTTP11Only = "http11_only"
10 + APIErrorCodeInvalidAddress = "invalid_address"
11 APIErrorCodeInvalidIP = "invalid_ip"
12 APIErrorCodeInvalidJSON = "invalid_json"
13 APIErrorCodeInvalidKey = "invalid_key"
13 - APIErrorCodeInvalidLeaseID = "invalid_lease_id"
14 APIErrorCodeInvalidMode = "invalid_mode"
15 APIErrorCodeInvalidRequest = "invalid_request"
16 APIErrorCodeInternal = "internal"
types/identity.go renamed
+69 -2
@@ -1,10 +1,75 @@
1 package types
2
3 -import "time"
3 +import (
4 + "strings"
5 + "time"
6 +)
7
5 -const DiscoveryPollInterval = 1 * time.Minute
8 +type Identity struct {
9 + Name string `json:"name,omitempty"`
10 + Address string `json:"address,omitempty"`
11 + PublicKey string `json:"-"`
12 + PrivateKey string `json:"-"`
13 +}
14 +
15 +func (i Identity) Copy() Identity {
16 + return Identity{
17 + Name: i.Name,
18 + Address: i.Address,
19 + PublicKey: i.PublicKey,
20 + PrivateKey: i.PrivateKey,
21 + }
22 +}
23 +
24 +const IdentityKeySeparator = ":"
25 +
26 +func (i Identity) Key() string {
27 + name := strings.TrimSpace(strings.ToLower(i.Name))
28 + address := strings.TrimSpace(strings.ToLower(i.Address))
29 + if name == "" && address == "" {
30 + return ""
31 + }
32 + return name + IdentityKeySeparator + address
33 +}
34 +
35 +type Lease struct {
36 + Identity
37 + ExpiresAt time.Time
38 + FirstSeenAt time.Time
39 + LastSeenAt time.Time
40 + BPS int64
41 + ClientIP string
42 + ReportedIP string
43 + Hostname string
44 + UDPEnabled bool
45 + Metadata LeaseMetadata
46 + Ready int
47 + IsApproved bool
48 + IsBanned bool
49 + IsDenied bool
50 + IsIPBanned bool
51 +}
52 +
53 +type LeaseMetadata struct {
54 + Description string `json:"description,omitempty"`
55 + Owner string `json:"owner,omitempty"`
56 + Thumbnail string `json:"thumbnail,omitempty"`
57 + Tags []string `json:"tags,omitempty"`
58 + Hide bool `json:"hide,omitempty"`
59 +}
60 +
61 +func (m LeaseMetadata) Copy() LeaseMetadata {
62 + return LeaseMetadata{
63 + Description: m.Description,
64 + Owner: m.Owner,
65 + Thumbnail: m.Thumbnail,
66 + Tags: append([]string(nil), m.Tags...),
67 + Hide: m.Hide,
68 + }
69 +}
70
71 type RelayDescriptor struct {
72 + Identity
73 RelayID string `json:"relay_id"`
74
75 Sequence uint64 `json:"sequence"`
@@ -25,6 +90,8 @@ type RelayDescriptor struct {
90 SupportsOverlayPeer bool `json:"supports_overlay_peer,omitempty"`
91 }
92
93 +const DiscoveryPollInterval = 1 * time.Minute
94 +
95 type RelayState struct {
96 Descriptor RelayDescriptor `json:"descriptor"`
97 Bootstrap bool `json:"bootstrap,omitempty"`
types/lease.go deleted
-42
@@ -1,42 +0,0 @@
1 -package types
2 -
3 -import "time"
4 -
5 -type LeaseMetadata struct {
6 - Description string `json:"description,omitempty"`
7 - Owner string `json:"owner,omitempty"`
8 - Thumbnail string `json:"thumbnail,omitempty"`
9 - Tags []string `json:"tags,omitempty"`
10 - Hide bool `json:"hide,omitempty"`
11 -}
12 -
13 -func (m LeaseMetadata) Copy() LeaseMetadata {
14 - return LeaseMetadata{
15 - Description: m.Description,
16 - Owner: m.Owner,
17 - Thumbnail: m.Thumbnail,
18 - Tags: append([]string(nil), m.Tags...),
19 - Hide: m.Hide,
20 - }
21 -}
22 -
23 -type Lease struct {
24 - ExpiresAt time.Time
25 - FirstSeenAt time.Time
26 - LastSeenAt time.Time
27 - ID string
28 - Name string
29 - BPS int64
30 - ClientIP string
31 - ReportedIP string
32 - Hostname string
33 - UDPEnabled bool
34 - Metadata LeaseMetadata
35 - OwnerAddress string
36 - Ready int
37 - UDPPort int
38 - IsApproved bool
39 - IsBanned bool
40 - IsDenied bool
41 - IsIPBanned bool
42 -}
types/transport.go
+1 -1
@@ -15,7 +15,7 @@ var ErrDatagramTooSmall = errors.New("datagram too small to decode")
15 type DatagramFrame struct {
16 FlowID uint32
17 Payload []byte
18 - LeaseID string
18 + Address string
19 RelayURL string
20 UDPAddr string
21 }
types/types.go
+2 -2
@@ -1,8 +1,8 @@
1 package types
2
3 const (
4 - ReleaseVersion = "v2.0.9"
5 - ProtocolVersion = "3"
4 + ReleaseVersion = "v2.1.0"
5 + ProtocolVersion = "4"
6 PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
7
8 HeaderAccessToken = "X-Portal-Access-Token"
utils/crypto.go
+7 -12
@@ -15,16 +15,11 @@ import (
15
16 "github.com/decred/dcrd/dcrec/secp256k1/v4"
17 secp256k1ecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
18 + "github.com/gosuda/portal/v2/types"
19 "golang.org/x/crypto/curve25519"
20 "golang.org/x/crypto/sha3"
21 )
22
22 -type Secp256k1Identity struct {
23 - Address string `json:"address"`
24 - PublicKey string `json:"public_key"`
25 - PrivateKey string `json:"private_key"`
26 -}
27 -
23 func NormalizeEVMAddress(raw string) (string, error) {
24 trimmed := strings.TrimSpace(raw)
25 if trimmed == "" {
@@ -129,33 +124,33 @@ func SignEthereumPersonalMessage(message, privateKeyHex string) (string, error)
124 return "0x" + hex.EncodeToString(signature), nil
125 }
126
132 -func ResolveSecp256k1Identity(rawPrivateKey string) (Secp256k1Identity, error) {
127 +func ResolveSecp256k1Identity(rawPrivateKey string) (types.Identity, error) {
128 privateKeyHex := strings.TrimSpace(rawPrivateKey)
129 if privateKeyHex == "" {
130 privateKey, err := secp256k1.GeneratePrivateKey()
131 if err != nil {
137 - return Secp256k1Identity{}, fmt.Errorf("generate secp256k1 private key: %w", err)
132 + return types.Identity{}, fmt.Errorf("generate secp256k1 private key: %w", err)
133 }
134 privateKeyHex = hex.EncodeToString(privateKey.Serialize())
135 }
136
137 decoded, normalizedKeyHex, err := decodeSecp256k1PrivateKeyHex(privateKeyHex, true)
138 if err != nil {
144 - return Secp256k1Identity{}, err
139 + return types.Identity{}, err
140 }
141
142 privateKey := secp256k1.PrivKeyFromBytes(decoded)
143 if privateKey == nil {
149 - return Secp256k1Identity{}, errors.New("invalid secp256k1 private key")
144 + return types.Identity{}, errors.New("invalid secp256k1 private key")
145 }
146
147 publicKeyHex := hex.EncodeToString(privateKey.PubKey().SerializeCompressed())
148 address, err := AddressFromCompressedPublicKeyHex(publicKeyHex)
149 if err != nil {
155 - return Secp256k1Identity{}, err
150 + return types.Identity{}, err
151 }
152
158 - return Secp256k1Identity{
153 + return types.Identity{
154 Address: address,
155 PublicKey: publicKeyHex,
156 PrivateKey: normalizedKeyHex,
utils/identity.go new
+291
@@ -0,0 +1,291 @@
1 +package utils
2 +
3 +import (
4 + "encoding/json"
5 + "errors"
6 + "fmt"
7 + "os"
8 + "path/filepath"
9 + "strings"
10 +
11 + "github.com/gosuda/portal/v2/types"
12 +)
13 +
14 +func NormalizeIdentity(identity types.Identity) (types.Identity, error) {
15 + normalized := identity.Copy()
16 +
17 + name, err := NormalizeDNSLabel(identity.Name)
18 + if err != nil {
19 + return types.Identity{}, err
20 + }
21 + address, err := NormalizeEVMAddress(identity.Address)
22 + if err != nil {
23 + return types.Identity{}, err
24 + }
25 +
26 + normalized.Name = name
27 + normalized.Address = address
28 + return normalized, nil
29 +}
30 +
31 +func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
32 + normalized := identity.Copy()
33 + normalized.Name = strings.TrimSpace(normalized.Name)
34 + normalized.Address = strings.TrimSpace(normalized.Address)
35 + normalized.PublicKey = strings.TrimSpace(normalized.PublicKey)
36 + normalized.PrivateKey = strings.TrimSpace(normalized.PrivateKey)
37 +
38 + if normalized.PrivateKey != "" {
39 + resolved, err := ResolveSecp256k1Identity(normalized.PrivateKey)
40 + if err != nil {
41 + return types.Identity{}, err
42 + }
43 + if normalized.PublicKey != "" && !strings.EqualFold(TrimHexPrefix(normalized.PublicKey), resolved.PublicKey) {
44 + return types.Identity{}, errors.New("identity public key does not match private key")
45 + }
46 + if normalized.Address != "" {
47 + address, err := NormalizeEVMAddress(normalized.Address)
48 + if err != nil {
49 + return types.Identity{}, err
50 + }
51 + if address != resolved.Address {
52 + return types.Identity{}, errors.New("identity address does not match private key")
53 + }
54 + }
55 + normalized.Address = resolved.Address
56 + normalized.PublicKey = resolved.PublicKey
57 + normalized.PrivateKey = resolved.PrivateKey
58 + return normalized, nil
59 + }
60 +
61 + if normalized.PublicKey != "" {
62 + address, err := AddressFromCompressedPublicKeyHex(normalized.PublicKey)
63 + if err != nil {
64 + return types.Identity{}, err
65 + }
66 + normalized.PublicKey = strings.ToLower(TrimHexPrefix(normalized.PublicKey))
67 + if normalized.Address == "" {
68 + normalized.Address = address
69 + return normalized, nil
70 + }
71 + normalized.Address, err = NormalizeEVMAddress(normalized.Address)
72 + if err != nil {
73 + return types.Identity{}, err
74 + }
75 + if normalized.Address != address {
76 + return types.Identity{}, errors.New("identity address does not match public key")
77 + }
78 + return normalized, nil
79 + }
80 +
81 + if normalized.Address != "" {
82 + address, err := NormalizeEVMAddress(normalized.Address)
83 + if err != nil {
84 + return types.Identity{}, err
85 + }
86 + normalized.Address = address
87 + }
88 + return normalized, nil
89 +}
90 +
91 +type storedIdentity struct {
92 + Name string `json:"name,omitempty"`
93 + Address string `json:"address,omitempty"`
94 + PublicKey string `json:"public_key,omitempty"`
95 + PrivateKey string `json:"private_key,omitempty"`
96 +}
97 +
98 +func SaveIdentity(path string, identity types.Identity) error {
99 + path = strings.TrimSpace(path)
100 + if path == "" {
101 + return errors.New("identity path is required")
102 + }
103 + normalized, err := NormalizeStoredIdentity(identity)
104 + if err != nil {
105 + return err
106 + }
107 + data, err := json.MarshalIndent(storedIdentity{
108 + Name: normalized.Name,
109 + Address: normalized.Address,
110 + PublicKey: normalized.PublicKey,
111 + PrivateKey: normalized.PrivateKey,
112 + }, "", " ")
113 + if err != nil {
114 + return err
115 + }
116 + dir := filepath.Dir(path)
117 + if dir != "" && dir != "." {
118 + if err := os.MkdirAll(dir, 0o700); err != nil {
119 + return fmt.Errorf("create identity directory: %w", err)
120 + }
121 + }
122 + if err := os.WriteFile(path, data, 0o600); err != nil {
123 + return fmt.Errorf("write identity file: %w", err)
124 + }
125 + return nil
126 +}
127 +
128 +func LoadIdentity(path string) (types.Identity, error) {
129 + path = strings.TrimSpace(path)
130 + if path == "" {
131 + return types.Identity{}, errors.New("identity path is required")
132 + }
133 + data, err := os.ReadFile(path)
134 + if err != nil {
135 + return types.Identity{}, fmt.Errorf("read identity file: %w", err)
136 + }
137 + var payload storedIdentity
138 + if err := json.Unmarshal(data, &payload); err != nil {
139 + return types.Identity{}, err
140 + }
141 + return NormalizeStoredIdentity(types.Identity{
142 + Name: payload.Name,
143 + Address: payload.Address,
144 + PublicKey: payload.PublicKey,
145 + PrivateKey: payload.PrivateKey,
146 + })
147 +}
148 +
149 +func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity, bool, error) {
150 + path = strings.TrimSpace(path)
151 + if path == "" {
152 + return types.Identity{}, false, errors.New("identity path is required")
153 + }
154 +
155 + stored, err := LoadIdentity(path)
156 + switch {
157 + case err == nil:
158 + if name := strings.TrimSpace(identity.Name); name != "" {
159 + stored.Name = name
160 + }
161 + if address := strings.TrimSpace(identity.Address); address != "" {
162 + stored.Address = address
163 + }
164 + if publicKey := strings.TrimSpace(identity.PublicKey); publicKey != "" {
165 + stored.PublicKey = publicKey
166 + }
167 + if privateKey := strings.TrimSpace(identity.PrivateKey); privateKey != "" {
168 + stored.PrivateKey = privateKey
169 + }
170 + if strings.TrimSpace(stored.PrivateKey) == "" {
171 + return types.Identity{}, false, errors.New("stored identity private key is required")
172 + }
173 + if err := SaveIdentity(path, stored); err != nil {
174 + return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
175 + }
176 + loaded, err := LoadIdentity(path)
177 + if err != nil {
178 + return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
179 + }
180 + return loaded, false, nil
181 + case !errors.Is(err, os.ErrNotExist):
182 + return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
183 + }
184 +
185 + created := identity.Copy()
186 + generated, err := ResolveSecp256k1Identity(created.PrivateKey)
187 + if err != nil {
188 + return types.Identity{}, false, fmt.Errorf("generate identity: %w", err)
189 + }
190 + if strings.TrimSpace(created.Address) == "" {
191 + created.Address = generated.Address
192 + }
193 + if strings.TrimSpace(created.PublicKey) == "" {
194 + created.PublicKey = generated.PublicKey
195 + }
196 + created.PrivateKey = generated.PrivateKey
197 + if err := SaveIdentity(path, created); err != nil {
198 + return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
199 + }
200 + loaded, err := LoadIdentity(path)
201 + if err != nil {
202 + return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
203 + }
204 + return loaded, true, nil
205 +}
206 +
207 +func NormalizeIdentityKey(raw string) string {
208 + key := strings.ToLower(strings.TrimSpace(raw))
209 + if key == "" {
210 + return ""
211 + }
212 + name, address, ok := strings.Cut(key, types.IdentityKeySeparator)
213 + if !ok || name == "" || address == "" {
214 + return ""
215 + }
216 + return name + types.IdentityKeySeparator + address
217 +}
218 +
219 +func NormalizeIdentityKeys(inputs []string) []string {
220 + if len(inputs) == 0 {
221 + return nil
222 + }
223 +
224 + seen := make(map[string]struct{}, len(inputs))
225 + out := make([]string, 0, len(inputs))
226 + for _, input := range inputs {
227 + key := NormalizeIdentityKey(input)
228 + if key == "" {
229 + continue
230 + }
231 + if _, ok := seen[key]; ok {
232 + continue
233 + }
234 + seen[key] = struct{}{}
235 + out = append(out, key)
236 + }
237 + if len(out) == 0 {
238 + return nil
239 + }
240 + return out
241 +}
242 +
243 +func NormalizeIdentityKeyBPS(inputs map[string]int64) map[string]int64 {
244 + if len(inputs) == 0 {
245 + return nil
246 + }
247 +
248 + out := make(map[string]int64, len(inputs))
249 + for input, bps := range inputs {
250 + key := NormalizeIdentityKey(input)
251 + if key == "" || bps <= 0 {
252 + continue
253 + }
254 + out[key] = bps
255 + }
256 + if len(out) == 0 {
257 + return nil
258 + }
259 + return out
260 +}
261 +
262 +func ResolveLeaseIdentity(identity types.Identity) (types.Identity, error) {
263 + resolved := identity.Copy()
264 +
265 + name, err := NormalizeDNSLabel(resolved.Name)
266 + if err != nil {
267 + return types.Identity{}, err
268 + }
269 + resolved.Name = name
270 +
271 + signingIdentity, err := ResolveSecp256k1Identity(resolved.PrivateKey)
272 + if err != nil {
273 + return types.Identity{}, err
274 + }
275 + if resolved.Address == "" {
276 + resolved.Address = signingIdentity.Address
277 + } else {
278 + address, err := NormalizeEVMAddress(resolved.Address)
279 + if err != nil {
280 + return types.Identity{}, err
281 + }
282 + if address != signingIdentity.Address {
283 + return types.Identity{}, errors.New("identity address does not match private key")
284 + }
285 + resolved.Address = address
286 + }
287 +
288 + resolved.PublicKey = signingIdentity.PublicKey
289 + resolved.PrivateKey = signingIdentity.PrivateKey
290 + return resolved, nil
291 +}