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 )