Refactor authentication and server list hooks
- Simplified the `useAuth` hook by removing unnecessary parameters and logic. - Updated authentication API calls to use new response types and paths. - Refactored `useServerList` to align with new API response structure and types. - Removed deprecated types and consolidated API paths for clarity. - Improved error handling and state management in both hooks. - Updated related tests to reflect changes in API structure and response handling.
Kim committed
May 29, 2026 at 14:01 UTC
035248a78dac82e4d06fcafd183ed95a1e9c722a
40 files changed
+1327
-2173
cmd/relay-server/admin.go
+124
-169
@@ -84,212 +84,167 @@ func (api *RelayAPI) serveAdmin(w http.ResponseWriter, r *http.Request) {
84
}
85
86
runtime := api.server.PolicyRuntime()
87
- methodNotAllowed := utils.MethodNotAllowedError()
87
invalidRequestBody := utils.InvalidRequestError(errors.New("invalid request body"))
88
89
switch path {
90
case "/admin/metrics":
91
promhttp.Handler().ServeHTTP(w, r)
92
return
94
- case types.PathAdminSnapshot:
93
+ case types.PathAdminState:
94
if !utils.RequireMethod(w, r, http.MethodGet) {
95
return
96
}
97
leases := api.server.AdminLeases()
98
api.attachAutomaticAdminThumbnails(leases)
100
- utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
101
- ApprovalMode: string(runtime.Approver().Mode()),
102
- LandingPageEnabled: api.landingPageEnabled.Load(),
103
- Leases: leases,
104
- UDP: types.AdminUDPSettingsResponse{
105
- Enabled: runtime.IsUDPEnabled(),
106
- MaxLeases: runtime.UDPMaxLeases(),
107
- },
108
- TCPPort: types.AdminTCPPortSettingsResponse{
109
- Enabled: runtime.IsTCPPortEnabled(),
110
- MaxLeases: runtime.TCPPortMaxLeases(),
111
- },
99
+ utils.WriteAPIData(w, http.StatusOK, types.AdminStateResponse{
100
+ Settings: api.adminSettings(runtime),
101
+ Leases: leases,
102
})
113
- case types.PathAdminLandingPage:
103
+ case types.PathAdminSettings:
104
if !utils.RequireMethod(w, r, http.MethodPost) {
105
return
106
}
117
- req, ok := utils.DecodeJSONRequestAs[types.AdminLandingPageSettingsRequest](w, r, adminBodyLimit, invalidRequestBody)
107
+ req, ok := utils.DecodeJSONRequestAs[types.AdminSettings](w, r, adminBodyLimit, invalidRequestBody)
108
if !ok {
109
return
110
}
121
- api.landingPageEnabled.Store(req.Enabled)
122
- landingPageEnabled := api.landingPageEnabled.Load()
123
- saveAdminState(api.adminSettingsPath, runtime, landingPageEnabled)
124
- utils.WriteAPIData(w, http.StatusOK, types.AdminLandingPageSettingsResponse{
125
- Enabled: landingPageEnabled,
126
- })
127
- case types.PathAdminUDP:
128
- api.handlePortSettings(w, r, invalidRequestBody, runtime,
129
- api.server.SetUDPPolicy,
130
- func() any {
131
- return types.AdminUDPSettingsResponse{Enabled: runtime.IsUDPEnabled(), MaxLeases: runtime.UDPMaxLeases()}
132
- },
133
- )
134
- case types.PathAdminTCPPort:
135
- api.handlePortSettings(w, r, invalidRequestBody, runtime,
136
- api.server.SetTCPPortPolicy,
137
- func() any {
138
- return types.AdminTCPPortSettingsResponse{Enabled: runtime.IsTCPPortEnabled(), MaxLeases: runtime.TCPPortMaxLeases()}
139
- },
140
- )
141
- case types.PathAdminApproval:
111
+ if !api.applyAdminSettings(w, runtime, req) {
112
+ return
113
+ }
114
+ utils.WriteAPIData(w, http.StatusOK, api.adminSettings(runtime))
115
+ case types.PathAdminLeasePolicy:
116
if !utils.RequireMethod(w, r, http.MethodPost) {
117
return
118
}
145
- req, ok := utils.DecodeJSONRequestAs[types.AdminApprovalModeRequest](w, r, adminBodyLimit, invalidRequestBody)
119
+ req, ok := utils.DecodeJSONRequestAs[types.AdminLeasePolicy](w, r, adminBodyLimit, invalidRequestBody)
120
if !ok {
121
return
122
}
149
- if err := runtime.Approver().SetMode(policy.Mode(strings.TrimSpace(req.Mode))); err != nil {
150
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
123
+ identityKey, ok := normalizeAdminIdentityKey(w, req.IdentityKey)
124
+ if !ok {
125
+ return
126
+ }
127
+ if !applyAdminLeasePolicy(w, runtime, identityKey, req) {
128
return
129
}
130
saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
154
- utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
155
- ApprovalMode: string(runtime.Approver().Mode()),
156
- })
131
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{})
132
+ case types.PathAdminIPPolicy:
133
+ if !utils.RequireMethod(w, r, http.MethodPost) {
134
+ return
135
+ }
136
+ req, ok := utils.DecodeJSONRequestAs[types.AdminIPPolicy](w, r, adminBodyLimit, invalidRequestBody)
137
+ if !ok {
138
+ return
139
+ }
140
+ ip := strings.TrimSpace(req.IP)
141
+ if net.ParseIP(ip) == nil {
142
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
143
+ return
144
+ }
145
+ if req.IsBanned {
146
+ runtime.IPFilter().BanIP(ip)
147
+ } else {
148
+ runtime.IPFilter().UnbanIP(ip)
149
+ }
150
+ saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
151
+ utils.WriteAPIData(w, http.StatusOK, map[string]any{})
152
default:
158
- switch {
159
- case strings.HasPrefix(path, types.PathAdminLeasesPrefix):
160
- rest := strings.TrimPrefix(path, types.PathAdminLeasesPrefix)
161
- parts := strings.Split(rest, "/")
162
- if len(parts) != 3 {
163
- http.NotFound(w, r)
164
- return
165
- }
166
-
167
- name, err := utils.DecodeBase64URLString(parts[0])
168
- if err != nil {
169
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
170
- return
171
- }
172
- address, err := utils.DecodeBase64URLString(parts[1])
173
- if err != nil {
174
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidAddress, "invalid address")
175
- return
176
- }
177
- normalizedIdentity, err := identity.NormalizeIdentity(types.Identity{
178
- Name: name,
179
- Address: address,
180
- })
181
- if err != nil {
182
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
183
- return
184
- }
185
- identityKey := normalizedIdentity.Key()
186
- approver := runtime.Approver()
187
-
188
- type identityAction struct {
189
- post func() bool // returns true if response was already written (error path)
190
- delete func()
191
- }
192
- actions := map[string]identityAction{
193
- "ban": {
194
- post: func() bool { runtime.BanIdentity(identityKey); return false },
195
- delete: func() { runtime.UnbanIdentity(identityKey) },
196
- },
197
- "bps": {
198
- post: func() bool {
199
- req, ok := utils.DecodeJSONRequestAs[types.AdminBPSRequest](w, r, adminBodyLimit, invalidRequestBody)
200
- if !ok {
201
- return true
202
- }
203
- if req.BPS <= 0 {
204
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "bps must be greater than zero")
205
- return true
206
- }
207
- runtime.BPSManager().SetIdentityBPS(identityKey, req.BPS)
208
- return false
209
- },
210
- delete: func() { runtime.BPSManager().DeleteIdentityBPS(identityKey) },
211
- },
212
- "approve": {
213
- post: func() bool { approver.Approve(identityKey); approver.Undeny(identityKey); return false },
214
- delete: func() { approver.Revoke(identityKey) },
215
- },
216
- "deny": {
217
- post: func() bool { approver.Deny(identityKey); return false },
218
- delete: func() { approver.Undeny(identityKey) },
219
- },
220
- }
153
+ http.NotFound(w, r)
154
+ }
155
+}
156
222
- action, ok := actions[parts[2]]
223
- if !ok {
224
- http.NotFound(w, r)
225
- return
226
- }
227
- switch r.Method {
228
- case http.MethodPost:
229
- if action.post() {
230
- return
231
- }
232
- case http.MethodDelete:
233
- action.delete()
234
- default:
235
- methodNotAllowed.Write(w)
236
- return
237
- }
238
- saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
239
- utils.WriteAPIData(w, http.StatusOK, map[string]any{})
240
- case strings.HasPrefix(path, types.PathAdminIPsPrefix):
241
- if !strings.HasSuffix(path, "/ban") {
242
- http.NotFound(w, r)
243
- return
244
- }
157
+func (api *RelayAPI) adminSettings(runtime *policy.Runtime) types.AdminSettings {
158
+ return types.AdminSettings{
159
+ ApprovalMode: string(runtime.Approver().Mode()),
160
+ LandingPageEnabled: api.landingPageEnabled.Load(),
161
+ UDP: types.AdminPortSettings{
162
+ Enabled: runtime.IsUDPEnabled(),
163
+ MaxLeases: runtime.UDPMaxLeases(),
164
+ },
165
+ TCPPort: types.AdminPortSettings{
166
+ Enabled: runtime.IsTCPPortEnabled(),
167
+ MaxLeases: runtime.TCPPortMaxLeases(),
168
+ },
169
+ }
170
+}
171
246
- rawIP := strings.TrimSuffix(strings.TrimPrefix(path, types.PathAdminIPsPrefix), "/ban")
247
- rawIP = strings.Trim(rawIP, "/")
248
- if net.ParseIP(rawIP) == nil {
249
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
250
- return
251
- }
172
+func (api *RelayAPI) applyAdminSettings(w http.ResponseWriter, runtime *policy.Runtime, req types.AdminSettings) bool {
173
+ if req.UDP.MaxLeases < 0 || req.TCPPort.MaxLeases < 0 {
174
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "max_leases must be non-negative")
175
+ return false
176
+ }
177
+ if err := runtime.Approver().SetMode(policy.Mode(strings.TrimSpace(req.ApprovalMode))); err != nil {
178
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "approval_mode must be 'auto' or 'manual'")
179
+ return false
180
+ }
181
+ api.landingPageEnabled.Store(req.LandingPageEnabled)
182
+ api.server.SetUDPPolicy(req.UDP.Enabled, req.UDP.MaxLeases)
183
+ api.server.SetTCPPortPolicy(req.TCPPort.Enabled, req.TCPPort.MaxLeases)
184
+ saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
185
+ return true
186
+}
187
253
- filter := runtime.IPFilter()
254
- switch r.Method {
255
- case http.MethodPost:
256
- filter.BanIP(rawIP)
257
- case http.MethodDelete:
258
- filter.UnbanIP(rawIP)
259
- default:
260
- methodNotAllowed.Write(w)
261
- return
262
- }
263
- saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
264
- utils.WriteAPIData(w, http.StatusOK, map[string]any{})
265
- default:
266
- http.NotFound(w, r)
267
- }
188
+func normalizeAdminIdentityKey(w http.ResponseWriter, raw string) (string, bool) {
189
+ raw = strings.TrimSpace(raw)
190
+ name, address, ok := strings.Cut(raw, types.IdentityKeySeparator)
191
+ if !ok {
192
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
193
+ return "", false
194
+ }
195
+ normalizedIdentity, err := identity.NormalizeIdentity(types.Identity{Name: name, Address: address})
196
+ if err != nil {
197
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
198
+ return "", false
199
}
200
+ return normalizedIdentity.Key(), true
201
}
202
271
-func (api *RelayAPI) handlePortSettings(
272
- w http.ResponseWriter,
273
- r *http.Request,
274
- invalidBody utils.APIErrorResponse,
275
- runtime *policy.Runtime,
276
- setPolicy func(bool, int),
277
- buildResponse func() any,
278
-) {
279
- if !utils.RequireMethod(w, r, http.MethodPost) {
280
- return
203
+func applyAdminLeasePolicy(w http.ResponseWriter, runtime *policy.Runtime, identityKey string, req types.AdminLeasePolicy) bool {
204
+ if req.IsBanned == nil && req.IsApproved == nil && req.IsDenied == nil && req.BPS == nil {
205
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "lease policy update is empty")
206
+ return false
207
}
282
- req, ok := utils.DecodeJSONRequestAs[types.AdminPortSettingsRequest](w, r, 1<<16, invalidBody)
283
- if !ok {
284
- return
208
+ if req.IsApproved != nil && req.IsDenied != nil && *req.IsApproved && *req.IsDenied {
209
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "lease cannot be approved and denied")
210
+ return false
211
}
286
- if req.MaxLeases < 0 {
287
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "max_leases must be non-negative")
288
- return
212
+ if req.BPS != nil {
213
+ if *req.BPS < 0 {
214
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "bps must be non-negative")
215
+ return false
216
+ }
217
+ if *req.BPS == 0 {
218
+ runtime.BPSManager().DeleteIdentityBPS(identityKey)
219
+ } else {
220
+ runtime.BPSManager().SetIdentityBPS(identityKey, *req.BPS)
221
+ }
222
}
290
- setPolicy(req.Enabled, req.MaxLeases)
291
- saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
292
- utils.WriteAPIData(w, http.StatusOK, buildResponse())
223
+ if req.IsBanned != nil {
224
+ if *req.IsBanned {
225
+ runtime.BanIdentity(identityKey)
226
+ } else {
227
+ runtime.UnbanIdentity(identityKey)
228
+ }
229
+ }
230
+ approver := runtime.Approver()
231
+ if req.IsDenied != nil {
232
+ if *req.IsDenied {
233
+ approver.Deny(identityKey)
234
+ approver.Revoke(identityKey)
235
+ } else {
236
+ approver.Undeny(identityKey)
237
+ }
238
+ }
239
+ if req.IsApproved != nil {
240
+ if *req.IsApproved {
241
+ approver.Approve(identityKey)
242
+ approver.Undeny(identityKey)
243
+ } else {
244
+ approver.Revoke(identityKey)
245
+ }
246
+ }
247
+ return true
248
}
249
250
func (api *RelayAPI) handleWalletChallenge(w http.ResponseWriter, r *http.Request) {
cmd/relay-server/api.go
+6
-6
@@ -84,8 +84,8 @@ func (api *RelayAPI) Handler() *http.ServeMux {
84
})
85
mux.HandleFunc(types.PathAdmin, api.serveAdmin)
86
mux.HandleFunc(types.PathAdminPrefix, api.serveAdmin)
87
- mux.HandleFunc(types.PathPublicSnapshot, api.servePublicSnapshot)
88
- mux.HandleFunc(types.PathTunnelStatus, api.serveTunnelStatus)
87
+ mux.HandleFunc(types.PathPublicState, api.servePublicState)
88
+ mux.HandleFunc(types.PathServiceStatus, api.serveServiceStatus)
89
mux.HandleFunc(types.PathThumbnailPrefix, api.serveThumbnail)
90
mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
91
serveInstallScript(w, r, api.server.PortalURL(), false)
@@ -98,20 +98,20 @@ func (api *RelayAPI) Handler() *http.ServeMux {
98
return mux
99
}
100
101
-func (api *RelayAPI) servePublicSnapshot(w http.ResponseWriter, r *http.Request) {
101
+func (api *RelayAPI) servePublicState(w http.ResponseWriter, r *http.Request) {
102
if !utils.RequireMethod(w, r, http.MethodGet) {
103
return
104
}
105
106
leases := api.server.PublicLeases()
107
api.attachAutomaticThumbnails(leases)
108
- utils.WriteAPIData(w, http.StatusOK, types.PublicSnapshotResponse{
108
+ utils.WriteAPIData(w, http.StatusOK, types.PublicStateResponse{
109
Leases: leases,
110
LandingPageEnabled: api.landingPageEnabled.Load(),
111
})
112
}
113
114
-func (api *RelayAPI) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
114
+func (api *RelayAPI) serveServiceStatus(w http.ResponseWriter, r *http.Request) {
115
if !utils.RequireMethod(w, r, http.MethodGet) {
116
return
117
}
@@ -122,7 +122,7 @@ func (api *RelayAPI) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
122
return
123
}
124
125
- resp := types.TunnelStatusResponse{
125
+ resp := types.ServiceStatusResponse{
126
Hostname: hostname,
127
}
128
if lease, ok := api.publicLeaseByHostname(hostname); ok {
docker-compose.yml
+2
-2
@@ -5,7 +5,7 @@ services:
5
# image: chromedp/headless-shell:stable
6
# restart: unless-stopped
7
8
- portal-api:
8
+ portal:
9
image: ghcr.io/gosuda/portal:latest
10
build:
11
context: .
@@ -86,7 +86,7 @@ services:
86
context: ./frontend
87
dockerfile: Dockerfile
88
depends_on:
89
- - portal-api
89
+ - portal
90
ports:
91
- "${FRONTEND_PORT:-8080}:8080"
92
restart: unless-stopped
docs/src/routes/api-reference/+page.md
+185
-247
@@ -1,35 +1,26 @@
1
---
2
title: API Reference
3
-description: Complete API reference for Portal relay server endpoints.
3
+description: Portal relay API contract, endpoint groups, auth, and shared response rules.
4
---
5
6
-<script>
7
-import Mermaid from '$lib/components/Mermaid.svelte'
8
-</script>
9
-
6
# API Reference
7
12
-This page provides a complete reference for the Portal relay server HTTP API. Control-plane endpoints are served over the relay API HTTPS listener; tenant TLS is handled separately by the SDK using the relay's keyless signing endpoint.
8
+Portal relay exposes one control-plane API. Frontends, SDK clients, peer relays,
9
+and operators all talk to this API, but each group has a small owned surface.
10
+Local agent endpoints under `/agent/*` are not part of the relay API.
11
14
-## Response Envelope
12
+## JSON Envelope
13
16
-Every API response uses a consistent JSON envelope:
14
+Matched JSON control endpoints return this envelope:
15
16
```json
17
{
18
"ok": true,
21
- "data": { ... },
22
- "error": null
19
+ "data": {}
20
}
21
```
22
26
-| Field | Type | Description |
27
-|-------|------|-------------|
28
-| `ok` | `boolean` | `true` if the request succeeded |
29
-| `data` | `T` | Response payload (omitted on error) |
30
-| `error` | `object \| null` | Error details (omitted on success) |
31
-
32
-Error responses include a structured error object:
23
+Error responses use the same envelope with `error` instead of `data`:
24
25
```json
26
{
@@ -41,235 +32,182 @@ Error responses include a structured error object:
32
}
33
```
34
44
-## Authentication
45
-
46
-Portal uses two separate authentication mechanisms depending on the caller.
47
-
48
-### SDK Authentication (SIWE Challenge/Response)
49
-
50
-SDK clients authenticate using Sign-In with Ethereum (SIWE):
51
-
52
-1. POST a challenge request to `/sdk/register/challenge` with your identity
53
-2. Sign the returned SIWE message with your Ethereum private key
54
-3. POST the signed message to `/sdk/register` to receive a JWT access token
55
-4. Include the access token in subsequent requests via the `X-Portal-Access-Token` header or in the JSON request body
56
-
57
-### Admin Authentication (Wallet Bearer Token)
58
-
59
-Admin clients authenticate with a wallet signature:
60
-
61
-1. POST to `/admin/auth/challenge` with `{ "address": "<wallet-address>" }`
62
-2. Sign the returned SIWE message with the wallet
63
-3. POST the signed message to `/admin/auth/login`
64
-4. Store the returned `access_token`
65
-5. Include it in subsequent admin requests as `Authorization: Bearer <access_token>`
66
-6. Tokens expire after 24 hours
67
-
68
-The local agent has its own loopback wallet auth endpoints under
69
-`/v1/agent/auth/*`. Agent wallet sessions can read `/v1/agent/status`; mutating
70
-agent actions require the bearer token stored in the agent state directory. See
71
-[Portal Agent](/portal-agent) for the local control API.
72
-
73
-## Endpoint Summary
74
-
75
-### SDK Endpoints
76
-
77
-| Method | Path | Description | Auth |
78
-|--------|------|-------------|------|
79
-| `GET` | [`/sdk/domain`](/api-reference/sdk#get-sdkdomain) | Get relay domain and version info | None |
80
-| `POST` | [`/sdk/register/challenge`](/api-reference/sdk#post-sdkregisterchallenge) | Request a SIWE challenge for registration | None |
81
-| `POST` | [`/sdk/register`](/api-reference/sdk#post-sdkregister) | Complete registration with signed challenge | None |
82
-| `POST` | [`/sdk/renew`](/api-reference/sdk#post-sdkrenew) | Renew an existing lease TTL | Access Token |
83
-| `POST` | [`/sdk/unregister`](/api-reference/sdk#post-sdkunregister) | Remove an active lease | Access Token |
84
-| `GET` | [`/sdk/connect`](/api-reference/sdk#get-sdkconnect) | Establish reverse tunnel connection | Access Token |
85
-
86
-### Admin Endpoints
87
-
88
-| Method | Path | Description | Auth |
89
-|--------|------|-------------|------|
90
-| `POST` | [`/admin/auth/challenge`](/api-reference/admin#post-adminauthchallenge) | Request wallet login challenge | None |
91
-| `POST` | [`/admin/auth/login`](/api-reference/admin#post-adminauthlogin) | Complete wallet login | None |
92
-| `POST` | [`/admin/logout`](/api-reference/admin#post-adminlogout) | Invalidate admin token | Bearer Token |
93
-| `GET` | [`/admin/auth/status`](/api-reference/admin#get-adminauthstatus) | Check authentication status | None |
94
-| `GET` | [`/admin/snapshot`](/api-reference/admin#get-adminsnapshot) | Get full relay state snapshot | Bearer Token |
95
-| `POST` | [`/admin/settings/landing-page`](/api-reference/admin#post-adminsettingslanding-page) | Toggle landing page | Bearer Token |
96
-| `POST` | [`/admin/settings/udp`](/api-reference/admin#post-adminsettingsudp) | Configure UDP settings | Bearer Token |
97
-| `POST` | [`/admin/settings/tcp-port`](/api-reference/admin#post-adminsettingstcp-port) | Configure TCP port settings | Bearer Token |
98
-| `POST` | [`/admin/settings/approval-mode`](/api-reference/admin#post-adminsettingsapproval-mode) | Set approval mode | Bearer Token |
99
-| `POST` | [`/admin/leases/{name}/{addr}/ban`](/api-reference/admin#lease-management) | Ban a lease identity | Bearer Token |
100
-| `DELETE` | [`/admin/leases/{name}/{addr}/ban`](/api-reference/admin#lease-management) | Unban a lease identity | Bearer Token |
101
-| `POST` | [`/admin/leases/{name}/{addr}/bps`](/api-reference/admin#lease-management) | Set bandwidth limit for a lease | Bearer Token |
102
-| `DELETE` | [`/admin/leases/{name}/{addr}/bps`](/api-reference/admin#lease-management) | Remove bandwidth limit | Bearer Token |
103
-| `POST` | [`/admin/leases/{name}/{addr}/approve`](/api-reference/admin#lease-management) | Approve a lease | Bearer Token |
104
-| `DELETE` | [`/admin/leases/{name}/{addr}/approve`](/api-reference/admin#lease-management) | Revoke lease approval | Bearer Token |
105
-| `POST` | [`/admin/leases/{name}/{addr}/deny`](/api-reference/admin#lease-management) | Deny a lease | Bearer Token |
106
-| `DELETE` | [`/admin/leases/{name}/{addr}/deny`](/api-reference/admin#lease-management) | Remove lease denial | Bearer Token |
107
-| `POST` | [`/admin/ips/{ip}/ban`](/api-reference/admin#ip-management) | Ban an IP address | Bearer Token |
108
-| `DELETE` | [`/admin/ips/{ip}/ban`](/api-reference/admin#ip-management) | Unban an IP address | Bearer Token |
109
-
110
-### System Endpoints
111
-
112
-| Method | Path | Description | Auth |
113
-|--------|------|-------------|------|
114
-| `GET` | `/healthz` | Health check | None |
115
-| `GET` | `/discovery` | Relay discovery | None |
116
-| `POST` | `/discovery/announce` | Relay discovery self-announce | Signed Descriptor |
117
-| `POST` | `/v1/sign` | Keyless TLS signing | Access Token |
118
-| `GET` | `/api/public/snapshot` | Public frontend snapshot | None |
119
-| `GET` | `/thumbnail/{hostname}` | Cached thumbnail screenshot | None |
120
-| `GET` | `/tunnel/status` | Tunnel connection status | None |
121
-
122
-## System Endpoints
123
-
124
-These endpoints are small enough to document inline.
125
-
126
-### `GET /healthz`
127
-
128
-Returns relay health status.
129
-
130
-**Response:**
131
-
132
-```json
133
-{
134
- "ok": true,
135
- "data": {
136
- "status": "ok"
137
- }
138
-}
139
-```
140
-
141
-### `GET /discovery`
142
-
143
-Returns signed relay discovery descriptors for this relay and any known peer relays. Only available when discovery is enabled in the server configuration.
144
-
145
-**Response fields:**
146
-
147
-| Field | Type | Description |
148
-|-------|------|-------------|
149
-| `protocol_version` | `string` | Protocol version identifier |
150
-| `generated_at` | `string` | ISO 8601 timestamp |
151
-| `relays` | `RelayDescriptor[]` | Signed descriptors for this relay and known peer relays |
35
+`data` is omitted on error. `error` is omitted on success.
36
153
-`RelayDescriptor` contains the signed relay contract and relay-reported telemetry:
154
-
155
-| Field | Type | Description |
156
-|-------|------|-------------|
157
-| `address` | `string` | Relay signing address used to verify `signature` |
158
-| `version` | `string` | Discovery protocol version used by this signed descriptor |
159
-| `issued_at` | `string` | Descriptor issue time |
160
-| `expires_at` | `string` | Descriptor expiry time |
161
-| `api_https_addr` | `string` | Public HTTPS API base URL |
162
-| `wireguard_public_key` | `string` | WireGuard overlay public key, present when overlay is enabled |
163
-| `wireguard_port` | `number` | Public WireGuard UDP port on the `api_https_addr` host, present when overlay is enabled |
164
-| `supports_overlay` | `boolean` | Relay can participate in WireGuard multi-hop overlay routing |
165
-| `supports_udp` | `boolean` | Relay can allocate public UDP leases |
166
-| `supports_tcp` | `boolean` | Relay can allocate raw TCP port leases |
167
-| `active_connections` | `number` | Current proxied connection count reported by the relay |
168
-| `tcp_bps` | `number` | Recent proxied TCP throughput in bytes per second |
169
-| `signature` | `string` | Signature over the descriptor fields above |
170
-
171
-Relay telemetry is sampled when the descriptor is issued; use `issued_at` to judge freshness.
172
-
173
-Overlay peer support is advertised by `supports_overlay`. When it is true, `wireguard_public_key` and `wireguard_port` are present. The WireGuard endpoint host is the `api_https_addr` host, and the overlay IPv4 is derived from the WireGuard public key. Relay-local observations such as recent overlay reachability are not part of the signed descriptor.
174
-
175
-**Example:**
176
-
177
-```bash
178
-curl https://relay.example.com/discovery
179
-```
180
-
181
-### `POST /discovery/announce`
182
-
183
-Submits this relay's signed descriptor to a bootstrap relay so registry-external relays can enter the discovery mesh. Relays self-announce periodically when discovery is enabled.
184
-
185
-**Auth:** Signed relay descriptor
186
-
187
-**Request fields:**
188
-
189
-| Field | Type | Required | Description |
190
-|-------|------|----------|-------------|
191
-| `protocol_version` | `string` | No | Discovery protocol version |
192
-| `descriptor` | `RelayDescriptor` | Yes | Signed relay descriptor |
193
-
194
-**Response fields:**
195
-
196
-| Field | Type | Description |
197
-|-------|------|-------------|
198
-| `protocol_version` | `string` | Discovery protocol version |
199
-| `accepted` | `boolean` | Whether the descriptor was accepted |
200
-
201
-### `POST /v1/sign`
202
-
203
-Keyless TLS signing endpoint. Used by the SDK-side tenant TLS server during the
204
-default stream handshake. Requests must include a valid lease access token in
205
-the `X-Portal-Access-Token` header.
206
-
207
-Only available when the API server is configured with a TLS private key.
208
-
209
-Returns `404 Not Found` if signing is not configured.
210
-
211
-### `GET /api/public/snapshot`
212
-
213
-Returns the public relay dashboard snapshot used by frontends.
214
-
215
-**Response fields:**
216
-
217
-| Field | Type | Description |
218
-|-------|------|-------------|
219
-| `leases` | `Lease[]` | Public lease rows visible on the relay index |
220
-| `landing_page_enabled` | `boolean` | Whether the public landing hero should be shown |
221
-
222
-**Example:**
223
-
224
-```bash
225
-curl https://relay.example.com/api/public/snapshot
226
-```
227
-
228
-### `GET /thumbnail/{hostname}`
229
-
230
-Returns a cached thumbnail screenshot for a registered tunnel hostname.
231
-
232
-**Response headers:**
233
-
234
-| Header | Value |
235
-|--------|-------|
236
-| `Content-Type` | Image content type (e.g. `image/png`) |
237
-| `Cache-Control` | `public, max-age=300` |
238
-
239
-Returns `404 Not Found` if the hostname is not registered or no thumbnail is available.
240
-
241
-**Example:**
242
-
243
-```bash
244
-curl https://relay.example.com/thumbnail/myapp.relay.example.com
245
-```
37
+The envelope does not apply to streaming or delegated endpoints:
38
247
-## Error Codes
248
-
249
-All error codes that may appear in the `error.code` field:
250
-
251
-| Code | Description |
252
-|------|-------------|
253
-| `feature_unavailable` | Requested feature is not available |
254
-| `hijack_failed` | HTTP connection hijack failed |
255
-| `hijack_unsupported` | HTTP connection hijack not supported |
256
-| `hostname_conflict` | Hostname already in use by another lease |
257
-| `http11_only` | Endpoint requires HTTP/1.1 |
258
-| `invalid_address` | Invalid Ethereum address |
259
-| `invalid_ip` | Invalid IP address format |
260
-| `invalid_json` | Malformed JSON request body |
261
-| `invalid_mode` | Invalid approval mode value |
262
-| `invalid_request` | General request validation failure |
263
-| `internal` | Internal server error |
264
-| `ip_banned` | Source IP is banned |
265
-| `lease_not_found` | No lease found for the given identity |
266
-| `lease_rejected` | Lease is not approved for routing |
267
-| `method_not_allowed` | HTTP method not allowed for this endpoint |
268
-| `unauthorized` | Authentication required or token invalid |
269
-| `udp_port_exhausted` | No UDP ports available |
270
-| `udp_disabled` | UDP transport is disabled |
271
-| `udp_capacity_exceeded` | UDP lease capacity reached |
272
-| `tcp_port_exhausted` | No TCP ports available |
273
-| `tcp_port_disabled` | TCP port transport is disabled |
274
-| `tcp_port_capacity_exceeded` | TCP port lease capacity reached |
275
-| `transport_mismatch` | Transport type mismatch |
39
+| Path | Format |
40
+|------|--------|
41
+| `/sdk/connect` | HTTP/1.1 connection hijack |
42
+| `/v1/sign` | keyless TLS signer protocol |
43
+| `/thumbnail/{hostname}` | image bytes |
44
+| `/install.sh`, `/install.ps1`, `/install/bin/*` | script or binary bytes |
45
+| `/x402/*` | x402 facilitator API |
46
+
47
+Unknown routes may be handled by the frontend/proxy layer or return a normal
48
+HTTP 404 outside the envelope.
49
+
50
+## Auth Schemes
51
+
52
+| Name | Used by | How it is sent |
53
+|------|---------|----------------|
54
+| None | public and challenge endpoints | no credential |
55
+| Admin bearer | admin API | `Authorization: Bearer <access_token>` |
56
+| Lease token header | tunnel stream and keyless signer | `X-Portal-Access-Token: <access_token>` |
57
+| Lease token body | lease renew/unregister | JSON field `access_token` |
58
+| Signed descriptor | relay discovery announce | signed `RelayDescriptor` body |
59
+| Signed hop route | relay overlay route | signed `HopRoute` body |
60
+
61
+Admin and SDK login both use SIWE, but they issue different tokens and are not
62
+interchangeable.
63
+
64
+## Endpoint Groups
65
+
66
+### Public
67
+
68
+| Method | Path | Auth | Response |
69
+|--------|------|------|----------|
70
+| `GET` | `/` | None | service identity |
71
+| `GET` | `/healthz` | None | `{ "status": "ok" }` |
72
+| `GET` | `/state` | None | `PublicStateResponse` |
73
+| `GET` | `/service/status?hostname=...` | None | `ServiceStatusResponse` |
74
+| `GET` | `/thumbnail/{hostname}` | None | image bytes |
75
+| `GET`/`HEAD` | `/install.sh`, `/install.ps1` | None | install script |
76
+| `GET`/`HEAD` | `/install/bin/{slug}` | None | install binary or redirect |
77
+
78
+### SDK
79
+
80
+| Method | Path | Auth | Body | Response |
81
+|--------|------|------|------|----------|
82
+| `GET` | `/sdk/domain` | None | none | `DomainResponse` |
83
+| `POST` | `/sdk/register/challenge` | None | `RegisterChallengeRequest` | `RegisterChallengeResponse` |
84
+| `POST` | `/sdk/register` | SIWE signature body | `RegisterRequest` | `RegisterResponse` |
85
+| `POST` | `/sdk/renew` | lease token body | `RenewRequest` | `RenewResponse` |
86
+| `POST` | `/sdk/unregister` | lease token body | `UnregisterRequest` | `{}` |
87
+| `GET` | `/sdk/connect` | lease token header | none | hijacked stream |
88
+
89
+`/sdk/hop` is a relay-to-relay overlay route endpoint. It is not used by normal
90
+SDK clients.
91
+
92
+### Admin
93
+
94
+| Method | Path | Auth | Body | Response |
95
+|--------|------|------|------|----------|
96
+| `POST` | `/admin/auth/challenge` | None | `WalletAuthChallengeRequest` | `WalletAuthChallengeResponse` |
97
+| `POST` | `/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
98
+| `GET` | `/admin/auth/status` | Optional admin bearer | none | `WalletAuthStatusResponse` |
99
+| `POST` | `/admin/auth/logout` | Admin bearer | none | `{}` |
100
+| `GET` | `/admin/state` | Admin bearer | none | `AdminStateResponse` |
101
+| `POST` | `/admin/settings` | Admin bearer | `AdminSettings` | `AdminSettings` |
102
+| `POST` | `/admin/lease-policy` | Admin bearer | `AdminLeasePolicy` | `{}` |
103
+| `POST` | `/admin/ip-policy` | Admin bearer | `AdminIPPolicy` | `{}` |
104
+
105
+`/admin` itself is a frontend route, not a relay API endpoint.
106
+
107
+### Relay And Payment
108
+
109
+| Method | Path | Auth | Response |
110
+|--------|------|------|----------|
111
+| `GET` | `/discovery` | None | `DiscoveryResponse` |
112
+| `POST` | `/discovery/announce` | Signed descriptor | `DiscoveryAnnounceResponse` |
113
+| `POST` | `/v1/sign` | Lease token header | keyless signer response |
114
+| `ANY` | `/x402/*` | x402-specific | delegated facilitator response |
115
+
116
+## Shared Types
117
+
118
+Timestamps are JSON-encoded Go `time.Time` values.
119
+
120
+`Identity`:
121
+
122
+| Field | Type | Notes |
123
+|-------|------|-------|
124
+| `name` | `string` | DNS label used by the lease |
125
+| `address` | `string` | Ethereum address |
126
+
127
+`LeaseMetadata`:
128
+
129
+| Field | Type | Notes |
130
+|-------|------|-------|
131
+| `description` | `string` | optional |
132
+| `owner` | `string` | optional |
133
+| `thumbnail` | `string` | optional URL or data value |
134
+| `tags` | `string[]` | optional |
135
+| `hide` | `boolean` | hidden leases are omitted from the public state |
136
+
137
+`Lease`:
138
+
139
+| Field | Type |
140
+|-------|------|
141
+| `name` | `string` |
142
+| `expires_at`, `first_seen_at`, `last_seen_at` | `string` |
143
+| `hostname` | `string` |
144
+| `udp_enabled`, `tcp_enabled` | `boolean` |
145
+| `tcp_addr` | `string` |
146
+| `metadata` | `LeaseMetadata` |
147
+| `ready` | `number` |
148
+
149
+`AdminLease` extends `Lease` with:
150
+
151
+| Field | Type |
152
+|-------|------|
153
+| `identity_key`, `address` | `string` |
154
+| `bps` | `number` |
155
+| `client_ip`, `reported_ip` | `string` |
156
+| `is_approved`, `is_banned`, `is_denied`, `is_ip_banned` | `boolean` |
157
+
158
+`AdminPortSettings`:
159
+
160
+| Field | Type | Notes |
161
+|-------|------|-------|
162
+| `enabled` | `boolean` | enables the transport |
163
+| `max_leases` | `number` | `0` means unlimited |
164
+
165
+`AdminSettings`:
166
+
167
+| Field | Type |
168
+|-------|------|
169
+| `approval_mode` | `"auto"` or `"manual"` |
170
+| `landing_page_enabled` | `boolean` |
171
+| `udp` | `AdminPortSettings` |
172
+| `tcp_port` | `AdminPortSettings` |
173
+
174
+`AdminLeasePolicy`:
175
+
176
+| Field | Type | Notes |
177
+|-------|------|-------|
178
+| `identity_key` | `string` | normalized `name:address` key |
179
+| `is_banned` | `boolean` | optional |
180
+| `is_approved` | `boolean` | optional |
181
+| `is_denied` | `boolean` | optional; `true` also revokes approval |
182
+| `bps` | `number` | optional; `0` removes the limit |
183
+
184
+`AdminIPPolicy`:
185
+
186
+| Field | Type |
187
+|-------|------|
188
+| `ip` | `string` |
189
+| `is_banned` | `boolean` |
190
+
191
+## Common Errors
192
+
193
+| Code | Meaning |
194
+|------|---------|
195
+| `invalid_json` | request body is not valid JSON |
196
+| `invalid_request` | request shape or value is invalid |
197
+| `method_not_allowed` | endpoint does not accept the method |
198
+| `unauthorized` | credential is missing, expired, or invalid |
199
+| `feature_unavailable` | feature is disabled or not configured |
200
+| `rate_limited` | request was throttled |
201
+| `hostname_conflict` | lease hostname is already registered |
202
+| `lease_not_found` | lease token or identity has no active lease |
203
+| `lease_rejected` | lease is not currently allowed to route |
204
+| `ip_banned` | source or reported IP is banned |
205
+| `invalid_address` | address path or body value is invalid |
206
+| `invalid_ip` | IP path value is invalid |
207
+| `invalid_mode` | approval mode is not `auto` or `manual` |
208
+| `http11_only` | endpoint requires HTTP/1.1 |
209
+| `hijack_unsupported`, `hijack_failed` | reverse stream setup failed |
210
+| `udp_disabled`, `udp_capacity_exceeded`, `udp_port_exhausted` | UDP lease cannot be allocated |
211
+| `tcp_port_disabled`, `tcp_port_capacity_exceeded`, `tcp_port_exhausted` | TCP port lease cannot be allocated |
212
+| `transport_mismatch` | request does not match the active lease transport |
213
+| `internal` | unexpected server failure |
docs/src/routes/api-reference/admin/+page.md
+101
-526
@@ -1,576 +1,151 @@
1
---
2
title: Admin API
3
-description: Portal relay admin endpoints for managing leases, settings, and access control.
3
+description: Portal relay admin endpoints for auth, state, settings, and access control.
4
---
5
6
-<script>
7
-import Mermaid from '$lib/components/Mermaid.svelte'
8
-
9
-const adminWorkflowDiagram = `sequenceDiagram
10
- participant Admin
11
- participant Relay as Portal Relay
12
- Admin->>Relay: POST /admin/auth/challenge
13
- Relay->>Admin: SIWE message
14
- Admin->>Relay: POST /admin/auth/login
15
- Note right of Admin: wallet signature in body
16
- Relay->>Admin: access_token
17
- Admin->>Relay: GET /admin/snapshot
18
- Note right of Admin: Authorization: Bearer ...
19
- Relay->>Admin: Full relay state
20
- Note left of Relay: leases, settings, bans
21
- alt Manage Leases
22
- Admin->>Relay: POST /admin/leases/.../ban
23
- Relay->>Admin: OK
24
- end
25
- alt Configure Settings
26
- Admin->>Relay: POST /admin/settings/approval-mode
27
- Relay->>Admin: Updated settings
28
- end
29
- Admin->>Relay: POST /admin/logout
30
- Relay->>Admin: Token invalidated`
31
-</script>
32
-
6
# Admin API
7
35
-These endpoints allow relay operators to manage leases, configure settings, and control access. All endpoints (except authentication) require a valid admin bearer token.
36
-
37
-## Admin Workflow
38
-
39
-<Mermaid code={adminWorkflowDiagram} />
40
-
41
----
42
-
43
-## Authentication
44
-
45
-### `POST /admin/auth/challenge`
46
-
47
-Request a SIWE message for wallet-based admin login.
48
-
49
-**Auth:** None
50
-
51
-**Request body:**
52
-
53
-| Field | Type | Required | Description |
54
-|-------|------|----------|-------------|
55
-| `address` | `string` | Yes | Ethereum wallet address |
56
-
57
-**Response fields:**
58
-
59
-| Field | Type | Description |
60
-|-------|------|-------------|
61
-| `challenge_id` | `string` | Challenge identifier |
62
-| `siwe_message` | `string` | Message to sign with the wallet |
63
-| `expires_at` | `string` | ISO 8601 challenge expiration |
64
-
65
-**Example:**
66
-
67
-```bash
68
-curl -X POST https://relay.example.com/admin/auth/challenge \
69
- -H "Content-Type: application/json" \
70
- -d '{ "address": "0x1234567890abcdef1234567890abcdef12345678" }'
71
-```
72
-
73
----
74
-
75
-### `POST /admin/auth/login`
76
-
77
-Complete wallet login with the signed SIWE message. On success, returns an access token used for subsequent admin requests.
78
-
79
-**Auth:** None
8
+Admin endpoints are the operator control surface for a relay. They all return
9
+the standard JSON envelope described in [API Reference](/api-reference), except
10
+for internal operational endpoints that are not part of the stable API.
11
81
-**Request body:**
12
+`/admin` is reserved for the frontend route. The relay API begins under the
13
+specific paths listed below.
14
83
-| Field | Type | Required | Description |
84
-|-------|------|----------|-------------|
85
-| `challenge_id` | `string` | Yes | Challenge identifier returned by `/admin/auth/challenge` |
86
-| `siwe_message` | `string` | Yes | Exact SIWE message returned by `/admin/auth/challenge` |
87
-| `siwe_signature` | `string` | Yes | Wallet signature for the SIWE message |
15
+## Auth Flow
16
89
-**Response fields:**
17
+1. `POST /admin/auth/challenge` with the wallet address.
18
+2. Sign the returned `siwe_message`.
19
+3. `POST /admin/auth/login` with the challenge id, message, and signature.
20
+4. Send the returned `access_token` as `Authorization: Bearer <token>`.
21
+5. `POST /admin/auth/logout` to invalidate the current token.
22
91
-| Field | Type | Description |
92
-|-------|------|-------------|
93
-| `access_token` | `string` | Bearer token for admin API requests |
94
-| `wallet_address` | `string` | Authenticated wallet address |
23
+Admin bearer tokens are separate from SDK lease tokens.
24
96
-**Error codes:**
25
+## Endpoints
26
98
-| Code | Status | Description |
99
-|------|--------|-------------|
100
-| `unauthorized` | 401/403 | Signature, challenge, or wallet address is invalid |
27
+| Method | Path | Auth | Body | Data |
28
+|--------|------|------|------|------|
29
+| `POST` | `/admin/auth/challenge` | None | `WalletAuthChallengeRequest` | `WalletAuthChallengeResponse` |
30
+| `POST` | `/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
31
+| `GET` | `/admin/auth/status` | Optional bearer | none | `WalletAuthStatusResponse` |
32
+| `POST` | `/admin/auth/logout` | Bearer | none | `{}` |
33
+| `GET` | `/admin/state` | Bearer | none | `AdminStateResponse` |
34
+| `POST` | `/admin/settings` | Bearer | `AdminSettings` | `AdminSettings` |
35
+| `POST` | `/admin/lease-policy` | Bearer | `AdminLeasePolicy` | `{}` |
36
+| `POST` | `/admin/ip-policy` | Bearer | `AdminIPPolicy` | `{}` |
37
102
-**Example:**
103
-
104
-```bash
105
-curl -X POST https://relay.example.com/admin/auth/login \
106
- -H "Content-Type: application/json" \
107
- -d '{ "challenge_id": "...", "siwe_message": "...", "siwe_signature": "0x..." }'
108
-```
109
-
110
-**Response:**
111
-
112
-```json
113
-{
114
- "ok": true,
115
- "data": {
116
- "access_token": "...",
117
- "wallet_address": "0x1234567890abcdef1234567890abcdef12345678"
118
- }
119
-}
120
-```
121
-
122
----
123
-
124
-### `POST /admin/logout`
125
-
126
-Invalidate the current admin bearer token.
127
-
128
-**Auth:** Bearer Token
129
-
130
-**Request body:** None
131
-
132
-**Response:** Empty data object.
133
-
134
-**Example:**
135
-
136
-```bash
137
-curl -X POST https://relay.example.com/admin/logout \
138
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
139
-```
140
-
141
----
38
+## Auth Payloads
39
143
-### `GET /admin/auth/status`
40
+`WalletAuthChallengeRequest`:
41
145
-Check the current wallet login state. Can be called without a token.
42
+| Field | Type | Required |
43
+|-------|------|----------|
44
+| `address` | `string` | yes |
45
147
-**Auth:** None (returns status regardless)
46
+`WalletAuthChallengeResponse`:
47
149
-**Response fields:**
48
+| Field | Type |
49
+|-------|------|
50
+| `challenge_id` | `string` |
51
+| `expires_at` | `string` |
52
+| `siwe_message` | `string` |
53
151
-| Field | Type | Description |
152
-|-------|------|-------------|
153
-| `authenticated` | `bool` | `true` if the request has a valid bearer token |
154
-| `wallet_address` | `string` | Authenticated wallet address, when logged in |
54
+`WalletAuthLoginRequest`:
55
156
-**Example:**
56
+| Field | Type | Required |
57
+|-------|------|----------|
58
+| `challenge_id` | `string` | yes |
59
+| `siwe_message` | `string` | yes |
60
+| `siwe_signature` | `string` | yes |
61
158
-```bash
159
-curl https://relay.example.com/admin/auth/status \
160
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
161
-```
62
+`WalletAuthLoginResponse`:
63
163
-**Response:**
64
+| Field | Type |
65
+|-------|------|
66
+| `access_token` | `string` |
67
+| `wallet_address` | `string` |
68
165
-```json
166
-{
167
- "ok": true,
168
- "data": {
169
- "authenticated": true,
170
- "wallet_address": "0x1234567890abcdef1234567890abcdef12345678"
171
- }
172
-}
173
-```
69
+`WalletAuthStatusResponse`:
70
175
----
71
+| Field | Type | Notes |
72
+|-------|------|-------|
73
+| `authenticated` | `boolean` | true only when a valid bearer token was sent |
74
+| `wallet_address` | `string` | omitted when unauthenticated |
75
76
## State
77
179
-### `GET /admin/snapshot`
180
-
181
-Get a full snapshot of the relay's current state including all active leases, approval mode, and transport settings.
182
-
183
-**Auth:** Bearer Token
184
-
185
-**Response fields:**
186
-
187
-| Field | Type | Description |
188
-|-------|------|-------------|
189
-| `approval_mode` | `string` | Current mode: `"auto"` or `"manual"` |
190
-| `landing_page_enabled` | `bool` | Whether the landing page is active |
191
-| `leases` | `AdminLease[]` | All active leases (see below) |
192
-| `udp` | `object` | UDP settings |
193
-| `udp.enabled` | `bool` | Whether UDP transport is enabled |
194
-| `udp.max_leases` | `int` | Maximum concurrent UDP leases (0 = unlimited) |
195
-| `tcp_port` | `object` | TCP port settings |
196
-| `tcp_port.enabled` | `bool` | Whether TCP port transport is enabled |
197
-| `tcp_port.max_leases` | `int` | Maximum concurrent TCP port leases (0 = unlimited) |
198
-
199
-**AdminLease fields:**
200
-
201
-| Field | Type | Description |
202
-|-------|------|-------------|
203
-| `identity_key` | `string` | Unique identity key |
204
-| `address` | `string` | Ethereum address |
205
-| `name` | `string` | Lease name |
206
-| `hostname` | `string` | Assigned hostname |
207
-| `expires_at` | `string` | ISO 8601 lease expiration |
208
-| `first_seen_at` | `string` | ISO 8601 first registration time |
209
-| `last_seen_at` | `string` | ISO 8601 last activity time |
210
-| `client_ip` | `string` | Client IP address |
211
-| `reported_ip` | `string` | Client-reported public IP |
212
-| `udp_addr` | `string` | UDP transport address |
213
-| `tcp_addr` | `string` | TCP transport address |
214
-| `metadata` | `object` | Lease metadata (description, tags, thumbnail) |
215
-| `ready` | `int` | Number of ready reverse connections |
216
-| `bps` | `int` | Bandwidth limit in bytes per second (0 = unlimited) |
217
-
218
-**Example:**
219
-
220
-```bash
221
-curl https://relay.example.com/admin/snapshot \
222
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
223
-```
78
+`GET /admin/state` returns the full operator view:
79
225
-**Response:**
80
+| Field | Type |
81
+|-------|------|
82
+| `settings` | `AdminSettings` |
83
+| `leases` | `AdminLease[]` |
84
227
-```json
228
-{
229
- "ok": true,
230
- "data": {
231
- "approval_mode": "auto",
232
- "landing_page_enabled": true,
233
- "leases": [
234
- {
235
- "identity_key": "my-app:0x1234...5678",
236
- "address": "0x1234...5678",
237
- "name": "my-app",
238
- "hostname": "my-app.relay.example.com",
239
- "expires_at": "2025-01-01T00:01:00Z",
240
- "first_seen_at": "2025-01-01T00:00:00Z",
241
- "last_seen_at": "2025-01-01T00:00:30Z",
242
- "client_ip": "203.0.113.1",
243
- "ready": 2,
244
- "bps": 0
245
- }
246
- ],
247
- "udp": {
248
- "enabled": true,
249
- "max_leases": 10
250
- },
251
- "tcp_port": {
252
- "enabled": false,
253
- "max_leases": 0
254
- }
255
- }
256
-}
257
-```
85
+`AdminLease` uses the shared `Lease` fields from [API Reference](/api-reference#shared-types)
86
+and adds:
87
259
----
88
+| Field | Type | Notes |
89
+|-------|------|-------|
90
+| `identity_key` | `string` | normalized `name:address` key |
91
+| `address` | `string` | normalized Ethereum address |
92
+| `bps` | `number` | bytes per second limit, `0` means unlimited |
93
+| `client_ip` | `string` | relay-observed client IP |
94
+| `reported_ip` | `string` | client-reported public IP, when present |
95
+| `is_approved` | `boolean` | effective approval result |
96
+| `is_banned` | `boolean` | identity is banned |
97
+| `is_denied` | `boolean` | identity is denied |
98
+| `is_ip_banned` | `boolean` | observed client IP is banned |
99
100
## Settings
101
263
-### `POST /admin/settings/landing-page`
264
-
265
-Enable or disable the relay landing page.
266
-
267
-**Auth:** Bearer Token
268
-
269
-**Request body:**
270
-
271
-| Field | Type | Required | Description |
272
-|-------|------|----------|-------------|
273
-| `enabled` | `bool` | Yes | `true` to enable, `false` to disable |
274
-
275
-**Response fields:**
276
-
277
-| Field | Type | Description |
278
-|-------|------|-------------|
279
-| `enabled` | `bool` | Current landing page state |
280
-
281
-**Example:**
282
-
283
-```bash
284
-curl -X POST https://relay.example.com/admin/settings/landing-page \
285
- -H "Content-Type: application/json" \
286
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
287
- -d '{ "enabled": true }'
288
-```
289
-
290
----
291
-
292
-### `POST /admin/settings/udp`
293
-
294
-Configure UDP (QUIC) transport settings.
295
-
296
-**Auth:** Bearer Token
297
-
298
-**Request body:**
299
-
300
-| Field | Type | Required | Description |
301
-|-------|------|----------|-------------|
302
-| `enabled` | `bool` | Yes | Enable or disable UDP transport |
303
-| `max_leases` | `int` | Yes | Maximum concurrent UDP leases (0 = unlimited) |
304
-
305
-**Response fields:**
306
-
307
-| Field | Type | Description |
308
-|-------|------|-------------|
309
-| `enabled` | `bool` | Current UDP enabled state |
310
-| `max_leases` | `int` | Current max leases value |
311
-
312
-**Error codes:**
313
-
314
-| Code | Status | Description |
315
-|------|--------|-------------|
316
-| `invalid_request` | 400 | `max_leases` must be non-negative |
317
-
318
-**Example:**
319
-
320
-```bash
321
-curl -X POST https://relay.example.com/admin/settings/udp \
322
- -H "Content-Type: application/json" \
323
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
324
- -d '{ "enabled": true, "max_leases": 10 }'
325
-```
326
-
327
----
328
-
329
-### `POST /admin/settings/tcp-port`
330
-
331
-Configure dedicated TCP port transport settings.
332
-
333
-**Auth:** Bearer Token
334
-
335
-**Request body:**
336
-
337
-| Field | Type | Required | Description |
338
-|-------|------|----------|-------------|
339
-| `enabled` | `bool` | Yes | Enable or disable TCP port transport |
340
-| `max_leases` | `int` | Yes | Maximum concurrent TCP port leases (0 = unlimited) |
341
-
342
-**Response fields:**
343
-
344
-| Field | Type | Description |
345
-|-------|------|-------------|
346
-| `enabled` | `bool` | Current TCP port enabled state |
347
-| `max_leases` | `int` | Current max leases value |
348
-
349
-**Error codes:**
350
-
351
-| Code | Status | Description |
352
-|------|--------|-------------|
353
-| `invalid_request` | 400 | `max_leases` must be non-negative |
354
-
355
-**Example:**
356
-
357
-```bash
358
-curl -X POST https://relay.example.com/admin/settings/tcp-port \
359
- -H "Content-Type: application/json" \
360
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
361
- -d '{ "enabled": true, "max_leases": 5 }'
362
-```
363
-
364
----
365
-
366
-### `POST /admin/settings/approval-mode`
367
-
368
-Set the lease approval mode. In `auto` mode, all leases are automatically approved. In `manual` mode, leases must be explicitly approved before they can route traffic.
369
-
370
-**Auth:** Bearer Token
371
-
372
-**Request body:**
373
-
374
-| Field | Type | Required | Description |
375
-|-------|------|----------|-------------|
376
-| `mode` | `string` | Yes | `"auto"` or `"manual"` |
377
-
378
-**Response fields:**
379
-
380
-| Field | Type | Description |
381
-|-------|------|-------------|
382
-| `approval_mode` | `string` | Current approval mode |
383
-
384
-**Error codes:**
385
-
386
-| Code | Status | Description |
387
-|------|--------|-------------|
388
-| `invalid_mode` | 400 | Mode must be `"auto"` or `"manual"` |
389
-
390
-**Example:**
391
-
392
-```bash
393
-curl -X POST https://relay.example.com/admin/settings/approval-mode \
394
- -H "Content-Type: application/json" \
395
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
396
- -d '{ "mode": "manual" }'
397
-```
398
-
399
-**Response:**
102
+Settings are written as one object through `POST /admin/settings` and returned
103
+in the same shape:
104
105
```json
106
{
403
- "ok": true,
404
- "data": {
405
- "approval_mode": "manual"
107
+ "approval_mode": "manual",
108
+ "landing_page_enabled": true,
109
+ "udp": {
110
+ "enabled": true,
111
+ "max_leases": 10
112
+ },
113
+ "tcp_port": {
114
+ "enabled": false,
115
+ "max_leases": 0
116
}
117
}
118
```
119
410
----
411
-
412
-## Lease Management
413
-
414
-Lease management endpoints use base64url-encoded identity components in the URL path:
415
-
416
-```
417
-/admin/leases/{name_b64}/{addr_b64}/{action}
418
-```
419
-
420
-Where `{name_b64}` is the base64url-encoded lease name and `{addr_b64}` is the base64url-encoded Ethereum address.
421
-
422
-All lease management endpoints return an empty data object on success. All changes are persisted to the admin state file.
423
-
424
-### `POST|DELETE /admin/leases/{name}/{addr}/ban`
425
-
426
-Ban or unban a lease identity. Banned identities cannot register new leases or renew existing ones.
427
-
428
-**Auth:** Bearer Token
429
-
430
-| Method | Description |
431
-|--------|-------------|
432
-| `POST` | Ban the identity |
433
-| `DELETE` | Remove the ban |
434
-
435
-**Example:**
436
-
437
-```bash
438
-# Ban an identity
439
-curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/ban \
440
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
441
-
442
-# Unban an identity
443
-curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/ban \
444
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
445
-```
446
-
447
----
120
+`max_leases` must be non-negative. `0` means unlimited.
121
449
-### `POST|DELETE /admin/leases/{name}/{addr}/bps`
122
+Supported modes:
123
451
-Set or remove a bandwidth limit (bytes per second) for a specific lease identity.
124
+| Mode | Behavior |
125
+|------|----------|
126
+| `auto` | active leases can route unless banned or denied |
127
+| `manual` | active leases route only after approval |
128
453
-**Auth:** Bearer Token
129
+## Lease Policy
130
455
-| Method | Description |
456
-|--------|-------------|
457
-| `POST` | Set bandwidth limit |
458
-| `DELETE` | Remove bandwidth limit |
131
+`POST /admin/lease-policy` accepts a partial policy update for one identity:
132
460
-**Request body (POST only):**
133
+| Field | Type | Effect |
134
+|-------|------|--------|
135
+| `identity_key` | `string` | normalized `name:address` key |
136
+| `is_banned` | `boolean` | ban or unban identity registration and renewal |
137
+| `is_approved` | `boolean` | approve or revoke explicit approval |
138
+| `is_denied` | `boolean` | deny or remove denial; `true` also revokes approval |
139
+| `bps` | `number` | set bytes-per-second limit; `0` removes the limit |
140
462
-| Field | Type | Required | Description |
463
-|-------|------|----------|-------------|
464
-| `bps` | `int` | Yes | Bandwidth limit in bytes per second (must be > 0) |
141
+Lease policy updates persist to the admin state file and return `{}` on success.
142
466
-**Error codes:**
143
+## IP Policy
144
468
-| Code | Status | Description |
469
-|------|--------|-------------|
470
-| `invalid_request` | 400 | `bps` must be greater than zero |
471
-
472
-**Example:**
473
-
474
-```bash
475
-# Set 1 MB/s bandwidth limit
476
-curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/bps \
477
- -H "Content-Type: application/json" \
478
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
479
- -d '{ "bps": 1048576 }'
480
-
481
-# Remove bandwidth limit
482
-curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/bps \
483
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
484
-```
485
-
486
----
487
-
488
-### `POST|DELETE /admin/leases/{name}/{addr}/approve`
489
-
490
-Approve or revoke approval for a lease identity. Only relevant when approval mode is `manual`.
491
-
492
-**Auth:** Bearer Token
493
-
494
-| Method | Description |
495
-|--------|-------------|
496
-| `POST` | Approve the identity (also removes any deny) |
497
-| `DELETE` | Revoke approval |
498
-
499
-**Example:**
500
-
501
-```bash
502
-# Approve an identity
503
-curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/approve \
504
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
505
-
506
-# Revoke approval
507
-curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/approve \
508
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
509
-```
510
-
511
----
512
-
513
-### `POST|DELETE /admin/leases/{name}/{addr}/deny`
514
-
515
-Deny or remove denial for a lease identity. Denied identities are blocked from routing even in `auto` mode.
516
-
517
-**Auth:** Bearer Token
518
-
519
-| Method | Description |
520
-|--------|-------------|
521
-| `POST` | Deny the identity |
522
-| `DELETE` | Remove the denial |
523
-
524
-**Example:**
525
-
526
-```bash
527
-# Deny an identity
528
-curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/deny \
529
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
530
-
531
-# Remove denial
532
-curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/deny \
533
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
534
-```
535
-
536
----
537
-
538
-## IP Management
539
-
540
-### `POST|DELETE /admin/ips/{ip}/ban`
541
-
542
-Ban or unban an IP address. Banned IPs are rejected at the SDK registration and renewal endpoints.
543
-
544
-**Auth:** Bearer Token
545
-
546
-| Method | Description |
547
-|--------|-------------|
548
-| `POST` | Ban the IP address |
549
-| `DELETE` | Unban the IP address |
550
-
551
-**Error codes:**
552
-
553
-| Code | Status | Description |
554
-|------|--------|-------------|
555
-| `invalid_ip` | 400 | Invalid IP address format |
556
-
557
-**Example:**
558
-
559
-```bash
560
-# Ban an IP
561
-curl -X POST https://relay.example.com/admin/ips/203.0.113.50/ban \
562
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
563
-
564
-# Unban an IP
565
-curl -X DELETE https://relay.example.com/admin/ips/203.0.113.50/ban \
566
- -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
567
-```
568
-
569
-**Response:**
145
+`POST /admin/ip-policy` accepts:
146
147
```json
572
-{
573
- "ok": true,
574
- "data": {}
575
-}
148
+{ "ip": "203.0.113.10", "is_banned": true }
149
```
150
+
151
+The IP must parse as a valid IPv4 or IPv6 address.
docs/src/routes/api-reference/sdk/+page.md
+111
-373
@@ -1,418 +1,156 @@
1
---
2
title: SDK API
3
-description: Portal SDK endpoints for tunnel registration and connection.
3
+description: Portal SDK endpoints for relay discovery, lease lifecycle, and reverse tunnel streaming.
4
---
5
6
-<script>
7
-import Mermaid from '$lib/components/Mermaid.svelte'
8
-
9
-const registrationDiagram = `sequenceDiagram
10
- participant Client as SDK Client
11
- participant Relay as Portal Relay
12
-
13
- Client->>Relay: POST /sdk/register/challenge
14
- Note right of Client: Send identity + metadata
15
- Relay->>Client: challenge_id + siwe_message
16
-
17
- Client->>Client: Sign SIWE message with private key
18
-
19
- Client->>Relay: POST /sdk/register
20
- Note right of Client: challenge_id + signed message
21
- Relay->>Client: access_token + lease info
22
- Note left of Relay: hostname, udp_addr, tcp_addr`
23
-
24
-const reverseConnectDiagram = `sequenceDiagram
25
- participant SDK as SDK Client
26
- participant Relay as Portal Relay
27
- participant Browser as End User
28
-
29
- SDK->>Relay: GET /sdk/connect
30
- Note right of SDK: X-Portal-Access-Token header
31
- Relay->>SDK: HTTP/1.1 200 OK (hijacked)
32
- Note over SDK,Relay: Connection upgraded to raw TCP
33
-
34
- Browser->>Relay: TLS ClientHello (SNI: app.relay.example.com)
35
- Relay->>Relay: Match SNI to lease
36
- Relay->>SDK: 0x02 marker, then encrypted tenant bytes
37
- Note over SDK: Tenant TLS terminates locally via keyless signer
38
- SDK->>Relay: Response traffic
39
- Relay->>Browser: Forward response
40
- Note over SDK,Browser: Relay bridges ciphertext only`
41
-</script>
42
-
6
# SDK API
7
45
-These endpoints are used by the Portal SDK to register tunnels, manage leases, and establish reverse connections to the relay server.
8
+SDK endpoints are the stable lease protocol between a Portal tunnel process and
9
+a relay. Normal JSON endpoints use the shared envelope from
10
+[API Reference](/api-reference). `GET /sdk/connect` is the only SDK endpoint
11
+that switches to a raw stream after a successful HTTP/1.1 response.
12
47
-## Registration Flow
13
+## Flow
14
49
-<Mermaid code={registrationDiagram} />
15
+1. `GET /sdk/domain` checks relay compatibility and optional ENS/x402 support.
16
+2. `POST /sdk/register/challenge` creates a SIWE challenge for the requested identity.
17
+3. The SDK signs the returned `siwe_message`.
18
+4. `POST /sdk/register` exchanges the signature for a lease `access_token`.
19
+5. The SDK keeps the lease alive with `/sdk/renew` and opens reverse streams with `/sdk/connect`.
20
+6. `POST /sdk/unregister` removes the lease.
21
51
-## Reverse Connect Flow
22
+## Endpoints
23
53
-<Mermaid code={reverseConnectDiagram} />
24
+| Method | Path | Auth | Body | Data |
25
+|--------|------|------|------|------|
26
+| `GET` | `/sdk/domain` | None | none | `DomainResponse` |
27
+| `POST` | `/sdk/register/challenge` | None | `RegisterChallengeRequest` | `RegisterChallengeResponse` |
28
+| `POST` | `/sdk/register` | SIWE signature body | `RegisterRequest` | `RegisterResponse` |
29
+| `POST` | `/sdk/renew` | lease token body | `RenewRequest` | `RenewResponse` |
30
+| `POST` | `/sdk/unregister` | lease token body | `UnregisterRequest` | `{}` |
31
+| `GET` | `/sdk/connect` | lease token header | none | hijacked stream |
32
55
----
33
+## Domain
34
57
-### `GET /sdk/domain`
58
-
59
-Get relay domain and protocol version information. Used by the SDK to verify relay compatibility before registration.
60
-
61
-**Auth:** None
62
-
63
-**Response fields:**
64
-
65
-| Field | Type | Description |
66
-|-------|------|-------------|
67
-| `protocol_version` | `string` | Protocol version (must match SDK version) |
68
-| `release_version` | `string` | Relay software release version |
69
-| `ens` | `object` | ENS gasless status for this relay |
70
-| `x402` | `object` | Relay-local x402 facilitator status and public payment metadata |
71
-
72
-`ens` fields:
73
-
74
-| Field | Type | Description |
75
-|-------|------|-------------|
76
-| `enabled` | `bool` | ENS gasless automation is enabled for a non-local relay domain |
77
-| `verified` | `bool` | DNSSEC is active according to Portal and the last ENS sync succeeded |
78
-| `provider` | `string` | DNS provider used for automation |
79
-| `address` | `string` | Base-domain ENS address, usually the relay identity address |
80
-| `dnssec_state` | `string` | Provider DNSSEC state |
81
-| `ds_record` | `string` | DS record that may need registrar publication |
82
-| `message` | `string` | Provider-specific DNSSEC guidance |
83
-| `last_error` | `string` | Last ENS/DNS sync error |
84
-
85
-`x402` fields:
86
-
87
-| Field | Type | Description |
88
-|-------|------|-------------|
89
-| `enabled` | `bool` | Relay-local facilitator is exposed under `/x402` |
90
-| `url` | `string` | Facilitator base URL to use in tunnel x402 config |
91
-| `network` | `string` | CAIP-2 payment network served by the facilitator |
92
-| `network_name` | `string` | Human-readable network name when known |
93
-| `supported_url` | `string` | URL for the facilitator `/supported` endpoint |
94
-
95
-**Example:**
96
-
97
-```bash
98
-curl https://relay.example.com/sdk/domain
99
-```
100
-
101
-**Response:**
102
-
103
-```json
104
-{
105
- "ok": true,
106
- "data": {
107
- "protocol_version": "5",
108
- "release_version": "v2.1.5",
109
- "ens": {
110
- "enabled": true,
111
- "verified": true,
112
- "provider": "cloudflare",
113
- "address": "0x1234567890abcdef1234567890abcdef12345678",
114
- "dnssec_state": "active"
115
- },
116
- "x402": {
117
- "enabled": true,
118
- "url": "https://relay.example.com/x402",
119
- "network": "eip155:84532",
120
- "network_name": "Base Sepolia",
121
- "supported_url": "https://relay.example.com/x402/supported"
122
- }
123
- }
124
-}
125
-```
35
+`GET /sdk/domain` returns:
36
127
----
37
+| Field | Type | Notes |
38
+|-------|------|-------|
39
+| `protocol_version` | `string` | SDK tunnel protocol version |
40
+| `release_version` | `string` | relay software release |
41
+| `ens` | `ENSStatus` | gasless ENS status |
42
+| `x402` | `X402FacilitatorInfo` | relay-local payment facilitator info |
43
129
-### `POST /sdk/register/challenge`
130
-
131
-Request a SIWE (Sign-In with Ethereum) challenge message for tunnel registration. This is the first step of the two-phase registration flow.
132
-
133
-**Auth:** None
134
-
135
-**Request body:**
136
-
137
-| Field | Type | Required | Description |
138
-|-------|------|----------|-------------|
139
-| `identity` | `object` | Yes | Identity object (see below) |
140
-| `identity.name` | `string` | Yes | Lease name (used as subdomain) |
141
-| `identity.address` | `string` | Yes | Ethereum address (hex, `0x`-prefixed) |
142
-| `metadata` | `object` | No | Lease metadata |
143
-| `metadata.description` | `string` | No | Human-readable description |
144
-| `metadata.tags` | `string[]` | No | Tags for categorization |
145
-| `metadata.thumbnail` | `string` | No | Base64-encoded thumbnail image |
146
-| `ttl` | `int` | No | Lease TTL in seconds (default: server-configured) |
147
-| `udp_enabled` | `bool` | No | Request UDP (QUIC) transport |
148
-| `tcp_enabled` | `bool` | No | Request dedicated TCP port |
149
-
150
-**Response fields:**
151
-
152
-| Field | Type | Description |
153
-|-------|------|-------------|
154
-| `challenge_id` | `string` | Unique challenge identifier |
155
-| `expires_at` | `string` | ISO 8601 challenge expiration |
156
-| `siwe_message` | `string` | SIWE message to sign |
157
-
158
-**Error codes:**
159
-
160
-| Code | Status | Description |
161
-|------|--------|-------------|
162
-| `ip_banned` | 403 | Source IP is banned |
163
-| `feature_unavailable` | 503 | UDP or TCP transport not available |
164
-| `udp_disabled` | 403 | UDP transport disabled by admin policy |
165
-| `udp_capacity_exceeded` | 503 | UDP lease capacity reached |
166
-| `tcp_port_disabled` | 403 | TCP port transport disabled by admin policy |
167
-| `tcp_port_capacity_exceeded` | 503 | TCP port lease capacity reached |
168
-
169
-**Example:**
170
-
171
-```bash
172
-curl -X POST https://relay.example.com/sdk/register/challenge \
173
- -H "Content-Type: application/json" \
174
- -d '{
175
- "identity": {
176
- "name": "my-app",
177
- "address": "0x1234567890abcdef1234567890abcdef12345678"
178
- },
179
- "metadata": {
180
- "description": "My web application"
181
- },
182
- "ttl": 60
183
- }'
184
-```
185
-
186
-**Response:**
187
-
188
-```json
189
-{
190
- "ok": true,
191
- "data": {
192
- "challenge_id": "abc123",
193
- "expires_at": "2025-01-01T00:05:00Z",
194
- "siwe_message": "relay.example.com wants you to sign in..."
195
- }
196
-}
197
-```
44
+`ENSStatus`:
45
199
----
46
+| Field | Type |
47
+|-------|------|
48
+| `enabled`, `verified` | `boolean` |
49
+| `provider`, `address`, `dnssec_state`, `ds_record`, `message`, `last_error` | `string` |
50
201
-### `POST /sdk/register`
202
-
203
-Complete tunnel registration by submitting the signed SIWE challenge. Returns an access token and lease information including the assigned hostname.
204
-
205
-**Auth:** None (authenticated by SIWE signature)
206
-
207
-**Request body:**
208
-
209
-| Field | Type | Required | Description |
210
-|-------|------|----------|-------------|
211
-| `challenge_id` | `string` | Yes | Challenge ID from `/sdk/register/challenge` |
212
-| `siwe_message` | `string` | Yes | The SIWE message that was signed |
213
-| `siwe_signature` | `string` | Yes | Ethereum personal sign signature (hex) |
214
-| `reported_ip` | `string` | No | Client-reported public IP address |
215
-
216
-**Response fields:**
217
-
218
-| Field | Type | Description |
219
-|-------|------|-------------|
220
-| `identity` | `object` | Normalized identity (name + address) |
221
-| `expires_at` | `string` | ISO 8601 lease expiration |
222
-| `hostname` | `string` | Assigned tunnel hostname (e.g. `my-app.relay.example.com`) |
223
-| `access_token` | `string` | ES256K JWT access token for subsequent API calls |
224
-| `sni_port` | `int` | SNI port for QUIC transport (omitted if UDP not enabled) |
225
-| `udp_addr` | `string` | UDP address for QUIC transport (e.g. `relay.example.com:4443`) |
226
-| `udp_enabled` | `bool` | Whether UDP transport is active |
227
-| `tcp_addr` | `string` | Dedicated TCP address (e.g. `relay.example.com:10001`) |
228
-| `tcp_enabled` | `bool` | Whether TCP port transport is active |
229
-
230
-**Error codes:**
231
-
232
-| Code | Status | Description |
233
-|------|--------|-------------|
234
-| `unauthorized` | 403 | Invalid SIWE signature |
235
-| `hostname_conflict` | 409 | Hostname already registered |
236
-| `ip_banned` | 403 | Source IP is banned |
237
-| `udp_port_exhausted` | 503 | No UDP ports available |
238
-| `tcp_port_exhausted` | 503 | No TCP ports available |
239
-| `udp_disabled` | 403 | UDP transport disabled by admin policy |
240
-| `udp_capacity_exceeded` | 503 | UDP lease capacity reached |
241
-| `tcp_port_disabled` | 403 | TCP port transport disabled by admin policy |
242
-| `tcp_port_capacity_exceeded` | 503 | TCP port lease capacity reached |
243
-| `feature_unavailable` | 503 | Requested transport not available |
244
-
245
-**Example:**
246
-
247
-```bash
248
-curl -X POST https://relay.example.com/sdk/register \
249
- -H "Content-Type: application/json" \
250
- -d '{
251
- "challenge_id": "abc123",
252
- "siwe_message": "relay.example.com wants you to sign in...",
253
- "siwe_signature": "0xdeadbeef..."
254
- }'
255
-```
256
-
257
-**Response:**
258
-
259
-```json
260
-{
261
- "ok": true,
262
- "data": {
263
- "identity": {
264
- "name": "my-app",
265
- "address": "0x1234567890abcdef1234567890abcdef12345678"
266
- },
267
- "expires_at": "2025-01-01T00:01:00Z",
268
- "hostname": "my-app.relay.example.com",
269
- "access_token": "eyJhbGciOiJFUzI1Nksi...",
270
- "udp_enabled": false,
271
- "tcp_enabled": false
272
- }
273
-}
274
-```
51
+`X402FacilitatorInfo`:
52
276
----
277
-
278
-### `POST /sdk/renew`
53
+| Field | Type |
54
+|-------|------|
55
+| `enabled` | `boolean` |
56
+| `url`, `network`, `network_name`, `supported_url` | `string` |
57
280
-Renew an existing lease to extend its TTL. Returns a new access token that should replace the old one.
58
+## Register Challenge
59
282
-**Auth:** Access token (in request body)
60
+`RegisterChallengeRequest`:
61
284
-**Request body:**
62
+| Field | Type | Required | Notes |
63
+|-------|------|----------|-------|
64
+| `identity` | `Identity` | yes | `name` and `address` |
65
+| `metadata` | `LeaseMetadata` | no | public lease metadata |
66
+| `ttl` | `number` | no | requested TTL in seconds |
67
+| `udp_enabled` | `boolean` | no | request UDP transport |
68
+| `tcp_enabled` | `boolean` | no | request dedicated TCP port |
69
286
-| Field | Type | Required | Description |
287
-|-------|------|----------|-------------|
288
-| `access_token` | `string` | Yes | Current JWT access token |
289
-| `ttl` | `int` | No | Requested TTL in seconds |
290
-| `reported_ip` | `string` | No | Client-reported public IP address |
70
+Overlay-only fields are also accepted by relay-to-relay clients:
71
+`hop_token`, `route_hostname`, `hostname_hash`, and `ech_config_list`.
72
292
-**Response fields:**
73
+`RegisterChallengeResponse`:
74
294
-| Field | Type | Description |
295
-|-------|------|-------------|
296
-| `expires_at` | `string` | ISO 8601 new expiration time |
297
-| `access_token` | `string` | New JWT access token (replaces old one) |
75
+| Field | Type |
76
+|-------|------|
77
+| `challenge_id` | `string` |
78
+| `expires_at` | `string` |
79
+| `siwe_message` | `string` |
80
299
-**Error codes:**
81
+## Register
82
301
-| Code | Status | Description |
302
-|------|--------|-------------|
303
-| `unauthorized` | 403 | Invalid or expired access token |
304
-| `lease_not_found` | 404 | No active lease for this identity |
305
-| `ip_banned` | 403 | Source IP is banned |
83
+`RegisterRequest`:
84
307
-**Example:**
85
+| Field | Type | Required |
86
+|-------|------|----------|
87
+| `challenge_id` | `string` | yes |
88
+| `siwe_message` | `string` | yes |
89
+| `siwe_signature` | `string` | yes |
90
+| `reported_ip` | `string` | no |
91
309
-```bash
310
-curl -X POST https://relay.example.com/sdk/renew \
311
- -H "Content-Type: application/json" \
312
- -d '{
313
- "access_token": "eyJhbGciOiJFUzI1Nksi...",
314
- "ttl": 60
315
- }'
316
-```
92
+`RegisterResponse`:
93
318
-**Response:**
94
+| Field | Type | Notes |
95
+|-------|------|-------|
96
+| `identity` | `Identity` | normalized lease identity |
97
+| `expires_at` | `string` | lease expiry |
98
+| `access_token` | `string` | token for renew, unregister, connect, and signer access |
99
+| `sni_port` | `number` | omitted when not needed |
100
+| `udp_addr`, `tcp_addr` | `string` | omitted when transport is disabled |
101
+| `udp_enabled`, `tcp_enabled` | `boolean` | active transport flags |
102
320
-```json
321
-{
322
- "ok": true,
323
- "data": {
324
- "expires_at": "2025-01-01T00:02:00Z",
325
- "access_token": "eyJhbGciOiJFUzI1Nksi...new"
326
- }
327
-}
328
-```
103
+The response does not include a separate `hostname` field. The public hostname
104
+is derived from the registered identity and relay root domain.
105
330
----
106
+## Renew And Unregister
107
332
-### `POST /sdk/unregister`
108
+`RenewRequest`:
109
334
-Remove an active lease and release all associated resources (hostname, ports, connections).
335
-
336
-**Auth:** Access token (in request body)
337
-
338
-**Request body:**
339
-
340
-| Field | Type | Required | Description |
341
-|-------|------|----------|-------------|
342
-| `access_token` | `string` | Yes | Current JWT access token |
343
-
344
-**Response fields:**
345
-
346
-Empty data object on success.
347
-
348
-**Error codes:**
349
-
350
-| Code | Status | Description |
351
-|------|--------|-------------|
352
-| `unauthorized` | 403 | Invalid or expired access token |
353
-| `lease_not_found` | 404 | No active lease for this identity |
354
-
355
-**Example:**
356
-
357
-```bash
358
-curl -X POST https://relay.example.com/sdk/unregister \
359
- -H "Content-Type: application/json" \
360
- -d '{
361
- "access_token": "eyJhbGciOiJFUzI1Nksi..."
362
- }'
363
-```
364
-
365
-**Response:**
366
-
367
-```json
368
-{
369
- "ok": true,
370
- "data": {}
371
-}
372
-```
373
-
374
----
110
+| Field | Type | Required |
111
+|-------|------|----------|
112
+| `access_token` | `string` | yes |
113
+| `ttl` | `number` | no |
114
+| `reported_ip` | `string` | no |
115
+| `metadata` | `LeaseMetadata` | no |
116
376
-### `GET /sdk/connect`
117
+`RenewResponse`:
118
378
-Establish a reverse tunnel connection using HTTP/1.1 connection hijacking. The SDK opens this connection and the relay holds it in a ready queue. When a client connects to the tunnel hostname via TLS SNI, the relay claims a ready connection and bridges traffic bidirectionally.
119
+| Field | Type |
120
+|-------|------|
121
+| `expires_at` | `string` |
122
+| `access_token` | `string` |
123
380
-**Auth:** `X-Portal-Access-Token` header
124
+`UnregisterRequest`:
125
382
-**Requirements:**
383
-- Must use HTTP/1.1 (not HTTP/2)
384
-- Connection header must be `keep-alive`
126
+| Field | Type | Required |
127
+|-------|------|----------|
128
+| `access_token` | `string` | yes |
129
386
-**Request headers:**
130
+`/sdk/unregister` returns `{}` on success.
131
388
-| Header | Value | Description |
389
-|--------|-------|-------------|
390
-| `X-Portal-Access-Token` | `string` | JWT access token from registration |
391
-| `Connection` | `keep-alive` | Required for hijack |
132
+## Reverse Connect
133
393
-**Response:**
134
+`GET /sdk/connect` opens a reverse tunnel stream.
135
395
-On success, the server responds with `HTTP/1.1 200 OK` and hijacks the underlying TCP connection. No JSON body is returned — the connection is upgraded to a raw bidirectional TCP stream.
136
+Requirements:
137
397
-**Error codes:**
138
+| Requirement | Value |
139
+|-------------|-------|
140
+| HTTP version | HTTP/1.1 |
141
+| Header | `X-Portal-Access-Token: <lease access_token>` |
142
+| Connection | keep-alive capable connection that supports hijack |
143
399
-| Code | Status | Description |
400
-|------|--------|-------------|
401
-| `unauthorized` | 403 | Invalid or expired access token |
402
-| `lease_not_found` | 404 | No active lease for this identity |
403
-| `lease_rejected` | 403 | Lease not approved for routing |
404
-| `http11_only` | 505 | Must use HTTP/1.1 |
405
-| `hijack_unsupported` | 500 | Server does not support hijacking |
406
-| `hijack_failed` | 500 | Connection hijack failed |
407
-| `ip_banned` | 403 | Source IP is banned |
144
+On success, the relay writes `HTTP/1.1 200 OK` and hijacks the TCP connection.
145
+There is no JSON response body. Before the hijack, failures still use the
146
+standard JSON error envelope.
147
409
-**Example:**
148
+The SDK keeps several ready reverse streams open. When an end user connects to
149
+the lease hostname, the relay claims one ready stream and bridges encrypted
150
+tenant bytes between the browser side and the SDK side.
151
411
-```bash
412
-curl -X GET https://relay.example.com/sdk/connect \
413
- -H "X-Portal-Access-Token: eyJhbGciOiJFUzI1Nksi..." \
414
- -H "Connection: keep-alive" \
415
- --http1.1
416
-```
152
+## Relay Overlay
153
418
-> **Note:** In practice, the SDK does not use curl for this endpoint. It opens a raw TLS connection, writes the HTTP/1.1 request manually, reads the 200 response, and then uses the connection as a bidirectional TCP stream for tunneled traffic.
154
+`/sdk/hop` is reserved for relay-to-relay overlay routing. It accepts
155
+`POST` and `DELETE` with a signed `HopRoute` body and returns `HopRouteResponse`
156
+or `{}`. Normal SDK clients should not call it directly.
docs/src/routes/architecture/+page.md
+1
-1
@@ -346,7 +346,7 @@ Notes:
346
347
## Admin API Surface
348
349
-The relay server is intentionally API-only: one JSON snapshot endpoint, public snapshot/status endpoints, installer endpoints, and a small set of admin action/auth routes. Route paths are enumerated in `types/paths.go` and `cmd/relay-server`.
349
+The relay server is intentionally API-only: public/admin state endpoints, public status endpoints, installer endpoints, and a small set of admin action/auth routes. Route paths are enumerated in `types/paths.go` and `cmd/relay-server`.
350
351
## Keyless TLS Trust Model
352
docs/src/routes/configuration/+page.md
+1
-1
@@ -232,7 +232,7 @@ Agent fields:
232
233
The local agent dashboard and mutating control API calls use the bearer token in
234
the agent state directory. Wallet-authenticated agent requests are read-only and
235
-can only read `/v1/agent/status`.
235
+can only read `/agent/status`.
236
237
Tunnel fields mirror `portal expose` flags:
238
docs/src/routes/portal-agent/+page.md
+12
-12
@@ -212,18 +212,18 @@ Control endpoints:
212
213
| Method | Path | Auth | Purpose |
214
|--------|------|------|---------|
215
-| `GET` | `/v1/agent/status` | Bearer token or wallet session | Read agent and tunnel status |
216
-| `POST` | `/v1/agent/shutdown` | Bearer token | Ask the agent to stop |
217
-| `POST` | `/v1/agent/tunnels` | Bearer token | Add a simple target tunnel |
218
-| `PATCH` | `/v1/agent/tunnels/{id}` | Bearer token | Update metadata, max active relays, or x402 facilitator URL |
219
-| `DELETE` | `/v1/agent/tunnels/{id}` | Bearer token | Delete a tunnel |
220
-| `POST` | `/v1/agent/tunnels/{id}/relays` | Bearer token | Connect a relay |
221
-| `DELETE` | `/v1/agent/tunnels/{id}/relays` | Bearer token | Disconnect a relay |
222
-| `POST` | `/v1/agent/tunnels/{id}/multi-hop` | Bearer token | Apply a multi-hop route |
223
-| `DELETE` | `/v1/agent/tunnels/{id}/multi-hop` | Bearer token | Clear multi-hop routing |
224
-
225
-Wallet auth endpoints also exist under `/v1/agent/auth/*`. Wallet-authenticated
226
-requests are read-only and can only call `/v1/agent/status`; mutating operations
215
+| `GET` | `/agent/status` | Bearer token or wallet session | Read agent and tunnel status |
216
+| `POST` | `/agent/shutdown` | Bearer token | Ask the agent to stop |
217
+| `POST` | `/agent/tunnels` | Bearer token | Add a simple target tunnel |
218
+| `PATCH` | `/agent/tunnels/{id}` | Bearer token | Update metadata, max active relays, or x402 facilitator URL |
219
+| `DELETE` | `/agent/tunnels/{id}` | Bearer token | Delete a tunnel |
220
+| `POST` | `/agent/tunnels/{id}/relays` | Bearer token | Connect a relay |
221
+| `DELETE` | `/agent/tunnels/{id}/relays` | Bearer token | Disconnect a relay |
222
+| `POST` | `/agent/tunnels/{id}/multi-hop` | Bearer token | Apply a multi-hop route |
223
+| `DELETE` | `/agent/tunnels/{id}/multi-hop` | Bearer token | Clear multi-hop routing |
224
+
225
+Wallet auth endpoints also exist under `/agent/auth/*`. Wallet-authenticated
226
+requests are read-only and can only call `/agent/status`; mutating operations
227
use the local bearer token from the state directory.
228
229
## Agent Wallet Access
docs/src/routes/siwe-authentication/+page.md
+2
-2
@@ -44,8 +44,8 @@ The relay admin UI uses browser wallet login:
44
The relay identity address is allowed by default. Add more admin wallets with
45
`ADMIN_WALLETS`.
46
47
-The local agent also exposes `/v1/agent/auth/*` wallet endpoints. Agent wallet
48
-sessions can read `/v1/agent/status`; tunnel mutations still require the local
47
+The local agent also exposes `/agent/auth/*` wallet endpoints. Agent wallet
48
+sessions can read `/agent/status`; tunnel mutations still require the local
49
bearer token stored in the agent state directory.
50
51
## ENS
docs/src/routes/wallet-and-ens/+page.md
+6
-6
@@ -15,7 +15,7 @@ related, but they do not all mean "connect a browser wallet".
15
| Tunnel identity | Local `identity.json` secp256k1 private key, or BIP-39 mnemonic plus derivation path | Signs SIWE lease registration challenges |
16
| Relay identity | Relay `IDENTITY_PATH/identity.json` secp256k1 private key, or BIP-39 mnemonic plus derivation path | Signs relay descriptors, admin default wallet, lease access tokens, and ENS base-domain address |
17
| Relay admin wallet | Browser wallet address allowlist | Signs in to `/admin` and receives an admin bearer token |
18
-| Agent wallet | Optional browser wallet allowlist | Reads loopback agent status through `/v1/agent/status` |
18
+| Agent wallet | Optional browser wallet allowlist | Reads loopback agent status through `/agent/status` |
19
| ENS gasless DNS | DNSSEC plus `ENS1 ...` TXT records | Lets ENS-aware clients resolve the relay domain and lease hostnames to Portal identities |
20
21
## Tunnel SIWE Registration
@@ -93,10 +93,10 @@ Challenges expire after two minutes. Admin bearer tokens expire after 24 hours.
93
The local agent also exposes SIWE wallet auth endpoints:
94
95
```text
96
-/v1/agent/auth/challenge
97
-/v1/agent/auth/login
98
-/v1/agent/auth/logout
99
-/v1/agent/auth/status
96
+/agent/auth/challenge
97
+/agent/auth/login
98
+/agent/auth/logout
99
+/agent/auth/status
100
```
101
102
Agent wallet access is intentionally narrow:
@@ -104,7 +104,7 @@ Agent wallet access is intentionally narrow:
104
- `agent.allowed_wallets` restricts which wallet addresses can sign in.
105
- when `allowed_wallets` is empty, any wallet can sign in to the loopback auth
106
endpoint.
107
-- wallet-authenticated requests can read `/v1/agent/status`.
107
+- wallet-authenticated requests can read `/agent/status`.
108
- config mutation, tunnel changes, relay changes, shutdown, and multi-hop edits
109
still require the bearer token in `<state_dir>/agent-endpoint.json`.
110
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+24
-37
@@ -1,23 +1,23 @@
1
-# Portal relay + multiple services — docker compose deployment example.
1
+# Portal relay + multiple services - docker compose deployment example.
2
#
3
-# This example shows how to run Portal alongside other web services
4
-# behind a single nginx instance on the same host.
3
+# This example runs Portal alongside other web services behind one nginx.
4
#
5
# Architecture:
6
# nginx:443 (L4 stream, ssl_preread)
8
-# ├─ portal.example.com → host.docker.internal:4017 (TLS passthrough, admin/API)
9
-# ├─ *.portal.example.com → host.docker.internal:4443 (TLS passthrough, tenant SNI)
10
-# └─ everything else → nginx:8443 (L7, TLS termination)
11
-# ├─ app-a.example.com → app-a-frontend:3000 / app-a-api:8000
12
-# └─ app-b.example.com → app-b-frontend:3000 / app-b-api:8001
7
+# - portal.example.com -> nginx:8443 (L7 path split)
8
+# - API/control paths -> host.docker.internal:4017 (portal, HTTPS)
9
+# - everything else -> portal-frontend:8080 (HTTP)
10
+# - *.portal.example.com -> host.docker.internal:4443 (portal SNI passthrough)
11
+# - everything else -> nginx:8443 (L7 for other apps)
12
#
13
# Prerequisites:
14
# 1. Copy .env.example to .env and set all required values.
15
# 2. Place TLS certificates in ./certs/:
17
-# - Portal root-domain certs: either managed by portal (set ACME_DNS_PROVIDER)
18
-# or mounted manually into ./.portal-certs as fullchain.pem/privatekey.pem
19
-# - Other services: app_a_fullchain.pem, app_a_privkey.pem, etc.
20
-# 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
16
+# - portal_fullchain.pem, portal_privkey.pem for portal.example.com
17
+# - app_a_fullchain.pem, app_a_privkey.pem for app-a.example.com
18
+# - app_b_fullchain.pem, app_b_privkey.pem for app-b.example.com
19
+# Portal also needs API TLS material in ./.portal-certs unless ACME_DNS_PROVIDER is configured.
20
+# 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot):
21
# mkdir -p ./.portal-certs
22
# sudo chown 65532:65532 ./.portal-certs
23
# chmod 755 ./.portal-certs
@@ -25,11 +25,6 @@
25
# docker compose up -d
26
27
services:
28
-
29
- # ─── nginx ──────────────────────────────────────────────────────────────────
30
- # Single entry point for all traffic (ports 80/443).
31
- # Routes portal traffic via L4 SNI passthrough.
32
- # Terminates TLS and proxies for all other services via L7.
28
nginx:
29
image: nginx:stable-alpine
30
container_name: nginx
@@ -42,6 +37,8 @@ services:
37
- ./nginx.conf:/etc/nginx/nginx.conf:ro
38
- ./certs:/etc/certs:ro
39
depends_on:
40
+ - portal
41
+ - portal-frontend
42
- app-a-api
43
- app-a-frontend
44
restart: unless-stopped
@@ -50,19 +47,12 @@ services:
47
- app-a-network
48
- app-b-network
49
53
- # ─── headless-shell (optional thumbnail screenshot sidecar) ─────────────────
54
- # Uncomment to enable auto-generated thumbnails for tunnel apps.
50
+ # Optional: uncomment to enable auto-generated thumbnails for tunnel apps.
51
# See docs/src/routes/deployment/+page.md for details.
52
# headless-shell:
53
# image: chromedp/headless-shell:stable
54
# restart: unless-stopped
55
60
- # ─── portal relay ───────────────────────────────────────────────────────────
61
- # NAT-traversal relay server.
62
- # TCP (4017, 4443) is reached by nginx via host.docker.internal.
63
- # SNI_PORT is 4443 to avoid conflicting with nginx on 443.
64
- # If you enable UDP, expose SNI_PORT/udp and MIN_PORT-MAX_PORT/udp as well.
65
- # If you enable raw TCP transport, expose MIN_PORT-MAX_PORT/tcp as well.
56
portal:
57
image: ghcr.io/gosuda/portal:latest
58
container_name: portal
@@ -72,10 +62,10 @@ services:
62
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
63
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
64
- "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
75
- # Uncomment below when enabling UDP transport (host SNI_PORT/udp is free — nginx only uses 443/tcp):
65
+ # Uncomment when enabling UDP transport:
66
# - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
67
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
78
- # Uncomment below when enabling raw TCP port transport:
68
+ # Uncomment when enabling raw TCP port transport:
69
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
70
stop_grace_period: 30s
71
environment:
@@ -116,18 +106,20 @@ services:
106
# - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
107
restart: unless-stopped
108
119
- # ─── App A: backend ─────────────────────────────────────────────────────────
120
- # Replace with your actual backend service image and config.
109
+ portal-frontend:
110
+ image: ghcr.io/gosuda/portal-frontend:latest
111
+ container_name: portal-frontend
112
+ depends_on:
113
+ - portal
114
+ restart: unless-stopped
115
+
116
app-a-api:
117
image: your-registry/app-a-api:latest
118
container_name: app-a-api
124
- # environment:
125
- # - DATABASE_URL=...
119
restart: unless-stopped
120
networks:
121
- app-a-network
122
130
- # ─── App A: frontend ────────────────────────────────────────────────────────
123
app-a-frontend:
124
image: your-registry/app-a-frontend:latest
125
container_name: app-a-frontend
@@ -137,8 +129,6 @@ services:
129
depends_on:
130
- app-a-api
131
140
- # ─── App B: backend ─────────────────────────────────────────────────────────
141
- # Replace with your actual backend service image and config.
132
app-b-api:
133
image: your-registry/app-b-api:latest
134
container_name: app-b-api
@@ -146,15 +136,12 @@ services:
136
networks:
137
- app-b-network
138
149
- # ─── App B: frontend ────────────────────────────────────────────────────────
139
app-b-frontend:
140
image: your-registry/app-b-frontend:latest
141
container_name: app-b-frontend
142
restart: unless-stopped
143
networks:
144
- app-b-network
156
- depends_on:
157
- - app-b-api
145
146
networks:
147
app-a-network:
docs/static/examples/nginx-proxy-multi-service/nginx.conf
+96
-75
@@ -1,21 +1,17 @@
1
-# Portal relay + multiple services — nginx reverse proxy configuration example.
1
+# Portal relay + multiple services - nginx reverse proxy configuration example.
2
#
3
-# This example shows how to run Portal alongside other web services
4
-# behind a single nginx instance. nginx handles:
5
-# 1. L4 SNI routing for portal (base domain + subdomains)
6
-# 2. L7 TLS termination + reverse proxy for other services
7
-#
8
-# Replace the following domains with your own:
9
-# portal.example.com → Portal relay
10
-# app-a.example.com → Your first web application
11
-# app-b.example.com → Your second web application
3
+# This example runs Portal alongside other web services behind one nginx.
4
+# Replace these domains with your own:
5
+# portal.example.com - Portal relay frontend + API
6
+# app-a.example.com - first web application
7
+# app-b.example.com - second web application
8
#
9
# Traffic flow:
14
-# :80 → redirect to HTTPS
15
-# :443 → L4 SNI inspection (ssl_preread)
16
-# portal.example.com → host.docker.internal:4017 (admin/API, TLS passthrough)
17
-# *.portal.example.com → host.docker.internal:4443 (portal SNI, raw TCP passthrough)
18
-# everything else → 127.0.0.1:8443 (nginx L7, TLS termination)
10
+# :80 -> redirect to HTTPS
11
+# :443 -> L4 SNI inspection (ssl_preread)
12
+# portal.example.com -> nginx L7 (path split)
13
+# *.portal.example.com -> portal SNI listener (raw TCP passthrough)
14
+# everything else -> nginx L7 for other services
15
16
user nginx;
17
worker_processes auto;
@@ -28,40 +24,20 @@ events {
24
worker_connections 1024;
25
}
26
31
-# ─── L4: SNI-based TCP routing ────────────────────────────────────────────────
32
-# nginx peeks at the TLS ClientHello via ssl_preread to extract SNI without
33
-# terminating TLS.
34
-#
35
-# Portal base domain → portal admin/API listener (TLS passthrough)
36
-# Portal subdomains → portal SNI listener (raw TCP passthrough)
37
-# Everything else → nginx L7 for TLS termination (other services)
27
stream {
28
map $ssl_preread_server_name $backend {
40
- # Portal base domain: TLS passthrough directly to portal admin listener.
41
- portal.example.com portal_admin;
42
- # Portal tenant subdomains: raw TCP passthrough to portal SNI listener.
43
- ~\.portal\.example\.com$ portal_sni;
44
- # All other domains: forward to nginx L7 for TLS termination.
45
- default local_https;
46
- }
47
-
48
- upstream portal_admin {
49
- # Portal admin/API TLS listener (host networking).
50
- # nginx does NOT terminate TLS here.
51
- # Use host.docker.internal to reach portal on the host from bridge network.
52
- server host.docker.internal:4017;
29
+ portal.example.com local_https;
30
+ ~\.portal\.example\.com$ portal_sni;
31
+ default local_https;
32
}
33
34
upstream portal_sni {
56
- # Portal SNI listener (host networking, port 4443 to avoid conflict
57
- # with nginx on 443). Relay routes by SNI and bridges raw TCP
58
- # to the claimed reverse session. TLS is not terminated.
59
- # Use host.docker.internal to reach portal on the host from bridge network.
35
+ # Portal SNI listener. TLS is not terminated here.
36
server host.docker.internal:4443;
37
}
38
39
upstream local_https {
64
- # nginx's own L7 HTTPS listener for other services.
40
+ # nginx's own L7 HTTPS listener.
41
server 127.0.0.1:8443;
42
}
43
@@ -72,15 +48,10 @@ stream {
48
proxy_socket_keepalive on;
49
proxy_connect_timeout 10s;
50
proxy_buffer_size 16k;
75
- # Long timeout for portal reverse sessions (24 hours).
51
proxy_timeout 600s;
52
}
53
}
54
80
-# ─── L7: TLS termination + reverse proxy for other services ──────────────────
81
-# This section handles TLS termination for non-portal domains.
82
-# Portal traffic never reaches this http block — it's handled entirely
83
-# by the stream block above via TLS passthrough.
55
http {
56
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
57
'$status $body_bytes_sent "$http_referer" '
@@ -97,17 +68,11 @@ http {
68
include /etc/nginx/mime.types;
69
default_type application/octet-stream;
70
100
- include /etc/nginx/conf.d/*.conf;
101
-
102
- map $http_upgrade $connection_upgrade {
103
- default upgrade;
104
- '' close;
71
+ upstream portal_frontend {
72
+ server portal-frontend:8080;
73
+ keepalive 16;
74
}
75
107
- # ─── Upstreams ────────────────────────────────────────────────────────────
108
- # Define backend services here. Each upstream corresponds to a Docker
109
- # service on the same docker network.
110
-
76
upstream app_a_backend {
77
server app-a-api:8000;
78
keepalive 32;
@@ -128,7 +93,6 @@ http {
93
keepalive 32;
94
}
95
131
- # ─── HTTP → HTTPS redirect ────────────────────────────────────────────────
96
server {
97
listen 80;
98
listen [::]:80;
@@ -136,7 +100,69 @@ http {
100
return 301 https://$host$request_uri;
101
}
102
139
- # ─── app-a.example.com ────────────────────────────────────────────────────
103
+ server {
104
+ listen 8443 ssl;
105
+ listen [::]:8443 ssl;
106
+ server_name portal.example.com;
107
+ server_tokens off;
108
+
109
+ ssl_certificate /etc/certs/portal_fullchain.pem;
110
+ ssl_certificate_key /etc/certs/portal_privkey.pem;
111
+
112
+ gzip on;
113
+ gzip_vary on;
114
+ gzip_min_length 1024;
115
+ gzip_types text/plain text/css application/json application/javascript
116
+ text/xml application/xml;
117
+
118
+ location = /sdk/connect {
119
+ proxy_pass https://host.docker.internal:4017;
120
+ proxy_ssl_verify off;
121
+ proxy_ssl_server_name on;
122
+ proxy_ssl_name $host;
123
+ proxy_http_version 1.1;
124
+
125
+ proxy_set_header Host $host;
126
+ proxy_set_header X-Real-IP $remote_addr;
127
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
128
+ proxy_set_header X-Forwarded-Proto https;
129
+ proxy_set_header Upgrade $http_upgrade;
130
+ proxy_set_header Connection $http_connection;
131
+
132
+ proxy_buffering off;
133
+ proxy_request_buffering off;
134
+ proxy_read_timeout 86400s;
135
+ proxy_send_timeout 86400s;
136
+ }
137
+
138
+ location ~ ^/(state$|admin/|sdk/|service/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
139
+ proxy_pass https://host.docker.internal:4017;
140
+ proxy_ssl_verify off;
141
+ proxy_ssl_server_name on;
142
+ proxy_ssl_name $host;
143
+ proxy_http_version 1.1;
144
+
145
+ proxy_set_header Host $host;
146
+ proxy_set_header X-Real-IP $remote_addr;
147
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
148
+ proxy_set_header X-Forwarded-Proto https;
149
+ proxy_set_header Connection "";
150
+
151
+ proxy_read_timeout 60s;
152
+ }
153
+
154
+ location / {
155
+ proxy_pass http://portal_frontend;
156
+ proxy_http_version 1.1;
157
+
158
+ proxy_set_header Host $host;
159
+ proxy_set_header X-Real-IP $remote_addr;
160
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
161
+ proxy_set_header X-Forwarded-Proto https;
162
+ proxy_set_header Connection "";
163
+ }
164
+ }
165
+
166
server {
167
listen 8443 ssl;
168
listen [::]:8443 ssl;
@@ -152,34 +178,31 @@ http {
178
gzip_types text/plain text/css application/json application/javascript
179
text/xml application/xml;
180
155
- # API / backend endpoint (e.g. SSE or long-polling)
181
location /api {
182
proxy_pass http://app_a_backend/api;
183
proxy_http_version 1.1;
159
- proxy_set_header Connection '';
184
+ proxy_set_header Connection "";
185
proxy_buffering off;
186
proxy_cache off;
187
chunked_transfer_encoding off;
163
- proxy_set_header Host $host;
164
- proxy_set_header X-Real-IP $remote_addr;
165
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
188
+ proxy_set_header Host $host;
189
+ proxy_set_header X-Real-IP $remote_addr;
190
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
191
proxy_set_header X-Forwarded-Proto $scheme;
192
proxy_read_timeout 86400s;
193
proxy_send_timeout 86400s;
194
}
195
171
- # Frontend
196
location / {
197
proxy_pass http://app_a_frontend;
198
proxy_http_version 1.1;
175
- proxy_set_header Host $host;
176
- proxy_set_header X-Real-IP $remote_addr;
177
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
199
+ proxy_set_header Host $host;
200
+ proxy_set_header X-Real-IP $remote_addr;
201
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
202
proxy_set_header X-Forwarded-Proto $scheme;
203
}
204
}
205
182
- # ─── app-b.example.com ────────────────────────────────────────────────────
206
server {
207
listen 8443 ssl;
208
listen [::]:8443 ssl;
@@ -195,29 +218,27 @@ http {
218
gzip_types text/plain text/css application/json application/javascript
219
text/xml application/xml;
220
198
- # API / backend endpoint
221
location /api {
222
proxy_pass http://app_b_backend/api;
223
proxy_http_version 1.1;
202
- proxy_set_header Connection '';
224
+ proxy_set_header Connection "";
225
proxy_buffering off;
226
proxy_cache off;
227
chunked_transfer_encoding off;
206
- proxy_set_header Host $host;
207
- proxy_set_header X-Real-IP $remote_addr;
208
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
228
+ proxy_set_header Host $host;
229
+ proxy_set_header X-Real-IP $remote_addr;
230
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
231
proxy_set_header X-Forwarded-Proto $scheme;
232
proxy_read_timeout 86400s;
233
proxy_send_timeout 86400s;
234
}
235
214
- # Frontend
236
location / {
237
proxy_pass http://app_b_frontend;
238
proxy_http_version 1.1;
218
- proxy_set_header Host $host;
219
- proxy_set_header X-Real-IP $remote_addr;
220
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
239
+ proxy_set_header Host $host;
240
+ proxy_set_header X-Real-IP $remote_addr;
241
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
242
proxy_set_header X-Forwarded-Proto $scheme;
243
}
244
}
docs/static/examples/nginx-proxy/deploy_portal.sh
+2
-3
@@ -1,7 +1,6 @@
1
#!/bin/bash
2
set -e
3
4
-docker pull ghcr.io/gosuda/portal:latest
5
-docker compose down portal
6
-docker compose up -d portal
4
+docker compose pull portal portal-frontend
5
+docker compose up -d portal portal-frontend
6
bash nginx_deploy.sh
docs/static/examples/nginx-proxy/docker-compose.yaml
+24
-34
@@ -1,37 +1,30 @@
1
-# Portal relay — nginx reverse proxy deployment example.
1
+# Portal relay + frontend - nginx reverse proxy deployment example.
2
# Replace "portal.example.com" with your actual domain throughout.
3
#
4
# Architecture:
5
# nginx:443/tcp (L4 stream, ssl_preread)
6
-# ├─ portal.example.com → 127.0.0.1:8443 (nginx L7, TLS termination) → 127.0.0.1:4017
7
-# └─ *.portal.example.com → 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
8
-# portal:4443/udp (QUIC tunnel listener — shares SNI_PORT, only if UDP_ENABLED=true and a valid MIN_PORT/MAX_PORT range is configured)
9
-# portal:MIN_PORT-MAX_PORT/udp (per-lease UDP relay ports — only if UDP_ENABLED=true)
10
-# portal:MIN_PORT-MAX_PORT/tcp (per-lease raw TCP ports — only if TCP_ENABLED=true)
11
-#
12
-# If you enable UDP transport, expose SNI_PORT/udp and the shared lease
13
-# range as MIN_PORT-MAX_PORT/udp. If you enable raw TCP transport, expose the same
14
-# MIN_PORT-MAX_PORT range again over TCP.
6
+# - portal.example.com -> 127.0.0.1:8443 (nginx L7, TLS termination)
7
+# - API/control paths -> 127.0.0.1:4017 (portal, HTTPS)
8
+# - everything else -> 127.0.0.1:8080 (portal-frontend, HTTP)
9
+# - *.portal.example.com -> 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
10
+# portal:4443/udp (QUIC tunnel listener, only if UDP_ENABLED=true)
11
+# portal:MIN-MAX/udp (per-lease UDP relay ports, only if UDP_ENABLED=true)
12
+# portal:MIN-MAX/tcp (per-lease raw TCP ports, only if TCP_ENABLED=true)
13
#
14
# Prerequisites:
15
# 1. Copy .env.example to .env and set all required values.
18
-# 2. Place your root-domain TLS certificate files in ./certs/:
16
+# 2. Place the root-domain TLS certificate files for nginx in ./certs/:
17
# ./certs/fullchain.pem
18
# ./certs/privkey.pem
21
-# Portal can instead use ACME or manual fullchain.pem/privatekey.pem in ./.portal-certs.
22
-# 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
19
+# 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot):
20
# mkdir -p ./.portal-certs
21
# sudo chown 65532:65532 ./.portal-certs
22
# chmod 755 ./.portal-certs
23
+# Portal also needs API TLS material in ./.portal-certs unless ACME_DNS_PROVIDER is configured.
24
# 4. Start all services:
25
# docker compose up -d
26
27
services:
30
-
31
- # ─── nginx ──────────────────────────────────────────────────────────────────
32
- # Handles all inbound traffic on ports 80 and 443.
33
- # L4 stream block routes by SNI; L7 http block terminates TLS for root domain.
34
- # Host networking so 127.0.0.1 reaches portal (also on host network).
28
nginx:
29
image: nginx:stable-alpine
30
container_name: nginx
@@ -41,21 +34,15 @@ services:
34
- ./certs:/etc/nginx/certs:ro
35
depends_on:
36
- portal
37
+ - portal-frontend
38
restart: unless-stopped
39
46
- # ─── headless-shell (optional thumbnail screenshot sidecar) ─────────────────
47
- # Uncomment to enable auto-generated thumbnails for tunnel apps.
40
+ # Optional: uncomment to enable auto-generated thumbnails for tunnel apps.
41
# See docs/src/routes/deployment/+page.md for details.
42
# headless-shell:
43
# image: chromedp/headless-shell:stable
44
# restart: unless-stopped
45
53
- # ─── portal relay ───────────────────────────────────────────────────────────
54
- # NAT-traversal relay server.
55
- # TCP (4017, 4443) is reached by nginx via 127.0.0.1.
56
- # SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
57
- # If you enable UDP, expose SNI_PORT/udp and MIN_PORT-MAX_PORT/udp as well.
58
- # If you enable raw TCP transport, expose MIN_PORT-MAX_PORT/tcp as well.
46
portal:
47
image: ghcr.io/gosuda/portal:latest
48
container_name: portal
@@ -65,10 +52,10 @@ services:
52
- "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
53
- "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
54
- "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
68
- # Uncomment below when enabling UDP transport (host SNI_PORT/udp is free — nginx only uses 443/tcp):
55
+ # Uncomment when enabling UDP transport:
56
# - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
57
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
71
- # Uncomment below when enabling raw TCP port transport:
58
+ # Uncomment when enabling raw TCP port transport:
59
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
60
stop_grace_period: 30s
61
environment:
@@ -76,23 +63,17 @@ services:
63
BOOTSTRAPS: ${BOOTSTRAPS:-}
64
DISCOVERY: ${DISCOVERY:-true}
65
WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
79
-
80
- # Listener ports (bound directly on host via host networking).
66
API_PORT: ${API_PORT:-4017}
82
- # Use a non-443 port to avoid conflict with nginx on the host.
67
SNI_PORT: ${SNI_PORT:-4443}
68
IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
85
-
69
MIN_PORT: ${MIN_PORT:-0}
70
MAX_PORT: ${MAX_PORT:-0}
71
UDP_ENABLED: ${UDP_ENABLED:-false}
72
TCP_ENABLED: ${TCP_ENABLED:-false}
90
-
73
LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
74
# HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
75
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
76
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
95
-
77
ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
78
ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
79
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
@@ -114,3 +95,12 @@ services:
95
# Uncomment when using a Google Cloud service account file for gcloud automation.
96
# - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
97
restart: unless-stopped
98
+
99
+ portal-frontend:
100
+ image: ghcr.io/gosuda/portal-frontend:latest
101
+ container_name: portal-frontend
102
+ depends_on:
103
+ - portal
104
+ ports:
105
+ - "${FRONTEND_PORT:-8080}:8080"
106
+ restart: unless-stopped
docs/static/examples/nginx-proxy/nginx.conf
+56
-52
@@ -1,41 +1,33 @@
1
-# Portal relay — nginx reverse proxy configuration example.
1
+# Portal relay + frontend - nginx reverse proxy configuration example.
2
# Replace "portal.example.com" with your actual domain throughout.
3
#
4
# Traffic flow:
5
-# :80 → redirect to HTTPS
6
-# :443 → L4 SNI inspection (ssl_preread, no TLS termination)
7
-# portal.example.com → :8443 (nginx L7, terminates TLS) → 127.0.0.1:4017
8
-# *.portal.example.com → 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
5
+# :80 -> redirect to HTTPS
6
+# :443 -> L4 SNI inspection (ssl_preread, no TLS termination)
7
+# portal.example.com -> :8443 (nginx L7, terminates TLS)
8
+# *.portal.example.com -> 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
9
+#
10
+# L7 path routing on portal.example.com:
11
+# API/control paths -> https://127.0.0.1:4017 (portal)
12
+# Everything else -> http://127.0.0.1:8080 (portal-frontend)
13
14
events {
15
worker_connections 4096;
16
}
17
14
-# ─── L4: SNI-based TCP routing ────────────────────────────────────────────────
15
-# nginx peeks at the TLS ClientHello via ssl_preread to extract SNI without
16
-# terminating TLS. Traffic is forwarded based on whether the SNI matches
17
-# the exact root domain or a wildcard subdomain.
18
-#
19
-# Root domain → nginx L7 listener (port 8443, TLS termination)
20
-# Subdomains → portal SNI listener (port 443, raw TCP passthrough)
18
stream {
19
map $ssl_preread_server_name $backend {
23
- # Exact root host: forward to nginx L7 for TLS termination.
24
- portal.example.com admin_tls;
25
- # Portal tenant subdomains: raw TCP passthrough to portal SNI listener.
26
- ~\.portal\.example\.com$ portal_sni;
27
- # Fallback (no matching SNI).
28
- default admin_tls;
20
+ portal.example.com portal_web;
21
+ ~\.portal\.example\.com$ portal_sni;
22
+ default portal_web;
23
}
24
31
- upstream admin_tls {
25
+ upstream portal_web {
26
server 127.0.0.1:8443;
27
}
28
29
upstream portal_sni {
36
- # Portal SNI listener (host networking, port 4443 to avoid conflict
37
- # with nginx on 443). Relay routes by SNI and bridges raw TCP
38
- # to the claimed reverse session. TLS is not terminated here.
30
+ # Portal SNI listener. TLS is not terminated here.
31
server 127.0.0.1:4443;
32
}
33
@@ -44,18 +36,10 @@ stream {
36
ssl_preread on;
37
proxy_pass $backend;
38
proxy_connect_timeout 5s;
47
- # Long timeout for persistent reverse sessions (24 hours).
39
proxy_timeout 86400s;
40
}
41
}
42
52
-# ─── L7: TLS termination + admin/API proxy ────────────────────────────────────
53
-# nginx terminates TLS for the root domain only, then proxies HTTP/1.1 to
54
-# the portal admin/API listener on port 4017.
55
-#
56
-# HTTP/2 is intentionally disabled on this listener.
57
-# /sdk/connect depends on HTTP/1.1 connection hijacking semantics.
58
-# Do NOT add 'http2' to the listen directives below.
43
http {
44
sendfile on;
45
tcp_nopush on;
@@ -71,10 +55,19 @@ http {
55
gzip_types text/plain text/css application/json application/javascript
56
text/xml application/xml application/xml+rss text/javascript;
57
74
- # ── Root domain: admin/API/frontend ──────────────────────────────────────
58
+ upstream portal_api {
59
+ # Portal API listener is HTTPS even though it is reached only internally.
60
+ server 127.0.0.1:4017;
61
+ keepalive 16;
62
+ }
63
+
64
+ upstream portal_frontend {
65
+ # Static frontend nginx container.
66
+ server 127.0.0.1:8080;
67
+ keepalive 16;
68
+ }
69
+
70
server {
76
- # Internal L7 listener. Receives traffic from the L4 stream block.
77
- # Do NOT add 'http2' — /sdk/connect requires HTTP/1.1 hijacking.
71
listen 8443 ssl;
72
server_name portal.example.com;
73
server_tokens off;
@@ -88,49 +81,60 @@ http {
81
ssl_session_cache shared:SSL:10m;
82
ssl_session_timeout 10m;
83
91
- # ── /sdk/connect: reverse session establishment ──────────────────────
92
- # The relay hijacks this HTTP/1.1 connection into a long-lived raw TCP
93
- # reverse session. After hijacking, data flows as raw bytes.
94
- # Buffering must be disabled; timeouts must be long.
84
+ # /sdk/connect is hijacked by the relay into a long-lived raw TCP
85
+ # reverse session. Keep HTTP/1.1, disable buffering, and use long timeouts.
86
location = /sdk/connect {
96
- proxy_pass http://127.0.0.1:4017;
87
+ proxy_pass https://portal_api;
88
+ proxy_ssl_verify off;
89
+ proxy_ssl_server_name on;
90
+ proxy_ssl_name $host;
91
proxy_http_version 1.1;
92
93
proxy_set_header Host $host;
94
proxy_set_header X-Real-IP $remote_addr;
95
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
96
proxy_set_header X-Forwarded-Proto https;
97
+ proxy_set_header Upgrade $http_upgrade;
98
+ proxy_set_header Connection $http_connection;
99
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 lease access token.
107
- proxy_set_header Upgrade $http_upgrade;
108
- proxy_set_header Connection $http_connection;
100
+ proxy_buffering off;
101
+ proxy_request_buffering off;
102
+ proxy_read_timeout 86400s;
103
+ proxy_send_timeout 86400s;
104
+ }
105
110
- # Disable all buffering. Once hijacked, data is raw TCP.
111
- proxy_buffering off;
112
- proxy_request_buffering off;
106
+ # API/control endpoints belong to portal. Exact /admin is intentionally
107
+ # not matched so the React admin route can be served by portal-frontend.
108
+ location ~ ^/(state$|admin/|sdk/|service/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
109
+ proxy_pass https://portal_api;
110
+ proxy_ssl_verify off;
111
+ proxy_ssl_server_name on;
112
+ proxy_ssl_name $host;
113
+ proxy_http_version 1.1;
114
114
- proxy_read_timeout 86400s;
115
- proxy_send_timeout 86400s;
115
+ proxy_set_header Host $host;
116
+ proxy_set_header X-Real-IP $remote_addr;
117
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
118
+ proxy_set_header X-Forwarded-Proto https;
119
+ proxy_set_header Connection "";
120
+
121
+ proxy_read_timeout 60s;
122
}
123
118
- # ── All other admin/API and frontend routes ──────────────────────────
124
location / {
120
- proxy_pass http://127.0.0.1:4017;
125
+ proxy_pass http://portal_frontend;
126
proxy_http_version 1.1;
127
128
proxy_set_header Host $host;
129
proxy_set_header X-Real-IP $remote_addr;
130
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
131
proxy_set_header X-Forwarded-Proto https;
127
- proxy_set_header Connection "";
132
+ proxy_set_header Connection "";
133
134
proxy_read_timeout 60s;
135
}
136
}
137
133
- # ── HTTP → HTTPS redirect ────────────────────────────────────────────────
138
server {
139
listen 80;
140
server_name _;
docs/static/examples/nginx-proxy/watch_and_deploy.sh
+14
-6
@@ -1,22 +1,30 @@
1
#!/usr/bin/env bash
2
set -euo pipefail
3
4
-IMAGE="ghcr.io/gosuda/portal:latest"
4
+IMAGES="${IMAGES:-ghcr.io/gosuda/portal:latest ghcr.io/gosuda/portal-frontend:latest}"
5
DIGEST_FILE="${DIGEST_FILE:-.portal_image_digest}"
6
INTERVAL="${INTERVAL:-60}"
7
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-deploy_portal.sh}"
8
9
get_remote_digest() {
10
- docker manifest inspect "$IMAGE" 2>/dev/null \
11
- | grep -m1 '"digest"' \
12
- | awk -F'"' '{print $4}'
10
+ for image in $IMAGES; do
11
+ digest="$(docker manifest inspect "$image" 2>/dev/null \
12
+ | grep -m1 '"digest"' \
13
+ | awk -F'"' '{print $4}')"
14
+ if [[ -z "$digest" ]]; then
15
+ return 1
16
+ fi
17
+ printf '%s=%s\n' "$image" "$digest"
18
+ done
19
}
20
15
-echo "Watching $IMAGE for digest changes (interval: ${INTERVAL}s)"
21
+echo "Watching $IMAGES for digest changes (interval: ${INTERVAL}s)"
22
echo "Deploy script: $DEPLOY_SCRIPT"
23
24
while true; do
19
- NEW_DIGEST=$(get_remote_digest)
25
+ if ! NEW_DIGEST=$(get_remote_digest); then
26
+ NEW_DIGEST=""
27
+ fi
28
29
if [[ -z "$NEW_DIGEST" ]]; then
30
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Failed to fetch digest, retrying in ${INTERVAL}s"
frontend/AGENTS.md
+13
-13
@@ -4,8 +4,8 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
4
5
## Frontend-Backend Contracts
6
7
-1. **Public list data comes from `/api/public/snapshot`.**
8
- Go shape is `types.PublicSnapshotResponse`; TS shape is `src/types/lease.ts`.
7
+1. **Public list data comes from `/state`.**
8
+ Go shape is `types.PublicStateResponse`; TS shape is `src/types/api.ts`.
9
- Why: the Go relay is API-only. Do not reintroduce Go HTML data injection for public lease state.
10
11
2. **API path constants require dual maintenance.**
@@ -25,21 +25,21 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
25
Leave it empty for same-origin development/proxying, or set it at build/dev time for a separately hosted relay API.
26
- Why: runtime-generated config files couple the static frontend bundle back to deployment state.
27
28
-6. **Admin state reads are aggregated through `/admin/snapshot`.**
29
- `src/hooks/useAdmin.ts` expects one payload carrying `leases`, settings, and `approval_mode`.
28
+6. **Admin state reads are aggregated through `/admin/state`.**
29
+ `src/hooks/useAdmin.ts` expects `{ settings, leases }`; all setting writes go through `/admin/settings` with the full settings object.
30
- Why: splitting those reads across multiple endpoints reintroduces extra request coordination and drift in the admin bootstrap path.
31
32
-7. **Lease/AdminLease JSON casing is a mixed implicit/explicit contract.**
33
- `Lease` (`../types/identity.go`): `Name` has `json:"name"`, while `FirstSeenAt`, `LastSeenAt`, `Hostname`, `Ready`, and `Metadata` use Go's default PascalCase names. `AdminLease`: `IdentityKey` and `Address` have snake_case json tags, while `BPS`, `ClientIP`, `ReportedIP`, `IsApproved`, `IsBanned`, `IsDenied`, and `IsIPBanned` use PascalCase. TS types in `src/types/lease.ts` include the fields currently rendered or used by actions.
34
- - Why: adding a `json:"..."` tag to any currently untagged field silently changes the wire name and breaks the TS consumer.
32
+7. **Lease/AdminLease JSON casing is snake_case.**
33
+ Go `Lease`/`AdminLease` JSON tags live in `../types/identity.go`; TS mirrors the wire shape in `src/types/api.ts`.
34
+ - Why: the frontend should not depend on Go's implicit PascalCase encoder output.
35
36
-8. **Admin lease paths use base64-url encoding with URI-component escaping.**
37
- TS `encodePathPart()` (`src/lib/apiPaths.ts`) does `btoa(value)` then replaces `+/=` with `-/_/""` before `encodeURIComponent()`. Go decodes via `utils.DecodeBase64URLString()`.
38
- - Why: two-layer codec. Changing either side silently produces 400s on admin lease actions.
36
+8. **Admin policy writes identify targets in the JSON body.**
37
+ Lease policy writes use `/admin/lease-policy` with `identity_key`; IP policy writes use `/admin/ip-policy` with `ip`.
38
+ - Why: path encoding rules add a second contract surface and are easy to drift across Go and TS.
39
40
-9. **`Metadata` is typed `unknown` in TS but has a concrete Go struct.**
41
- Go `LeaseMetadata` (`../types/identity.go`) has more fields than the frontend renders. TS parses only rendered fields at runtime in `src/lib/metadata.ts`.
42
- - Why: adding or renaming a rendered Go metadata field silently drops data in the frontend. No compile-time contract exists.
40
+9. **Lease metadata has a wire type and a UI parser.**
41
+ Go `LeaseMetadata` (`../types/identity.go`) mirrors TS `LeaseMetadata` (`src/types/api.ts`). UI display defaults are owned by `src/lib/metadata.ts`.
42
+ - Why: API contract fields and UI fallback behavior should not be mixed.
43
44
10. **ApprovalMode is a closed two-value enum: `"auto"` | `"manual"`.**
45
TS `normalizeApprovalMode()` (`src/hooks/useAdmin.ts`) collapses any non-`"manual"` value to `"auto"`.
frontend/README.md
+11
-9
@@ -19,8 +19,8 @@ The Go relay is API-only. This frontend is a standalone Vite app that talks to
19
the relay over the JSON API and does not receive server-side injected lease
20
data.
21
22
-- Public relay state is loaded from `/api/public/snapshot`.
23
-- Admin state is loaded from `/admin/snapshot`.
22
+- Public relay state is loaded from `/state`.
23
+- Admin state is loaded from `/admin/state`.
24
- All JSON API responses use the `{ ok, data?, error? }` envelope parsed by `src/lib/apiClient.ts`.
25
- `VITE_PORTAL_API_BASE_URL` points the frontend at a relay API origin. Admin auth uses a bearer token returned by `/admin/auth/login`.
26
@@ -44,7 +44,7 @@ frontend/
44
ServerDetail.tsx
45
ServerList.tsx
46
types/
47
- lease.ts
47
+ api.ts
48
App.tsx
49
main.tsx
50
index.css
@@ -81,9 +81,11 @@ VITE_PORTAL_API_BASE_URL=https://relay.example.com npm run dev
81
82
## Docker
83
84
-The frontend Docker image serves the built Vite app with nginx and proxies API
85
-paths to `portal-api:4017` in Docker Compose. The app uses same-origin relative
86
-API paths, so it does not need runtime config file generation.
84
+The frontend Docker image serves the built Vite app with nginx over HTTP and
85
+proxies API paths to the HTTPS relay at `portal:4017` in Docker Compose.
86
+TLS for public domains should live in the outer reverse proxy. The app uses
87
+same-origin relative API paths, so it does not need runtime config file
88
+generation.
89
90
```bash
91
docker compose up -d portal-frontend
@@ -105,8 +107,8 @@ docker compose up -d portal-frontend
107
Relay server exposes:
108
109
- `/` - relay API identity response
108
-- `/api/public/snapshot` - public leases and landing-page state
109
-- `/tunnel/status` - tunnel readiness check used by the command form
110
+- `/state` - public leases and landing-page state
111
+- `/service/status` - public hostname and service readiness check used by the command form
112
- `/thumbnail/{hostname}` - cached generated screenshots
113
- `/install.sh` and `/install.ps1` - CLI installers
114
- `/admin/*` - admin API/control endpoints
@@ -116,5 +118,5 @@ Relay server exposes:
118
## Notes
119
120
- API path constants are duplicated in Go (`types/paths.go`) and TS (`src/lib/apiPaths.ts`).
119
-- Lease JSON field casing is intentionally mixed to match Go's current wire output; see `src/types/lease.ts`.
121
+- Frontend API wire types live in `src/types/api.ts`.
122
- Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
frontend/nginx.conf
+3
-2
@@ -5,13 +5,14 @@ server {
5
root /usr/share/nginx/html;
6
index index.html;
7
8
- location ~ ^/(api/|admin/|sdk/|tunnel/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
8
+ location ~ ^/(state$|admin/|sdk/|service/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
9
proxy_http_version 1.1;
10
proxy_set_header Host $host;
11
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
12
proxy_set_header X-Forwarded-Host $host;
13
proxy_set_header X-Forwarded-Proto $scheme;
14
- proxy_pass http://portal-api:4017;
14
+ proxy_ssl_verify off;
15
+ proxy_pass https://portal:4017;
16
}
17
18
location / {
frontend/src/components/Header.tsx
+3
-18
@@ -5,6 +5,7 @@ import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5
import { useAuth } from "@/hooks/useAuth";
6
import { apiClient } from "@/lib/apiClient";
7
import { API_PATHS } from "@/lib/apiPaths";
8
+import type { DomainResponse, X402FacilitatorInfo } from "@/types/api";
9
import {
10
Tooltip,
11
TooltipContent,
@@ -19,22 +20,6 @@ interface HeaderProps {
20
showQuickStartLink?: boolean;
21
}
22
22
-interface DomainStatusResponse {
23
- release_version?: string;
24
- ens?: {
25
- verified?: boolean;
26
- };
27
- x402?: X402FacilitatorInfo;
28
-}
29
-
30
-interface X402FacilitatorInfo {
31
- enabled?: boolean;
32
- url?: string;
33
- network?: string;
34
- network_name?: string;
35
- supported_url?: string;
36
-}
37
-
23
const repoURL = "https://github.com/gosuda/portal-tunnel";
24
25
function formatWalletAddress(address: string): string {
@@ -64,7 +49,7 @@ export function Header({
49
walletAddress,
50
login,
51
logout,
67
- } = useAuth(isAdmin ? "admin" : "auto");
52
+ } = useAuth();
53
const [authError, setAuthError] = useState("");
54
55
const handleWalletLogin = async () => {
@@ -95,7 +80,7 @@ export function Header({
80
81
void (async () => {
82
try {
98
- const status = await apiClient.get<DomainStatusResponse>(
83
+ const status = await apiClient.get<DomainResponse>(
84
API_PATHS.sdk.domain
85
);
86
if (!cancelled) {
frontend/src/components/ServerListView.tsx
+4
-15
@@ -16,6 +16,7 @@ import { FloatingActionBar } from "@/components/FloatingActionBar";
16
import { readCurrentOrigin } from "@/hooks/useTunnelCommand";
17
import { apiClient } from "@/lib/apiClient";
18
import { API_PATHS, ROUTE_PATHS } from "@/lib/apiPaths";
19
+import type { DiscoveryResponse, DomainResponse, RelayDescriptor } from "@/types/api";
20
import {
21
Dialog,
22
DialogContent,
@@ -25,18 +26,6 @@ import {
26
27
type ListServer = BaseServer | AdminServer;
28
28
-interface RelayDomainResponse {
29
- release_version?: string;
30
-}
31
-
32
-interface RelayDiscoveryDescriptor {
33
- api_https_addr?: string;
34
-}
35
-
36
-interface RelayDiscoveryResponse {
37
- relays?: RelayDiscoveryDescriptor[];
38
-}
39
-
29
interface KnownRelay {
30
relayURL: string;
31
isCurrent: boolean;
@@ -60,7 +49,7 @@ async function loadRelayReleaseVersion(
49
50
try {
51
const domain = await Promise.race([
63
- apiClient.get<RelayDomainResponse>(domainURL),
52
+ apiClient.get<DomainResponse>(domainURL),
53
timeoutPromise,
54
]);
55
return typeof domain?.release_version === "string"
@@ -76,7 +65,7 @@ function normalizeRelayURL(relayURL: string | undefined): string {
65
}
66
67
function normalizeKnownRelays(
79
- relays: RelayDiscoveryDescriptor[] | undefined,
68
+ relays: RelayDescriptor[] | undefined,
69
currentRelayURL: string
70
): KnownRelay[] {
71
const seen = new Set<string>();
@@ -312,7 +301,7 @@ export function ServerListView({
301
302
try {
303
const discovery =
315
- await apiClient.get<RelayDiscoveryResponse>(API_PATHS.discovery);
304
+ await apiClient.get<DiscoveryResponse>(API_PATHS.discovery);
305
nextKnownRelays = normalizeKnownRelays(
306
discovery?.relays,
307
currentRelayURL
frontend/src/components/TunnelCommandForm.tsx
+18
-23
@@ -9,10 +9,11 @@ import { Check, Copy, RefreshCw, X } from "lucide-react";
9
import { Input } from "@/components/ui/input";
10
import { apiClient } from "@/lib/apiClient";
11
import { API_PATHS } from "@/lib/apiPaths";
12
+import type { ServiceStatusResponse } from "@/types/api";
13
import { cn } from "@/lib/utils";
14
import {
15
buildTunnelPreviewURL,
15
- buildTunnelStatusHostname,
16
+ buildServiceStatusHostname,
17
normalizeAbsoluteHTTPURL,
18
} from "@/lib/tunnelCommand";
19
import {
@@ -27,13 +28,7 @@ interface TunnelCommandFormProps {
28
mode?: "full" | "hero";
29
}
30
30
-type TunnelStatus = "waiting" | "registered" | "alive";
31
-
32
-interface TunnelStatusResponse {
33
- hostname: string;
34
- registered: boolean;
35
- service_alive: boolean;
36
-}
31
+type ServiceStatus = "waiting" | "registered" | "alive";
32
33
export function TunnelCommandForm({
34
className,
@@ -70,7 +65,7 @@ function HeroTunnelCommandForm({
65
handleShuffleName,
66
} = useTunnelCommand();
67
73
- const [tunnelStatus, setTunnelStatus] = useState<TunnelStatus>("waiting");
68
+ const [serviceStatus, setServiceStatus] = useState<ServiceStatus>("waiting");
69
70
const previewURL = useMemo(
71
() => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
@@ -78,7 +73,7 @@ function HeroTunnelCommandForm({
73
);
74
const statusHostname = useMemo(
75
() =>
81
- buildTunnelStatusHostname(currentOrigin, effectiveName, target, nameSeed),
76
+ buildServiceStatusHostname(currentOrigin, effectiveName, target, nameSeed),
77
[currentOrigin, effectiveName, nameSeed, target]
78
);
79
@@ -92,27 +87,27 @@ function HeroTunnelCommandForm({
87
const poll = async () => {
88
try {
89
const params = new URLSearchParams({ hostname: statusHostname });
95
- const statusResponse = await apiClient.get<TunnelStatusResponse>(
96
- `${API_PATHS.tunnel.status}?${params.toString()}`
90
+ const statusResponse = await apiClient.get<ServiceStatusResponse>(
91
+ `${API_PATHS.service.status}?${params.toString()}`
92
);
93
if (cancelled) {
94
return;
95
}
96
97
if (!statusResponse.registered) {
103
- setTunnelStatus("waiting");
98
+ setServiceStatus("waiting");
99
return;
100
}
101
107
- setTunnelStatus(statusResponse.service_alive ? "alive" : "registered");
102
+ setServiceStatus(statusResponse.service_alive ? "alive" : "registered");
103
} catch {
104
if (!cancelled) {
110
- setTunnelStatus("waiting");
105
+ setServiceStatus("waiting");
106
}
107
}
108
};
109
115
- setTunnelStatus("waiting");
110
+ setServiceStatus("waiting");
111
void poll();
112
const interval = window.setInterval(() => {
113
void poll();
@@ -124,17 +119,17 @@ function HeroTunnelCommandForm({
119
};
120
}, [statusHostname]);
121
127
- const tunnelStatusTone = {
122
+ const serviceStatusTone = {
123
alive: isTerminal ? "bg-green-400" : "bg-green-600",
124
registered: isTerminal ? "bg-sky-400" : "bg-sky-600",
125
waiting: isTerminal ? "bg-slate-500" : "bg-slate-400",
131
- }[tunnelStatus];
132
- const tunnelStatusHeadline = {
126
+ }[serviceStatus];
127
+ const serviceStatusHeadline = {
128
alive: "This URL is live now",
129
registered: "URL reserved",
130
waiting: "Waiting for Connection",
136
- }[tunnelStatus];
137
- const isPreviewURLDisabled = tunnelStatus === "waiting";
131
+ }[serviceStatus];
132
+ const isPreviewURLDisabled = serviceStatus === "waiting";
133
const heroSectionLabelClass = cn(
134
"text-[13px] font-semibold tracking-[0.04em] sm:text-sm",
135
isTerminal ? "text-slate-100" : "text-foreground/85"
@@ -305,10 +300,10 @@ function HeroTunnelCommandForm({
300
)}
301
>
302
<span
308
- className={cn("h-2 w-2 rounded-full", tunnelStatusTone)}
303
+ className={cn("h-2 w-2 rounded-full", serviceStatusTone)}
304
aria-hidden="true"
305
/>
311
- <span>{tunnelStatusHeadline}</span>
306
+ <span>{serviceStatusHeadline}</span>
307
</div>
308
{isPreviewURLDisabled ? (
309
<span
frontend/src/hooks/useAdmin.test.ts
+70
-61
@@ -1,14 +1,14 @@
1
import { act, renderHook, waitFor } from "@testing-library/react";
2
import { beforeEach, describe, expect, it, vi } from "vitest";
3
4
-import type { AdminLeaseData } from "@/types/lease";
4
+import type { AdminLease, AdminSettings } from "@/types/api";
5
import { useAdmin } from "@/hooks/useAdmin";
6
-import { API_PATHS, adminLeasePath } from "@/lib/apiPaths";
6
+import { API_PATHS } from "@/lib/apiPaths";
7
import { APIClientError, apiClient } from "@/lib/apiClient";
8
9
-type DeferredAdminSnapshot = {
10
- leases: AdminLeaseData[];
11
- approval_mode: "auto" | "manual";
9
+type DeferredAdminState = {
10
+ leases: AdminLease[];
11
+ settings: AdminSettings;
12
};
13
14
vi.mock("@/hooks/useList", () => ({
@@ -38,33 +38,42 @@ vi.mock("@/lib/apiClient", async () => {
38
apiClient: {
39
get: vi.fn(),
40
post: vi.fn(),
41
- delete: vi.fn(),
41
},
42
};
43
});
44
46
-function buildLease(address: string, name: string = "relay-1"): AdminLeaseData {
45
+function buildSettings(approvalMode: "auto" | "manual" = "auto"): AdminSettings {
46
return {
48
- FirstSeenAt: "2026-03-02T00:00:00Z",
49
- LastSeenAt: "2026-03-03T00:00:00Z",
47
+ approval_mode: approvalMode,
48
+ landing_page_enabled: true,
49
+ udp: { enabled: false, max_leases: 0 },
50
+ tcp_port: { enabled: false, max_leases: 0 },
51
+ };
52
+}
53
+
54
+function buildLease(address: string, name: string = "relay-1"): AdminLease {
55
+ return {
56
+ expires_at: "2026-03-04T00:00:00Z",
57
+ first_seen_at: "2026-03-02T00:00:00Z",
58
+ last_seen_at: "2026-03-03T00:00:00Z",
59
identity_key: `${name.toLowerCase()}:${address.toLowerCase()}`,
60
address,
61
name,
53
- BPS: 1024,
54
- ClientIP: "203.0.113.10",
55
- ReportedIP: "",
56
- Hostname: "relay.example.com",
57
- Metadata: {
62
+ bps: 1024,
63
+ client_ip: "203.0.113.10",
64
+ reported_ip: "",
65
+ hostname: "relay.example.com",
66
+ metadata: {
67
description: "relay",
68
tags: ["core"],
69
thumbnail: "",
70
owner: "ops",
71
},
63
- Ready: 1,
64
- IsApproved: true,
65
- IsBanned: address === "0x00000000000000000000000000000000000000A1",
66
- IsDenied: false,
67
- IsIPBanned: false,
72
+ ready: 1,
73
+ is_approved: true,
74
+ is_banned: address === "0x00000000000000000000000000000000000000A1",
75
+ is_denied: false,
76
+ is_ip_banned: false,
77
};
78
}
79
@@ -77,23 +86,26 @@ async function waitForLoaded(result: { current: { loading: boolean } }) {
86
describe("useAdmin", () => {
87
const mockGet = vi.mocked(apiClient.get);
88
const mockPost = vi.mocked(apiClient.post);
80
- const mockDelete = vi.mocked(apiClient.delete);
89
90
beforeEach(() => {
91
vi.clearAllMocks();
92
93
mockGet.mockImplementation(async (path: string) => {
86
- if (path === API_PATHS.admin.snapshot) {
94
+ if (path === API_PATHS.admin.state) {
95
return {
96
leases: [buildLease("0x00000000000000000000000000000000000000A1")],
89
- approval_mode: "not-a-mode",
97
+ settings: { ...buildSettings(), approval_mode: "not-a-mode" },
98
} as never;
99
}
100
throw new Error(`Unexpected GET path: ${path}`);
101
});
102
95
- mockPost.mockResolvedValue({} as never);
96
- mockDelete.mockResolvedValue({} as never);
103
+ mockPost.mockImplementation(async <T,>(path: string, body?: unknown): Promise<T> => {
104
+ if (path === API_PATHS.admin.settings) {
105
+ return body as T;
106
+ }
107
+ return {} as T;
108
+ });
109
});
110
111
it("normalizes fetchData results on success", async () => {
@@ -110,7 +122,7 @@ describe("useAdmin", () => {
122
123
it("surfaces fetchData API errors", async () => {
124
mockGet.mockImplementation(async (path: string) => {
113
- if (path === API_PATHS.admin.snapshot) {
125
+ if (path === API_PATHS.admin.state) {
126
throw new APIClientError("failed to load leases", 500, "server_error");
127
}
128
throw new Error(`Unexpected GET path: ${path}`);
@@ -158,7 +170,7 @@ describe("useAdmin", () => {
170
});
171
});
172
161
- it("encodes addresses for action routes", async () => {
173
+ it("posts identity keys in lease policy bodies", async () => {
174
const { result } = renderHook(() => useAdmin());
175
await waitForLoaded(result);
176
const identityKey = "relay-1:0x00000000000000000000000000000000000000a1";
@@ -168,16 +180,14 @@ describe("useAdmin", () => {
180
});
181
182
const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
171
- expect(calledPaths).toContain(
172
- adminLeasePath(
173
- "relay-1",
174
- "0x00000000000000000000000000000000000000A1",
175
- "approve"
176
- ),
177
- );
183
+ expect(calledPaths).toContain(API_PATHS.admin.leasePolicy);
184
+ expect(mockPost).toHaveBeenCalledWith(API_PATHS.admin.leasePolicy, {
185
+ identity_key: identityKey,
186
+ is_approved: true,
187
+ });
188
});
189
180
- it("posts bps updates to the lease action route", async () => {
190
+ it("posts bps updates to the lease policy endpoint", async () => {
191
const { result } = renderHook(() => useAdmin());
192
await waitForLoaded(result);
193
@@ -189,33 +199,32 @@ describe("useAdmin", () => {
199
});
200
201
expect(mockPost).toHaveBeenCalledWith(
192
- adminLeasePath(
193
- "relay-1",
194
- "0x00000000000000000000000000000000000000A1",
195
- "bps"
196
- ),
197
- { bps: 4096 },
202
+ API_PATHS.admin.leasePolicy,
203
+ {
204
+ identity_key: "relay-1:0x00000000000000000000000000000000000000a1",
205
+ bps: 4096,
206
+ },
207
);
208
});
209
210
it("keeps loading false while refreshing bps in the background", async () => {
211
let getCalls = 0;
212
let resolveRefresh:
204
- | ((value: DeferredAdminSnapshot | PromiseLike<DeferredAdminSnapshot>) => void)
213
+ | ((value: DeferredAdminState | PromiseLike<DeferredAdminState>) => void)
214
| undefined;
215
216
mockGet.mockImplementation((path: string) => {
208
- if (path !== API_PATHS.admin.snapshot) {
217
+ if (path !== API_PATHS.admin.state) {
218
throw new Error(`Unexpected GET path: ${path}`);
219
}
220
getCalls++;
221
if (getCalls === 1) {
222
return Promise.resolve({
223
leases: [buildLease("0x00000000000000000000000000000000000000A1")],
215
- approval_mode: "auto",
224
+ settings: buildSettings(),
225
} as never);
226
}
218
- return new Promise<DeferredAdminSnapshot>((resolve) => {
227
+ return new Promise<DeferredAdminState>((resolve) => {
228
resolveRefresh = resolve;
229
}) as never;
230
});
@@ -232,8 +241,8 @@ describe("useAdmin", () => {
241
await Promise.resolve();
242
expect(result.current.loading).toBe(false);
243
resolveRefresh?.({
235
- leases: [{ ...buildLease("0x00000000000000000000000000000000000000A1"), BPS: 2048 }],
236
- approval_mode: "auto",
244
+ leases: [{ ...buildLease("0x00000000000000000000000000000000000000A1"), bps: 2048 }],
245
+ settings: buildSettings(),
246
});
247
await pending;
248
});
@@ -241,15 +250,15 @@ describe("useAdmin", () => {
250
expect(result.current.servers[0]?.bps).toBe(2048);
251
});
252
244
- it("bulk deny posts deduped addresses to action routes", async () => {
253
+ it("bulk deny posts deduped identity keys in lease policy bodies", async () => {
254
mockGet.mockImplementation(async (path: string) => {
246
- if (path === API_PATHS.admin.snapshot) {
255
+ if (path === API_PATHS.admin.state) {
256
return {
257
leases: [
258
buildLease("0x00000000000000000000000000000000000000A1", "relay-1"),
259
buildLease("0x00000000000000000000000000000000000000B2", "relay-2"),
260
],
252
- approval_mode: "auto",
261
+ settings: buildSettings(),
262
} as never;
263
}
264
throw new Error(`Unexpected GET path: ${path}`);
@@ -257,26 +266,26 @@ describe("useAdmin", () => {
266
267
const { result } = renderHook(() => useAdmin());
268
await waitForLoaded(result);
260
- const addressA = "0x00000000000000000000000000000000000000A1";
261
- const addressB = "0x00000000000000000000000000000000000000B2";
269
+ const identityKeyA = "relay-1:0x00000000000000000000000000000000000000a1";
270
+ const identityKeyB = "relay-2:0x00000000000000000000000000000000000000b2";
271
272
await act(async () => {
273
await result.current.handleBulkDeny([
265
- "relay-1:0x00000000000000000000000000000000000000a1",
266
- "relay-1:0x00000000000000000000000000000000000000a1",
267
- "relay-2:0x00000000000000000000000000000000000000b2",
274
+ identityKeyA,
275
+ identityKeyA,
276
+ identityKeyB,
277
]);
278
});
279
271
- const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
272
- expect(calledPaths).toEqual(
280
+ const denyCalls = mockPost.mock.calls.filter(([, body]) => {
281
+ return (body as { is_denied?: boolean }).is_denied === true;
282
+ });
283
+ expect(denyCalls).toHaveLength(2);
284
+ expect(denyCalls).toEqual(
285
expect.arrayContaining([
274
- adminLeasePath("relay-1", addressA, "deny"),
275
- adminLeasePath("relay-2", addressB, "deny"),
286
+ [API_PATHS.admin.leasePolicy, { identity_key: identityKeyA, is_denied: true }],
287
+ [API_PATHS.admin.leasePolicy, { identity_key: identityKeyB, is_denied: true }],
288
]),
289
);
278
-
279
- const denyCalls = calledPaths.filter((path) => path.endsWith("/deny"));
280
- expect(denyCalls).toHaveLength(2);
290
});
291
});
frontend/src/hooks/useAdmin.ts
+124
-136
@@ -1,35 +1,23 @@
1
import { useEffect, useMemo, useState } from "react";
2
-import type { AdminLeaseData } from "@/types/lease";
2
import { useList, type BaseServer } from "@/hooks/useList";
3
import type { BanFilter } from "@/types/filters";
5
-import {
6
- API_PATHS,
7
- adminIPBanPath,
8
- adminLeasePath,
9
-} from "@/lib/apiPaths";
4
+import { API_PATHS } from "@/lib/apiPaths";
5
import { APIClientError, apiClient } from "@/lib/apiClient";
6
import { parseLeaseMetadata } from "@/lib/metadata";
12
-
13
-export type ApprovalMode = "auto" | "manual";
7
+import type {
8
+ AdminIPPolicy,
9
+ AdminLease,
10
+ AdminLeasePolicy,
11
+ AdminPortSettings,
12
+ AdminSettings,
13
+ AdminStateResponse,
14
+ ApprovalMode,
15
+} from "@/types/api";
16
+
17
+export type { ApprovalMode } from "@/types/api";
18
19
type LeaseAction = "approve" | "deny" | "ban";
20
17
-type ApprovalModeResponse = {
18
- approval_mode?: ApprovalMode;
19
-};
20
-
21
-type LandingPageSettingsResponse = {
22
- enabled?: boolean;
23
-};
24
-
25
-type AdminSnapshotResponse = {
26
- approval_mode?: ApprovalMode;
27
- landing_page_enabled?: boolean;
28
- leases?: AdminLeaseData[];
29
- udp?: { enabled: boolean; max_leases: number };
30
- tcp_port?: { enabled: boolean; max_leases: number };
31
-};
32
-
21
export interface AdminServer extends BaseServer {
22
identityKey: string;
23
address: string;
@@ -52,6 +40,13 @@ export interface TCPPortSettings {
40
maxLeases: number;
41
}
42
43
+const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
44
+ approval_mode: "auto",
45
+ landing_page_enabled: true,
46
+ udp: { enabled: false, max_leases: 0 },
47
+ tcp_port: { enabled: false, max_leases: 0 },
48
+};
49
+
50
const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
51
invalid_mode: "Invalid approval mode. Choose auto or manual and retry.",
52
invalid_address: "Selected address is invalid. Refresh and try again.",
@@ -88,26 +83,11 @@ function toAdminErrorMessage(error: unknown, fallback: string): string {
83
return fallback;
84
}
85
91
-function resolveLeaseIdentity(
92
- rows: AdminLeaseData[],
93
- identityKey: string
94
-): { name: string; address: string } {
95
- const match = rows.find((row) => row.identity_key.trim() === identityKey);
96
- if (!match) {
97
- throw new Error("Missing lease identity");
98
- }
99
-
100
- return {
101
- name: (match.name || "").trim(),
102
- address: match.address.trim(),
103
- };
104
-}
105
-
86
function toAdminServer(
107
- row: AdminLeaseData,
87
+ row: AdminLease,
88
): AdminServer {
109
- const metadata = parseLeaseMetadata(row.Metadata);
110
- const hostname = row.Hostname || "";
89
+ const metadata = parseLeaseMetadata(row.metadata);
90
+ const hostname = row.hostname || "";
91
const serviceName = row.name || "";
92
const address = row.address.trim();
93
@@ -118,20 +98,20 @@ function toAdminServer(
98
tags: metadata.tags,
99
thumbnail: metadata.thumbnail,
100
owner: metadata.owner,
121
- online: (row.Ready || 0) > 0,
101
+ online: (row.ready || 0) > 0,
102
dns: hostname,
103
link: hostname ? `https://${hostname}/` : "",
124
- lastUpdated: row.LastSeenAt || undefined,
125
- firstSeen: row.FirstSeenAt || undefined,
104
+ lastUpdated: row.last_seen_at || undefined,
105
+ firstSeen: row.first_seen_at || undefined,
106
identityKey: row.identity_key.trim(),
107
address,
128
- isBanned: row.IsBanned,
129
- bps: row.BPS,
130
- isApproved: row.IsApproved,
131
- isDenied: row.IsDenied,
132
- ip: row.ClientIP,
133
- displayIP: row.ReportedIP || row.ClientIP,
134
- isIPBanned: row.IsIPBanned,
108
+ isBanned: row.is_banned,
109
+ bps: row.bps,
110
+ isApproved: row.is_approved,
111
+ isDenied: row.is_denied,
112
+ ip: row.client_ip,
113
+ displayIP: row.reported_ip || row.client_ip,
114
+ isIPBanned: row.is_ip_banned,
115
};
116
}
117
@@ -139,57 +119,55 @@ function normalizeApprovalMode(value: string | undefined): ApprovalMode {
119
return value === "manual" ? "manual" : "auto";
120
}
121
142
-interface AdminSnapshot {
143
- serverData: AdminLeaseData[];
144
- approvalMode: ApprovalMode;
145
- landingPageEnabled: boolean;
146
- udpSettings: UDPSettings;
147
- tcpPortSettings: TCPPortSettings;
122
+function normalizeAdminSettings(settings: AdminSettings | undefined): AdminSettings {
123
+ return {
124
+ approval_mode: normalizeApprovalMode(settings?.approval_mode),
125
+ landing_page_enabled:
126
+ settings?.landing_page_enabled ?? DEFAULT_ADMIN_SETTINGS.landing_page_enabled,
127
+ udp: {
128
+ enabled: settings?.udp?.enabled ?? DEFAULT_ADMIN_SETTINGS.udp.enabled,
129
+ max_leases: settings?.udp?.max_leases ?? DEFAULT_ADMIN_SETTINGS.udp.max_leases,
130
+ },
131
+ tcp_port: {
132
+ enabled: settings?.tcp_port?.enabled ?? DEFAULT_ADMIN_SETTINGS.tcp_port.enabled,
133
+ max_leases: settings?.tcp_port?.max_leases ?? DEFAULT_ADMIN_SETTINGS.tcp_port.max_leases,
134
+ },
135
+ };
136
}
137
150
-async function loadAdminSnapshot(): Promise<AdminSnapshot> {
151
- const snapshot = await apiClient.get<AdminSnapshotResponse>(API_PATHS.admin.snapshot);
152
- const normalizedLeases = Array.isArray(snapshot?.leases) ? snapshot.leases : [];
138
+interface AdminState {
139
+ serverData: AdminLease[];
140
+ settings: AdminSettings;
141
+}
142
+
143
+async function loadAdminState(): Promise<AdminState> {
144
+ const state = await apiClient.get<AdminStateResponse>(API_PATHS.admin.state);
145
+ const normalizedLeases = Array.isArray(state?.leases) ? state.leases : [];
146
147
return {
148
serverData: normalizedLeases,
156
- approvalMode: normalizeApprovalMode(snapshot?.approval_mode),
157
- landingPageEnabled: snapshot?.landing_page_enabled ?? true,
158
- udpSettings: {
159
- enabled: snapshot?.udp?.enabled ?? false,
160
- maxLeases: snapshot?.udp?.max_leases ?? 0,
161
- },
162
- tcpPortSettings: {
163
- enabled: snapshot?.tcp_port?.enabled ?? false,
164
- maxLeases: snapshot?.tcp_port?.max_leases ?? 0,
165
- },
149
+ settings: normalizeAdminSettings(state?.settings),
150
};
151
}
152
153
export function useAdmin(enabled = true) {
170
- const [serverData, setServerData] = useState<AdminLeaseData[]>([]);
171
- const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
172
- const [landingPageEnabled, setLandingPageEnabled] = useState(true);
173
- const [udpSettings, setUDPSettings] = useState<UDPSettings>({ enabled: false, maxLeases: 0 });
174
- const [tcpPortSettings, setTCPPortSettings] = useState<TCPPortSettings>({ enabled: false, maxLeases: 0 });
154
+ const [serverData, setServerData] = useState<AdminLease[]>([]);
155
+ const [adminSettings, setAdminSettings] = useState<AdminSettings>(DEFAULT_ADMIN_SETTINGS);
156
const [loading, setLoading] = useState(true);
157
const [error, setError] = useState("");
158
159
const [banFilter, setBanFilter] = useState<BanFilter>("all");
160
180
- const applySnapshot = (snapshot: AdminSnapshot) => {
181
- setServerData(snapshot.serverData);
182
- setApprovalMode(snapshot.approvalMode);
183
- setLandingPageEnabled(snapshot.landingPageEnabled);
184
- setUDPSettings(snapshot.udpSettings);
185
- setTCPPortSettings(snapshot.tcpPortSettings);
161
+ const applyAdminState = (state: AdminState) => {
162
+ setServerData(state.serverData);
163
+ setAdminSettings(state.settings);
164
};
165
166
const fetchData = async () => {
167
setError("");
168
169
try {
192
- applySnapshot(await loadAdminSnapshot());
170
+ applyAdminState(await loadAdminState());
171
} catch (err: unknown) {
172
setError(toAdminErrorMessage(err, "Failed to load admin data"));
173
}
@@ -209,11 +187,11 @@ export function useAdmin(enabled = true) {
187
setError("");
188
setLoading(true);
189
try {
212
- const snapshot = await loadAdminSnapshot();
190
+ const state = await loadAdminState();
191
if (!mounted) {
192
return;
193
}
216
- applySnapshot(snapshot);
194
+ applyAdminState(state);
195
} catch (err: unknown) {
196
if (!mounted) {
197
return;
@@ -266,16 +244,27 @@ export function useAdmin(enabled = true) {
244
}
245
};
246
269
- const updateLeaseAction = async (
247
+ const postAdminSettings = async (settings: AdminSettings) => {
248
+ const response = await apiClient.post<AdminSettings>(API_PATHS.admin.settings, settings);
249
+ setAdminSettings(normalizeAdminSettings(response));
250
+ };
251
+
252
+ const currentAdminSettings = (overrides: Partial<AdminSettings> = {}): AdminSettings => ({
253
+ ...adminSettings,
254
+ ...overrides,
255
+ });
256
+
257
+ const updateLeasePolicy = async (
258
identityKey: string,
271
- action: LeaseAction,
272
- enabled: boolean
259
+ policy: Omit<AdminLeasePolicy, "identity_key">,
260
) => {
274
- const identity = resolveLeaseIdentity(serverData, identityKey);
275
- const method = enabled ? apiClient.post : apiClient.delete;
276
- await method<ApprovalModeResponse>(
277
- adminLeasePath(identity.name, identity.address, action)
278
- );
261
+ if (!identityKey) {
262
+ throw new Error("Missing lease identity");
263
+ }
264
+ await apiClient.post<unknown>(API_PATHS.admin.leasePolicy, {
265
+ identity_key: identityKey,
266
+ ...policy,
267
+ } satisfies AdminLeasePolicy);
268
};
269
270
const handleBanFilterChange = (value: BanFilter) => {
@@ -283,22 +272,21 @@ export function useAdmin(enabled = true) {
272
};
273
274
const handleBanStatus = (identityKey: string, isBan: boolean) =>
286
- runAdminAction(() => updateLeaseAction(identityKey, "ban", isBan));
275
+ runAdminAction(() => updateLeasePolicy(identityKey, { is_banned: isBan }));
276
277
const handleBPSChange = async (identityKey: string, bps: number) => {
278
if (!identityKey) {
279
throw new Error("Missing lease identity");
280
}
281
293
- const identity = resolveLeaseIdentity(serverData, identityKey);
282
const normalizedBPS = Math.max(0, Math.trunc(bps));
283
const previousBPS =
296
- serverData.find((row) => row.identity_key.trim() === identityKey)?.BPS ?? 0;
284
+ serverData.find((row) => row.identity_key.trim() === identityKey)?.bps ?? 0;
285
286
setServerData((prev) =>
287
prev.map((row) =>
288
row.identity_key.trim() === identityKey
301
- ? { ...row, BPS: normalizedBPS }
289
+ ? { ...row, bps: normalizedBPS }
290
: row
291
)
292
);
@@ -306,21 +294,16 @@ export function useAdmin(enabled = true) {
294
try {
295
await runAdminAction(async () => {
296
if (!Number.isFinite(normalizedBPS) || normalizedBPS <= 0) {
309
- await apiClient.delete<ApprovalModeResponse>(
310
- adminLeasePath(identity.name, identity.address, "bps")
311
- );
297
+ await updateLeasePolicy(identityKey, { bps: 0 });
298
return;
299
}
314
- await apiClient.post<ApprovalModeResponse>(
315
- adminLeasePath(identity.name, identity.address, "bps"),
316
- { bps: normalizedBPS }
317
- );
300
+ await updateLeasePolicy(identityKey, { bps: normalizedBPS });
301
});
302
} catch (err) {
303
setServerData((prev) =>
304
prev.map((row) =>
305
row.identity_key.trim() === identityKey
323
- ? { ...row, BPS: previousBPS }
306
+ ? { ...row, bps: previousBPS }
307
: row
308
)
309
);
@@ -330,47 +313,39 @@ export function useAdmin(enabled = true) {
313
314
const handleApprovalModeChange = async (mode: ApprovalMode) => {
315
await runAdminAction(async () => {
333
- const response = await apiClient.post<ApprovalModeResponse>(
334
- API_PATHS.admin.approvalMode,
335
- { mode }
336
- );
337
- const nextMode = normalizeApprovalMode(response?.approval_mode ?? mode);
338
- setApprovalMode(nextMode);
316
+ await postAdminSettings(currentAdminSettings({ approval_mode: mode }));
317
});
318
};
319
342
- const handleSettingsChange = (path: string, setter: (s: { enabled: boolean; maxLeases: number }) => void) =>
320
+ const handleSettingsChange = (key: "udp" | "tcp_port") =>
321
async (settings: { enabled: boolean; maxLeases: number }) => {
322
await runAdminAction(async () => {
345
- const response = await apiClient.post<{ enabled: boolean; max_leases: number }>(path, {
323
+ const nextPortSettings: AdminPortSettings = {
324
enabled: settings.enabled,
325
max_leases: settings.maxLeases,
348
- });
349
- setter({
350
- enabled: response?.enabled ?? settings.enabled,
351
- maxLeases: response?.max_leases ?? settings.maxLeases,
352
- });
326
+ };
327
+ const nextSettings =
328
+ key === "udp"
329
+ ? currentAdminSettings({ udp: nextPortSettings })
330
+ : currentAdminSettings({ tcp_port: nextPortSettings });
331
+ await postAdminSettings(nextSettings);
332
});
333
};
334
356
- const handleUDPSettingsChange = handleSettingsChange(API_PATHS.admin.udpSettings, setUDPSettings);
357
- const handleTCPPortSettingsChange = handleSettingsChange(API_PATHS.admin.tcpPortSettings, setTCPPortSettings);
335
+ const handleUDPSettingsChange = handleSettingsChange("udp");
336
+ const handleTCPPortSettingsChange = handleSettingsChange("tcp_port");
337
338
const handleLandingPageEnabledChange = async (enabled: boolean) => {
339
await runAdminAction(async () => {
361
- const response = await apiClient.post<LandingPageSettingsResponse>(
362
- API_PATHS.admin.landingPage,
363
- { enabled }
364
- );
365
- setLandingPageEnabled(response?.enabled ?? enabled);
340
+ await postAdminSettings(currentAdminSettings({ landing_page_enabled: enabled }));
341
});
342
};
343
344
const handleApproveStatus = (identityKey: string, approve: boolean) =>
370
- runAdminAction(() => updateLeaseAction(identityKey, "approve", approve));
345
+ runAdminAction(() => updateLeasePolicy(identityKey, { is_approved: approve }));
346
347
const handleDenyStatus = (identityKey: string, deny: boolean) =>
373
- runAdminAction(() => updateLeaseAction(identityKey, "deny", deny));
348
+ runAdminAction(() => updateLeasePolicy(identityKey, { is_denied: deny }));
349
350
const handleIPBanStatus = (ip: string, isBan: boolean) =>
351
runAdminAction(async () => {
@@ -378,11 +353,10 @@ export function useAdmin(enabled = true) {
353
if (!normalizedIP) {
354
throw new Error("Missing IP address");
355
}
381
- if (isBan) {
382
- await apiClient.post<ApprovalModeResponse>(adminIPBanPath(normalizedIP));
383
- return;
384
- }
385
- await apiClient.delete<ApprovalModeResponse>(adminIPBanPath(normalizedIP));
356
+ await apiClient.post<unknown>(API_PATHS.admin.ipPolicy, {
357
+ ip: normalizedIP,
358
+ is_banned: isBan,
359
+ } satisfies AdminIPPolicy);
360
});
361
362
const runBulkLeaseAction = async (identityKeys: string[], action: LeaseAction) => {
@@ -395,10 +369,13 @@ export function useAdmin(enabled = true) {
369
370
const results = await Promise.allSettled(
371
normalizedIdentityKeys.map((identityKey) => {
398
- const identity = resolveLeaseIdentity(serverData, identityKey);
399
- return apiClient.post<ApprovalModeResponse>(
400
- adminLeasePath(identity.name, identity.address, action)
401
- );
372
+ const policy: AdminLeasePolicy =
373
+ action === "approve"
374
+ ? { identity_key: identityKey, is_approved: true }
375
+ : action === "deny"
376
+ ? { identity_key: identityKey, is_denied: true }
377
+ : { identity_key: identityKey, is_banned: true };
378
+ return apiClient.post<unknown>(API_PATHS.admin.leasePolicy, policy);
379
})
380
);
381
@@ -424,6 +401,17 @@ export function useAdmin(enabled = true) {
401
402
const handleBulkBan = (identityKeys: string[]) => handleBulkAction(identityKeys, "ban");
403
404
+ const approvalMode = normalizeApprovalMode(adminSettings.approval_mode);
405
+ const landingPageEnabled = adminSettings.landing_page_enabled;
406
+ const udpSettings: UDPSettings = {
407
+ enabled: adminSettings.udp.enabled,
408
+ maxLeases: adminSettings.udp.max_leases,
409
+ };
410
+ const tcpPortSettings: TCPPortSettings = {
411
+ enabled: adminSettings.tcp_port.enabled,
412
+ maxLeases: adminSettings.tcp_port.max_leases,
413
+ };
414
+
415
return {
416
servers,
417
...listState,
frontend/src/hooks/useAuth.ts
+37
-104
@@ -9,11 +9,15 @@ import {
9
import { API_PATHS } from "@/lib/apiPaths";
10
import { APIClientError, apiClient } from "@/lib/apiClient";
11
import { writeAdminAuthToken } from "@/lib/adminAuthToken";
12
+import type {
13
+ WalletAuthChallengeResponse,
14
+ WalletAuthLoginResponse,
15
+ WalletAuthStatusResponse,
16
+} from "@/types/api";
17
18
interface AuthState {
19
isAuthenticated: boolean;
20
isLoading: boolean;
16
- authTarget: ResolvedAuthTarget | "";
21
walletAddress: string;
22
}
23
@@ -22,79 +26,30 @@ interface LoginResult {
26
error?: string;
27
}
28
25
-interface WalletAuthStatusPayload {
26
- authenticated: boolean;
27
- wallet_address?: string;
28
-}
29
-
30
-interface WalletAuthChallengePayload {
31
- challenge_id: string;
32
- siwe_message: string;
33
-}
34
-
35
-interface WalletAuthLoginPayload {
36
- access_token?: string;
37
- wallet_address?: string;
38
-}
39
-
40
-type AuthTarget = "admin" | "agent" | "auto";
41
-type ResolvedAuthTarget = "admin" | "agent";
42
-
43
-const authPaths = {
44
- admin: {
45
- challenge: API_PATHS.admin.authChallenge,
46
- login: API_PATHS.admin.authLogin,
47
- logout: API_PATHS.admin.logout,
48
- status: API_PATHS.admin.authStatus,
49
- },
50
- agent: {
51
- challenge: API_PATHS.agent.authChallenge,
52
- login: API_PATHS.agent.authLogin,
53
- logout: API_PATHS.agent.authLogout,
54
- status: API_PATHS.agent.authStatus,
55
- },
56
-} as const;
57
-
58
-function authCandidates(target: AuthTarget, preferred?: ResolvedAuthTarget | ""): ResolvedAuthTarget[] {
59
- if (target === "admin" || target === "agent") {
60
- return [target];
61
- }
62
- if (preferred === "admin") {
63
- return ["admin", "agent"];
64
- }
65
- if (preferred === "agent") {
66
- return ["agent", "admin"];
67
- }
68
- return ["admin", "agent"];
69
-}
70
-
71
-function emptyAuthState(target: ResolvedAuthTarget | "" = ""): AuthState {
29
+function emptyAuthState(): AuthState {
30
return {
31
isAuthenticated: false,
32
isLoading: false,
75
- authTarget: target,
33
walletAddress: "",
34
};
35
}
36
80
-async function fetchAuthState(target: AuthTarget, preferred?: ResolvedAuthTarget | ""): Promise<AuthState> {
81
- for (const candidate of authCandidates(target, preferred)) {
82
- try {
83
- const data = await apiClient.get<WalletAuthStatusPayload>(authPaths[candidate].status);
84
- return {
85
- isAuthenticated: data.authenticated,
86
- isLoading: false,
87
- authTarget: candidate,
88
- walletAddress: data.wallet_address || "",
89
- };
90
- } catch {
91
- continue;
92
- }
37
+async function fetchAuthState(): Promise<AuthState> {
38
+ try {
39
+ const data = await apiClient.get<WalletAuthStatusResponse>(
40
+ API_PATHS.admin.authStatus
41
+ );
42
+ return {
43
+ isAuthenticated: data.authenticated,
44
+ isLoading: false,
45
+ walletAddress: data.wallet_address || "",
46
+ };
47
+ } catch {
48
+ return emptyAuthState();
49
}
94
- return emptyAuthState(target === "admin" || target === "agent" ? target : "");
50
}
51
97
-export function useAuth(target: AuthTarget = "admin") {
52
+export function useAuth() {
53
const { address: connectedAddress, isConnected } = useAccount();
54
const connectors = useConnectors();
55
const { connectAsync } = useConnect();
@@ -103,19 +58,18 @@ export function useAuth(target: AuthTarget = "admin") {
58
const [authState, setAuthState] = useState<AuthState>({
59
isAuthenticated: false,
60
isLoading: true,
106
- authTarget: "",
61
walletAddress: "",
62
});
63
64
const checkAuth = async () => {
111
- setAuthState(await fetchAuthState(target, authState.authTarget));
65
+ setAuthState(await fetchAuthState());
66
};
67
68
useEffect(() => {
69
void (async () => {
116
- setAuthState(await fetchAuthState(target));
70
+ setAuthState(await fetchAuthState());
71
})();
118
- }, [target]);
72
+ }, []);
73
74
const login = async (): Promise<LoginResult> => {
75
try {
@@ -131,44 +85,31 @@ export function useAuth(target: AuthTarget = "admin") {
85
if (!address) {
86
return { success: false, error: "Wallet provider is unavailable." };
87
}
134
- let authTarget = authState.authTarget;
135
- if (!authTarget) {
136
- const nextState = await fetchAuthState(target);
137
- setAuthState(nextState);
138
- authTarget = nextState.authTarget;
139
- }
140
- if (!authTarget) {
141
- return { success: false, error: "Wallet login is unavailable." };
142
- }
143
-
144
- const challenge = await apiClient.post<WalletAuthChallengePayload>(
145
- authPaths[authTarget].challenge,
88
+ const challenge = await apiClient.post<WalletAuthChallengeResponse>(
89
+ API_PATHS.admin.authChallenge,
90
{ address }
91
);
92
const signature = await signMessageAsync({
93
account: address,
94
message: challenge.siwe_message,
95
});
152
- const data = await apiClient.post<WalletAuthLoginPayload>(
153
- authPaths[authTarget].login,
96
+ const data = await apiClient.post<WalletAuthLoginResponse>(
97
+ API_PATHS.admin.authLogin,
98
{
99
challenge_id: challenge.challenge_id,
100
siwe_message: challenge.siwe_message,
101
siwe_signature: signature,
102
}
103
);
160
- if (authTarget === "admin") {
161
- const accessToken = data.access_token?.trim() || "";
162
- if (!accessToken) {
163
- writeAdminAuthToken("");
164
- return { success: false, error: "Admin login did not return an access token." };
165
- }
166
- writeAdminAuthToken(accessToken);
104
+ const accessToken = data.access_token?.trim() || "";
105
+ if (!accessToken) {
106
+ writeAdminAuthToken("");
107
+ return { success: false, error: "Admin login did not return an access token." };
108
}
109
+ writeAdminAuthToken(accessToken);
110
setAuthState((prev) => ({
111
...prev,
112
isAuthenticated: true,
171
- authTarget,
113
walletAddress: data.wallet_address || address,
114
}));
115
return { success: true };
@@ -188,20 +129,12 @@ export function useAuth(target: AuthTarget = "admin") {
129
};
130
131
const logout = async () => {
191
- const candidates = authCandidates(target, authState.authTarget);
192
- for (const candidate of candidates) {
193
- try {
194
- await apiClient.post<unknown>(authPaths[candidate].logout);
195
- if (candidate === "admin") {
196
- writeAdminAuthToken("");
197
- }
198
- break;
199
- } catch {
200
- if (candidate === "admin") {
201
- writeAdminAuthToken("");
202
- }
203
- continue;
204
- }
132
+ try {
133
+ await apiClient.post<unknown>(API_PATHS.admin.logout);
134
+ } catch {
135
+ // Logging out should clear local state even if the remote token is stale.
136
+ } finally {
137
+ writeAdminAuthToken("");
138
}
139
setAuthState((prev) => ({ ...prev, isAuthenticated: false, walletAddress: "" }));
140
try {
frontend/src/hooks/useServerList.ts
+18
-18
@@ -3,17 +3,17 @@ import { useList, type BaseServer } from "@/hooks/useList";
3
import { apiClient } from "@/lib/apiClient";
4
import { API_PATHS } from "@/lib/apiPaths";
5
import { parseLeaseMetadata } from "@/lib/metadata";
6
-import type { PublicLeaseData, PublicSnapshotResponse } from "@/types/lease";
6
+import type { Lease, PublicStateResponse } from "@/types/api";
7
8
-type PublicSnapshot = {
9
- leases: PublicLeaseData[];
8
+type PublicState = {
9
+ leases: Lease[];
10
landingPageEnabled: boolean;
11
};
12
13
-function convertPublicLeasesToServers(leases: PublicLeaseData[]): BaseServer[] {
13
+function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
14
return leases.map((row) => {
15
- const metadata = parseLeaseMetadata(row.Metadata);
16
- const hostname = row.Hostname || "";
15
+ const metadata = parseLeaseMetadata(row.metadata);
16
+ const hostname = row.hostname || "";
17
const serviceName = row.name || "";
18
19
return {
@@ -23,17 +23,17 @@ function convertPublicLeasesToServers(leases: PublicLeaseData[]): BaseServer[] {
23
tags: metadata.tags,
24
thumbnail: metadata.thumbnail || "",
25
owner: metadata.owner || "",
26
- online: (row.Ready || 0) > 0,
26
+ online: (row.ready || 0) > 0,
27
dns: hostname,
28
link: hostname ? `https://${hostname}/` : "",
29
- lastUpdated: row.LastSeenAt || undefined,
30
- firstSeen: row.FirstSeenAt || undefined,
29
+ lastUpdated: row.last_seen_at || undefined,
30
+ firstSeen: row.first_seen_at || undefined,
31
};
32
});
33
}
34
35
export function useServerList() {
36
- const [snapshot, setSnapshot] = useState<PublicSnapshot>({
36
+ const [publicState, setPublicState] = useState<PublicState>({
37
leases: [],
38
landingPageEnabled: true,
39
});
@@ -43,20 +43,20 @@ export function useServerList() {
43
44
void (async () => {
45
try {
46
- const data = await apiClient.get<PublicSnapshotResponse>(
47
- API_PATHS.public.snapshot
46
+ const data = await apiClient.get<PublicStateResponse>(
47
+ API_PATHS.public.state
48
);
49
if (cancelled) {
50
return;
51
}
52
- setSnapshot({
52
+ setPublicState({
53
leases: Array.isArray(data?.leases) ? data.leases : [],
54
landingPageEnabled: data?.landing_page_enabled ?? true,
55
});
56
} catch (error) {
57
- console.error("Failed to load public relay snapshot", error);
57
+ console.error("Failed to load public relay state", error);
58
if (!cancelled) {
59
- setSnapshot({ leases: [], landingPageEnabled: true });
59
+ setPublicState({ leases: [], landingPageEnabled: true });
60
}
61
}
62
})();
@@ -67,8 +67,8 @@ export function useServerList() {
67
}, []);
68
69
const servers: BaseServer[] = useMemo(
70
- () => convertPublicLeasesToServers(snapshot.leases),
71
- [snapshot.leases]
70
+ () => convertPublicLeasesToServers(publicState.leases),
71
+ [publicState.leases]
72
);
73
74
const list = useList({
@@ -78,6 +78,6 @@ export function useServerList() {
78
79
return {
80
...list,
81
- landingPageEnabled: snapshot.landingPageEnabled,
81
+ landingPageEnabled: publicState.landingPageEnabled,
82
};
83
}
frontend/src/lib/apiClient.test.ts
+2
-11
@@ -133,7 +133,7 @@ describe("apiClient", () => {
133
} satisfies Partial<APIClientError>);
134
});
135
136
- it("sends JSON bodies for post and omits content-type for delete without body", async () => {
136
+ it("sends JSON bodies for post", async () => {
137
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
138
await apiClient.post("/api/post", { id: 1 });
139
@@ -144,22 +144,13 @@ describe("apiClient", () => {
144
Accept: "application/json",
145
"Content-Type": "application/json",
146
});
147
-
148
- fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
149
- await apiClient.delete("/api/post");
150
-
151
- const deleteInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
152
- expect(deleteInit.method).toBe("DELETE");
153
- expect(deleteInit.headers).toEqual({
154
- Accept: "application/json",
155
- });
147
});
148
149
it("sends bearer token for admin API calls", async () => {
150
writeAdminAuthToken("admin-token");
151
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
152
162
- await apiClient.post("/admin/logout");
153
+ await apiClient.post("/admin/auth/logout");
154
155
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
156
expect(init.credentials).toBe("same-origin");
frontend/src/lib/apiClient.ts
+26
-29
@@ -1,15 +1,6 @@
1
import { readAdminAuthToken } from "@/lib/adminAuthToken";
2
-
3
-type APIErrorPayload = {
4
- code?: string;
5
- message?: string;
6
-};
7
-
8
-type APIEnvelope<T> = {
9
- ok?: boolean;
10
- data?: T;
11
- error?: APIErrorPayload;
12
-};
2
+import { API_PATHS } from "@/lib/apiPaths";
3
+import type { APIEnvelope } from "@/types/api";
4
5
export class APIClientError extends Error {
6
readonly code: string;
@@ -48,19 +39,28 @@ function ensureJsonEnvelope<T>(raw: unknown, path: string, status: number): APIE
39
if (!isRecord(raw) || typeof raw.ok !== "boolean") {
40
throw new APIClientError(`Invalid API response for ${path}`, status, "invalid_envelope", raw);
41
}
51
- if (raw.error !== undefined && !isRecord(raw.error)) {
42
+ if (raw.ok) {
43
+ return {
44
+ ok: true,
45
+ data: (raw as { data: T }).data,
46
+ };
47
+ }
48
+
49
+ if (!isRecord(raw.error)) {
50
throw new APIClientError(`Invalid error payload for ${path}`, status, "invalid_envelope", raw.error);
51
}
52
const errorValue = raw.error;
53
+ if (typeof errorValue.code !== "string" || typeof errorValue.message !== "string") {
54
+ throw new APIClientError(`Invalid error payload for ${path}`, status, "invalid_envelope", raw.error);
55
+ }
56
+
57
return {
56
- ok: raw.ok,
57
- data: (raw as { data?: T }).data,
58
- error: errorValue
59
- ? {
60
- code: typeof errorValue.code === "string" ? errorValue.code : "request_failed",
61
- message: typeof errorValue.message === "string" ? errorValue.message : "Request failed",
62
- }
63
- : undefined,
58
+ ok: false,
59
+ data: raw.data,
60
+ error: {
61
+ code: errorValue.code,
62
+ message: errorValue.message,
63
+ },
64
};
65
}
66
@@ -99,8 +99,8 @@ async function request<T>(path: string, init: RequestInit): Promise<T> {
99
const pathname = new URL(path, window.location.origin).pathname;
100
if (
101
pathname.startsWith("/admin/") &&
102
- pathname !== "/admin/auth/challenge" &&
103
- pathname !== "/admin/auth/login"
102
+ pathname !== API_PATHS.admin.authChallenge &&
103
+ pathname !== API_PATHS.admin.authLogin
104
) {
105
const token = readAdminAuthToken();
106
if (token) {
@@ -137,13 +137,13 @@ async function request<T>(path: string, init: RequestInit): Promise<T> {
137
throw new APIClientError(message, response.status, code, envelope.data);
138
}
139
140
-function jsonRequestInit(method: "POST" | "DELETE", body?: unknown): RequestInit {
140
+function jsonRequestInit(body?: unknown): RequestInit {
141
if (body === undefined) {
142
- return { method, headers: {} };
142
+ return { method: "POST", headers: {} };
143
}
144
145
return {
146
- method,
146
+ method: "POST",
147
headers: { "Content-Type": "application/json" },
148
body: JSON.stringify(body),
149
};
@@ -154,9 +154,6 @@ export const apiClient = {
154
return request<T>(path, { method: "GET" });
155
},
156
post<T>(path: string, body?: unknown): Promise<T> {
157
- return request<T>(path, jsonRequestInit("POST", body));
158
- },
159
- delete<T>(path: string): Promise<T> {
160
- return request<T>(path, jsonRequestInit("DELETE"));
157
+ return request<T>(path, jsonRequestInit(body));
158
},
159
};
frontend/src/lib/apiPaths.test.ts
deleted
-24
@@ -1,24 +0,0 @@
1
-import { describe, expect, it } from "vitest";
2
-
3
-import { adminLeasePath } from "@/lib/apiPaths";
4
-
5
-describe("API_PATHS contract alignment", () => {
6
- it("encodes lease identities as base64url path segments", () => {
7
- const name = "relay-1";
8
- const address = "0x00000000000000000000000000000000000000A1";
9
- const expectedName = Buffer.from(name)
10
- .toString("base64")
11
- .replace(/\+/g, "-")
12
- .replace(/\//g, "_")
13
- .replace(/=+$/, "");
14
- const expectedAddress = Buffer.from(address)
15
- .toString("base64")
16
- .replace(/\+/g, "-")
17
- .replace(/\//g, "_")
18
- .replace(/=+$/, "");
19
- expect(expectedAddress).not.toContain("=");
20
- expect(adminLeasePath(name, address, "approve")).toBe(
21
- `/admin/leases/${encodeURIComponent(expectedName)}/${encodeURIComponent(expectedAddress)}/approve`
22
- );
23
- });
24
-});
frontend/src/lib/apiPaths.ts
+8
-37
@@ -1,30 +1,22 @@
1
export const API_PATHS = {
2
public: {
3
- snapshot: "/api/public/snapshot",
3
+ state: "/state",
4
},
5
admin: {
6
- snapshot: "/admin/snapshot",
6
+ state: "/admin/state",
7
authChallenge: "/admin/auth/challenge",
8
authLogin: "/admin/auth/login",
9
- logout: "/admin/logout",
9
+ logout: "/admin/auth/logout",
10
authStatus: "/admin/auth/status",
11
-
12
- approvalMode: "/admin/settings/approval-mode",
13
- landingPage: "/admin/settings/landing-page",
14
- udpSettings: "/admin/settings/udp",
15
- tcpPortSettings: "/admin/settings/tcp-port",
11
+ settings: "/admin/settings",
12
+ leasePolicy: "/admin/lease-policy",
13
+ ipPolicy: "/admin/ip-policy",
14
},
15
sdk: {
16
domain: "/sdk/domain",
17
},
20
- tunnel: {
21
- status: "/tunnel/status",
22
- },
23
- agent: {
24
- authChallenge: "/v1/agent/auth/challenge",
25
- authLogin: "/v1/agent/auth/login",
26
- authLogout: "/v1/agent/auth/logout",
27
- authStatus: "/v1/agent/auth/status",
18
+ service: {
19
+ status: "/service/status",
20
},
21
discovery: "/discovery",
22
install: {
@@ -38,24 +30,3 @@ export const ROUTE_PATHS = {
30
serverDetail: "/server/:id",
31
admin: "/admin",
32
} as const;
41
-
42
-const ADMIN_LEASES_PATH = "/admin/leases";
43
-const ADMIN_IPS_PATH = "/admin/ips";
44
-
45
-function encodePathPart(value: string): string {
46
- return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
47
-}
48
-
49
-export function adminLeasePath(
50
- name: string,
51
- address: string,
52
- action: "ban" | "bps" | "approve" | "deny"
53
-): string {
54
- const encodedName = encodePathPart(name);
55
- const encodedAddress = encodePathPart(address);
56
- return `${ADMIN_LEASES_PATH}/${encodeURIComponent(encodedName)}/${encodeURIComponent(encodedAddress)}/${action}`;
57
-}
58
-
59
-export function adminIPBanPath(ip: string): string {
60
- return `${ADMIN_IPS_PATH}/${encodeURIComponent(ip.trim())}/ban`;
61
-}
frontend/src/lib/metadata.ts
+6
-1
@@ -1,4 +1,9 @@
1
-import type { Metadata } from "@/types/lease";
1
+interface Metadata {
2
+ description: string;
3
+ tags: string[];
4
+ thumbnail: string;
5
+ owner: string;
6
+}
7
8
const EMPTY_METADATA: Metadata = {
9
description: "",
frontend/src/lib/tunnelCommand.ts
+1
-1
@@ -154,7 +154,7 @@ export function buildTunnelPreviewURL(
154
return `https://${subdomain}.${baseHost}`;
155
}
156
157
-export function buildTunnelStatusHostname(
157
+export function buildServiceStatusHostname(
158
origin: string,
159
name: string,
160
target: string,
frontend/src/pages/Admin.tsx
+1
-1
@@ -9,7 +9,7 @@ export function Admin() {
9
isAuthenticated,
10
isLoading: authLoading,
11
checkAuth,
12
- } = useAuth("admin");
12
+ } = useAuth();
13
14
const {
15
servers,
frontend/src/types/api.ts
new
+142
@@ -0,0 +1,142 @@
1
+export interface APIErrorPayload {
2
+ code: string;
3
+ message: string;
4
+}
5
+
6
+export type APIEnvelope<T> =
7
+ | {
8
+ ok: true;
9
+ data: T;
10
+ error?: never;
11
+ }
12
+ | {
13
+ ok: false;
14
+ error: APIErrorPayload;
15
+ data?: unknown;
16
+ };
17
+
18
+export type ApprovalMode = "auto" | "manual";
19
+
20
+export interface LeaseMetadata {
21
+ description?: string;
22
+ owner?: string;
23
+ thumbnail?: string;
24
+ tags?: string[];
25
+ hide?: boolean;
26
+}
27
+
28
+export interface Lease {
29
+ name?: string;
30
+ expires_at: string;
31
+ first_seen_at: string;
32
+ last_seen_at: string;
33
+ hostname: string;
34
+ udp_enabled?: boolean;
35
+ tcp_enabled?: boolean;
36
+ tcp_addr?: string;
37
+ metadata: LeaseMetadata;
38
+ ready: number;
39
+}
40
+
41
+export interface AdminLease extends Lease {
42
+ identity_key: string;
43
+ address: string;
44
+ bps: number;
45
+ client_ip: string;
46
+ reported_ip?: string;
47
+ is_approved: boolean;
48
+ is_banned: boolean;
49
+ is_denied: boolean;
50
+ is_ip_banned: boolean;
51
+}
52
+
53
+export interface PublicStateResponse {
54
+ leases?: Lease[];
55
+ landing_page_enabled: boolean;
56
+}
57
+
58
+export interface AdminPortSettings {
59
+ enabled: boolean;
60
+ max_leases: number;
61
+}
62
+
63
+export interface AdminStateResponse {
64
+ settings: AdminSettings;
65
+ leases?: AdminLease[];
66
+}
67
+
68
+export interface AdminSettings {
69
+ approval_mode: ApprovalMode;
70
+ landing_page_enabled: boolean;
71
+ udp: AdminPortSettings;
72
+ tcp_port: AdminPortSettings;
73
+}
74
+
75
+export interface WalletAuthStatusResponse {
76
+ authenticated: boolean;
77
+ wallet_address?: string;
78
+}
79
+
80
+export interface WalletAuthChallengeResponse {
81
+ challenge_id: string;
82
+ expires_at: string;
83
+ siwe_message: string;
84
+}
85
+
86
+export interface WalletAuthLoginResponse {
87
+ access_token?: string;
88
+ wallet_address?: string;
89
+}
90
+
91
+export interface ENSStatus {
92
+ enabled: boolean;
93
+ verified: boolean;
94
+ provider?: string;
95
+ address?: string;
96
+ dnssec_state?: string;
97
+ ds_record?: string;
98
+ message?: string;
99
+ last_error?: string;
100
+}
101
+
102
+export interface X402FacilitatorInfo {
103
+ enabled: boolean;
104
+ url?: string;
105
+ network?: string;
106
+ network_name?: string;
107
+ supported_url?: string;
108
+}
109
+
110
+export interface DomainResponse {
111
+ protocol_version: string;
112
+ release_version: string;
113
+ ens: ENSStatus;
114
+ x402: X402FacilitatorInfo;
115
+}
116
+
117
+export interface RelayDescriptor {
118
+ api_https_addr?: string;
119
+}
120
+
121
+export interface DiscoveryResponse {
122
+ relays?: RelayDescriptor[];
123
+}
124
+
125
+export interface ServiceStatusResponse {
126
+ hostname: string;
127
+ registered: boolean;
128
+ service_alive: boolean;
129
+}
130
+
131
+export interface AdminLeasePolicy {
132
+ identity_key: string;
133
+ bps?: number;
134
+ is_approved?: boolean;
135
+ is_banned?: boolean;
136
+ is_denied?: boolean;
137
+}
138
+
139
+export interface AdminIPPolicy {
140
+ ip: string;
141
+ is_banned: boolean;
142
+}
frontend/src/types/lease.ts
deleted
-32
@@ -1,32 +0,0 @@
1
-export interface Metadata {
2
- description: string;
3
- tags: string[];
4
- thumbnail: string;
5
- owner: string;
6
-}
7
-
8
-export interface PublicLeaseData {
9
- FirstSeenAt: string;
10
- LastSeenAt: string;
11
- name?: string;
12
- Hostname: string;
13
- Metadata: unknown;
14
- Ready: number;
15
-}
16
-
17
-export interface AdminLeaseData extends PublicLeaseData {
18
- identity_key: string;
19
- address: string;
20
- BPS: number;
21
- ClientIP: string;
22
- ReportedIP: string;
23
- IsApproved: boolean;
24
- IsBanned: boolean;
25
- IsDenied: boolean;
26
- IsIPBanned: boolean;
27
-}
28
-
29
-export interface PublicSnapshotResponse {
30
- leases?: PublicLeaseData[];
31
- landing_page_enabled?: boolean;
32
-}
portal/x402/x402.go
+1
-1
@@ -84,7 +84,7 @@ func MountFacilitator(mux *http.ServeMux, cfg FacilitatorConfig) error {
84
if err != nil {
85
return fmt.Errorf("create x402 facilitator: %w", err)
86
}
87
- mux.Handle(types.PathX402FacilitatorPrefix, http.StripPrefix(types.PathX402Facilitator, facilitatorapi.NewServer(facilitator)))
87
+ mux.Handle(types.PathX402Facilitator+"/", http.StripPrefix(types.PathX402Facilitator, facilitatorapi.NewServer(facilitator)))
88
return nil
89
}
90
types/api.go
+20
-33
@@ -201,13 +201,13 @@ type ENSStatus struct {
201
LastError string `json:"last_error,omitempty"`
202
}
203
204
-type TunnelStatusResponse struct {
204
+type ServiceStatusResponse struct {
205
Hostname string `json:"hostname"`
206
Registered bool `json:"registered"`
207
ServiceAlive bool `json:"service_alive"`
208
}
209
210
-type PublicSnapshotResponse struct {
210
+type PublicStateResponse struct {
211
Leases []Lease `json:"leases,omitempty"`
212
LandingPageEnabled bool `json:"landing_page_enabled"`
213
}
@@ -238,45 +238,32 @@ type WalletAuthStatusResponse struct {
238
WalletAddress string `json:"wallet_address,omitempty"`
239
}
240
241
-type AdminSnapshotResponse struct {
242
- ApprovalMode string `json:"approval_mode"`
243
- LandingPageEnabled bool `json:"landing_page_enabled"`
244
- Leases []AdminLease `json:"leases,omitempty"`
245
- UDP AdminUDPSettingsResponse `json:"udp"`
246
- TCPPort AdminTCPPortSettingsResponse `json:"tcp_port"`
241
+type AdminStateResponse struct {
242
+ Settings AdminSettings `json:"settings"`
243
+ Leases []AdminLease `json:"leases,omitempty"`
244
}
245
249
-type AdminApprovalModeRequest struct {
250
- Mode string `json:"mode"`
246
+type AdminSettings struct {
247
+ ApprovalMode string `json:"approval_mode"`
248
+ LandingPageEnabled bool `json:"landing_page_enabled"`
249
+ UDP AdminPortSettings `json:"udp"`
250
+ TCPPort AdminPortSettings `json:"tcp_port"`
251
}
252
253
-type AdminApprovalModeResponse struct {
254
- ApprovalMode string `json:"approval_mode"`
253
+type AdminLeasePolicy struct {
254
+ IdentityKey string `json:"identity_key"`
255
+ BPS *int64 `json:"bps,omitempty"`
256
+ IsApproved *bool `json:"is_approved,omitempty"`
257
+ IsBanned *bool `json:"is_banned,omitempty"`
258
+ IsDenied *bool `json:"is_denied,omitempty"`
259
}
260
257
-type AdminLandingPageSettingsRequest struct {
258
- Enabled bool `json:"enabled"`
259
-}
260
-
261
-type AdminLandingPageSettingsResponse struct {
262
- Enabled bool `json:"enabled"`
263
-}
264
-
265
-type AdminBPSRequest struct {
266
- BPS int64 `json:"bps"`
267
-}
268
-
269
-type AdminPortSettingsRequest struct {
261
+type AdminPortSettings struct {
262
Enabled bool `json:"enabled"`
263
MaxLeases int `json:"max_leases"`
264
}
265
274
-type AdminUDPSettingsResponse struct {
275
- Enabled bool `json:"enabled"`
276
- MaxLeases int `json:"max_leases"`
277
-}
278
-
279
-type AdminTCPPortSettingsResponse struct {
280
- Enabled bool `json:"enabled"`
281
- MaxLeases int `json:"max_leases"`
266
+type AdminIPPolicy struct {
267
+ IP string `json:"ip"`
268
+ IsBanned bool `json:"is_banned"`
269
}
types/identity.go
+17
-17
@@ -78,29 +78,29 @@ func (m LeaseMetadata) Copy() LeaseMetadata {
78
}
79
80
type Lease struct {
81
- Name string `json:"name,omitempty"`
82
- ExpiresAt time.Time
83
- FirstSeenAt time.Time
84
- LastSeenAt time.Time
85
- Hostname string
86
- UDPEnabled bool
87
- TCPEnabled bool
88
- TCPAddr string
89
- Metadata LeaseMetadata
90
- Ready int
81
+ Name string `json:"name,omitempty"`
82
+ ExpiresAt time.Time `json:"expires_at"`
83
+ FirstSeenAt time.Time `json:"first_seen_at"`
84
+ LastSeenAt time.Time `json:"last_seen_at"`
85
+ Hostname string `json:"hostname"`
86
+ UDPEnabled bool `json:"udp_enabled,omitempty"`
87
+ TCPEnabled bool `json:"tcp_enabled,omitempty"`
88
+ TCPAddr string `json:"tcp_addr,omitempty"`
89
+ Metadata LeaseMetadata `json:"metadata"`
90
+ Ready int `json:"ready"`
91
}
92
93
type AdminLease struct {
94
Lease
95
IdentityKey string `json:"identity_key,omitempty"`
96
Address string `json:"address,omitempty"`
97
- BPS int64
98
- ClientIP string
99
- ReportedIP string
100
- IsApproved bool
101
- IsBanned bool
102
- IsDenied bool
103
- IsIPBanned bool
97
+ BPS int64 `json:"bps"`
98
+ ClientIP string `json:"client_ip"`
99
+ ReportedIP string `json:"reported_ip,omitempty"`
100
+ IsApproved bool `json:"is_approved"`
101
+ IsBanned bool `json:"is_banned"`
102
+ IsDenied bool `json:"is_denied"`
103
+ IsIPBanned bool `json:"is_ip_banned"`
104
}
105
106
type RelayDescriptor struct {
types/paths.go
+35
-35
@@ -1,39 +1,27 @@
1
package types
2
3
const (
4
- PathV1Sign = "/v1/sign"
5
- PathHealthz = "/healthz"
6
- PathRoot = "/"
4
+ PathV1Sign = "/v1/sign"
5
+ PathHealthz = "/healthz"
6
+ PathRoot = "/"
7
+
8
PathAdmin = "/admin"
9
PathAdminPrefix = "/admin/"
9
- PathAdminSnapshot = "/admin/snapshot"
10
- PathAdminLeasesPrefix = "/admin/leases/"
10
+ PathAdminState = "/admin/state"
11
+ PathAdminSettings = "/admin/settings"
12
+ PathAdminLeasePolicy = "/admin/lease-policy"
13
+ PathAdminIPPolicy = "/admin/ip-policy"
14
PathAdminAuthChallenge = "/admin/auth/challenge"
15
PathAdminAuthLogin = "/admin/auth/login"
13
- PathAdminLogout = "/admin/logout"
16
+ PathAdminLogout = "/admin/auth/logout"
17
PathAdminAuthStatus = "/admin/auth/status"
15
- PathAdminApproval = "/admin/settings/approval-mode"
16
- PathAdminLandingPage = "/admin/settings/landing-page"
17
- PathAdminUDP = "/admin/settings/udp"
18
- PathAdminTCPPort = "/admin/settings/tcp-port"
19
- PathAdminIPsPrefix = "/admin/ips/"
20
- PathInstallShell = "/install.sh"
21
- PathInstallPowerShell = "/install.ps1"
22
- PathInstallBinPrefix = "/install/bin/"
23
-
24
- PathPublicSnapshot = "/api/public/snapshot"
25
-
26
- PathAgentPrefix = "/v1/agent"
27
- PathAgentStatus = PathAgentPrefix + "/status"
28
- PathAgentShutdown = PathAgentPrefix + "/shutdown"
29
- PathAgentTunnels = PathAgentPrefix + "/tunnels"
30
- PathAgentTunnelsPrefix = PathAgentPrefix + "/tunnels/"
31
- PathAgentAuthChallenge = PathAgentPrefix + "/auth/challenge"
32
- PathAgentAuthLogin = PathAgentPrefix + "/auth/login"
33
- PathAgentAuthLogout = PathAgentPrefix + "/auth/logout"
34
- PathAgentAuthStatus = PathAgentPrefix + "/auth/status"
18
+ PathPublicState = "/state"
19
36
- PathTunnelStatus = "/tunnel/status"
20
+ PathInstallShell = "/install.sh"
21
+ PathInstallPowerShell = "/install.ps1"
22
+ PathInstallBinPrefix = "/install/bin/"
23
+
24
+ PathServiceStatus = "/service/status"
25
PathThumbnailPrefix = "/thumbnail/"
26
27
PathSDKDomain = "/sdk/domain"
@@ -43,12 +31,24 @@ const (
31
PathSDKUnregister = "/sdk/unregister"
32
PathSDKHop = "/sdk/hop"
33
PathSDKConnect = "/sdk/connect"
46
- PathDiscovery = "/discovery"
47
- PathDiscoveryAnnounce = "/discovery/announce"
48
-
49
- PathX402Facilitator = "/x402"
50
- PathX402FacilitatorPrefix = PathX402Facilitator + "/"
51
- X402SupportedPath = PathX402Facilitator + "/supported"
52
- X402VerifyPath = PathX402Facilitator + "/verify"
53
- X402SettlePath = PathX402Facilitator + "/settle"
34
+
35
+ PathDiscovery = "/discovery"
36
+ PathDiscoveryAnnounce = "/discovery/announce"
37
+
38
+ PathX402Facilitator = "/x402"
39
+ X402SupportedPath = PathX402Facilitator + "/supported"
40
+ X402VerifyPath = PathX402Facilitator + "/verify"
41
+ X402SettlePath = PathX402Facilitator + "/settle"
42
+)
43
+
44
+const (
45
+ PathAgentPrefix = "/agent"
46
+ PathAgentStatus = PathAgentPrefix + "/status"
47
+ PathAgentShutdown = PathAgentPrefix + "/shutdown"
48
+ PathAgentTunnels = PathAgentPrefix + "/tunnels"
49
+ PathAgentTunnelsPrefix = PathAgentPrefix + "/tunnels/"
50
+ PathAgentAuthChallenge = PathAgentPrefix + "/auth/challenge"
51
+ PathAgentAuthLogin = PathAgentPrefix + "/auth/login"
52
+ PathAgentAuthLogout = PathAgentPrefix + "/auth/logout"
53
+ PathAgentAuthStatus = PathAgentPrefix + "/auth/status"
54
)