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(&registerReq); 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(&registerReq); 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(&registerReq); 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(&registry); 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()