feat: init siwe access token, deprecated reverse token
Kim committed
Mar 30, 2026 at 15:49 UTC
a3f4ebc754adde2ef7900c9fcb2f4cc5a574f7d4
32 files changed
+1414
-746
AGENTS.md
new
+33
@@ -0,0 +1,33 @@
1
+# AGENTS.md
2
+
3
+Keep this file short and behavioral.
4
+Architecture, product behavior, and design rationale belong in `docs/architecture.md` and `docs/adr/README.md`.
5
+
6
+## Development Principles
7
+
8
+- Minimizing concepts, duplication, and ceremony.
9
+- Prefer a single stable contract with one real owner.
10
+- Prefer local simplicity over premature or speculative abstraction.
11
+- Add indirection only when it removes real coupling or protects a real boundary.
12
+- Tests should protect stable contracts and invariants, not drive the spec.
13
+
14
+## Project Principles
15
+
16
+- When caller and callee are both local and no real boundary exists, change both directly; do not preserve local call shapes.
17
+- If a field, method, wrapper, or abstraction has no clear, current use and does not protect a real boundary, remove it immediately.
18
+- No wrapper functions or helpers unless they remove real coupling or protect a real boundary.
19
+- Prefer direct code over layers, facades, and indirection.
20
+- Prefer flattening and merging nearby responsibilities over splitting by default.
21
+- Remove dead fields, methods, config, and stale state while touching nearby code.
22
+- Do not duplicate normalization, validation, or defaulting logic; keep it in a single real owner.
23
+- Keep shared stateless transforms in `utils/`; keep stateful and domain-shaped logic with the real owner.
24
+- Keep stable shared contracts, constants, and public paths in `types/`, not in runtime or helpers.
25
+- Resolve complexity in the lowest coherent owner and expose only the minimum surface upward.
26
+- Shared runtime logic must live in one real owner and be reused, not mirrored.
27
+
28
+## Verification
29
+
30
+- CI commands: `make vet`, `make lint`, `make test`, `make vuln`.
31
+- `make tidy` is local maintenance, not a CI requirement.
32
+- Run tests only when explicitly requested.
33
+- If verification seems necessary, ask before running it.
cmd/relay-server/admin.go
+2
-2
@@ -164,7 +164,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
164
SameSite: http.SameSiteStrictMode,
165
MaxAge: -1,
166
})
167
- utils.WriteAPIOK(w, http.StatusOK)
167
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{})
168
return
169
case types.PathAdminAuthStatus:
170
if r.Method != http.MethodGet {
@@ -189,7 +189,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
189
}
190
writeOK := func() {
191
f.saveAdminState(runtime)
192
- utils.WriteAPIOK(w, http.StatusOK)
192
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{})
193
}
194
195
switch path {
docs/architecture.md
+69
-50
@@ -3,7 +3,7 @@
3
## Overview
4
5
Portal publishes local services on public subdomains and optional UDP ports through a relay.
6
-Backends connect outward to the relay. Stream traffic is routed by SNI, and tenant TLS remains end-to-end between the client and the SDK/tunnel endpoint for the stream path.
6
+Backends connect outward to the relay. Stream traffic is routed by SNI, and tenant TLS remains end-to-end between the client and the SDK or tunnel endpoint for the stream path.
7
8
High-level path:
9
@@ -15,7 +15,7 @@ Stream client
15
-> Local service
16
17
UDP client
18
- -> Relay lease UDP port (29900-29999 by default)
18
+ -> Relay lease UDP port (50000+ by default)
19
-> Internal QUIC tunnel
20
-> SDK / portal-tunnel
21
-> Local UDP service
@@ -38,8 +38,8 @@ UDP client
38
- Relay does not terminate tenant TLS. It peeks ClientHello for SNI and bridges raw encrypted bytes after routing.
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 reverse token.
42
-- `/sdk/register` requires the caller to supply the reverse token that later authorizes the lease lifecycle, but registration itself is not separately authenticated by that token.
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.
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
@@ -72,6 +72,7 @@ UDP client
72
Portal has three distinct network roles:
73
74
- **Control-plane HTTP requests**
75
+ - `POST /sdk/register/challenge`
76
- `POST /sdk/register`
77
- `POST /sdk/renew`
78
- `POST /sdk/unregister`
@@ -82,8 +83,8 @@ Portal has three distinct network roles:
83
- hijacked into a long-lived raw TCP session
84
- starts idle in the per-lease stream ready queue, then becomes the tenant data path when claimed
85
- **Internal datagram tunnel**
85
- - QUIC on `API_PORT/udp`
86
- - authenticated by a QUIC control stream
86
+ - QUIC to the relay URL host:port with ALPN `portal-tunnel`
87
+ - authenticated by a first-stream control message carrying `lease_id` + `access_token`
88
- carries relay-to-SDK/tunnel datagram traffic only
89
90
That distinction matters because `/sdk/connect` stops being ordinary HTTP once hijacked, while the UDP backhaul is a separate internal QUIC carrier.
@@ -101,26 +102,25 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
102
103
### Relay Core (`portal/`)
104
104
-- `Server`: owns listeners, lease registry, API handlers, and shutdown lifecycle
105
+- `Server`: owns listeners, lease registry, API handlers, discovery, and shutdown lifecycle
106
- `routeTable`: exact + single-label wildcard hostname lookup
107
- `transport.RelayStream`: per-lease ready queue for reverse stream sessions
107
-- `transport.RelayDatagram`: per-lease raw UDP port and datagram backhaul runtime
108
+- `transport.RelayDatagram`: per-lease raw UDP socket plus QUIC DATAGRAM bridge runtime
109
+- `transport.PortAllocator`: count-based UDP port allocator with sticky name-based reservation and grace period
110
+- `transport.datagramSession`: internal QUIC DATAGRAM bind/send/receive primitive shared by relay and SDK datagram runtimes
111
- `acme`: Cloudflare/Route53-backed root/wildcard A-record sync + certificate provisioning/renewal for the relay root host and wildcard
112
- `keyless`: admin/API TLS attach helpers and tenant-side signer integration
110
-- `portal/datagram/`: subpackage for QUIC/UDP datagram transport
111
- - `Session`: owns one active QUIC DATAGRAM connection, decodes frames, exposes `Incoming()` channel
112
- - `FlowMux`: per-lease QUIC connection manager; multiplexes UDP datagrams using flow IDs over DATAGRAM frames (RFC 9221); embeds a `Session` with dispatch and idle-flow cleanup goroutines
113
- - `Relay`: binds a public UDP port per lease, bridges between raw UDP sockets and `FlowMux` using `TouchFlow`/`SendDatagram`
114
- - `PortAllocator`: assigns and recycles per-lease UDP ports from a count-based pool (base port 50000, count via `UDP_PORT_COUNT`) with sticky name-based reservation and grace period
115
- - `ParseQUICInitialSNI`: decrypts QUIC v1 Initial packets and extracts TLS ClientHello SNI for the QUIC SNI router
116
-- `Server` additionally owns: `quicTunnel` (QUIC listener, ALPN `portal-tunnel`), `quicSNI` (raw UDP PacketConn for QUIC SNI routing), `quicSNIRoutes` (cached FlowMux per source address)
113
+- `auth`: SIWE register challenge creation/verification plus lease access token issue/verify
114
+- `discovery`: signed relay descriptor publication and relay-set synchronization
115
+- `Server` additionally owns `quicTunnel` (QUIC listener, ALPN `portal-tunnel`) when UDP transport is enabled
116
117
### SDK (`sdk/`)
118
119
- `ExposeConfig.Discovery`: when true, `Expose` fetches the default Portal relay registry, merges it with explicit relay inputs, normalizes the result, and runs the relay discovery loop
120
- Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
122
-- `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
123
-- `api_client.go`: internal relay client for control-plane requests, reverse session dialing, and internal QUIC tunnel setup
121
+- `Listener`: validates one relay URL locally, then starts relay compatibility checks, SIWE-based lease registration, reverse session maintenance, and lease renewal in the background until ready
122
+- `Listener` owns a `transport.ClientStream` and, when UDP is enabled, a `transport.ClientDatagram`
123
+- `api_client.go`: internal relay client for register challenge, register, renew, unregister, reverse session dialing, and QUIC tunnel setup
124
- `mitm.go`: tenant-side TLS passthrough self-probe. The SDK opens a probe connection to its own public URL, compares TLS exporter values on both SDK-controlled ends, and logs suspected relay-side TLS termination on mismatch; strict callers can opt into relay banning instead
125
- `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
126
- `NewListener` callers provide explicit normalized relay URLs
@@ -132,12 +132,11 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
132
- Tenant TLS is created automatically through the relay keyless signer; callers do not provide a local self-signed fallback path
133
- MITM self-probes are traffic-triggered, not periodic. A listener triggers at most one asynchronous probe per 30-second cooldown, and only after real tenant traffic performs I/O on an accepted connection
134
- Probe identification does not use a dedicated ALPN or fixed plaintext marker. The first encrypted probe payload is `nonce + random padding`, and inbound probe matching is only attempted while a probe is in flight
135
-- `Listener` embeds a `datagram.Session` for QUIC datagram transport (no separate UDP listener type)
136
-- `Listener.AcceptDatagram()` / `SendDatagram()`: read/write datagram frames via the session
137
-- `Listener.WaitDatagramReady()`: blocks until relay publishes `udp_addr` and `quic_addr`
135
+- `Listener.AcceptDatagram()` / `SendDatagram()`: read/write datagram frames via the client datagram runtime
136
+- `Listener.DatagramReady()`: reports the published `udp_addr` plus whether the QUIC datagram plane is currently connected
137
- `Exposure.AcceptDatagram()`: receives datagrams from all backing relay listeners with relay context populated on `DatagramFrame`
138
- `Exposure.SendDatagram()`: sends a datagram frame back through the owning relay listener
140
-- `Exposure.WaitDatagramReady()`: blocks until at least one relay's datagram plane is ready
139
+- `Exposure.WaitDatagramReady()`: blocks until at least one relay listener has both a published `udp_addr` and a connected datagram plane
140
141
### Tunnel (`cmd/portal-tunnel`)
142
@@ -152,13 +151,13 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
151
- `--ban-mitm` flag (bool, default `false`): when enabled, TLS self-probe mismatches ban the relay for the current exposure instead of only logging
152
- `runUDPBestEffort`: waits for datagram readiness, then calls `proxyExposureDatagrams`
153
- `proxyExposureDatagrams` (`relays.go`): per-flow UDP sockets to local target with idle cleanup; uses `Exposure.SendDatagram()` for the return path
155
-- Best-effort UDP — failures logged but do not terminate the TCP tunnel
154
+- Best-effort UDP failures are logged but do not terminate the TCP tunnel
155
156
## Transport Model
157
158
### Raw reverse transport (TLS only)
159
161
-1. SDK/tunnel registers one lease per relay with `POST /sdk/register`.
160
+1. SDK/tunnel registers one lease per relay through `POST /sdk/register/challenge` followed by `POST /sdk/register`.
161
2. SDK opens one or more reverse sessions per registered lease with `GET /sdk/connect?lease_id=...`.
162
3. Each relay hijacks `/sdk/connect` requests and places the connection in the per-lease stream ready queue.
163
4. While idle, the relay writes `0x00` keepalive markers.
@@ -183,18 +182,18 @@ Result: this is a detect-only signal by default. It raises the cost of adaptive
182
183
### UDP/QUIC Datagram Transport
184
186
-1. SDK/tunnel registers a lease with `udp_enabled=true` via `POST /sdk/register`.
187
-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`, creates `FlowMux` + `Relay` in `leaseDatagramRuntime`.
188
-3. Response includes `udp_addr` (public UDP endpoint) and `quic_addr` (QUIC tunnel endpoint).
189
-4. SDK opens a QUIC connection to `quic_addr` (ALPN `portal-tunnel`, TLS 1.3, datagrams enabled).
190
-5. Authentication: SDK sends `{lease_id, reverse_token}` JSON on the first QUIC stream; relay validates and calls `FlowMux.Register(conn)`.
191
-6. External UDP client sends a packet to `udp_addr` → `Relay.readLoop` → `FlowMux.TouchFlow` (assigns flow ID) → `FlowMux.SendDatagram` → QUIC DATAGRAM frame.
192
-7. SDK-side `Session.receiveLoop` decodes frame → `Listener.AcceptDatagram()` → `Exposure.AcceptDatagram()` → `proxyExposureDatagrams` → local UDP target.
193
-8. Return path: local response → per-flow read goroutine → `Exposure.SendDatagram()` → `Session.Send` → QUIC DATAGRAM → `FlowMux.runDispatchLoop` → reply callback → `conn.WriteToUDP` to original client.
185
+1. SDK/tunnel requests a register challenge with `udp_enabled=true`, signs the returned SIWE message, and then completes `POST /sdk/register`.
186
+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.
187
+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.
188
+4. SDK `transport.ClientDatagram` opens a QUIC connection with ALPN `portal-tunnel` and QUIC DATAGRAM support enabled.
189
+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)`.
190
+6. External UDP client sends a packet to `udp_addr` -> `RelayDatagram.readLoop` -> `TouchFlow` (assigns flow ID) -> `SendDatagram` -> QUIC DATAGRAM frame.
191
+7. SDK-side `datagramSession.receiveLoop` decodes frames -> `Listener.AcceptDatagram()` -> `Exposure.AcceptDatagram()` -> `proxyExposureDatagrams` -> local UDP target.
192
+8. Return path: local response -> `Exposure.SendDatagram()` -> `Listener.SendDatagram()` -> `ClientDatagram.Send()` -> QUIC DATAGRAM -> `RelayDatagram.dispatch()` -> `conn.WriteToUDP` to the original client.
193
194
```text
196
-Client --UDP--> [:50000+ Relay] --DATAGRAM--> [FlowMux/Session] --QUIC--> [Session/Listener] --UDP--> Local Service
197
- <--QUIC DATAGRAM return path--
195
+Client --UDP--> [:50000+ Relay] --DATAGRAM--> [RelayDatagram] --QUIC--> [ClientDatagram] --UDP--> Local Service
196
+ <--QUIC DATAGRAM return path--
197
```
198
199
Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
@@ -203,20 +202,41 @@ Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
202
203
### 1. Register
204
205
+- `POST /sdk/register/challenge`
206
- `POST /sdk/register`
207
- JSON envelope response
208
-- Caller provides:
208
+- Challenge request fields:
209
- `name`
210
- - `reverse_token`
211
- - optional `metadata`
212
- - optional `ttl`
213
- - optional `udp_enabled` (default `false`)
210
+ - `metadata`
211
+ - `owner_address`
212
+ - `ttl`
213
+ - `udp_enabled`
214
+- Challenge response fields:
215
+ - `challenge_id`
216
+ - `expires_at`
217
+ - `siwe_message`
218
+- Caller signs the returned SIWE message with the owner Ethereum private key (`personal_sign`) and then submits:
219
+ - `challenge_id`
220
+ - `siwe_message`
221
+ - `siwe_signature`
222
- `name` must be a valid single DNS label and relay publishes the lease at `<name>.<root host>`
223
- Registration reserves the hostname and publishes the route immediately; if no reverse session is ready yet, inbound SNI claims wait up to `ClaimTimeout`
216
-- When datagram-capable, response includes `udp_addr`, `quic_addr`, and `transport`
217
-- UDP registration requires two conditions: server must have `UDP_PORT_COUNT > 0` AND admin must enable UDP in the admin panel
224
+- When registration succeeds, the response includes:
225
+ - `lease_id`
226
+ - `hostname`
227
+ - `expires_at`
228
+ - `access_token`
229
+ - optional `udp_addr`
230
+- `access_token` is a relay-issued ES256K JWT signed by the relay owner key and validated with:
231
+ - `iss = PORTAL_URL`
232
+ - `aud = portal-sdk`
233
+ - `sub = owner_address`
234
+ - `lease_id`
235
+ - `iat`, `nbf`, `exp`
236
+ - `jti`
237
+- UDP registration requires two conditions: server must have `UDP_PORT_COUNT > 0` and admin must enable UDP in the admin panel
238
- `APIErrorCodeUDPDisabled` (HTTP 403) when UDP is disabled by admin policy
219
-- `APIErrorCodeUDPCapacityExceeded` (HTTP 503) when admin-configured max UDP lease limit is reached
239
+- `APIErrorCodeUDPCapacityExceeded` (HTTP 503) when the admin-configured max UDP lease limit is reached
240
- `APIErrorCodeUDPPortExhausted` (HTTP 503) when the UDP port pool is exhausted
241
- `PORTAL_URL` is normalized to its host component only; path/query segments are ignored for routing
242
@@ -224,23 +244,23 @@ Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
244
245
- `GET /sdk/connect?lease_id=...`
246
- Requires HTTP/1.1
227
-- Requires `X-Portal-Token` header with the lease reverse token
247
+- Requires `X-Portal-Access-Token` header with the lease access token
248
- Relay validates:
249
- lease exists and is not expired
230
- - reverse token matches the registered lease token
250
+ - the lease access token signature, issuer, audience, lease ID, and expiry are valid
251
- After claim, relay writes `0x02` before switching the session into tenant TLS passthrough
252
- After hijack, the connection becomes a broker-managed reverse session
253
254
### 3. Renew
255
256
- `POST /sdk/renew`
237
-- Requires `lease_id` + `reverse_token`
238
-- Extends lease TTL
257
+- Requires `lease_id` + `access_token`
258
+- Extends lease TTL and returns a refreshed `access_token`
259
260
### 4. Unregister
261
262
- `POST /sdk/unregister`
243
-- Requires `lease_id` + `reverse_token`
263
+- Requires `lease_id` + `access_token`
264
- Removes the lease, routes, and ready reverse sessions
265
266
## Routing Behavior
@@ -294,10 +314,9 @@ Cross-package public contract lives in:
314
- `types/paths.go`
315
- shared `/sdk/*`, admin, health, install, and signer paths
316
- `types/transport.go`
297
- - `LeaseCapabilities` (Stream/Datagram booleans)
317
+ - `ErrDatagramTooSmall`
318
- `DatagramFrame` wire frame plus SDK relay context
319
- `EncodeDatagram` / `DecodeDatagram`
300
- - Transport constants: `TransportTCP`, `TransportUDP`, `TransportBoth`
320
321
Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
322
@@ -322,11 +341,11 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
341
- SNI-based routing with root-host fallback
342
- End-to-end tenant TLS with relay-backed keyless signing
343
- Traffic-triggered detect-only MITM self-probing for probable relay-side TLS termination
325
-- Per-lease reverse token authorization for reverse session lifecycle
344
+- SIWE owner proof for registration plus relay-issued ES256K JWT access tokens for the lease lifecycle
345
- Lease-local stream and datagram ownership through per-lease transport runtimes
346
- Optional QUIC/UDP datagram transport coexisting with TCP on the same lease
347
- Per-lease UDP port allocation with sticky name-based reservation
329
-- QUIC tunnel authentication via control stream (lease ID + reverse token)
348
+- QUIC tunnel authentication via control stream (lease ID + lease access token)
349
350
## ADRs
351
docs/deployment.md
+131
-130
@@ -1,95 +1,91 @@
1
# Portal Relay Deployment Guide
2
3
-This guide covers production deployment of Portal Relay on a public domain.
3
+This guide covers the production steps for running Portal Relay on a public domain.
4
5
## 1. Prerequisites
6
7
You need:
8
9
-- A public domain (example: `example.com`)
10
-- A public Linux server with a static public IP
11
-- Open inbound ports: `443/tcp`, `4017/tcp`
12
-- Optional UDP ports (if enabling UDP transport): `4017/udp`, `50000+/udp` (see section 3.2)
9
+- A public domain, for example `example.com`
10
+- A public Linux server with a static public IPv4
11
- Docker and Docker Compose
14
-- A DNS provider account for ACME DNS-01 automation with a supported provider (`cloudflare` or `route53`)
12
+- A supported DNS provider account for ACME DNS-01 automation: `cloudflare` or `route53`
13
+- Open inbound ports:
14
+ - `443/tcp`
15
+ - `4017/tcp`
16
+ - optional for UDP transport:
17
+ - `4017/udp`
18
+ - `50000+/udp` (see section 4)
19
20
## 2. DNS Provider Setup
21
22
### 2.1 Choose ACME DNS provider
23
20
-Set `ACME_DNS_PROVIDER` to one of the currently supported values:
24
+Set `ACME_DNS_PROVIDER` to one of:
25
22
-- `ACME_DNS_PROVIDER=cloudflare`, or
23
-- `ACME_DNS_PROVIDER=route53`
26
+- `cloudflare`
27
+- `route53`
28
25
-Both providers keep root and wildcard A records synchronized to the relay public IPv4 and use DNS-01 for certificate issuance.
26
-
27
-### 2.2 Cloudflare setup (`ACME_DNS_PROVIDER=cloudflare`)
29
+### 2.2 Cloudflare setup
30
31
#### Add domain to Cloudflare
32
33
1. Cloudflare Dashboard -> `Websites` -> `Add a Site`
32
-2. Enter your domain (`example.com`)
34
+2. Enter your domain, for example `example.com`
35
3. Complete onboarding and apply Cloudflare nameservers at your registrar
36
4. Wait until zone status is `Active`
37
38
#### Create DNS records
39
38
-Cloudflare Dashboard -> `DNS` -> `Records`:
39
-
40
-- Record 1 (root host)
41
- - Type: `A`
42
- - Name: `@`
43
- - Content: `<server-ip>`
44
- - Proxy status: `DNS only`
45
-- Record 2 (wildcard)
46
- - Type: `A`
47
- - Name: `*`
48
- - Content: `<server-ip>`
49
- - Proxy status: `DNS only`
50
-
51
-Expected records:
40
+If `PORTAL_URL=https://example.com`, create:
41
42
- `example.com -> <server-ip>`
43
- `*.example.com -> <server-ip>`
44
56
-If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.example.com:8443`), create host-scoped records instead:
45
+If you deploy on a non-apex host such as `PORTAL_URL=https://portal.example.com:8443`, create:
46
47
- `portal.example.com -> <server-ip>`
48
- `*.portal.example.com -> <server-ip>`
49
61
-Portal derives public lease hostnames from the normalized `PORTAL_URL` host.
62
-Requests to the exact root host are not served by the wildcard route; they fall back to the admin/API listener.
50
+Set both records as:
51
+
52
+- Type: `A`
53
+- Proxy status: `DNS only`
54
55
#### Create Cloudflare API token
56
66
-Cloudflare Dashboard -> `My Profile` -> `API Tokens` -> `Create Token`.
57
+Cloudflare Dashboard -> `My Profile` -> `API Tokens` -> `Create Token`
58
68
-Grant:
59
+Required permissions:
60
61
- `Zone:Read`
62
- `DNS:Edit`
63
64
Scope:
65
75
-- Zone resources limited to your target zone (for example, `example.com`)
66
+- Limit the token to the target zone
67
+
68
+Save the token for `CLOUDFLARE_TOKEN`.
69
+
70
+### 2.3 Route53 setup
71
77
-Save this token for `CLOUDFLARE_TOKEN`.
72
+Create or select a public hosted zone that covers your relay host.
73
79
-### 2.3 Route53 setup (`ACME_DNS_PROVIDER=route53`)
74
+Provide Route53 write access through either:
75
81
-Create or select a public hosted zone that covers your `PORTAL_URL` root host and provide Route53 write permissions through either static AWS credentials or ambient AWS credentials (for example, an instance role).
76
+- static AWS credentials, or
77
+- ambient AWS credentials such as an instance role
78
79
Static credential environment variables:
80
81
- `AWS_ACCESS_KEY_ID`
82
- `AWS_SECRET_ACCESS_KEY`
87
-- Optional `AWS_SESSION_TOKEN` for temporary credentials
88
-- `AWS_REGION` (for example, `us-east-1`)
83
+- optional `AWS_SESSION_TOKEN`
84
+- `AWS_REGION`, for example `us-east-1`
85
86
Optional:
87
92
-- `AWS_HOSTED_ZONE_ID` (when omitted, relay selects a matching public hosted zone by domain suffix)
88
+- `AWS_HOSTED_ZONE_ID`
89
90
Equivalent relay flags:
91
@@ -99,82 +95,11 @@ Equivalent relay flags:
95
- `--aws-region`
96
- `--aws-hosted-zone-id`
97
102
-## 3. Relay Runtime Behavior
103
-
104
-### 3.1 Control Plane and Reverse Sessions
105
-
106
-- `/sdk/register` creates a lease and stores the caller-provided reverse token.
107
-- `/sdk/connect` requires:
108
- - `lease_id` query parameter
109
- - `X-Portal-Token` header
110
- - HTTP/1.1
111
-- `/sdk/renew` and `/sdk/unregister` require `lease_id` + `reverse_token`.
112
-- `/sdk/connect` is hijacked into a long-lived reverse TCP session after validation.
113
-
114
-### 3.2 Enabling UDP Transport
115
-
116
-UDP transport is disabled by default. To enable UDP for real-time workloads (game servers, VoIP), complete all steps:
117
-
118
-**Step 1: Open UDP ports on your VM/host**
119
-
120
-If running on a cloud VM (AWS EC2, GCP, OCI, etc.), open the required UDP ports in the security group / firewall rules:
121
-- `4017/udp` — QUIC tunnel listener (relay ↔ tunnel)
122
-- `50000-50009/udp` — Raw UDP lease ports (adjust count to match `UDP_PORT_COUNT`)
123
-
124
-Example (UFW, 10 ports):
125
-```bash
126
-sudo ufw allow 4017/udp
127
-sudo ufw allow 50000:50009/udp
128
-```
129
-
130
-**Step 2: Expose UDP ports in Docker**
131
-
132
-If using Docker with `network_mode: host`, UDP ports are directly accessible on the host — no additional Docker config needed.
133
-
134
-If using bridge networking, map the UDP ports explicitly in `docker-compose.yaml`:
135
-```yaml
136
-ports:
137
- - "4017:4017/udp"
138
- - "50000-50009:50000-50009/udp"
139
-```
140
-
141
-**Step 3: Configure UDP port count in `.env`**
142
-
143
-Set `UDP_PORT_COUNT` to the number of concurrent UDP leases you want to support. Ports are allocated starting from port 50000:
144
-```bash
145
-UDP_PORT_COUNT=10 # allocates ports 50000-50009
146
-```
147
-
148
-| Variable | Default | Description |
149
-|---|---|---|
150
-| `UDP_PORT_COUNT` | `0` (disabled) | Number of UDP ports to allocate, starting at port 50000 |
151
-
152
-**Step 4: Enable UDP in the admin panel**
153
-
154
-Navigate to `/admin`, toggle UDP transport to "Enabled", and optionally set a max concurrent UDP lease limit.
155
-
156
-> **Docker note:** Use `network_mode: host` for the portal container to avoid Docker iptables port-mapping overhead. Docker creates one iptables rule per mapped port, so large UDP ranges cause very slow container start/stop. Host networking bypasses this entirely and allows dynamic UDP port allocation. See the nginx-proxy examples for the recommended setup.
98
+## 3. Run Relay Server
99
158
-> **UDP buffer tuning (Linux):** Increase kernel UDP buffer limits for QUIC performance:
159
-> ```bash
160
-> sudo sysctl -w net.core.rmem_max=7500000
161
-> sudo sysctl -w net.core.wmem_max=7500000
162
-> ```
163
-> To persist across reboots, add to `/etc/sysctl.conf` or a file in `/etc/sysctl.d/`.
100
+### 3.1 Create `.env` at repository root
101
165
-### 3.3 Certificates and DNS Maintenance
166
-
167
-- Relay certificates live in `KEYLESS_DIR`:
168
- - `fullchain.pem`
169
- - `privatekey.pem`
170
-- On non-localhost deployments, ACME DNS-01 uses the configured supported DNS provider to:
171
- - ensure root and wildcard A records point to the current public IP
172
- - provision the relay certificate
173
- - keep DNS and certificate state refreshed over time
174
-
175
-## 4. Run Relay Server
176
-
177
-### 4.1 Create `.env` at repository root
102
+Example:
103
104
```bash
105
PORTAL_URL=https://example.com
@@ -200,8 +125,11 @@ AWS_REGION=us-east-1
125
AWS_HOSTED_ZONE_ID=Z1234567890ABC
126
```
127
203
-For non-apex deployments, set `PORTAL_URL` to the non-apex host value (for example, `https://portal.example.com:8443`).
204
-`PORTAL_URL` path/query segments are ignored for route derivation; only the host component is used.
128
+Notes:
129
+
130
+- For non-apex deployments, set `PORTAL_URL` to the non-apex host value, for example `https://portal.example.com:8443`
131
+- Portal uses the `PORTAL_URL` host for public lease hostnames
132
+- `KEYLESS_DIR` stores relay certificate material
133
134
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:
135
@@ -209,15 +137,75 @@ If the relay sits behind a reverse proxy or ingress and you want admin/auth and
137
TRUST_PROXY_HEADERS=true
138
```
139
212
-By default, forwarded headers are accepted from private, loopback, and link-local proxy source ranges.
140
If your proxy source addresses are public or you want a stricter allowlist, also set `TRUSTED_PROXY_CIDRS`.
141
215
-### 4.2 Start Relay
142
+### 3.2 Start Relay
143
144
```bash
218
-docker compose up
145
+docker compose up -d
146
```
147
148
+## 4. Optional UDP Setup
149
+
150
+UDP transport is disabled by default.
151
+
152
+### 4.1 Open UDP ports on your VM or host
153
+
154
+Open these UDP ports in your cloud security group or firewall:
155
+
156
+- `4017/udp`
157
+- the lease port range starting at `50000`, for example `50000-50009/udp`
158
+
159
+UFW example for 10 UDP ports:
160
+
161
+```bash
162
+sudo ufw allow 4017/udp
163
+sudo ufw allow 50000:50009/udp
164
+```
165
+
166
+### 4.2 Expose UDP ports in Docker
167
+
168
+If you use `network_mode: host`, the container uses host UDP ports directly.
169
+
170
+If you use bridge networking, map the ports explicitly in `docker-compose.yaml`:
171
+
172
+```yaml
173
+ports:
174
+ - "4017:4017/udp"
175
+ - "50000-50009:50000-50009/udp"
176
+```
177
+
178
+### 4.3 Configure `UDP_PORT_COUNT`
179
+
180
+Set `UDP_PORT_COUNT` in `.env` to the number of UDP leases you want to support.
181
+
182
+Example:
183
+
184
+```bash
185
+UDP_PORT_COUNT=10
186
+```
187
+
188
+That allocates lease UDP ports `50000-50009`.
189
+
190
+| Variable | Default | Description |
191
+|---|---|---|
192
+| `UDP_PORT_COUNT` | `0` | Number of UDP ports to allocate, starting at port 50000 |
193
+
194
+### 4.4 Enable UDP in the admin panel
195
+
196
+After the relay starts, open `/admin`, enable UDP transport, and optionally set a max concurrent UDP lease limit.
197
+
198
+### 4.5 Optional Linux UDP buffer tuning
199
+
200
+For better QUIC performance on Linux:
201
+
202
+```bash
203
+sudo sysctl -w net.core.rmem_max=7500000
204
+sudo sysctl -w net.core.wmem_max=7500000
205
+```
206
+
207
+To persist this across reboots, add the values to `/etc/sysctl.conf` or a file in `/etc/sysctl.d/`.
208
+
209
## 5. Auto-Update
210
211
Automatically redeploy when a new `ghcr.io/gosuda/portal:latest` image is pushed.
@@ -263,7 +251,6 @@ Requires=docker.service
251
[Service]
252
Type=simple
253
User=opc
266
-# Set to the directory containing watch_and_deploy.sh and deploy_portal.sh
254
WorkingDirectory=<path-to-project>
255
ExecStart=/bin/bash <path-to-project>/watch_and_deploy.sh
256
Restart=always
@@ -288,13 +275,8 @@ sudo usermod -aG docker opc
275
### 5.4 Verify and monitor
276
277
```bash
291
-# Service status
278
sudo systemctl status portal-watcher
293
-
294
-# Live logs
279
sudo journalctl -u portal-watcher -f
296
-
297
-# Today's logs only
280
sudo journalctl -u portal-watcher --since today
281
```
282
@@ -304,12 +286,13 @@ sudo journalctl -u portal-watcher --since today
286
287
Required inbound ports:
288
307
-- `443/tcp` — SNI router (tenant TLS passthrough)
308
-- `4017/tcp` — Admin/API listener
309
-- `4017/udp` — QUIC tunnel listener (only if `UDP_PORT_COUNT > 0`)
310
-- `50000+/udp` — Raw UDP lease ports (only if `UDP_PORT_COUNT > 0`, adjust range to match count)
289
+- `443/tcp`
290
+- `4017/tcp`
291
+- optional for UDP:
292
+ - `4017/udp`
293
+ - `50000+/udp` matching `UDP_PORT_COUNT`
294
312
-UFW example (with 10 UDP ports):
295
+UFW example with 10 UDP ports:
296
297
```bash
298
sudo ufw allow 443/tcp
@@ -321,4 +304,22 @@ sudo ufw status
304
305
### 6.2 QUIC UDP buffer warnings
306
324
-If relay logs show `failed to sufficiently increase receive buffer size`, the kernel UDP buffer limit is too low. Apply the sysctl settings from section 3.2.
307
+If relay logs show `failed to sufficiently increase receive buffer size`, apply the sysctl settings from section 4.5.
308
+
309
+### 6.3 Docker DNS resolution fails
310
+
311
+If logs show `discover bootstraps failed`, `sync dns records`, or `lookup <host> on 127.0.0.11:53: write: operation not permitted`, Docker is usually using the wrong host resolver config.
312
+
313
+On Linux hosts with `systemd-resolved`, point `/etc/resolv.conf` at the upstream resolver list and restart Docker:
314
+
315
+```bash
316
+sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
317
+sudo systemctl restart docker
318
+docker compose up -d
319
+```
320
+
321
+Verify from the container:
322
+
323
+```bash
324
+docker exec -it portal-1 nslookup api4.ipify.org
325
+```
docs/examples/nginx-proxy/nginx.conf
+1
-1
@@ -103,7 +103,7 @@ http {
103
104
# Pass through Upgrade and Connection headers for HTTP/1.1
105
# connection hijacking. The relay takes ownership of the connection
106
- # after validating the lease and reverse token.
106
+ # after validating the lease and lease access token.
107
proxy_set_header Upgrade $http_upgrade;
108
proxy_set_header Connection $http_connection;
109
frontend/README.md
+2
-2
@@ -172,11 +172,11 @@ npm run serve
172
### Connection Responsibilities
173
174
- Conn #1 (`browser -> app`) is the data plane and keeps existing tenant-facing TLS behavior.
175
-- Conn #2 (`relay -> tunnel`) is the control plane and enforces lease token admission.
175
+- Conn #2 (`relay -> tunnel`) is the control plane and enforces lease access token admission.
176
177
### Breaking-Change Expectation
178
179
-- Clients with invalid lease tokens are expected to fail admission.
179
+- Clients with invalid lease access tokens are expected to fail admission.
180
- Client certificates are not required for `/sdk/*` admission.
181
182
### Radix Select Values
go.mod
+8
-4
@@ -7,11 +7,13 @@ require (
7
github.com/aws/aws-sdk-go-v2/config v1.32.8
8
github.com/aws/aws-sdk-go-v2/credentials v1.19.8
9
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
10
- github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1
10
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0
11
github.com/go-acme/lego/v4 v4.32.0
12
+ github.com/go-jose/go-jose/v4 v4.1.3
13
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
14
github.com/quic-go/quic-go v0.59.0
15
github.com/rs/zerolog v1.34.0
16
+ github.com/spruceid/siwe-go v0.2.1
17
golang.org/x/crypto v0.48.0
18
golang.org/x/net v0.50.0
19
golang.org/x/sync v0.19.0
@@ -30,12 +32,16 @@ require (
32
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
33
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
34
github.com/aws/smithy-go v1.24.0 // indirect
35
+ github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
36
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
34
- github.com/go-jose/go-jose/v4 v4.1.3 // indirect
37
+ github.com/dchest/uniuri v1.2.0 // indirect
38
+ github.com/ethereum/go-ethereum v1.12.1 // indirect
39
github.com/google/btree v1.1.2 // indirect
40
+ github.com/holiman/uint256 v1.2.3 // indirect
41
github.com/mattn/go-colorable v0.1.13 // indirect
42
github.com/mattn/go-isatty v0.0.20 // indirect
43
github.com/miekg/dns v1.1.72 // indirect
44
+ github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect
45
golang.org/x/mod v0.32.0 // indirect
46
golang.org/x/sys v0.41.0 // indirect
47
golang.org/x/text v0.34.0 // indirect
@@ -44,5 +50,3 @@ require (
50
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
51
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
52
)
47
-
48
-exclude golang.zx2c4.com/wireguard/tun/netstack v0.0.0-20220703234212-c31a7b1ab478
go.sum
+16
-2
@@ -28,15 +28,23 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
28
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
29
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
30
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
31
+github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
32
+github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
33
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
34
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
35
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
36
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
37
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
38
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
39
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
40
+github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
41
+github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
42
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
43
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
38
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
39
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
44
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
45
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
46
+github.com/ethereum/go-ethereum v1.12.1 h1:1kXDPxhLfyySuQYIfRxVBGYuaHdxNNxevA73vjIwsgk=
47
+github.com/ethereum/go-ethereum v1.12.1/go.mod h1:zKetLweqBR8ZS+1O9iJWI8DvmmD2NzD19apjEWDCsnw=
48
github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
49
github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
50
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
@@ -48,6 +56,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
56
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
57
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
58
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
59
+github.com/holiman/uint256 v1.2.3 h1:K8UWO1HUJpRMXBxbmaY1Y8IAMZC/RsKB+ArEnnK4l5o=
60
+github.com/holiman/uint256 v1.2.3/go.mod h1:SC8Ryt4n+UBbPbIBKaG9zbbDlp4jOru9xFZmPzLUTxw=
61
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
62
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
63
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -61,9 +71,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
71
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
72
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
73
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
74
+github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 h1:mLbKGKe5gDGHE8uJLYMmA/fkp/htaXEMl2Hj0k4xfYE=
75
+github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
76
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
77
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
78
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
79
+github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnuc=
80
+github.com/spruceid/siwe-go v0.2.1/go.mod h1:MHpHbptGsM3lHth2L8quhZ9ipiwST8zsJH1CjWpeO1k=
81
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
82
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
83
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
portal/acme/acme.go
+4
-49
@@ -12,9 +12,6 @@ import (
12
"encoding/pem"
13
"errors"
14
"fmt"
15
- "io"
16
- "net"
17
- "net/http"
15
"os"
16
"path/filepath"
17
"strings"
@@ -80,7 +77,7 @@ type acmeUser struct {
77
}
78
79
func NewManager(cfg Config) (*Manager, error) {
83
- cfg.BaseDomain = normalizeHost(cfg.BaseDomain)
80
+ cfg.BaseDomain = strings.TrimPrefix(utils.NormalizeHostname(cfg.BaseDomain), "*.")
81
cfg.KeyDir = strings.TrimSpace(cfg.KeyDir)
82
cfg.DNSProvider = strings.ToLower(strings.TrimSpace(cfg.DNSProvider))
83
cfg.CloudflareToken = strings.TrimSpace(cfg.CloudflareToken)
@@ -291,7 +288,7 @@ func (m *Manager) syncDNS(ctx context.Context) error {
288
return errors.New("acme dns provider is required")
289
}
290
294
- publicIP, err := detectPublicIPv4(ctx)
291
+ publicIP, err := utils.ResolvePublicIPv4(ctx)
292
if err != nil {
293
return fmt.Errorf("detect public ip: %w", err)
294
}
@@ -317,7 +314,7 @@ func certNeedsRenewal(certFile string, domains []string) (bool, error) {
314
if err != nil {
315
return false, err
316
}
320
- cert, err := ParseCertificatePEM(certPEM)
317
+ cert, err := utils.ParseCertificatePEM(certPEM)
318
if err != nil {
319
return false, err
320
}
@@ -336,7 +333,7 @@ func certCoversDomains(certFile string, domains []string) (bool, error) {
333
if err != nil {
334
return false, err
335
}
339
- cert, err := ParseCertificatePEM(certPEM)
336
+ cert, err := utils.ParseCertificatePEM(certPEM)
337
if err != nil {
338
return false, err
339
}
@@ -528,45 +525,3 @@ func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
525
}
526
return os.Chmod(path, mode)
527
}
531
-
532
-func ParseCertificatePEM(pemData []byte) (*x509.Certificate, error) {
533
- block, _ := pem.Decode(pemData)
534
- if block == nil {
535
- return nil, errors.New("no pem block found")
536
- }
537
- return x509.ParseCertificate(block.Bytes)
538
-}
539
-
540
-func normalizeHost(host string) string {
541
- host = strings.ToLower(strings.TrimSpace(host))
542
- host = strings.TrimPrefix(host, "*.")
543
- host = strings.TrimSuffix(host, ".")
544
- return host
545
-}
546
-
547
-func detectPublicIPv4(ctx context.Context) (string, error) {
548
- ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
549
- defer cancel()
550
-
551
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api4.ipify.org", nil)
552
- if err != nil {
553
- return "", err
554
- }
555
-
556
- resp, err := http.DefaultClient.Do(req)
557
- if err != nil {
558
- return "", err
559
- }
560
- defer resp.Body.Close()
561
-
562
- body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
563
- if err != nil {
564
- return "", err
565
- }
566
- ip := strings.TrimSpace(string(body))
567
- parsed := net.ParseIP(ip)
568
- if parsed == nil || parsed.To4() == nil {
569
- return "", fmt.Errorf("invalid ipv4 address: %q", ip)
570
- }
571
- return ip, nil
572
-}
portal/acme/cloudflare/provider.go
+11
-50
@@ -1,9 +1,7 @@
1
package cloudflare
2
3
import (
4
- "bytes"
4
"context"
6
- "encoding/json"
5
"errors"
6
"fmt"
7
"net/http"
@@ -12,6 +10,8 @@ import (
10
11
"github.com/go-acme/lego/v4/challenge"
12
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
13
+
14
+ "github.com/gosuda/portal/v2/utils"
15
)
16
17
const (
@@ -87,7 +87,7 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
87
if p == nil {
88
return errors.New("cloudflare provider is nil")
89
}
90
- baseDomain = normalizeHost(baseDomain)
90
+ baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
91
if baseDomain == "" {
92
return errors.New("base domain is required")
93
}
@@ -155,7 +155,7 @@ func listZones(ctx context.Context, token, name string) ([]zone, error) {
155
u.RawQuery = q.Encode()
156
157
var out zonesResult
158
- if err := doGet(ctx, token, u.String(), &out); err != nil {
158
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodGet, u.String(), nil, cloudflareHeaders(token), &out); err != nil {
159
return nil, err
160
}
161
if !out.Success {
@@ -172,7 +172,7 @@ func listDNSRecords(ctx context.Context, token, zoneID, name, recordType string)
172
u.RawQuery = q.Encode()
173
174
var out recordsResult
175
- if err := doGet(ctx, token, u.String(), &out); err != nil {
175
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodGet, u.String(), nil, cloudflareHeaders(token), &out); err != nil {
176
return nil, err
177
}
178
if !out.Success {
@@ -192,7 +192,7 @@ func createDNSRecord(ctx context.Context, token, zoneID, name, ip string) error
192
}
193
194
var out recordResult
195
- if err := doMutate(ctx, http.MethodPost, token, endpoint, body, &out); err != nil {
195
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodPost, endpoint, body, cloudflareHeaders(token), &out); err != nil {
196
return err
197
}
198
if !out.Success {
@@ -212,7 +212,7 @@ func updateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip stri
212
}
213
214
var out recordResult
215
- if err := doMutate(ctx, http.MethodPut, token, endpoint, body, &out); err != nil {
215
+ if err := utils.HTTPDoJSON(ctx, nil, http.MethodPut, endpoint, body, cloudflareHeaders(token), &out); err != nil {
216
return err
217
}
218
if !out.Success {
@@ -221,43 +221,11 @@ func updateDNSRecord(ctx context.Context, token, zoneID, recordID, name, ip stri
221
return nil
222
}
223
224
-func doGet(ctx context.Context, token, rawURL string, out any) error {
225
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
226
- if err != nil {
227
- return err
228
- }
229
- req.Header.Set("Authorization", "Bearer "+token)
230
- req.Header.Set("Content-Type", "application/json")
231
-
232
- resp, err := http.DefaultClient.Do(req)
233
- if err != nil {
234
- return err
235
- }
236
- defer resp.Body.Close()
237
-
238
- return json.NewDecoder(resp.Body).Decode(out)
239
-}
240
-
241
-func doMutate(ctx context.Context, method, token, rawURL string, body any, out any) error {
242
- payload, err := json.Marshal(body)
243
- if err != nil {
244
- return err
245
- }
246
-
247
- req, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(payload))
248
- if err != nil {
249
- return err
250
- }
251
- req.Header.Set("Authorization", "Bearer "+token)
252
- req.Header.Set("Content-Type", "application/json")
253
-
254
- resp, err := http.DefaultClient.Do(req)
255
- if err != nil {
256
- return err
224
+func cloudflareHeaders(token string) http.Header {
225
+ return http.Header{
226
+ "Authorization": []string{"Bearer " + token},
227
+ "Content-Type": []string{"application/json"},
228
}
258
- defer resp.Body.Close()
259
-
260
- return json.NewDecoder(resp.Body).Decode(out)
229
}
230
231
func wrapErrors(errs []apiError) error {
@@ -270,10 +238,3 @@ func wrapErrors(errs []apiError) error {
238
}
239
return errors.New(strings.Join(messages, "; "))
240
}
273
-
274
-func normalizeHost(host string) string {
275
- host = strings.ToLower(strings.TrimSpace(host))
276
- host = strings.TrimPrefix(host, "*.")
277
- host = strings.TrimSuffix(host, ".")
278
- return host
279
-}
portal/acme/local.go
+4
-1
@@ -11,7 +11,10 @@ import (
11
"math/big"
12
"net"
13
"path/filepath"
14
+ "strings"
15
"time"
16
+
17
+ "github.com/gosuda/portal/v2/utils"
18
)
19
20
const localDevelopmentCertificateTTL = 3650 * 24 * time.Hour
@@ -91,7 +94,7 @@ func ensureLocalDevelopmentCertificate(keyDir, baseHost string) error {
94
}
95
96
func localDevelopmentDomains(baseHost string) []string {
94
- baseHost = normalizeHost(baseHost)
97
+ baseHost = strings.TrimPrefix(utils.NormalizeHostname(baseHost), "*.")
98
domains := []string{"localhost", "*.localhost", "127.0.0.1", "::1"}
99
if baseHost != "" && baseHost != "localhost" {
100
domains = append(domains, baseHost)
portal/acme/route53/provider.go
+6
-10
@@ -14,6 +14,8 @@ import (
14
"github.com/aws/aws-sdk-go-v2/service/route53/types"
15
"github.com/go-acme/lego/v4/challenge"
16
"github.com/go-acme/lego/v4/providers/dns/route53"
17
+
18
+ "github.com/gosuda/portal/v2/utils"
19
)
20
21
const defaultAWSRegion = "us-east-1"
@@ -72,7 +74,7 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
74
if p == nil {
75
return errors.New("route53 provider is nil")
76
}
75
- baseDomain = normalizeHost(baseDomain)
77
+ baseDomain = utils.NormalizeHostname(baseDomain)
78
if baseDomain == "" {
79
return errors.New("base domain is required")
80
}
@@ -143,7 +145,7 @@ func findHostedZoneID(ctx context.Context, client *awsroute53.Client, domain, ex
145
if hostedZone.Config != nil && hostedZone.Config.PrivateZone {
146
continue
147
}
146
- zoneName := normalizeHost(aws.ToString(hostedZone.Name))
148
+ zoneName := utils.NormalizeHostname(aws.ToString(hostedZone.Name))
149
zoneID := normalizeZoneID(aws.ToString(hostedZone.Id))
150
if zoneName == "" || zoneID == "" {
151
continue
@@ -169,7 +171,7 @@ func upsertARecord(ctx context.Context, client *awsroute53.Client, hostedZoneID,
171
return errors.New("hosted zone id is required")
172
}
173
172
- fqdn := ensureTrailingDot(normalizeHost(name))
174
+ fqdn := ensureTrailingDot(utils.NormalizeHostname(name))
175
recordSet := &types.ResourceRecordSet{
176
Name: aws.String(fqdn),
177
Type: types.RRTypeA,
@@ -213,7 +215,7 @@ func domainCandidates(domain string) []string {
215
216
candidates := make([]string, 0, len(parts)-1)
217
for i := range len(parts) - 1 {
216
- candidate := normalizeHost(strings.Join(parts[i:], "."))
218
+ candidate := utils.NormalizeHostname(strings.Join(parts[i:], "."))
219
if candidate != "" {
220
candidates = append(candidates, candidate)
221
}
@@ -245,12 +247,6 @@ func validateConfig(cfg Config) error {
247
return nil
248
}
249
248
-func normalizeHost(host string) string {
249
- host = strings.ToLower(strings.TrimSpace(host))
250
- host = strings.TrimSuffix(host, ".")
251
- return host
252
-}
253
-
250
func normalizeZoneID(raw string) string {
251
trimmed := strings.TrimSpace(raw)
252
return strings.TrimPrefix(trimmed, "/hostedzone/")
portal/api_server.go
+139
-61
@@ -9,12 +9,14 @@ import (
9
"io"
10
"net"
11
"net/http"
12
+ "net/url"
13
"strings"
14
"time"
15
16
"github.com/quic-go/quic-go"
17
"github.com/rs/zerolog/log"
18
19
+ "github.com/gosuda/portal/v2/portal/auth"
20
"github.com/gosuda/portal/v2/portal/discovery"
21
"github.com/gosuda/portal/v2/portal/keyless"
22
"github.com/gosuda/portal/v2/portal/policy"
@@ -71,6 +73,8 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
73
s.handleHealthz(w, r)
74
case types.PathSDKDomain:
75
s.handleDomain(w, r)
76
+ case types.PathSDKRegisterChallenge:
77
+ s.handleRegisterChallenge(w, r)
78
case types.PathSDKRegister:
79
s.handleRegister(w, r)
80
case types.PathSDKRenew:
@@ -194,28 +198,90 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
198
return
199
}
200
197
- resp, err := s.registerLease(req, clientIP)
201
+ challenge, err := s.registry.consumeVerifiedRegisterChallenge(req)
202
if err != nil {
199
- status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
200
- if errors.Is(err, errFeatureUnavailable) {
201
- status, code = http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable
202
- }
203
- if errors.Is(err, errHostnameConflict) {
204
- status, code = http.StatusConflict, types.APIErrorCodeHostnameConflict
205
- }
206
- if errors.Is(err, errIPBanned) {
207
- status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
208
- }
209
- if errors.Is(err, transport.ErrPortExhausted) {
210
- status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted
203
+ switch err {
204
+ case auth.ErrInvalidSignature:
205
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
206
+ default:
207
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
208
}
212
- if errors.Is(err, errUDPDisabled) {
213
- status, code = http.StatusForbidden, types.APIErrorCodeUDPDisabled
209
+ return
210
+ }
211
+
212
+ resp, err := s.registerLease(challenge.Request, clientIP, req.ReportedIP)
213
+ if err != nil {
214
+ switch err {
215
+ case errFeatureUnavailable:
216
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, err.Error())
217
+ case errHostnameConflict:
218
+ utils.WriteAPIError(w, http.StatusConflict, types.APIErrorCodeHostnameConflict, err.Error())
219
+ case errIPBanned:
220
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, err.Error())
221
+ case transport.ErrPortExhausted:
222
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted, err.Error())
223
+ case errUDPDisabled:
224
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUDPDisabled, err.Error())
225
+ case errUDPCapacityExceeded:
226
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeUDPCapacityExceeded, err.Error())
227
+ default:
228
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
229
}
215
- if errors.Is(err, errUDPCapacityExceeded) {
216
- status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPCapacityExceeded
230
+ return
231
+ }
232
+
233
+ utils.WriteAPIData(w, http.StatusCreated, resp)
234
+}
235
+
236
+func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request) {
237
+ if r.Method != http.MethodPost {
238
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
239
+ return
240
+ }
241
+
242
+ clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.trustedProxyCIDRs)
243
+ if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
244
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
245
+ return
246
+ }
247
+
248
+ var req types.RegisterChallengeRequest
249
+ if err := utils.DecodeJSONBody(w, r, &req, defaultControlBodyLimit); err != nil {
250
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
251
+ return
252
+ }
253
+
254
+ scheme := "https"
255
+ if r.TLS == nil {
256
+ scheme = "http"
257
+ }
258
+ domain := strings.TrimSpace(r.Host)
259
+ if domain == "" {
260
+ domain = s.rootHost
261
+ }
262
+ registerURI := (&url.URL{
263
+ Scheme: scheme,
264
+ Host: domain,
265
+ Path: types.PathSDKRegister,
266
+ }).String()
267
+
268
+ if req.UDPEnabled && (s.cfg.UDPPortCount <= 0 || s.group != nil && s.quicTunnel == nil) {
269
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
270
+ return
271
+ }
272
+
273
+ resp, err := s.registry.issueRegisterChallenge(req, domain, registerURI)
274
+ if err != nil {
275
+ switch err {
276
+ case errFeatureUnavailable:
277
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, err.Error())
278
+ case errUDPDisabled:
279
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUDPDisabled, err.Error())
280
+ case errUDPCapacityExceeded:
281
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeUDPCapacityExceeded, err.Error())
282
+ default:
283
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
284
}
218
- utils.WriteAPIError(w, status, code, err.Error())
285
return
286
}
287
@@ -240,27 +306,37 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
306
return
307
}
308
309
+ claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.ownerIdentity.PublicKey, s.cfg.PortalURL, strings.TrimSpace(req.LeaseID), time.Now().UTC())
310
+ if err != nil {
311
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, errUnauthorized.Error())
312
+ return
313
+ }
314
+
315
ttl := s.cfg.LeaseTTL
316
if req.TTL > 0 {
317
ttl = time.Duration(req.TTL) * time.Second
318
}
247
- record, err := s.registry.Renew(strings.TrimSpace(req.LeaseID), req.ReverseToken, ttl, clientIP, utils.SanitizeReportedIP(req.ReportedIP))
319
+ record, err := s.registry.Renew(strings.TrimSpace(req.LeaseID), ttl, clientIP, utils.SanitizeReportedIP(req.ReportedIP))
320
if err != nil {
249
- status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
250
- if errors.Is(err, errLeaseNotFound) {
251
- status, code = http.StatusNotFound, types.APIErrorCodeLeaseNotFound
252
- }
253
- if errors.Is(err, errUnauthorized) {
254
- status, code = http.StatusForbidden, types.APIErrorCodeUnauthorized
255
- }
256
- if errors.Is(err, errIPBanned) {
257
- status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
321
+ switch err {
322
+ case errLeaseNotFound:
323
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
324
+ default:
325
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
326
}
259
- utils.WriteAPIError(w, status, code, err.Error())
327
+ return
328
+ }
329
+ nextAccessToken, _, err := auth.IssueLeaseAccessToken(s.ownerIdentity.PrivateKey, s.ownerIdentity.Address, s.cfg.PortalURL, claims.Subject, record.ID, ttl)
330
+ if err != nil {
331
+ utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
332
return
333
}
334
263
- utils.WriteAPIData(w, http.StatusOK, types.RenewResponse{LeaseID: record.ID, ExpiresAt: record.ExpiresAt})
335
+ utils.WriteAPIData(w, http.StatusOK, types.RenewResponse{
336
+ LeaseID: record.ID,
337
+ ExpiresAt: record.ExpiresAt,
338
+ AccessToken: nextAccessToken,
339
+ })
340
}
341
342
func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
@@ -274,24 +350,26 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
350
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
351
return
352
}
353
+ if _, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.ownerIdentity.PublicKey, s.cfg.PortalURL, strings.TrimSpace(req.LeaseID), time.Now().UTC()); err != nil {
354
+ utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, errUnauthorized.Error())
355
+ return
356
+ }
357
278
- record, err := s.registry.Unregister(strings.TrimSpace(req.LeaseID), req.ReverseToken)
358
+ record, err := s.registry.Unregister(strings.TrimSpace(req.LeaseID))
359
if err != nil {
280
- status, code := http.StatusBadRequest, types.APIErrorCodeInvalidRequest
281
- if errors.Is(err, errLeaseNotFound) {
282
- status, code = http.StatusNotFound, types.APIErrorCodeLeaseNotFound
283
- }
284
- if errors.Is(err, errUnauthorized) {
285
- status, code = http.StatusForbidden, types.APIErrorCodeUnauthorized
360
+ switch err {
361
+ case errLeaseNotFound:
362
+ utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeLeaseNotFound, err.Error())
363
+ default:
364
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
365
}
287
- utils.WriteAPIError(w, status, code, err.Error())
366
return
367
}
368
if record != nil {
369
record.Close()
370
}
371
294
- utils.WriteAPIOK(w, http.StatusOK)
372
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{})
373
}
374
375
func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
@@ -305,7 +383,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
383
}
384
385
leaseID := strings.TrimSpace(r.URL.Query().Get("lease_id"))
308
- token := strings.TrimSpace(r.Header.Get(types.HeaderReverseToken))
386
+ token := strings.TrimSpace(r.Header.Get(types.HeaderAccessToken))
387
clientIP := policy.ExtractClientIP(r, s.cfg.TrustProxyHeaders, s.trustedProxyCIDRs)
388
if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
389
utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeIPBanned, "request denied because source IP is banned")
@@ -390,13 +468,13 @@ func (s *Server) handleQUICTunnelConn(conn *quic.Conn) {
468
return
469
}
470
_ = stream.SetReadDeadline(time.Time{})
393
- if msg.LeaseID == "" || msg.ReverseToken == "" {
471
+ if msg.LeaseID == "" || msg.AccessToken == "" {
472
_ = json.NewEncoder(stream).Encode(types.QUICControlResponse{OK: false, Error: "invalid_control_message"})
473
_ = conn.CloseWithError(1, "invalid control message")
474
return
475
}
476
399
- lease, err := s.admitLeaseByID(msg.LeaseID, msg.ReverseToken, true)
477
+ lease, err := s.admitLeaseByID(msg.LeaseID, msg.AccessToken, true)
478
switch {
479
case err == nil:
480
case errors.Is(err, errLeaseNotFound):
@@ -445,7 +523,7 @@ func (s *Server) admitLeaseByID(leaseID, token string, requireDatagram bool) (*l
523
if !s.registry.policy.IsLeaseRoutable(lease.ID) {
524
return nil, errLeaseRejected
525
}
448
- if !utils.TokenMatches(lease.ReverseToken, token) {
526
+ if _, err := auth.VerifyLeaseAccessToken(token, s.ownerIdentity.PublicKey, s.cfg.PortalURL, leaseID, time.Now().UTC()); err != nil {
527
return nil, errUnauthorized
528
}
529
if lease.stream == nil || (requireDatagram && lease.datagram == nil) {
@@ -454,14 +532,11 @@ func (s *Server) admitLeaseByID(leaseID, token string, requireDatagram bool) (*l
532
return lease, nil
533
}
534
457
-func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (types.RegisterResponse, error) {
535
+func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, reportedIP string) (types.RegisterResponse, error) {
536
name, err := utils.NormalizeDNSLabel(req.Name)
537
if err != nil {
538
return types.RegisterResponse{}, err
539
}
462
- if strings.TrimSpace(req.ReverseToken) == "" {
463
- return types.RegisterResponse{}, errors.New("reverse token is required")
464
- }
540
if s.registry.policy.IPFilter().IsIPBanned(clientIP) {
541
return types.RegisterResponse{}, errIPBanned
542
}
@@ -493,10 +568,13 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
568
return types.RegisterResponse{}, errUDPCapacityExceeded
569
}
570
}
496
-
571
leaseID := utils.RandomID("lease_")
498
- now := time.Now()
499
- expiresAt := now.Add(ttl)
572
+ accessToken, claims, err := auth.IssueLeaseAccessToken(s.ownerIdentity.PrivateKey, s.ownerIdentity.Address, s.cfg.PortalURL, ownerAddress, leaseID, ttl)
573
+ if err != nil {
574
+ return types.RegisterResponse{}, err
575
+ }
576
+ issuedAt := claims.IssuedAt.Time().UTC()
577
+ expiresAt := claims.Expiry.Time().UTC()
578
record := &leaseRecord{
579
Lease: types.Lease{
580
ID: leaseID,
@@ -505,14 +583,13 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
583
Metadata: req.Metadata,
584
OwnerAddress: ownerAddress,
585
ExpiresAt: expiresAt,
508
- FirstSeenAt: now,
509
- LastSeenAt: now,
586
+ FirstSeenAt: issuedAt,
587
+ LastSeenAt: issuedAt,
588
ClientIP: clientIP,
511
- ReportedIP: utils.SanitizeReportedIP(req.ReportedIP),
589
+ ReportedIP: utils.SanitizeReportedIP(reportedIP),
590
UDPEnabled: req.UDPEnabled,
591
},
514
- ReverseToken: req.ReverseToken,
515
- stream: transport.NewRelayStream(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
592
+ stream: transport.NewRelayStream(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
593
}
594
if req.UDPEnabled {
595
if s.ports == nil {
@@ -520,7 +597,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
597
}
598
port, err := s.ports.Allocate(name)
599
if err != nil {
523
- return types.RegisterResponse{}, fmt.Errorf("allocate udp port: %w", err)
600
+ return types.RegisterResponse{}, err
601
}
602
record.datagram = transport.NewRelayDatagram(leaseID, port)
603
record.ports = s.ports
@@ -537,11 +614,12 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
614
}
615
616
resp := types.RegisterResponse{
540
- LeaseID: leaseID,
541
- Hostname: hostname,
542
- Metadata: record.Metadata,
543
- ExpiresAt: expiresAt,
544
- UDPEnabled: record.UDPEnabled,
617
+ LeaseID: leaseID,
618
+ Hostname: hostname,
619
+ Metadata: record.Metadata,
620
+ ExpiresAt: expiresAt,
621
+ AccessToken: accessToken,
622
+ UDPEnabled: record.UDPEnabled,
623
}
624
if record.datagram != nil {
625
resp.UDPAddr = fmt.Sprintf("%s:%d", s.rootHost, record.datagram.UDPPort())
portal/auth/auth.go
new
+295
@@ -0,0 +1,295 @@
1
+package auth
2
+
3
+import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
12
+ secp256k1ecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
13
+ jose "github.com/go-jose/go-jose/v4"
14
+ "github.com/go-jose/go-jose/v4/jwt"
15
+ "github.com/spruceid/siwe-go"
16
+
17
+ "github.com/gosuda/portal/v2/types"
18
+ "github.com/gosuda/portal/v2/utils"
19
+)
20
+
21
+const (
22
+ registerStatement = "Register a portal lease"
23
+ leaseAccessTokenAudience = "portal-sdk"
24
+)
25
+
26
+var (
27
+ ErrChallengeExpired = errors.New("register challenge expired")
28
+ ErrChallengeNotFound = errors.New("register challenge not found")
29
+ ErrInvalidSignature = errors.New("siwe signature is invalid")
30
+ ErrMessageMismatch = errors.New("siwe message does not match register challenge")
31
+)
32
+
33
+const leaseTokenAlgorithm = jose.SignatureAlgorithm("ES256K")
34
+
35
+type LeaseAccessTokenClaims struct {
36
+ LeaseID string `json:"lease_id"`
37
+ jwt.Claims
38
+}
39
+
40
+type es256kOpaqueSigner struct {
41
+ keyID string
42
+ privateKey *secp256k1.PrivateKey
43
+}
44
+
45
+func (s *es256kOpaqueSigner) Public() *jose.JSONWebKey {
46
+ return &jose.JSONWebKey{KeyID: strings.TrimSpace(s.keyID)}
47
+}
48
+
49
+func (s *es256kOpaqueSigner) Algs() []jose.SignatureAlgorithm {
50
+ return []jose.SignatureAlgorithm{leaseTokenAlgorithm}
51
+}
52
+
53
+func (s *es256kOpaqueSigner) SignPayload(payload []byte, alg jose.SignatureAlgorithm) ([]byte, error) {
54
+ if alg != leaseTokenAlgorithm {
55
+ return nil, jose.ErrUnsupportedAlgorithm
56
+ }
57
+ if s == nil || s.privateKey == nil {
58
+ return nil, errors.New("signing key is required")
59
+ }
60
+
61
+ hash := sha256.Sum256(payload)
62
+ compact := secp256k1ecdsa.SignCompact(s.privateKey, hash[:], false)
63
+ if len(compact) != 65 {
64
+ return nil, errors.New("invalid compact signature length")
65
+ }
66
+
67
+ signature := make([]byte, 64)
68
+ copy(signature[:32], compact[1:33])
69
+ copy(signature[32:], compact[33:65])
70
+ return signature, nil
71
+}
72
+
73
+type es256kOpaqueVerifier struct {
74
+ publicKey *secp256k1.PublicKey
75
+}
76
+
77
+func (v *es256kOpaqueVerifier) VerifyPayload(payload []byte, signature []byte, alg jose.SignatureAlgorithm) error {
78
+ if alg != leaseTokenAlgorithm {
79
+ return jose.ErrUnsupportedAlgorithm
80
+ }
81
+ if v == nil || v.publicKey == nil {
82
+ return errors.New("verification key is required")
83
+ }
84
+ if len(signature) != 64 {
85
+ return errors.New("invalid es256k signature length")
86
+ }
87
+
88
+ var r, s secp256k1.ModNScalar
89
+ if overflow := r.SetByteSlice(signature[:32]); overflow || r.IsZero() {
90
+ return errors.New("invalid es256k signature r")
91
+ }
92
+ if overflow := s.SetByteSlice(signature[32:]); overflow || s.IsZero() {
93
+ return errors.New("invalid es256k signature s")
94
+ }
95
+
96
+ hash := sha256.Sum256(payload)
97
+ return verifyRawSignature(hash[:], &r, &s, v.publicKey)
98
+}
99
+
100
+func verifyRawSignature(hash []byte, r, s *secp256k1.ModNScalar, publicKey *secp256k1.PublicKey) error {
101
+ signature := secp256k1ecdsa.NewSignature(r, s)
102
+ if !signature.Verify(hash, publicKey) {
103
+ return errors.New("token signature is invalid")
104
+ }
105
+ return nil
106
+}
107
+
108
+type RegisterChallenge struct {
109
+ ChallengeID string
110
+ ExpiresAt time.Time
111
+ Request types.RegisterChallengeRequest
112
+ SIWEMessage string
113
+
114
+ domain string
115
+ nonce string
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)
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
+
129
+ challengeID := utils.RandomID("rch_")
130
+ nonce := siwe.GenerateNonce()
131
+ expiresAt := now.UTC().Add(ttl)
132
+ siweMessage, err := BuildRegisterChallengeMessage(domain, ownerAddress, uri, challengeID, nonce, now.UTC(), expiresAt)
133
+ if err != nil {
134
+ return nil, err
135
+ }
136
+
137
+ return &RegisterChallenge{
138
+ ChallengeID: challengeID,
139
+ 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
+ },
147
+ SIWEMessage: siweMessage,
148
+ domain: strings.TrimSpace(domain),
149
+ nonce: nonce,
150
+ }, nil
151
+}
152
+
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{}{
155
+ "statement": registerStatement,
156
+ "chainId": 1,
157
+ "issuedAt": issuedAt.UTC().Format(time.RFC3339),
158
+ "expirationTime": expiresAt.UTC().Format(time.RFC3339),
159
+ "requestId": challengeID,
160
+ })
161
+ if err != nil {
162
+ return "", fmt.Errorf("build siwe message: %w", err)
163
+ }
164
+ return message.String(), nil
165
+}
166
+
167
+func (c *RegisterChallenge) Expired(now time.Time) bool {
168
+ if c == nil {
169
+ return true
170
+ }
171
+ return now.UTC().After(c.ExpiresAt)
172
+}
173
+
174
+func (c *RegisterChallenge) Verify(req types.RegisterRequest, now time.Time) error {
175
+ if c == nil {
176
+ return ErrChallengeNotFound
177
+ }
178
+ if strings.TrimSpace(req.SIWEMessage) != c.SIWEMessage {
179
+ return ErrMessageMismatch
180
+ }
181
+ if err := VerifyRegisterChallengeMessage(c.SIWEMessage, req.SIWESignature, c.domain, c.nonce, now.UTC()); err != nil {
182
+ return ErrInvalidSignature
183
+ }
184
+ return nil
185
+}
186
+
187
+func VerifyRegisterChallengeMessage(messageText, signature, domain, nonce string, now time.Time) error {
188
+ message, err := siwe.ParseMessage(strings.TrimSpace(messageText))
189
+ if err != nil {
190
+ return err
191
+ }
192
+ normalizedDomain := strings.TrimSpace(domain)
193
+ normalizedNonce := strings.TrimSpace(nonce)
194
+ verifiedAt := now.UTC()
195
+ _, err = message.Verify(strings.TrimSpace(signature), &normalizedDomain, &normalizedNonce, &verifiedAt)
196
+ return err
197
+}
198
+
199
+func IssueLeaseAccessToken(privateKeyHex, keyID, issuer, ownerAddress, leaseID string, ttl time.Duration) (string, LeaseAccessTokenClaims, error) {
200
+ privateKeyBytes, err := decodePrivateKeyHex(privateKeyHex)
201
+ if err != nil {
202
+ return "", LeaseAccessTokenClaims{}, err
203
+ }
204
+ normalizedOwnerAddress, err := utils.NormalizeEVMAddress(ownerAddress)
205
+ if err != nil {
206
+ return "", LeaseAccessTokenClaims{}, err
207
+ }
208
+
209
+ signer, err := jose.NewSigner(jose.SigningKey{
210
+ Algorithm: leaseTokenAlgorithm,
211
+ Key: &es256kOpaqueSigner{
212
+ keyID: strings.TrimSpace(keyID),
213
+ privateKey: secp256k1.PrivKeyFromBytes(privateKeyBytes),
214
+ },
215
+ }, (&jose.SignerOptions{}).WithType("JWT"))
216
+ if err != nil {
217
+ return "", LeaseAccessTokenClaims{}, err
218
+ }
219
+
220
+ now := time.Now().UTC()
221
+ expiresAt := now.Add(ttl)
222
+ claims := LeaseAccessTokenClaims{
223
+ LeaseID: strings.TrimSpace(leaseID),
224
+ Claims: jwt.Claims{
225
+ Issuer: strings.TrimSpace(issuer),
226
+ Subject: normalizedOwnerAddress,
227
+ Audience: jwt.Audience{leaseAccessTokenAudience},
228
+ ID: utils.RandomID("tok_"),
229
+ IssuedAt: jwt.NewNumericDate(now),
230
+ NotBefore: jwt.NewNumericDate(now),
231
+ Expiry: jwt.NewNumericDate(expiresAt),
232
+ },
233
+ }
234
+
235
+ token, err := jwt.Signed(signer).Claims(claims).Serialize()
236
+ if err != nil {
237
+ return "", LeaseAccessTokenClaims{}, err
238
+ }
239
+ return token, claims, nil
240
+}
241
+
242
+func VerifyLeaseAccessToken(token, publicKeyHex, issuer, leaseID string, now time.Time) (LeaseAccessTokenClaims, error) {
243
+ pubKeyText := strings.TrimSpace(publicKeyHex)
244
+ if pubKeyText == "" {
245
+ return LeaseAccessTokenClaims{}, errors.New("public key is required")
246
+ }
247
+ if strings.HasPrefix(strings.ToLower(pubKeyText), "0x") {
248
+ pubKeyText = pubKeyText[2:]
249
+ }
250
+
251
+ pubKeyBytes, err := hex.DecodeString(pubKeyText)
252
+ if err != nil {
253
+ return LeaseAccessTokenClaims{}, err
254
+ }
255
+ publicKey, err := secp256k1.ParsePubKey(pubKeyBytes)
256
+ if err != nil {
257
+ return LeaseAccessTokenClaims{}, err
258
+ }
259
+
260
+ parsed, err := jwt.ParseSigned(strings.TrimSpace(token), []jose.SignatureAlgorithm{leaseTokenAlgorithm})
261
+ if err != nil {
262
+ return LeaseAccessTokenClaims{}, err
263
+ }
264
+
265
+ var claims LeaseAccessTokenClaims
266
+ if err := parsed.Claims(&es256kOpaqueVerifier{publicKey: publicKey}, &claims); err != nil {
267
+ return LeaseAccessTokenClaims{}, err
268
+ }
269
+ if strings.TrimSpace(leaseID) != "" && claims.LeaseID != strings.TrimSpace(leaseID) {
270
+ return LeaseAccessTokenClaims{}, errors.New("lease access token lease id does not match request")
271
+ }
272
+ if err := claims.Claims.ValidateWithLeeway(jwt.Expected{
273
+ Issuer: strings.TrimSpace(issuer),
274
+ AnyAudience: jwt.Audience{leaseAccessTokenAudience},
275
+ Time: now.UTC(),
276
+ }, 0); err != nil {
277
+ return LeaseAccessTokenClaims{}, err
278
+ }
279
+ return claims, nil
280
+}
281
+
282
+func decodePrivateKeyHex(privateKeyHex string) ([]byte, error) {
283
+ trimmed := strings.TrimSpace(privateKeyHex)
284
+ if strings.HasPrefix(strings.ToLower(trimmed), "0x") {
285
+ trimmed = trimmed[2:]
286
+ }
287
+ decoded, err := hex.DecodeString(trimmed)
288
+ if err != nil {
289
+ return nil, err
290
+ }
291
+ if len(decoded) != secp256k1.PrivKeyBytesLen {
292
+ return nil, errors.New("secp256k1 private key must be 32 bytes")
293
+ }
294
+ return decoded, nil
295
+}
portal/discovery/discovery.go
+2
-21
@@ -305,27 +305,8 @@ func doGET[T any](ctx context.Context, baseURL, path string, query url.Values, r
305
client = &clone
306
}
307
308
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
309
- if err != nil {
310
- return zero, err
311
- }
312
-
313
- resp, err := client.Do(httpReq)
314
- if err != nil {
308
+ if err := utils.HTTPDoAPI(ctx, client, http.MethodGet, requestURL.String(), nil, nil, &zero); err != nil {
309
return zero, err
310
}
317
- defer resp.Body.Close()
318
-
319
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
320
- return zero, utils.DecodeAPIRequestError(resp)
321
- }
322
-
323
- envelope, err := utils.DecodeAPIEnvelope[T](resp.Body)
324
- if err != nil {
325
- return zero, fmt.Errorf("decode response: %w", err)
326
- }
327
- if !envelope.OK {
328
- return zero, utils.NewAPIRequestError(resp.StatusCode, envelope.Error)
329
- }
330
- return envelope.Data, nil
311
+ return zero, nil
312
}
portal/lease.go
+88
-40
@@ -3,32 +3,37 @@ package portal
3
import (
4
"context"
5
"errors"
6
- "fmt"
6
"strings"
7
"sync"
8
"time"
9
10
+ "github.com/gosuda/portal/v2/portal/auth"
11
"github.com/gosuda/portal/v2/portal/policy"
12
"github.com/gosuda/portal/v2/portal/transport"
13
"github.com/gosuda/portal/v2/types"
14
"github.com/gosuda/portal/v2/utils"
15
)
16
17
+const defaultRegisterChallengeTTL = 2 * time.Minute
18
+
19
type leaseRegistry struct {
18
- routes *routeTable
19
- leaseByID map[string]*leaseRecord
20
- policy *policy.Runtime
21
- mu sync.RWMutex
20
+ routes *routeTable
21
+ leaseByID map[string]*leaseRecord
22
+ registerChallenges map[string]*auth.RegisterChallenge
23
+ policy *policy.Runtime
24
+ mu sync.RWMutex
25
}
26
27
func newLeaseRegistry(runtime *policy.Runtime) *leaseRegistry {
28
if runtime == nil {
29
runtime = policy.NewRuntime()
30
}
31
+
32
return &leaseRegistry{
29
- routes: newRouteTable(),
30
- leaseByID: make(map[string]*leaseRecord),
31
- policy: runtime,
33
+ routes: newRouteTable(),
34
+ leaseByID: make(map[string]*leaseRecord),
35
+ registerChallenges: make(map[string]*auth.RegisterChallenge),
36
+ policy: runtime,
37
}
38
}
39
@@ -43,6 +48,7 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
48
}
49
r.routes = newRouteTable()
50
r.leaseByID = make(map[string]*leaseRecord)
51
+ r.registerChallenges = make(map[string]*auth.RegisterChallenge)
52
return out
53
}
54
@@ -64,18 +70,6 @@ func (r *leaseRegistry) RunJanitor(ctx context.Context, interval time.Duration)
70
}
71
}
72
67
-func (r *leaseRegistry) lookup(leaseID string) (*leaseRecord, bool) {
68
- record, ok := r.leaseByID[leaseID]
69
- return record, ok
70
-}
71
-
72
-func (r *leaseRegistry) Get(leaseID string) (*leaseRecord, bool) {
73
- r.mu.RLock()
74
- defer r.mu.RUnlock()
75
-
76
- return r.lookup(leaseID)
77
-}
78
-
73
func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
74
host = utils.NormalizeHostname(host)
75
if host == "" {
@@ -111,7 +105,7 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
105
defer r.mu.Unlock()
106
107
if ownerLeaseID, ok := r.routes.LookupExact(hostname); ok && ownerLeaseID != leaseID {
114
- return fmt.Errorf("%w: %s", errHostnameConflict, hostname)
108
+ return errHostnameConflict
109
}
110
111
record.ID = leaseID
@@ -124,17 +118,14 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
118
return nil
119
}
120
127
-func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
121
+func (r *leaseRegistry) Renew(leaseID string, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
122
r.mu.Lock()
123
defer r.mu.Unlock()
124
131
- record, ok := r.lookup(leaseID)
125
+ record, ok := r.leaseByID[leaseID]
126
if !ok {
127
return nil, errLeaseNotFound
128
}
135
- if !utils.TokenMatches(record.ReverseToken, reverseToken) {
136
- return nil, errUnauthorized
137
- }
129
130
now := time.Now()
131
record.ExpiresAt = now.Add(ttl)
@@ -149,17 +140,14 @@ func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, c
140
return record, nil
141
}
142
152
-func (r *leaseRegistry) Unregister(leaseID, reverseToken string) (*leaseRecord, error) {
143
+func (r *leaseRegistry) Unregister(leaseID string) (*leaseRecord, error) {
144
r.mu.Lock()
145
defer r.mu.Unlock()
146
156
- record, ok := r.lookup(leaseID)
147
+ record, ok := r.leaseByID[leaseID]
148
if !ok {
149
return nil, errLeaseNotFound
150
}
160
- if !utils.TokenMatches(record.ReverseToken, reverseToken) {
161
- return nil, errUnauthorized
162
- }
151
152
delete(r.leaseByID, record.ID)
153
r.routes.Delete(record.Hostname)
@@ -171,18 +159,71 @@ func (r *leaseRegistry) FindByID(leaseID string) (*leaseRecord, error) {
159
r.mu.RLock()
160
defer r.mu.RUnlock()
161
174
- record, ok := r.lookup(leaseID)
162
+ record, ok := r.leaseByID[leaseID]
163
if !ok || time.Now().After(record.ExpiresAt) {
164
return nil, errLeaseNotFound
165
}
166
return record, nil
167
}
168
169
+func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string) (types.RegisterChallengeResponse, error) {
170
+ if req.UDPEnabled {
171
+ if !r.policy.IsUDPEnabled() {
172
+ return types.RegisterChallengeResponse{}, errUDPDisabled
173
+ }
174
+ if max := r.policy.UDPMaxLeases(); max > 0 && r.CountDatagramLeases() >= max {
175
+ return types.RegisterChallengeResponse{}, errUDPCapacityExceeded
176
+ }
177
+ }
178
+
179
+ now := time.Now().UTC()
180
+ challenge, err := auth.NewRegisterChallenge(req, domain, uri, now, defaultRegisterChallengeTTL)
181
+ if err != nil {
182
+ return types.RegisterChallengeResponse{}, err
183
+ }
184
+
185
+ r.mu.Lock()
186
+ r.registerChallenges[challenge.ChallengeID] = challenge
187
+ r.mu.Unlock()
188
+
189
+ return types.RegisterChallengeResponse{
190
+ ChallengeID: challenge.ChallengeID,
191
+ ExpiresAt: challenge.ExpiresAt,
192
+ SIWEMessage: challenge.SIWEMessage,
193
+ }, nil
194
+}
195
+
196
+func (r *leaseRegistry) consumeVerifiedRegisterChallenge(req types.RegisterRequest) (*auth.RegisterChallenge, error) {
197
+ challengeID := strings.TrimSpace(req.ChallengeID)
198
+ if challengeID == "" {
199
+ return nil, auth.ErrChallengeNotFound
200
+ }
201
+
202
+ now := time.Now().UTC()
203
+ r.mu.Lock()
204
+ defer r.mu.Unlock()
205
+
206
+ challenge := r.registerChallenges[challengeID]
207
+ if challenge == nil {
208
+ return nil, auth.ErrChallengeNotFound
209
+ }
210
+ if challenge.Expired(now) {
211
+ delete(r.registerChallenges, challengeID)
212
+ return nil, auth.ErrChallengeExpired
213
+ }
214
+ if err := challenge.Verify(req, now); err != nil {
215
+ return nil, err
216
+ }
217
+
218
+ delete(r.registerChallenges, challengeID)
219
+ return challenge, nil
220
+}
221
+
222
func (r *leaseRegistry) Touch(leaseID, clientIP string, now time.Time) *leaseRecord {
223
r.mu.Lock()
224
defer r.mu.Unlock()
225
185
- record, ok := r.lookup(leaseID)
226
+ record, ok := r.leaseByID[leaseID]
227
if !ok {
228
return nil
229
}
@@ -195,7 +236,15 @@ func (r *leaseRegistry) Touch(leaseID, clientIP string, now time.Time) *leaseRec
236
}
237
238
func (r *leaseRegistry) cleanupExpired(now time.Time) {
198
- for _, lease := range r.removeExpired(now) {
239
+ expiredLeases := r.removeExpired(now)
240
+ r.mu.Lock()
241
+ for challengeID, challenge := range r.registerChallenges {
242
+ if challenge == nil || challenge.Expired(now) {
243
+ delete(r.registerChallenges, challengeID)
244
+ }
245
+ }
246
+ r.mu.Unlock()
247
+ for _, lease := range expiredLeases {
248
lease.Close()
249
}
250
}
@@ -253,12 +302,11 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
302
303
type leaseRecord struct {
304
types.Lease
256
- ReverseToken string
257
- datagram *transport.RelayDatagram
258
- ports *transport.PortAllocator
259
- stream *transport.RelayStream
260
- startErr error
261
- startOnce sync.Once
305
+ datagram *transport.RelayDatagram
306
+ ports *transport.PortAllocator
307
+ stream *transport.RelayStream
308
+ startErr error
309
+ startOnce sync.Once
310
}
311
312
func (r *leaseRecord) Start() error {
portal/lease_test.go
+7
-12
@@ -23,8 +23,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
23
Hostname: "demo.example.com",
24
ExpiresAt: time.Now().Add(30 * time.Second),
25
},
26
- ReverseToken: "tok_1",
27
- stream: transport.NewRelayStream("lease_1", time.Minute, 1),
26
+ stream: transport.NewRelayStream("lease_1", time.Minute, 1),
27
}
28
29
if err := registry.Register(record); err != nil {
@@ -36,7 +35,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
35
t.Fatalf("Lookup() = %v, %v, want registered lease", lookedUp, ok)
36
}
37
39
- renewed, err := registry.Renew(record.ID, record.ReverseToken, time.Minute, "203.0.113.10", "")
38
+ renewed, err := registry.Renew(record.ID, time.Minute, "203.0.113.10", "")
39
if err != nil {
40
t.Fatalf("Renew() error = %v", err)
41
}
@@ -47,7 +46,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
46
t.Fatalf("Renew() did not register client IP for lease")
47
}
48
50
- removed, err := registry.Unregister(record.ID, record.ReverseToken)
49
+ removed, err := registry.Unregister(record.ID)
50
if err != nil {
51
t.Fatalf("Unregister() error = %v", err)
52
}
@@ -73,8 +72,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
72
Hostname: "*.example.com",
73
ExpiresAt: time.Now().Add(30 * time.Second),
74
},
76
- ReverseToken: "tok_wildcard",
77
- stream: transport.NewRelayStream("lease_wildcard", time.Minute, 1),
75
+ stream: transport.NewRelayStream("lease_wildcard", time.Minute, 1),
76
}
77
if err := registry.Register(wildcardLease); err != nil {
78
t.Fatalf("Register(wildcard) error = %v", err)
@@ -93,8 +91,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
91
Hostname: "*.example.com",
92
ExpiresAt: time.Now().Add(30 * time.Second),
93
},
96
- ReverseToken: "tok_conflict",
97
- stream: transport.NewRelayStream("lease_conflict", time.Minute, 1),
94
+ stream: transport.NewRelayStream("lease_conflict", time.Minute, 1),
95
}
96
err := registry.Register(conflict)
97
if !errors.Is(err, errHostnameConflict) {
@@ -119,8 +116,7 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
116
ExpiresAt: time.Now().Add(30 * time.Second),
117
ClientIP: "203.0.113.20",
118
},
122
- ReverseToken: "tok_policy",
123
- stream: transport.NewRelayStream("lease_policy", time.Minute, 1),
119
+ stream: transport.NewRelayStream("lease_policy", time.Minute, 1),
120
}
121
if err := registry.Register(record); err != nil {
122
t.Fatalf("Register() error = %v", err)
@@ -159,8 +155,7 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
155
Hostname: "expired.example.com",
156
ExpiresAt: time.Now().Add(-time.Second),
157
},
162
- ReverseToken: "tok_expired",
163
- stream: transport.NewRelayStream("lease_expired", time.Minute, 1),
158
+ stream: transport.NewRelayStream("lease_expired", time.Minute, 1),
159
}
160
if err := registry.Register(record); err != nil {
161
t.Fatalf("Register() error = %v", err)
portal/server.go
+3
-3
@@ -159,9 +159,9 @@ func NewServer(cfg ServerConfig) (*Server, error) {
159
Msg("generated relay owner private key; set OWNER_PRIVATE_KEY unique identity")
160
}
161
162
- runtime := policy.NewRuntime()
163
- runtime.SetUDPPolicy(cfg.UDPPortCount > 0, 0)
164
- registry := newLeaseRegistry(runtime)
162
+ policy := policy.NewRuntime()
163
+ policy.SetUDPPolicy(cfg.UDPPortCount > 0, 0)
164
+ registry := newLeaseRegistry(policy)
165
ports := transport.NewPortAllocator(portMin, portMax, 5*time.Minute)
166
167
s := &Server{
portal/server_test.go
+13
-13
@@ -237,10 +237,10 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
237
t.Fatalf("NewServer() error = %v", err)
238
}
239
240
- resp, err := server.registerLease(types.RegisterRequest{
240
+ resp, err := server.registerLease(types.RegisterChallengeRequest{
241
Name: "Demo-App",
242
- ReverseToken: "tok_1",
243
- }, "203.0.113.10")
242
+ OwnerAddress: server.OwnerAddress(),
243
+ }, "203.0.113.10", "")
244
if err != nil {
245
t.Fatalf("registerLease() error = %v", err)
246
}
@@ -250,9 +250,9 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
250
t.Fatalf("registerLease() hostname = %q, want %q", resp.Hostname, wantHostname)
251
}
252
253
- record, ok := server.registry.Get(resp.LeaseID)
254
- if !ok {
255
- t.Fatal("registry.Get() = false, want registered lease")
253
+ record, err := server.registry.FindByID(resp.LeaseID)
254
+ if err != nil {
255
+ t.Fatalf("registry.FindByID() error = %v, want registered lease", err)
256
}
257
snapshot := server.registry.Snapshot(record)
258
if snapshot.Name != "demo-app" {
@@ -275,23 +275,23 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
275
}
276
server.registry.policy.SetUDPPolicy(true, 0)
277
278
- resp, err := server.registerLease(types.RegisterRequest{
278
+ resp, err := server.registerLease(types.RegisterChallengeRequest{
279
Name: "demo-udp",
280
- ReverseToken: "tok_udp",
280
+ OwnerAddress: server.OwnerAddress(),
281
UDPEnabled: true,
282
- }, "203.0.113.10")
282
+ }, "203.0.113.10", "")
283
if err != nil {
284
t.Fatalf("registerLease() error = %v", err)
285
}
286
t.Cleanup(func() {
287
- if record, ok := server.registry.Get(resp.LeaseID); ok {
287
+ if record, err := server.registry.FindByID(resp.LeaseID); err == nil {
288
record.Close()
289
}
290
})
291
292
- record, ok := server.registry.Get(resp.LeaseID)
293
- if !ok {
294
- t.Fatal("registry.Get() = false, want registered lease")
292
+ record, err := server.registry.FindByID(resp.LeaseID)
293
+ if err != nil {
294
+ t.Fatalf("registry.FindByID() error = %v, want registered lease", err)
295
}
296
if record.stream == nil {
297
t.Fatal("stream = nil, want stream runtime")
portal/transport/datagram_client.go
+2
-2
@@ -13,8 +13,8 @@ import (
13
)
14
15
type ClientDatagramState struct {
16
- LeaseID string
17
- ReverseToken string
16
+ LeaseID string
17
+ AccessToken string
18
}
19
20
type ClientDatagram struct {
sdk/api_client.go
+82
-80
@@ -13,6 +13,7 @@ import (
13
"net/http"
14
"net/url"
15
"strings"
16
+ "sync"
17
"time"
18
19
"github.com/quic-go/quic-go"
@@ -36,6 +37,7 @@ const (
37
var errRelayIncompatible = errors.New("relay is incompatible")
38
39
type apiClient struct {
40
+ mu sync.RWMutex
41
baseURL *url.URL
42
httpClient *http.Client
43
rawTLSConfig *tls.Config
@@ -43,8 +45,9 @@ type apiClient struct {
45
requestTimeout time.Duration
46
rootCAPEM []byte
47
name string
46
- reverseToken string
48
+ accessToken string
49
metadata types.LeaseMetadata
50
+ ownerPrivateKey string
51
ownerAddress string
52
resolvedPublicIP string
53
}
@@ -55,9 +58,9 @@ func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
58
return nil, err
59
}
60
58
- reverseToken := strings.TrimSpace(cfg.ReverseToken)
59
- if reverseToken == "" {
60
- reverseToken = utils.RandomID("tok_")
61
+ identity, err := utils.ResolveSecp256k1Identity(cfg.OwnerPrivateKey)
62
+ if err != nil {
63
+ return nil, fmt.Errorf("resolve owner identity: %w", err)
64
}
65
66
normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
@@ -74,14 +77,14 @@ func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
77
requestTimeout := utils.DurationOrDefault(cfg.RequestTimeout, defaultRequestTimeout)
78
79
return &apiClient{
77
- baseURL: baseURL,
78
- dialTimeout: dialTimeout,
79
- requestTimeout: requestTimeout,
80
- rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
81
- name: name,
82
- reverseToken: reverseToken,
83
- metadata: cfg.Metadata.Copy(),
84
- ownerAddress: cfg.ownerAddress,
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,
88
}, nil
89
}
90
@@ -99,18 +102,38 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
102
return types.RegisterResponse{}, err
103
}
104
102
- var resp types.RegisterResponse
103
- if err := a.doJSON(ctx, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
105
+ var challenge types.RegisterChallengeResponse
106
+ if err := utils.HTTPDoAPI(ctx, a.httpClient, http.MethodPost, a.baseURL.ResolveReference(&url.URL{Path: types.PathSDKRegisterChallenge}).String(), types.RegisterChallengeRequest{
107
Name: a.name,
108
Metadata: a.metadata.Copy(),
109
OwnerAddress: a.ownerAddress,
107
- ReverseToken: a.reverseToken,
110
TTL: int(ttl / time.Second),
111
UDPEnabled: udpEnabled,
110
- ReportedIP: a.reportedIP(ctx),
111
- }, &resp); err != nil {
112
+ }, nil, &challenge); err != nil {
113
+ return types.RegisterResponse{}, err
114
+ }
115
+
116
+ signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, a.ownerPrivateKey)
117
+ if err != nil {
118
return types.RegisterResponse{}, err
119
}
120
+
121
+ var resp types.RegisterResponse
122
+ if err := utils.HTTPDoAPI(ctx, a.httpClient, http.MethodPost, a.baseURL.ResolveReference(&url.URL{Path: types.PathSDKRegister}).String(), types.RegisterRequest{
123
+ ChallengeID: challenge.ChallengeID,
124
+ SIWEMessage: challenge.SIWEMessage,
125
+ SIWESignature: signature,
126
+ ReportedIP: a.reportedIP(ctx),
127
+ }, nil, &resp); err != nil {
128
+ return types.RegisterResponse{}, err
129
+ }
130
+ resp.AccessToken = strings.TrimSpace(resp.AccessToken)
131
+ if resp.AccessToken == "" {
132
+ return types.RegisterResponse{}, errors.New("relay did not return access token")
133
+ }
134
+ a.mu.Lock()
135
+ a.accessToken = resp.AccessToken
136
+ a.mu.Unlock()
137
return resp, nil
138
}
139
@@ -161,7 +184,7 @@ func (a *apiClient) reportedIP(ctx context.Context) string {
184
185
func (a *apiClient) ensureCompatible(ctx context.Context, httpClient *http.Client) error {
186
var resp types.DomainResponse
164
- if err := a.doJSONWithClient(ctx, httpClient, http.MethodGet, types.PathSDKDomain, nil, &resp); err != nil {
187
+ if err := utils.HTTPDoAPI(ctx, httpClient, http.MethodGet, a.baseURL.ResolveReference(&url.URL{Path: types.PathSDKDomain}).String(), nil, nil, &resp); err != nil {
188
err = fmt.Errorf("check relay compatibility: %w", err)
189
var netErr net.Error
190
var apiErr *types.APIRequestError
@@ -184,19 +207,43 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
207
return err
208
}
209
187
- return a.doJSON(ctx, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
188
- LeaseID: leaseID,
189
- ReverseToken: a.reverseToken,
190
- TTL: int(ttl / time.Second),
191
- ReportedIP: a.reportedIP(ctx),
192
- }, &types.RenewResponse{})
210
+ a.mu.RLock()
211
+ accessToken := a.accessToken
212
+ a.mu.RUnlock()
213
+ if strings.TrimSpace(accessToken) == "" {
214
+ return errors.New("access token is not available")
215
+ }
216
+
217
+ var resp types.RenewResponse
218
+ if err := utils.HTTPDoAPI(ctx, a.httpClient, http.MethodPost, a.baseURL.ResolveReference(&url.URL{Path: types.PathSDKRenew}).String(), types.RenewRequest{
219
+ LeaseID: leaseID,
220
+ AccessToken: accessToken,
221
+ TTL: int(ttl / time.Second),
222
+ ReportedIP: a.reportedIP(ctx),
223
+ }, nil, &resp); err != nil {
224
+ return err
225
+ }
226
+ resp.AccessToken = strings.TrimSpace(resp.AccessToken)
227
+ if resp.AccessToken == "" {
228
+ return errors.New("relay did not return renewed access token")
229
+ }
230
+
231
+ a.mu.Lock()
232
+ if a.accessToken == accessToken {
233
+ a.accessToken = resp.AccessToken
234
+ }
235
+ a.mu.Unlock()
236
+ return nil
237
}
238
239
func (a *apiClient) unregisterLease(ctx context.Context, leaseID string) error {
196
- return a.doJSON(ctx, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
197
- LeaseID: leaseID,
198
- ReverseToken: a.reverseToken,
199
- }, nil)
240
+ a.mu.RLock()
241
+ accessToken := a.accessToken
242
+ a.mu.RUnlock()
243
+ return utils.HTTPDoAPI(ctx, a.httpClient, http.MethodPost, a.baseURL.ResolveReference(&url.URL{Path: types.PathSDKUnregister}).String(), types.UnregisterRequest{
244
+ LeaseID: leaseID,
245
+ AccessToken: accessToken,
246
+ }, nil, nil)
247
}
248
249
func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net.Conn, error) {
@@ -226,7 +273,10 @@ func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net
273
Host: a.baseURL.Host,
274
Header: make(http.Header),
275
}
229
- req.Header.Set(types.HeaderReverseToken, a.reverseToken)
276
+ a.mu.RLock()
277
+ accessToken := a.accessToken
278
+ a.mu.RUnlock()
279
+ req.Header.Set(types.HeaderAccessToken, accessToken)
280
req.Header.Set("Connection", "keep-alive")
281
282
if writeErr := req.Write(conn); writeErr != nil {
@@ -251,54 +301,6 @@ func (a *apiClient) openReverseSession(ctx context.Context, leaseID string) (net
301
return wrapBufferedConn(conn, reader), nil
302
}
303
254
-func (a *apiClient) doJSON(ctx context.Context, method, path string, payload any, out any) error {
255
- return a.doJSONWithClient(ctx, a.httpClient, method, path, payload, out)
256
-}
257
-
258
-func (a *apiClient) doJSONWithClient(ctx context.Context, httpClient *http.Client, method, path string, payload any, out any) error {
259
- if httpClient == nil {
260
- return errors.New("api client is not ready")
261
- }
262
-
263
- var body io.Reader
264
- if payload != nil {
265
- buf, err := json.Marshal(payload)
266
- if err != nil {
267
- return fmt.Errorf("marshal payload: %w", err)
268
- }
269
- body = bytes.NewReader(buf)
270
- }
271
-
272
- ref, _ := url.Parse(path)
273
- req, err := http.NewRequestWithContext(ctx, method, a.baseURL.ResolveReference(ref).String(), body)
274
- if err != nil {
275
- return err
276
- }
277
- req.Header.Set("Content-Type", "application/json")
278
-
279
- resp, err := httpClient.Do(req)
280
- if err != nil {
281
- return err
282
- }
283
- defer resp.Body.Close()
284
-
285
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
286
- return utils.DecodeAPIRequestError(resp)
287
- }
288
-
289
- envelope, err := utils.DecodeAPIEnvelope[json.RawMessage](resp.Body)
290
- if err != nil {
291
- return fmt.Errorf("decode response: %w", err)
292
- }
293
- if !envelope.OK {
294
- return utils.NewAPIRequestError(resp.StatusCode, envelope.Error)
295
- }
296
- if out == nil {
297
- return nil
298
- }
299
- return json.Unmarshal(envelope.Data, out)
300
-}
301
-
304
type bufferedConn struct {
305
net.Conn
306
reader *bytes.Reader
@@ -323,7 +325,7 @@ func (c *bufferedConn) Read(p []byte) (int, error) {
325
}
326
327
// openQUICSession opens a QUIC connection to the relay for datagram transport.
326
-func (a *apiClient) openQUICSession(ctx context.Context, leaseID, reverseToken string) (*quic.Conn, error) {
328
+func (a *apiClient) openQUICSession(ctx context.Context, leaseID, accessToken string) (*quic.Conn, error) {
329
if err := a.ensureHTTPClient(ctx); err != nil {
330
return nil, err
331
}
@@ -350,8 +352,8 @@ func (a *apiClient) openQUICSession(ctx context.Context, leaseID, reverseToken s
352
}
353
354
controlMsg := types.QUICControlMessage{
353
- LeaseID: leaseID,
354
- ReverseToken: reverseToken,
355
+ LeaseID: leaseID,
356
+ AccessToken: accessToken,
357
}
358
if err := json.NewEncoder(stream).Encode(controlMsg); err != nil {
359
_ = conn.CloseWithError(1, "control write failed")
sdk/expose.go
+9
-13
@@ -27,11 +27,10 @@ type Exposure struct {
27
name string
28
TargetAddr string
29
UDPAddr string
30
- reverseToken string
30
udpEnabled bool
31
banMITM bool
32
metadata types.LeaseMetadata
34
- ownerAddress string
33
+ ownerPrivateKey string
34
rootCAPEM []byte
35
discoveryEnabled bool
36
@@ -51,7 +50,6 @@ type ExposeConfig struct {
50
Name string
51
TargetAddr string
52
UDPAddr string
54
- ReverseToken string
53
UDPEnabled bool
54
BanMITM bool
55
Discovery bool
@@ -91,11 +89,10 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
89
name: cfg.Name,
90
TargetAddr: targetAddr,
91
UDPAddr: udpAddr,
94
- reverseToken: cfg.ReverseToken,
92
udpEnabled: cfg.UDPEnabled,
93
banMITM: cfg.BanMITM,
94
metadata: cfg.Metadata.Copy(),
98
- ownerAddress: identity.Address,
95
+ ownerPrivateKey: identity.PrivateKey,
96
rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
97
discoveryEnabled: cfg.Discovery,
98
accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
@@ -309,14 +306,13 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
306
}
307
for _, relayURL := range missingRelayURLs {
308
listener, err := NewListener(context.Background(), relayURL, ListenerConfig{
312
- Name: e.name,
313
- ReverseToken: e.reverseToken,
314
- UDPEnabled: e.udpEnabled,
315
- BanMITM: e.banMITM,
316
- Metadata: e.metadata.Copy(),
317
- RootCAPEM: append([]byte(nil), e.rootCAPEM...),
318
- ownerAddress: e.ownerAddress,
319
- relaySet: e.relaySet,
309
+ Name: e.name,
310
+ OwnerPrivateKey: e.ownerPrivateKey,
311
+ UDPEnabled: e.udpEnabled,
312
+ BanMITM: e.banMITM,
313
+ Metadata: e.metadata.Copy(),
314
+ RootCAPEM: append([]byte(nil), e.rootCAPEM...),
315
+ relaySet: e.relaySet,
316
})
317
if err != nil {
318
if failOnError {
sdk/listener.go
+7
-5
@@ -23,7 +23,7 @@ import (
23
24
type ListenerConfig struct {
25
Name string
26
- ReverseToken string
26
+ OwnerPrivateKey string
27
UDPEnabled bool
28
BanMITM bool
29
Metadata types.LeaseMetadata
@@ -36,7 +36,6 @@ type ListenerConfig struct {
36
ReadyTarget int
37
RetryCount int
38
RetryWait time.Duration
39
- ownerAddress string
39
relaySet *discovery.RelaySet
40
}
41
@@ -109,7 +108,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
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) {
112
- return l.api.openQUICSession(ctx, state.LeaseID, state.ReverseToken)
111
+ return l.api.openQUICSession(ctx, state.LeaseID, state.AccessToken)
112
})
113
}
114
@@ -373,10 +372,13 @@ func (l *Listener) currentDatagramState() (transport.ClientDatagramState, bool)
372
if l.api == nil || l.leaseID == "" || l.udpAddr == "" {
373
return transport.ClientDatagramState{}, false
374
}
375
+ l.api.mu.RLock()
376
+ accessToken := l.api.accessToken
377
+ l.api.mu.RUnlock()
378
379
return transport.ClientDatagramState{
378
- LeaseID: l.leaseID,
379
- ReverseToken: l.api.reverseToken,
380
+ LeaseID: l.leaseID,
381
+ AccessToken: accessToken,
382
}, true
383
}
384
sdk/sdk_test.go
+127
-36
@@ -9,11 +9,14 @@ import (
9
"testing"
10
"time"
11
12
+ "github.com/spruceid/siwe-go"
13
+
14
"github.com/gosuda/portal/v2/types"
15
"github.com/gosuda/portal/v2/utils"
16
)
17
18
func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
19
+ challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
20
registerReqCh := make(chan types.RegisterRequest, 1)
21
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
switch r.URL.Path {
@@ -24,6 +27,23 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
27
SDKVersion: types.SDKProtocolVersion,
28
},
29
})
30
+ case types.PathSDKRegisterChallenge:
31
+ var challengeReq types.RegisterChallengeRequest
32
+ if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
33
+ t.Fatalf("decode register challenge request: %v", err)
34
+ }
35
+ select {
36
+ case challengeReqCh <- challengeReq:
37
+ default:
38
+ }
39
+ writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
40
+ OK: true,
41
+ Data: types.RegisterChallengeResponse{
42
+ ChallengeID: "challenge-1",
43
+ ExpiresAt: time.Now().Add(time.Minute).UTC(),
44
+ SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.OwnerAddress, "challenge-1"),
45
+ },
46
+ })
47
case types.PathSDKRegister:
48
var registerReq types.RegisterRequest
49
if err := json.NewDecoder(r.Body).Decode(®isterReq); err != nil {
@@ -36,9 +56,10 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
56
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
57
OK: true,
58
Data: types.RegisterResponse{
39
- LeaseID: "lease-1",
40
- Hostname: "127.0.0.1",
41
- Metadata: registerReq.Metadata,
59
+ LeaseID: "lease-1",
60
+ Hostname: "127.0.0.1",
61
+ Metadata: types.LeaseMetadata{Owner: "alice"},
62
+ AccessToken: "jwt-register-1",
63
},
64
})
65
case types.PathSDKConnect:
@@ -49,7 +70,7 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
70
case types.PathSDKRenew:
71
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
72
OK: true,
52
- Data: types.RenewResponse{LeaseID: "lease-1"},
73
+ Data: types.RenewResponse{LeaseID: "lease-1", AccessToken: "jwt-renew-1"},
74
})
75
case types.PathSDKUnregister:
76
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -69,6 +90,16 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
90
}
91
defer listener.Close()
92
93
+ var challengeReq types.RegisterChallengeRequest
94
+ waitForSDKTest(t, func() bool {
95
+ select {
96
+ case challengeReq = <-challengeReqCh:
97
+ return true
98
+ default:
99
+ return false
100
+ }
101
+ })
102
+
103
var registerReq types.RegisterRequest
104
waitForSDKTest(t, func() bool {
105
select {
@@ -82,14 +113,26 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
113
return listener.LeaseID() == "lease-1"
114
})
115
85
- if registerReq.TTL != 42 {
86
- t.Fatalf("register request TTL = %d, want 42", registerReq.TTL)
116
+ if challengeReq.TTL != 42 {
117
+ t.Fatalf("register challenge TTL = %d, want 42", challengeReq.TTL)
118
+ }
119
+ if challengeReq.UDPEnabled {
120
+ t.Fatal("register challenge UDPEnabled = true, want false")
121
+ }
122
+ if challengeReq.Name != "demo-app" {
123
+ t.Fatalf("register challenge Name = %q, want %q", challengeReq.Name, "demo-app")
124
}
88
- if registerReq.UDPEnabled {
89
- t.Fatal("register request UDPEnabled = true, want false")
125
+ if challengeReq.OwnerAddress == "" {
126
+ t.Fatal("register challenge OwnerAddress = empty, want derived address")
127
}
91
- if registerReq.Name != "demo-app" {
92
- t.Fatalf("register request Name = %q, want %q", registerReq.Name, "demo-app")
128
+ if registerReq.ChallengeID != "challenge-1" {
129
+ t.Fatalf("register request ChallengeID = %q, want %q", registerReq.ChallengeID, "challenge-1")
130
+ }
131
+ if registerReq.SIWEMessage == "" {
132
+ t.Fatal("register request SIWEMessage = empty, want signed challenge payload")
133
+ }
134
+ if registerReq.SIWESignature == "" {
135
+ t.Fatal("register request SIWESignature = empty, want signature")
136
}
137
if listener.LeaseID() != "lease-1" {
138
t.Fatalf("LeaseID() = %q, want %q", listener.LeaseID(), "lease-1")
@@ -126,7 +169,7 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
169
t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
170
}
171
129
- registerReqCh := make(chan types.RegisterRequest, 1)
172
+ challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
173
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174
switch r.URL.Path {
175
case types.PathSDKDomain:
@@ -136,20 +179,34 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
179
SDKVersion: types.SDKProtocolVersion,
180
},
181
})
182
+ case types.PathSDKRegisterChallenge:
183
+ var challengeReq types.RegisterChallengeRequest
184
+ if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
185
+ t.Fatalf("decode register challenge request: %v", err)
186
+ }
187
+ select {
188
+ case challengeReqCh <- challengeReq:
189
+ default:
190
+ }
191
+ writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
192
+ OK: true,
193
+ Data: types.RegisterChallengeResponse{
194
+ ChallengeID: "challenge-1",
195
+ ExpiresAt: time.Now().Add(time.Minute).UTC(),
196
+ SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.OwnerAddress, "challenge-1"),
197
+ },
198
+ })
199
case types.PathSDKRegister:
200
var registerReq types.RegisterRequest
201
if err := json.NewDecoder(r.Body).Decode(®isterReq); err != nil {
202
t.Fatalf("decode register request: %v", err)
203
}
144
- select {
145
- case registerReqCh <- registerReq:
146
- default:
147
- }
204
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
205
OK: true,
206
Data: types.RegisterResponse{
151
- LeaseID: "lease-1",
152
- Hostname: "127.0.0.1",
207
+ LeaseID: "lease-1",
208
+ Hostname: "127.0.0.1",
209
+ AccessToken: "jwt-register-2",
210
},
211
})
212
case types.PathSDKConnect:
@@ -160,7 +217,7 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
217
case types.PathSDKRenew:
218
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
219
OK: true,
163
- Data: types.RenewResponse{LeaseID: "lease-1"},
220
+ Data: types.RenewResponse{LeaseID: "lease-1", AccessToken: "jwt-renew-2"},
221
})
222
case types.PathSDKUnregister:
223
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -180,23 +237,23 @@ func TestExposeResolvesOwnerPrivateKey(t *testing.T) {
237
}
238
defer exposure.Close()
239
183
- var registerReq types.RegisterRequest
240
+ var challengeReq types.RegisterChallengeRequest
241
waitForSDKTest(t, func() bool {
242
select {
186
- case registerReq = <-registerReqCh:
243
+ case challengeReq = <-challengeReqCh:
244
return true
245
default:
246
return false
247
}
248
})
249
193
- if registerReq.OwnerAddress != identity.Address {
194
- t.Fatalf("register request OwnerAddress = %q, want %q", registerReq.OwnerAddress, identity.Address)
250
+ if challengeReq.OwnerAddress != identity.Address {
251
+ t.Fatalf("register challenge OwnerAddress = %q, want %q", challengeReq.OwnerAddress, identity.Address)
252
}
253
}
254
255
func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
199
- registerReqCh := make(chan types.RegisterRequest, 1)
256
+ challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
257
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258
switch r.URL.Path {
259
case types.PathSDKDomain:
@@ -206,20 +263,34 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
263
SDKVersion: types.SDKProtocolVersion,
264
},
265
})
266
+ case types.PathSDKRegisterChallenge:
267
+ var challengeReq types.RegisterChallengeRequest
268
+ if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
269
+ t.Fatalf("decode register challenge request: %v", err)
270
+ }
271
+ select {
272
+ case challengeReqCh <- challengeReq:
273
+ default:
274
+ }
275
+ writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
276
+ OK: true,
277
+ Data: types.RegisterChallengeResponse{
278
+ ChallengeID: "challenge-1",
279
+ ExpiresAt: time.Now().Add(time.Minute).UTC(),
280
+ SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.OwnerAddress, "challenge-1"),
281
+ },
282
+ })
283
case types.PathSDKRegister:
284
var registerReq types.RegisterRequest
285
if err := json.NewDecoder(r.Body).Decode(®isterReq); err != nil {
286
t.Fatalf("decode register request: %v", err)
287
}
214
- select {
215
- case registerReqCh <- registerReq:
216
- default:
217
- }
288
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
289
OK: true,
290
Data: types.RegisterResponse{
221
- LeaseID: "lease-1",
222
- Hostname: "127.0.0.1",
291
+ LeaseID: "lease-1",
292
+ Hostname: "127.0.0.1",
293
+ AccessToken: "jwt-register-3",
294
},
295
})
296
case types.PathSDKConnect:
@@ -230,7 +301,7 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
301
case types.PathSDKRenew:
302
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
303
OK: true,
233
- Data: types.RenewResponse{LeaseID: "lease-1"},
304
+ Data: types.RenewResponse{LeaseID: "lease-1", AccessToken: "jwt-renew-3"},
305
})
306
case types.PathSDKUnregister:
307
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
@@ -249,22 +320,42 @@ func TestExposeGeneratesOwnerAddressWithoutPrivateKey(t *testing.T) {
320
}
321
defer exposure.Close()
322
252
- var registerReq types.RegisterRequest
323
+ var challengeReq types.RegisterChallengeRequest
324
waitForSDKTest(t, func() bool {
325
select {
255
- case registerReq = <-registerReqCh:
326
+ case challengeReq = <-challengeReqCh:
327
return true
328
default:
329
return false
330
}
331
})
332
262
- if registerReq.OwnerAddress == "" {
263
- t.Fatal("register request OwnerAddress = empty, want generated address")
333
+ if challengeReq.OwnerAddress == "" {
334
+ t.Fatal("register challenge OwnerAddress = empty, want generated address")
335
+ }
336
+ if _, err := utils.NormalizeEVMAddress(challengeReq.OwnerAddress); err != nil {
337
+ t.Fatalf("register challenge OwnerAddress = %q, want valid EVM address: %v", challengeReq.OwnerAddress, err)
338
+ }
339
+}
340
+
341
+func mustSDKTestSIWEMessage(t *testing.T, r *http.Request, ownerAddress, challengeID string) string {
342
+ t.Helper()
343
+
344
+ scheme := "https"
345
+ if r.TLS == nil {
346
+ scheme = "http"
347
}
265
- if _, err := utils.NormalizeEVMAddress(registerReq.OwnerAddress); err != nil {
266
- t.Fatalf("register request OwnerAddress = %q, want valid EVM address: %v", registerReq.OwnerAddress, err)
348
+ message, err := siwe.InitMessage(r.Host, ownerAddress, scheme+"://"+r.Host+types.PathSDKRegister, "testnonce123", map[string]interface{}{
349
+ "statement": "Register a portal lease",
350
+ "chainId": 1,
351
+ "issuedAt": time.Now().UTC().Format(time.RFC3339),
352
+ "expirationTime": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
353
+ "requestId": challengeID,
354
+ })
355
+ if err != nil {
356
+ t.Fatalf("siwe.InitMessage() error = %v", err)
357
}
358
+ return message.String()
359
}
360
361
func writeSDKTestEnvelope[T any](w http.ResponseWriter, status int, envelope types.APIEnvelope[T]) {
types/api.go
+32
-19
@@ -54,22 +54,34 @@ func (e *APIRequestError) Is(target error) bool {
54
}
55
56
type RegisterRequest struct {
57
+ ChallengeID string `json:"challenge_id"`
58
+ SIWEMessage string `json:"siwe_message"`
59
+ SIWESignature string `json:"siwe_signature"`
60
+ ReportedIP string `json:"reported_ip,omitempty"`
61
+}
62
+
63
+type RegisterChallengeRequest struct {
64
Name string `json:"name"`
58
- ReverseToken string `json:"reverse_token"`
65
Metadata LeaseMetadata `json:"metadata"`
60
- OwnerAddress string `json:"owner_address,omitempty"`
66
+ OwnerAddress string `json:"owner_address"`
67
TTL int `json:"ttl,omitempty"`
68
UDPEnabled bool `json:"udp_enabled,omitempty"`
63
- ReportedIP string `json:"reported_ip,omitempty"`
69
+}
70
+
71
+type RegisterChallengeResponse struct {
72
+ ChallengeID string `json:"challenge_id"`
73
+ ExpiresAt time.Time `json:"expires_at"`
74
+ SIWEMessage string `json:"siwe_message"`
75
}
76
77
type RegisterResponse struct {
67
- ExpiresAt time.Time `json:"expires_at"`
68
- LeaseID string `json:"lease_id"`
69
- Hostname string `json:"hostname"`
70
- Metadata LeaseMetadata `json:"metadata"`
71
- UDPAddr string `json:"udp_addr,omitempty"`
72
- UDPEnabled bool `json:"udp_enabled,omitempty"`
78
+ ExpiresAt time.Time `json:"expires_at"`
79
+ LeaseID string `json:"lease_id"`
80
+ Hostname string `json:"hostname"`
81
+ Metadata LeaseMetadata `json:"metadata"`
82
+ AccessToken string `json:"access_token"`
83
+ UDPAddr string `json:"udp_addr,omitempty"`
84
+ UDPEnabled bool `json:"udp_enabled,omitempty"`
85
}
86
87
type DiscoveryResponse struct {
@@ -80,8 +92,8 @@ type DiscoveryResponse struct {
92
}
93
94
type QUICControlMessage struct {
83
- LeaseID string `json:"lease_id"`
84
- ReverseToken string `json:"reverse_token"`
95
+ LeaseID string `json:"lease_id"`
96
+ AccessToken string `json:"access_token"`
97
}
98
99
type QUICControlResponse struct {
@@ -90,20 +102,21 @@ type QUICControlResponse struct {
102
}
103
104
type RenewRequest struct {
93
- LeaseID string `json:"lease_id"`
94
- ReverseToken string `json:"reverse_token"`
95
- TTL int `json:"ttl,omitempty"`
96
- ReportedIP string `json:"reported_ip,omitempty"`
105
+ LeaseID string `json:"lease_id"`
106
+ AccessToken string `json:"access_token"`
107
+ TTL int `json:"ttl,omitempty"`
108
+ ReportedIP string `json:"reported_ip,omitempty"`
109
}
110
111
type RenewResponse struct {
100
- ExpiresAt time.Time `json:"expires_at"`
101
- LeaseID string `json:"lease_id"`
112
+ ExpiresAt time.Time `json:"expires_at"`
113
+ LeaseID string `json:"lease_id"`
114
+ AccessToken string `json:"access_token"`
115
}
116
117
type UnregisterRequest struct {
105
- LeaseID string `json:"lease_id"`
106
- ReverseToken string `json:"reverse_token"`
118
+ LeaseID string `json:"lease_id"`
119
+ AccessToken string `json:"access_token"`
120
}
121
122
type DomainResponse struct {
types/paths.go
+8
-7
@@ -25,11 +25,12 @@ const (
25
26
PathTunnelStatus = "/tunnel/status"
27
28
- PathSDKPrefix = "/sdk/"
29
- PathSDKDomain = "/sdk/domain"
30
- PathSDKRegister = "/sdk/register"
31
- PathSDKRenew = "/sdk/renew"
32
- PathSDKUnregister = "/sdk/unregister"
33
- PathSDKConnect = "/sdk/connect"
34
- PathDiscovery = "/discovery"
28
+ PathSDKPrefix = "/sdk/"
29
+ PathSDKDomain = "/sdk/domain"
30
+ PathSDKRegisterChallenge = "/sdk/register/challenge"
31
+ PathSDKRegister = "/sdk/register"
32
+ PathSDKRenew = "/sdk/renew"
33
+ PathSDKUnregister = "/sdk/unregister"
34
+ PathSDKConnect = "/sdk/connect"
35
+ PathDiscovery = "/discovery"
36
)
types/types.go
+5
-5
@@ -1,11 +1,11 @@
1
package types
2
3
const (
4
- ReleaseVersion = "v2.0.7"
5
- SDKProtocolVersion = "2"
4
+ ReleaseVersion = "v2.0.8"
5
+ SDKProtocolVersion = "3"
6
PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
7
8
- HeaderReverseToken = "X-Portal-Token"
9
- MarkerKeepalive = byte(0x00)
10
- MarkerTLSStart = byte(0x02)
8
+ HeaderAccessToken = "X-Portal-Token"
9
+ MarkerKeepalive = byte(0x00)
10
+ MarkerTLSStart = byte(0x02)
11
)
utils/api.go
-4
@@ -20,10 +20,6 @@ func WriteAPIData(w http.ResponseWriter, status int, data any) {
20
WriteAPIEnvelope(w, status, types.APIEnvelope[any]{OK: true, Data: data})
21
}
22
23
-func WriteAPIOK(w http.ResponseWriter, status int) {
24
- WriteAPIData(w, status, map[string]any{})
25
-}
26
-
23
func WriteAPIError(w http.ResponseWriter, status int, code, message string) {
24
WriteAPIEnvelope(w, status, types.APIEnvelope[any]{
25
OK: false,
utils/crypto.go
+114
-88
@@ -26,37 +26,6 @@ type Secp256k1Identity struct {
26
PrivateKey string `json:"private_key"`
27
}
28
29
-func AddressFromCompressedPublicKeyHex(rawPublicKey string) (string, error) {
30
- publicKeyHex := strings.TrimSpace(rawPublicKey)
31
- if publicKeyHex == "" {
32
- return "", errors.New("public key is required")
33
- }
34
- if strings.HasPrefix(strings.ToLower(publicKeyHex), "0x") {
35
- publicKeyHex = publicKeyHex[2:]
36
- }
37
-
38
- decoded, err := hex.DecodeString(publicKeyHex)
39
- if err != nil {
40
- return "", errors.New("public key must be hex encoded")
41
- }
42
-
43
- publicKey, err := secp256k1.ParsePubKey(decoded)
44
- if err != nil {
45
- return "", errors.New("invalid secp256k1 public key")
46
- }
47
-
48
- uncompressed := publicKey.SerializeUncompressed()
49
- if len(uncompressed) != 65 || uncompressed[0] != 0x04 {
50
- return "", errors.New("invalid uncompressed secp256k1 public key")
51
- }
52
-
53
- hasher := sha3.NewLegacyKeccak256()
54
- _, _ = hasher.Write(uncompressed[1:])
55
- hash := hasher.Sum(nil)
56
-
57
- return NormalizeEVMAddress("0x" + hex.EncodeToString(hash[len(hash)-20:]))
58
-}
59
-
29
func NormalizeEVMAddress(raw string) (string, error) {
30
trimmed := strings.TrimSpace(raw)
31
if trimmed == "" {
@@ -107,6 +76,63 @@ func NormalizeEVMAddress(raw string) (string, error) {
76
return "0x" + checksummed, nil
77
}
78
79
+func AddressFromCompressedPublicKeyHex(rawPublicKey string) (string, error) {
80
+ publicKeyHex := strings.TrimSpace(rawPublicKey)
81
+ if publicKeyHex == "" {
82
+ return "", errors.New("public key is required")
83
+ }
84
+ if strings.HasPrefix(strings.ToLower(publicKeyHex), "0x") {
85
+ publicKeyHex = publicKeyHex[2:]
86
+ }
87
+
88
+ decoded, err := hex.DecodeString(publicKeyHex)
89
+ if err != nil {
90
+ return "", errors.New("public key must be hex encoded")
91
+ }
92
+
93
+ publicKey, err := secp256k1.ParsePubKey(decoded)
94
+ if err != nil {
95
+ return "", errors.New("invalid secp256k1 public key")
96
+ }
97
+
98
+ uncompressed := publicKey.SerializeUncompressed()
99
+ if len(uncompressed) != 65 || uncompressed[0] != 0x04 {
100
+ return "", errors.New("invalid uncompressed secp256k1 public key")
101
+ }
102
+
103
+ hasher := sha3.NewLegacyKeccak256()
104
+ _, _ = hasher.Write(uncompressed[1:])
105
+ hash := hasher.Sum(nil)
106
+
107
+ return NormalizeEVMAddress("0x" + hex.EncodeToString(hash[len(hash)-20:]))
108
+}
109
+
110
+func SignEthereumPersonalMessage(message, privateKeyHex string) (string, error) {
111
+ decoded, _, err := decodeSecp256k1PrivateKeyHex(privateKeyHex, false)
112
+ if err != nil {
113
+ return "", err
114
+ }
115
+
116
+ privateKey := secp256k1.PrivKeyFromBytes(decoded)
117
+ data := []byte(message)
118
+ prefix := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(data)))
119
+ hasher := sha3.NewLegacyKeccak256()
120
+ _, _ = hasher.Write(prefix)
121
+ _, _ = hasher.Write(data)
122
+ hash := hasher.Sum(nil)
123
+
124
+ compactSignature := secp256k1ecdsa.SignCompact(privateKey, hash, false)
125
+ if len(compactSignature) != 65 {
126
+ return "", errors.New("invalid compact signature length")
127
+ }
128
+
129
+ signature := make([]byte, 65)
130
+ copy(signature[:32], compactSignature[1:33])
131
+ copy(signature[32:64], compactSignature[33:65])
132
+ signature[64] = compactSignature[0]
133
+ return "0x" + hex.EncodeToString(signature), nil
134
+}
135
+
136
func ResolveSecp256k1Identity(rawPrivateKey string) (Secp256k1Identity, error) {
137
privateKeyHex := strings.TrimSpace(rawPrivateKey)
138
generated := false
@@ -197,6 +223,39 @@ func VerifySHA256Secp256k1DER(payload []byte, publicKeyHex, signatureHex string)
223
return nil
224
}
225
226
+func decodeSecp256k1PrivateKeyHex(raw string, requireNonZero bool) ([]byte, string, error) {
227
+ privateKeyHex := strings.TrimSpace(raw)
228
+ if privateKeyHex == "" {
229
+ return nil, "", errors.New("private key is required")
230
+ }
231
+ if strings.HasPrefix(strings.ToLower(privateKeyHex), "0x") {
232
+ privateKeyHex = privateKeyHex[2:]
233
+ }
234
+
235
+ decoded, err := hex.DecodeString(privateKeyHex)
236
+ if err != nil {
237
+ return nil, "", errors.New("secp256k1 private key must be hex encoded")
238
+ }
239
+ if len(decoded) != secp256k1.PrivKeyBytesLen {
240
+ return nil, "", fmt.Errorf("secp256k1 private key must be %d bytes", secp256k1.PrivKeyBytesLen)
241
+ }
242
+ if !requireNonZero {
243
+ return decoded, privateKeyHex, nil
244
+ }
245
+
246
+ isZero := true
247
+ for _, b := range decoded {
248
+ if b != 0 {
249
+ isZero = false
250
+ break
251
+ }
252
+ }
253
+ if isZero {
254
+ return nil, "", errors.New("secp256k1 private key must not be zero")
255
+ }
256
+ return decoded, privateKeyHex, nil
257
+}
258
+
259
func NormalizeWireGuardPrivateKey(raw string) (string, error) {
260
key, err := decodeWireGuardKey(raw)
261
if err != nil {
@@ -226,6 +285,14 @@ func WireGuardPublicKeyFromPrivate(raw string) (string, error) {
285
return base64.StdEncoding.EncodeToString(publicKey[:]), nil
286
}
287
288
+func WireGuardKeyHex(raw string) (string, error) {
289
+ key, err := decodeWireGuardKey(raw)
290
+ if err != nil {
291
+ return "", err
292
+ }
293
+ return hex.EncodeToString(key[:]), nil
294
+}
295
+
296
func ValidateWireGuardPublicKey(raw string) error {
297
key := strings.TrimSpace(raw)
298
if key == "" {
@@ -260,6 +327,22 @@ func ValidateWireGuardEndpoint(raw string) error {
327
return nil
328
}
329
330
+func WireGuardListenPort(rawEndpoint string) (int, error) {
331
+ endpoint := strings.TrimSpace(rawEndpoint)
332
+ if endpoint == "" {
333
+ return 0, errors.New("wireguard endpoint is required")
334
+ }
335
+ _, portText, err := net.SplitHostPort(endpoint)
336
+ if err != nil {
337
+ return 0, errors.New("wireguard endpoint must be host:port")
338
+ }
339
+ port, err := strconv.Atoi(portText)
340
+ if err != nil || port <= 0 || port > 65535 {
341
+ return 0, errors.New("wireguard endpoint port is invalid")
342
+ }
343
+ return port, nil
344
+}
345
+
346
func ValidateOverlayIPv4(raw string) error {
347
ipText := strings.TrimSpace(raw)
348
if ipText == "" {
@@ -298,22 +381,6 @@ func NormalizeOverlayCIDRs(inputs []string) ([]string, error) {
381
return out, nil
382
}
383
301
-func WireGuardListenPort(rawEndpoint string) (int, error) {
302
- endpoint := strings.TrimSpace(rawEndpoint)
303
- if endpoint == "" {
304
- return 0, errors.New("wireguard endpoint is required")
305
- }
306
- _, portText, err := net.SplitHostPort(endpoint)
307
- if err != nil {
308
- return 0, errors.New("wireguard endpoint must be host:port")
309
- }
310
- port, err := strconv.Atoi(portText)
311
- if err != nil || port <= 0 || port > 65535 {
312
- return 0, errors.New("wireguard endpoint port is invalid")
313
- }
314
- return port, nil
315
-}
316
-
384
func DeriveWireGuardOverlayIPv4(publicKey string) (string, error) {
385
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(publicKey))
386
if err != nil {
@@ -332,47 +399,6 @@ func DeriveWireGuardOverlayIPv4(publicKey string) (string, error) {
399
}).String(), nil
400
}
401
335
-func WireGuardKeyHex(raw string) (string, error) {
336
- key, err := decodeWireGuardKey(raw)
337
- if err != nil {
338
- return "", err
339
- }
340
- return hex.EncodeToString(key[:]), nil
341
-}
342
-
343
-func decodeSecp256k1PrivateKeyHex(raw string, requireNonZero bool) ([]byte, string, error) {
344
- privateKeyHex := strings.TrimSpace(raw)
345
- if privateKeyHex == "" {
346
- return nil, "", errors.New("private key is required")
347
- }
348
- if strings.HasPrefix(strings.ToLower(privateKeyHex), "0x") {
349
- privateKeyHex = privateKeyHex[2:]
350
- }
351
-
352
- decoded, err := hex.DecodeString(privateKeyHex)
353
- if err != nil {
354
- return nil, "", errors.New("secp256k1 private key must be hex encoded")
355
- }
356
- if len(decoded) != secp256k1.PrivKeyBytesLen {
357
- return nil, "", fmt.Errorf("secp256k1 private key must be %d bytes", secp256k1.PrivKeyBytesLen)
358
- }
359
- if !requireNonZero {
360
- return decoded, privateKeyHex, nil
361
- }
362
-
363
- isZero := true
364
- for _, b := range decoded {
365
- if b != 0 {
366
- isZero = false
367
- break
368
- }
369
- }
370
- if isZero {
371
- return nil, "", errors.New("secp256k1 private key must not be zero")
372
- }
373
- return decoded, privateKeyHex, nil
374
-}
375
-
402
func decodeWireGuardKey(raw string) ([32]byte, error) {
403
var key [32]byte
404
value := strings.TrimSpace(raw)
utils/http.go
new
+105
@@ -0,0 +1,105 @@
1
+package utils
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+)
11
+
12
+func HTTPDo(ctx context.Context, client *http.Client, method, rawURL string, body io.Reader, headers http.Header) (*http.Response, error) {
13
+ if client == nil {
14
+ client = http.DefaultClient
15
+ }
16
+
17
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
18
+ if err != nil {
19
+ return nil, err
20
+ }
21
+ for key, values := range headers {
22
+ for _, value := range values {
23
+ req.Header.Add(key, value)
24
+ }
25
+ }
26
+ return client.Do(req)
27
+}
28
+
29
+func HTTPDoJSON(ctx context.Context, client *http.Client, method, rawURL string, payload any, headers http.Header, out any) error {
30
+ body, reqHeaders, err := httpJSONRequest(payload, headers)
31
+ if err != nil {
32
+ return err
33
+ }
34
+
35
+ resp, err := HTTPDo(ctx, client, method, rawURL, body, reqHeaders)
36
+ if err != nil {
37
+ return err
38
+ }
39
+ defer resp.Body.Close()
40
+
41
+ if out == nil {
42
+ return nil
43
+ }
44
+ return json.NewDecoder(resp.Body).Decode(out)
45
+}
46
+
47
+func HTTPDoAPI(ctx context.Context, client *http.Client, method, rawURL string, payload any, headers http.Header, out any) error {
48
+ body, reqHeaders, err := httpJSONRequest(payload, headers)
49
+ if err != nil {
50
+ return err
51
+ }
52
+
53
+ resp, err := HTTPDo(ctx, client, method, rawURL, body, reqHeaders)
54
+ if err != nil {
55
+ return err
56
+ }
57
+ defer resp.Body.Close()
58
+
59
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
60
+ return DecodeAPIRequestError(resp)
61
+ }
62
+
63
+ envelope, err := DecodeAPIEnvelope[json.RawMessage](resp.Body)
64
+ if err != nil {
65
+ return fmt.Errorf("decode response: %w", err)
66
+ }
67
+ if !envelope.OK {
68
+ return NewAPIRequestError(resp.StatusCode, envelope.Error)
69
+ }
70
+ if out == nil {
71
+ return nil
72
+ }
73
+ return json.Unmarshal(envelope.Data, out)
74
+}
75
+
76
+func HTTPReadString(resp *http.Response, limit int64) (string, error) {
77
+ if resp == nil {
78
+ return "", nil
79
+ }
80
+ body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
81
+ if err != nil {
82
+ return "", err
83
+ }
84
+ return string(body), nil
85
+}
86
+
87
+func httpJSONRequest(payload any, headers http.Header) (io.Reader, http.Header, error) {
88
+ reqHeaders := make(http.Header, len(headers))
89
+ for key, values := range headers {
90
+ reqHeaders[key] = append([]string(nil), values...)
91
+ }
92
+
93
+ if payload == nil {
94
+ return nil, reqHeaders, nil
95
+ }
96
+
97
+ buf, err := json.Marshal(payload)
98
+ if err != nil {
99
+ return nil, nil, fmt.Errorf("marshal payload: %w", err)
100
+ }
101
+ if reqHeaders.Get("Content-Type") == "" {
102
+ reqHeaders.Set("Content-Type", "application/json")
103
+ }
104
+ return bytes.NewReader(buf), reqHeaders, nil
105
+}
utils/network.go
+80
-28
@@ -3,7 +3,7 @@ package utils
3
import (
4
"context"
5
"encoding/json"
6
- "io"
6
+ "errors"
7
"net"
8
"net/http"
9
"strings"
@@ -12,43 +12,102 @@ import (
12
"github.com/gosuda/portal/v2/types"
13
)
14
15
+var (
16
+ publicIPEndpoints = []string{
17
+ "https://api.ipify.org",
18
+ "https://ifconfig.me/ip",
19
+ "https://icanhazip.com",
20
+ "https://checkip.amazonaws.com",
21
+ }
22
+ publicIPv4Endpoints = []string{
23
+ "https://api4.ipify.org",
24
+ "https://ipv4.icanhazip.com",
25
+ "https://v4.ident.me",
26
+ "https://checkip.amazonaws.com",
27
+ }
28
+)
29
+
30
// ResolvePublicIP attempts to determine the caller's public IP address
31
// using well-known external services. Returns empty string on failure.
32
// Best-effort with a short timeout to avoid blocking registration.
33
func ResolvePublicIP(ctx context.Context) string {
19
- ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
34
+ endpoints := append(append([]string{}, publicIPEndpoints...), publicIPv4Endpoints...)
35
+ ip, err := resolvePublicIP(ctx, 5*time.Second, 1500*time.Millisecond, false, endpoints...)
36
+ if err != nil {
37
+ return ""
38
+ }
39
+ return ip
40
+}
41
+
42
+func ResolvePublicIPv4(ctx context.Context) (string, error) {
43
+ endpoints := append(append([]string{}, publicIPv4Endpoints...), publicIPEndpoints...)
44
+ return resolvePublicIP(ctx, 15*time.Second, 3*time.Second, true, endpoints...)
45
+}
46
+
47
+func resolvePublicIP(ctx context.Context, totalTimeout, attemptTimeout time.Duration, requireIPv4 bool, endpoints ...string) (string, error) {
48
+ ctx, cancel := context.WithTimeout(ctx, totalTimeout)
49
defer cancel()
50
22
- endpoints := []string{
23
- "https://api.ipify.org",
24
- "https://ifconfig.me/ip",
25
- }
26
- client := &http.Client{Timeout: 3 * time.Second}
51
+ client := &http.Client{}
52
+ headers := http.Header{"User-Agent": []string{"portal-tunnel"}}
53
+ var lastErr error
54
55
for _, endpoint := range endpoints {
29
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
30
- if err != nil {
31
- continue
56
+ if err := ctx.Err(); err != nil {
57
+ lastErr = err
58
+ break
59
}
33
- req.Header.Set("User-Agent", "portal-tunnel")
60
35
- resp, err := client.Do(req)
61
+ requestTimeout := attemptTimeout
62
+ if deadline, ok := ctx.Deadline(); ok {
63
+ remaining := time.Until(deadline)
64
+ if remaining <= 0 {
65
+ lastErr = context.DeadlineExceeded
66
+ break
67
+ }
68
+ if requestTimeout <= 0 || requestTimeout > remaining {
69
+ requestTimeout = remaining
70
+ }
71
+ }
72
+
73
+ requestCtx, cancelRequest := context.WithTimeout(ctx, requestTimeout)
74
+ resp, err := HTTPDo(requestCtx, client, http.MethodGet, endpoint, nil, headers)
75
+ cancelRequest()
76
if err != nil {
77
+ lastErr = err
78
continue
79
}
80
40
- body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
81
+ body, readErr := HTTPReadString(resp, 256)
82
_ = resp.Body.Close()
42
- if resp.StatusCode != http.StatusOK || readErr != nil {
83
+ if resp.StatusCode != http.StatusOK {
84
+ lastErr = errors.New(resp.Status)
85
+ continue
86
+ }
87
+ if readErr != nil {
88
+ lastErr = readErr
89
continue
90
}
91
46
- if candidate := SanitizeReportedIP(string(body)); candidate != "" {
47
- return candidate
92
+ candidate := SanitizeReportedIP(body)
93
+ if candidate == "" {
94
+ lastErr = errors.New("invalid public ip response")
95
+ continue
96
+ }
97
+ if requireIPv4 {
98
+ parsed := net.ParseIP(candidate)
99
+ if parsed == nil || parsed.To4() == nil {
100
+ lastErr = errors.New("public ip is not ipv4")
101
+ continue
102
+ }
103
}
104
+ return candidate, nil
105
}
106
51
- return ""
107
+ if lastErr == nil {
108
+ lastErr = errors.New("resolve public ip failed")
109
+ }
110
+ return "", lastErr
111
}
112
113
func SanitizeReportedIP(raw string) string {
@@ -71,25 +130,18 @@ func ResolvePortalRelayURLs(ctx context.Context, explicit []string, includeDefau
130
return explicit, nil
131
}
132
74
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, types.PortalRelayRegistryURL, nil)
75
- if err != nil {
76
- return explicit, nil
77
- }
78
-
133
client := &http.Client{Timeout: 5 * time.Second}
80
- resp, err := client.Do(req)
134
+ var registry struct {
135
+ Relays []string `json:"relays"`
136
+ }
137
+ resp, err := HTTPDo(ctx, client, http.MethodGet, types.PortalRelayRegistryURL, nil, nil)
138
if err != nil {
139
return explicit, nil
140
}
141
defer resp.Body.Close()
85
-
142
if resp.StatusCode != http.StatusOK {
143
return explicit, nil
144
}
89
-
90
- var registry struct {
91
- Relays []string `json:"relays"`
92
- }
145
if err := json.NewDecoder(resp.Body).Decode(®istry); err != nil {
146
return explicit, nil
147
}
utils/utils.go
+9
-8
@@ -3,10 +3,10 @@ package utils
3
import (
4
"context"
5
"crypto/rand"
6
- "crypto/subtle"
6
"crypto/x509"
7
"encoding/base64"
8
"encoding/hex"
9
+ "encoding/pem"
10
"errors"
11
"fmt"
12
"io"
@@ -500,13 +500,6 @@ func RandomHex(size int) (string, error) {
500
}
501
502
// Security and TLS helpers.
503
-func TokenMatches(expected, actual string) bool {
504
- if len(expected) == 0 || len(actual) == 0 {
505
- return false
506
- }
507
- return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
508
-}
509
-
503
func CertPoolFromPEM(rootCAPEM []byte) (*x509.CertPool, error) {
504
if len(rootCAPEM) == 0 {
505
return nil, nil
@@ -518,6 +511,14 @@ func CertPoolFromPEM(rootCAPEM []byte) (*x509.CertPool, error) {
511
return pool, nil
512
}
513
514
+func ParseCertificatePEM(pemData []byte) (*x509.Certificate, error) {
515
+ block, _ := pem.Decode(pemData)
516
+ if block == nil {
517
+ return nil, errors.New("no pem block found")
518
+ }
519
+ return x509.ParseCertificate(block.Bytes)
520
+}
521
+
522
func SleepOrDone(ctx context.Context, d time.Duration) bool {
523
timer := time.NewTimer(d)
524
defer timer.Stop()