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
+}