refactor: rename admin settings to policy and update related paths and types

Kim committed May 29, 2026 at 16:07 UTC a3e0e1b07349aede28d3781139a9983c8ab72c0f
55 files changed +2212 -1606
.dockerignore
+1
@@ -13,6 +13,7 @@ docs/
13
14 # Local relay/tunnel state
15 .portal-certs/
16 +.portal-frontend-state/
17 *identity.json
18
19 # IDE
.env.example
+9 -4
@@ -15,7 +15,7 @@ MAX_PORT=0
15 UDP_ENABLED=false
16 TCP_ENABLED=false
17
18 -# Supported managed values: cloudflare, gcloud, hetzner, route53, vultr.
18 +# Supported managed values: cloudflare, gcloud, hetzner, njalla, route53, vultr.
19 # Reused for ACME DNS-01, managed A records, ECH HTTPS records, and optional ENS DNS automation.
20 ACME_DNS_PROVIDER=
21
@@ -43,21 +43,26 @@ AWS_DNSSEC_KMS_KEY_ARN=
43 # Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr)
44 VULTR_API_KEY=
45
46 +# Njalla DNS settings (required when ACME_DNS_PROVIDER=njalla)
47 +NJALLA_TOKEN=
48 +
49 # ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
50 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
51 ENS_GASLESS_ENABLED=false
52
53 # Admin/auth configuration. The relay identity wallet is always allowed.
54 ADMIN_WALLETS=
52 -LANDING_PAGE_ENABLED=false
55 # Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
56 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
57 TRUST_PROXY_HEADERS=false
58 TRUSTED_PROXY_CIDRS=
59
60 +# Frontend-owned presentation state.
61 +LANDING_PAGE_ENABLED=false
62 +
63 # Optional: auto-generated thumbnail screenshots for tunnel apps without a thumbnail.
59 -# Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
60 -# Leave empty or remove to disable. See docs/src/routes/deployment/+page.md.
64 +# Used by the portal-api service. Requires the headless-shell sidecar.
65 +# Leave empty to keep generated screenshots disabled. See docs/src/routes/deployment/+page.md.
66 # HEADLESS_SHELL_URL=ws://headless-shell:9222
67
68 X402_FACILITATOR_ENABLED=false
.github/workflows/branch-artifacts.yml
+25 -5
@@ -51,7 +51,7 @@ jobs:
51 echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
52 echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
53 {
54 - echo "api_image_tags<<EOF"
54 + echo "portal_image_tags<<EOF"
55 echo "${REGISTRY}/gosuda/portal:branch-${safe_ref}"
56 echo "${REGISTRY}/gosuda/portal:branch-${safe_ref}-${short_sha}"
57 echo "EOF"
@@ -59,6 +59,10 @@ jobs:
59 echo "${REGISTRY}/gosuda/portal-frontend:branch-${safe_ref}"
60 echo "${REGISTRY}/gosuda/portal-frontend:branch-${safe_ref}-${short_sha}"
61 echo "EOF"
62 + echo "portal_api_image_tags<<EOF"
63 + echo "${REGISTRY}/gosuda/portal-api:branch-${safe_ref}"
64 + echo "${REGISTRY}/gosuda/portal-api:branch-${safe_ref}-${short_sha}"
65 + echo "EOF"
66 } >> "$GITHUB_OUTPUT"
67
68 - name: Log in to Container Registry
@@ -74,20 +78,20 @@ jobs:
78 - name: Set up Docker Buildx
79 uses: docker/setup-buildx-action@v3
80
77 - - name: Build and push API Docker image
81 + - name: Build and push portal Docker image
82 uses: docker/build-push-action@v6
83 with:
84 context: .
85 file: Dockerfile
86 platforms: ${{ env.PLATFORMS }}
87 push: true
84 - tags: ${{ steps.meta.outputs.api_image_tags }}
88 + tags: ${{ steps.meta.outputs.portal_image_tags }}
89 labels: |
90 org.opencontainers.image.source=https://github.com/${{ github.repository }}
91 org.opencontainers.image.revision=${{ steps.meta.outputs.commit_sha }}
92 org.opencontainers.image.ref.name=${{ env.BUILD_REF }}
89 - cache-from: type=gha,scope=api
90 - cache-to: type=gha,mode=max,scope=api
93 + cache-from: type=gha,scope=portal
94 + cache-to: type=gha,mode=max,scope=portal
95
96 - name: Build and push frontend Docker image
97 uses: docker/build-push-action@v6
@@ -104,6 +108,22 @@ jobs:
108 cache-from: type=gha,scope=frontend
109 cache-to: type=gha,mode=max,scope=frontend
110
111 + - name: Build and push portal API Docker image
112 + uses: docker/build-push-action@v6
113 + with:
114 + context: ./frontend
115 + file: ./frontend/Dockerfile
116 + target: api
117 + platforms: ${{ env.PLATFORMS }}
118 + push: true
119 + tags: ${{ steps.meta.outputs.portal_api_image_tags }}
120 + labels: |
121 + org.opencontainers.image.source=https://github.com/${{ github.repository }}
122 + org.opencontainers.image.revision=${{ steps.meta.outputs.commit_sha }}
123 + org.opencontainers.image.ref.name=${{ env.BUILD_REF }}
124 + cache-from: type=gha,scope=portal-api
125 + cache-to: type=gha,mode=max,scope=portal-api
126 +
127 - name: Build binaries
128 shell: bash
129 run: |
.github/workflows/cd.yml
+7 -1
@@ -27,11 +27,16 @@ jobs:
27 - image: gosuda/portal
28 context: .
29 file: Dockerfile
30 - cache_scope: api
30 + cache_scope: portal
31 - image: gosuda/portal-frontend
32 context: ./frontend
33 file: ./frontend/Dockerfile
34 cache_scope: frontend
35 + - image: gosuda/portal-api
36 + context: ./frontend
37 + file: ./frontend/Dockerfile
38 + target: api
39 + cache_scope: portal-api
40
41 steps:
42 - name: Checkout code
@@ -67,6 +72,7 @@ jobs:
72 with:
73 context: ${{ matrix.context }}
74 file: ${{ matrix.file }}
75 + target: ${{ matrix.target }}
76 platforms: ${{ env.PLATFORMS }}
77 push: true
78 tags: ${{ steps.meta.outputs.tags }}
.gitignore
+1
@@ -147,4 +147,5 @@ keyless_tls/
147
148 # Local relay/tunnel state
149 .portal-certs/
150 +.portal-frontend-state/
151 *identity.json
Dockerfile
+1 -1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
19 make build-tunnel && \
20 GOOS=${TARGETOS} GOARCH=${TARGETARCH} make build-server
21
22 -FROM gcr.io/distroless/static-debian12:nonroot AS api
22 +FROM gcr.io/distroless/static-debian12:nonroot AS runtime
23
24 COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
25
README.md
+1 -1
@@ -109,7 +109,7 @@ cd portal-tunnel && cp .env.example .env
109 docker compose up
110 ```
111
112 -For public deployment with DNS automation (ACME), TCP/UDP port ranges, and admin settings, see [Deployment](docs/src/routes/deployment/+page.md).
112 +For public deployment with DNS automation (ACME), TCP/UDP port ranges, and relay policy, see [Deployment](docs/src/routes/deployment/+page.md).
113
114 ## How End-to-End Encryption Works
115
cmd/relay-server/admin.go deleted
-402
@@ -1,402 +0,0 @@
1 -package main
2 -
3 -import (
4 - "errors"
5 - "net"
6 - "net/http"
7 - "net/url"
8 - "strings"
9 - "time"
10 -
11 - "github.com/gosuda/portal-tunnel/v2/portal"
12 - "github.com/gosuda/portal-tunnel/v2/portal/auth"
13 - "github.com/gosuda/portal-tunnel/v2/portal/identity"
14 - "github.com/gosuda/portal-tunnel/v2/portal/policy"
15 - "github.com/gosuda/portal-tunnel/v2/types"
16 - "github.com/gosuda/portal-tunnel/v2/utils"
17 - "github.com/prometheus/client_golang/prometheus/promhttp"
18 -)
19 -
20 -const (
21 - adminBodyLimit = 1 << 16
22 -)
23 -
24 -func loadAdminState(path string, server *portal.Server) (persistedAdminState, error) {
25 - path = strings.TrimSpace(path)
26 - if path == "" {
27 - return persistedAdminState{}, nil
28 - }
29 -
30 - var payload persistedAdminState
31 - if _, err := utils.ReadJSONFileIfExists(path, &payload); err != nil {
32 - return persistedAdminState{}, err
33 - }
34 - if err := payload.apply(server); err != nil {
35 - return persistedAdminState{}, err
36 - }
37 - return payload, nil
38 -}
39 -
40 -func (api *RelayAPI) serveAdmin(w http.ResponseWriter, r *http.Request) {
41 - path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
42 - if path == "" {
43 - path = types.PathRoot
44 - }
45 -
46 - switch path {
47 - case types.PathAdmin:
48 - http.NotFound(w, r)
49 - return
50 - case types.PathAdminAuthChallenge:
51 - if !utils.RequireMethod(w, r, http.MethodPost) {
52 - return
53 - }
54 - api.handleWalletChallenge(w, r)
55 - return
56 - case types.PathAdminAuthLogin:
57 - if !utils.RequireMethod(w, r, http.MethodPost) {
58 - return
59 - }
60 - api.handleWalletLogin(w, r)
61 - return
62 - case types.PathAdminLogout:
63 - if !utils.RequireMethod(w, r, http.MethodPost) {
64 - return
65 - }
66 - api.auth.DeleteSession(adminAccessToken(r))
67 - utils.WriteAPIData(w, http.StatusOK, map[string]any{})
68 - return
69 - case types.PathAdminAuthStatus:
70 - if !utils.RequireMethod(w, r, http.MethodGet) {
71 - return
72 - }
73 - walletAddress, authenticated := api.authenticatedWallet(r)
74 - utils.WriteAPIData(w, http.StatusOK, types.WalletAuthStatusResponse{
75 - Authenticated: authenticated,
76 - WalletAddress: walletAddress,
77 - })
78 - return
79 - }
80 -
81 - if _, ok := api.authenticatedWallet(r); !ok {
82 - utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
83 - return
84 - }
85 -
86 - runtime := api.server.PolicyRuntime()
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
93 - case types.PathAdminState:
94 - if !utils.RequireMethod(w, r, http.MethodGet) {
95 - return
96 - }
97 - leases := api.server.AdminLeases()
98 - api.attachAutomaticAdminThumbnails(leases)
99 - utils.WriteAPIData(w, http.StatusOK, types.AdminStateResponse{
100 - Settings: api.adminSettings(runtime),
101 - Leases: leases,
102 - })
103 - case types.PathAdminSettings:
104 - if !utils.RequireMethod(w, r, http.MethodPost) {
105 - return
106 - }
107 - req, ok := utils.DecodeJSONRequestAs[types.AdminSettings](w, r, adminBodyLimit, invalidRequestBody)
108 - if !ok {
109 - return
110 - }
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 - }
119 - req, ok := utils.DecodeJSONRequestAs[types.AdminLeasePolicy](w, r, adminBodyLimit, invalidRequestBody)
120 - if !ok {
121 - return
122 - }
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())
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:
153 - http.NotFound(w, r)
154 - }
155 -}
156 -
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 -
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 -
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 -
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 - }
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 - }
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 - }
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) {
251 - req, ok := utils.DecodeJSONRequestAs[types.WalletAuthChallengeRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
252 - if !ok {
253 - return
254 - }
255 - resp, err := api.auth.IssueChallenge(req, adminAuthDomain(r, api.server.RelayIdentity().Name), adminAuthURI(r, types.PathAdminAuthLogin), time.Now().UTC())
256 - if err != nil {
257 - writeWalletAuthError(w, err)
258 - return
259 - }
260 - utils.WriteAPIData(w, http.StatusCreated, resp)
261 -}
262 -
263 -func (api *RelayAPI) handleWalletLogin(w http.ResponseWriter, r *http.Request) {
264 - req, ok := utils.DecodeJSONRequestAs[types.WalletAuthLoginRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
265 - if !ok {
266 - return
267 - }
268 - token, walletAddress, err := api.auth.Login(req, time.Now().UTC())
269 - if err != nil {
270 - writeWalletAuthError(w, err)
271 - return
272 - }
273 -
274 - utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{
275 - AccessToken: token,
276 - WalletAddress: walletAddress,
277 - })
278 -}
279 -
280 -func (api *RelayAPI) authenticatedWallet(r *http.Request) (string, bool) {
281 - return api.auth.ValidateSession(adminAccessToken(r))
282 -}
283 -
284 -func adminAuthDomain(r *http.Request, fallback string) string {
285 - domain := strings.TrimSpace(r.Host)
286 - if domain != "" {
287 - return domain
288 - }
289 - return strings.TrimSpace(fallback)
290 -}
291 -
292 -func adminAuthURI(r *http.Request, endpointPath string) string {
293 - scheme := "https"
294 - if r.TLS == nil {
295 - scheme = "http"
296 - }
297 - return (&url.URL{
298 - Scheme: scheme,
299 - Host: adminAuthDomain(r, "localhost"),
300 - Path: endpointPath,
301 - }).String()
302 -}
303 -
304 -func adminAccessToken(r *http.Request) string {
305 - parts := strings.Fields(r.Header.Get("Authorization"))
306 - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
307 - return ""
308 - }
309 - return strings.TrimSpace(parts[1])
310 -}
311 -
312 -func writeWalletAuthError(w http.ResponseWriter, err error) {
313 - switch {
314 - case errors.Is(err, auth.ErrWalletAuthUnauthorized):
315 - utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
316 - case errors.Is(err, auth.ErrWalletAuthChallengeNotFound), errors.Is(err, auth.ErrWalletAuthChallengeExpired), errors.Is(err, auth.ErrWalletAuthInvalidSignature):
317 - utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
318 - default:
319 - utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
320 - }
321 -}
322 -
323 -func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled bool) {
324 - path = strings.TrimSpace(path)
325 - if path == "" {
326 - return
327 - }
328 -
329 - approver := runtime.Approver()
330 - udpEnabled := runtime.IsUDPEnabled()
331 - udpMaxLeases := runtime.UDPMaxLeases()
332 - tcpPortEnabled := runtime.IsTCPPortEnabled()
333 - tcpPortMaxLeases := runtime.TCPPortMaxLeases()
334 - payload := persistedAdminState{
335 - ApprovalMode: string(approver.Mode()),
336 - ApprovedIdentityKeys: approver.ApprovedKeys(),
337 - DeniedIdentityKeys: approver.DeniedKeys(),
338 - BannedIdentityKeys: runtime.BannedIdentityKeys(),
339 - BannedIPs: runtime.IPFilter().BannedIPs(),
340 - IdentityBPS: runtime.BPSManager().IdentityBPSLimits(),
341 - UDPEnabled: &udpEnabled,
342 - UDPMaxLeases: &udpMaxLeases,
343 - TCPPortEnabled: &tcpPortEnabled,
344 - TCPPortMaxLeases: &tcpPortMaxLeases,
345 - LandingPageEnabled: &landingPageEnabled,
346 - }
347 - _ = utils.WriteJSONFile(path, payload, 0o600)
348 -}
349 -
350 -type persistedAdminState struct {
351 - ApprovalMode string `json:"approval_mode"`
352 - ApprovedIdentityKeys []string `json:"approved_identity_keys,omitempty"`
353 - DeniedIdentityKeys []string `json:"denied_identity_keys,omitempty"`
354 - BannedIdentityKeys []string `json:"banned_identity_keys,omitempty"`
355 - BannedIPs []string `json:"banned_ips,omitempty"`
356 - IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
357 - UDPEnabled *bool `json:"udp_enabled,omitempty"`
358 - UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
359 - TCPPortEnabled *bool `json:"tcp_port_enabled,omitempty"`
360 - TCPPortMaxLeases *int `json:"tcp_port_max_leases,omitempty"`
361 - LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
362 -}
363 -
364 -func applyOptionalPolicy(enabled *bool, maxLeases *int, getEnabled func() bool, getMax func() int, set func(bool, int)) {
365 - if enabled == nil && maxLeases == nil {
366 - return
367 - }
368 - e := getEnabled()
369 - m := getMax()
370 - if enabled != nil {
371 - e = *enabled
372 - }
373 - if maxLeases != nil {
374 - m = *maxLeases
375 - }
376 - set(e, m)
377 -}
378 -
379 -func (s persistedAdminState) apply(server *portal.Server) error {
380 - if server == nil {
381 - return nil
382 - }
383 - runtime := server.PolicyRuntime()
384 - if runtime == nil {
385 - return nil
386 - }
387 - if mode := strings.TrimSpace(s.ApprovalMode); mode != "" {
388 - if err := runtime.Approver().SetMode(policy.Mode(mode)); err != nil {
389 - return err
390 - }
391 - }
392 - runtime.Approver().SetDecisions(
393 - identity.NormalizeIdentityKeys(s.ApprovedIdentityKeys),
394 - identity.NormalizeIdentityKeys(s.DeniedIdentityKeys),
395 - )
396 - runtime.SetBannedIdentityKeys(identity.NormalizeIdentityKeys(s.BannedIdentityKeys))
397 - runtime.IPFilter().SetBannedIPs(s.BannedIPs)
398 - runtime.BPSManager().SetIdentityBPSLimits(identity.NormalizeIdentityKeyBPS(s.IdentityBPS))
399 - applyOptionalPolicy(s.UDPEnabled, s.UDPMaxLeases, runtime.IsUDPEnabled, runtime.UDPMaxLeases, server.SetUDPPolicy)
400 - applyOptionalPolicy(s.TCPPortEnabled, s.TCPPortMaxLeases, runtime.IsTCPPortEnabled, runtime.TCPPortMaxLeases, server.SetTCPPortPolicy)
401 - return nil
402 -}
cmd/relay-server/api.go
+393 -84
@@ -6,31 +6,36 @@ import (
6 "encoding/hex"
7 "errors"
8 "fmt"
9 + "net"
10 "net/http"
11 + "net/url"
12 "strings"
11 - "sync/atomic"
13 + "time"
14
15 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
16 "github.com/gosuda/portal-tunnel/v2/portal"
17 "github.com/gosuda/portal-tunnel/v2/portal/auth"
18 "github.com/gosuda/portal-tunnel/v2/portal/identity"
19 + "github.com/gosuda/portal-tunnel/v2/portal/policy"
20 "github.com/gosuda/portal-tunnel/v2/types"
21 "github.com/gosuda/portal-tunnel/v2/utils"
22 + "github.com/prometheus/client_golang/prometheus/promhttp"
23 )
24
25 //go:embed dist/*
26 var embeddedDistFS embed.FS
27
24 -type RelayAPI struct {
25 - server *portal.Server
26 - auth *auth.WalletAuthenticator
27 - adminSettingsPath string
28 - thumbnails *thumbnailService
28 +const (
29 + controlBodyLimit = 1 << 16
30 +)
31
30 - landingPageEnabled atomic.Bool
32 +type RelayAPI struct {
33 + server *portal.Server
34 + auth *auth.WalletAuthenticator
35 + policyStatePath string
36 }
37
33 -func NewRelayAPI(server *portal.Server, identityPath string, defaultLandingPageEnabled bool, headlessShellURL string, adminWallets []string) (*RelayAPI, error) {
38 +func NewRelayAPI(server *portal.Server, identityPath string, adminWallets []string) (*RelayAPI, error) {
39 if server == nil {
40 return nil, errors.New("relay api requires portal server")
41 }
@@ -38,12 +43,11 @@ func NewRelayAPI(server *portal.Server, identityPath string, defaultLandingPageE
43 if runtime == nil {
44 return nil, errors.New("relay api requires policy runtime")
45 }
41 - adminSettingsPath := identity.ResolveRelayAdminSettingsPath(identityPath)
42 - if adminSettingsPath == "" {
46 + policyStatePath := identity.ResolveRelayPolicyPath(identityPath)
47 + if policyStatePath == "" {
48 return nil, errors.New("relay api requires identity path")
49 }
45 - state, err := loadAdminState(adminSettingsPath, server)
46 - if err != nil {
50 + if err := loadPolicyState(policyStatePath, identity.ResolveLegacyRelayPolicyPath(identityPath), server); err != nil {
51 return nil, err
52 }
53 relayIdentity := server.RelayIdentity()
@@ -57,16 +61,10 @@ func NewRelayAPI(server *portal.Server, identityPath string, defaultLandingPageE
61 }
62
63 api := &RelayAPI{
60 - server: server,
61 - auth: authenticator,
62 - adminSettingsPath: strings.TrimSpace(adminSettingsPath),
63 - thumbnails: newThumbnailService(headlessShellURL),
64 + server: server,
65 + auth: authenticator,
66 + policyStatePath: strings.TrimSpace(policyStatePath),
67 }
65 - landingPageEnabled := defaultLandingPageEnabled
66 - if state.LandingPageEnabled != nil {
67 - landingPageEnabled = *state.LandingPageEnabled
68 - }
69 - api.landingPageEnabled.Store(landingPageEnabled)
68 return api, nil
69 }
70
@@ -84,9 +82,9 @@ func (api *RelayAPI) Handler() *http.ServeMux {
82 })
83 mux.HandleFunc(types.PathAdmin, api.serveAdmin)
84 mux.HandleFunc(types.PathAdminPrefix, api.serveAdmin)
87 - mux.HandleFunc(types.PathPublicState, api.servePublicState)
88 - mux.HandleFunc(types.PathServiceStatus, api.serveServiceStatus)
89 - mux.HandleFunc(types.PathThumbnailPrefix, api.serveThumbnail)
85 + mux.HandleFunc(types.PathPolicy, api.servePolicy)
86 + mux.HandleFunc(types.PathPolicyPrefix, api.servePolicy)
87 + mux.HandleFunc(types.PathState, api.servePublicState)
88 mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
89 serveInstallScript(w, r, api.server.PortalURL(), false)
90 })
@@ -104,111 +102,422 @@ func (api *RelayAPI) servePublicState(w http.ResponseWriter, r *http.Request) {
102 }
103
104 leases := api.server.PublicLeases()
107 - api.attachAutomaticThumbnails(leases)
105 utils.WriteAPIData(w, http.StatusOK, types.PublicStateResponse{
109 - Leases: leases,
110 - LandingPageEnabled: api.landingPageEnabled.Load(),
106 + Leases: leases,
107 })
108 }
109
114 -func (api *RelayAPI) serveServiceStatus(w http.ResponseWriter, r *http.Request) {
115 - if !utils.RequireMethod(w, r, http.MethodGet) {
116 - return
110 +func loadPolicyState(path, legacyPath string, server *portal.Server) error {
111 + path = strings.TrimSpace(path)
112 + legacyPath = strings.TrimSpace(legacyPath)
113 + if path == "" {
114 + return nil
115 }
116
119 - hostname := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hostname")))
120 - if hostname == "" {
121 - utils.InvalidRequestError(errors.New("hostname is required")).Write(w)
122 - return
117 + var payload persistedPolicyState
118 + loaded, err := utils.ReadJSONFileIfExists(path, &payload)
119 + if err != nil {
120 + return err
121 }
124 -
125 - resp := types.ServiceStatusResponse{
126 - Hostname: hostname,
122 + loadedFromLegacy := false
123 + if !loaded && legacyPath != "" && legacyPath != path {
124 + loaded, err = utils.ReadJSONFileIfExists(legacyPath, &payload)
125 + if err != nil {
126 + return err
127 + }
128 + loadedFromLegacy = loaded
129 }
128 - if lease, ok := api.publicLeaseByHostname(hostname); ok {
129 - resp.Hostname = lease.Hostname
130 - resp.Registered = true
131 - resp.ServiceAlive = lease.Ready > 0
130 + if !loaded {
131 + return nil
132 }
133 - utils.WriteAPIData(w, http.StatusOK, resp)
133 + if err := payload.apply(server); err != nil {
134 + return err
135 + }
136 + if loadedFromLegacy {
137 + savePolicyState(path, server.PolicyRuntime())
138 + }
139 + return nil
140 }
141
136 -func (api *RelayAPI) serveThumbnail(w http.ResponseWriter, r *http.Request) {
137 - if !utils.RequireMethod(w, r, http.MethodGet) {
138 - return
142 +func (api *RelayAPI) serveAdmin(w http.ResponseWriter, r *http.Request) {
143 + path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
144 + if path == "" {
145 + path = types.PathRoot
146 }
147
141 - hostname := strings.TrimPrefix(r.URL.Path, types.PathThumbnailPrefix)
142 - hostname = strings.TrimSpace(strings.ToLower(hostname))
143 - if hostname == "" || api.thumbnails == nil {
148 + switch path {
149 + case types.PathAdmin:
150 http.NotFound(w, r)
151 return
152 + case types.PathAdminAuthChallenge:
153 + if !utils.RequireMethod(w, r, http.MethodPost) {
154 + return
155 + }
156 + api.handleWalletChallenge(w, r)
157 + return
158 + case types.PathAdminAuthLogin:
159 + if !utils.RequireMethod(w, r, http.MethodPost) {
160 + return
161 + }
162 + api.handleWalletLogin(w, r)
163 + return
164 + case types.PathAdminLogout:
165 + if !utils.RequireMethod(w, r, http.MethodPost) {
166 + return
167 + }
168 + api.auth.DeleteSession(adminAccessToken(r))
169 + utils.WriteAPIData(w, http.StatusOK, map[string]any{})
170 + return
171 + case types.PathAdminAuthStatus:
172 + if !utils.RequireMethod(w, r, http.MethodGet) {
173 + return
174 + }
175 + walletAddress, authenticated := api.authenticatedWallet(r)
176 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthStatusResponse{
177 + Authenticated: authenticated,
178 + WalletAddress: walletAddress,
179 + })
180 + return
181 }
182
148 - lease, ok := api.publicLeaseByHostname(hostname)
149 - if !ok || lease.Metadata.Thumbnail != "" {
150 - api.thumbnails.remove(hostname)
183 + if _, ok := api.authenticatedWallet(r); !ok {
184 + utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
185 + return
186 + }
187 +
188 + switch path {
189 + case "/admin/metrics":
190 + promhttp.Handler().ServeHTTP(w, r)
191 + return
192 + default:
193 http.NotFound(w, r)
194 + }
195 +}
196 +
197 +func (api *RelayAPI) servePolicy(w http.ResponseWriter, r *http.Request) {
198 + path := strings.TrimSuffix(strings.TrimSpace(r.URL.Path), "/")
199 + if path == "" {
200 + path = types.PathRoot
201 + }
202 +
203 + if _, ok := api.authenticatedWallet(r); !ok {
204 + utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
205 return
206 }
207
155 - data, contentType, ok := api.thumbnails.get(hostname)
156 - if !ok {
157 - var err error
158 - data, contentType, err = api.thumbnails.load(hostname)
159 - if err != nil {
160 - http.NotFound(w, r)
208 + runtime := api.server.PolicyRuntime()
209 + invalidRequestBody := utils.InvalidRequestError(errors.New("invalid request body"))
210 +
211 + switch path {
212 + case types.PathPolicy:
213 + switch r.Method {
214 + case http.MethodGet:
215 + utils.WriteAPIData(w, http.StatusOK, api.policySettings(runtime))
216 + case http.MethodPost:
217 + req, ok := utils.DecodeJSONRequestAs[types.PolicySettings](w, r, controlBodyLimit, invalidRequestBody)
218 + if !ok {
219 + return
220 + }
221 + if !api.applyPolicySettings(w, runtime, req) {
222 + return
223 + }
224 + utils.WriteAPIData(w, http.StatusOK, api.policySettings(runtime))
225 + default:
226 + w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
227 + utils.MethodNotAllowedError().Write(w)
228 + }
229 + case types.PathPolicyState:
230 + if !utils.RequireMethod(w, r, http.MethodGet) {
231 + return
232 + }
233 + leases := api.server.PolicyLeases()
234 + utils.WriteAPIData(w, http.StatusOK, types.PolicyStateResponse{
235 + Policy: api.policySettings(runtime),
236 + Leases: leases,
237 + })
238 + case types.PathPolicyLeases:
239 + if !utils.RequireMethod(w, r, http.MethodPost) {
240 return
241 }
242 + req, ok := utils.DecodeJSONRequestAs[types.LeasePolicyUpdate](w, r, controlBodyLimit, invalidRequestBody)
243 + if !ok {
244 + return
245 + }
246 + identityKey, ok := normalizePolicyIdentityKey(w, req.IdentityKey)
247 + if !ok {
248 + return
249 + }
250 + if !applyLeasePolicyUpdate(w, runtime, identityKey, req) {
251 + return
252 + }
253 + savePolicyState(api.policyStatePath, runtime)
254 + utils.WriteAPIData(w, http.StatusOK, map[string]any{})
255 + case types.PathPolicyIPs:
256 + if !utils.RequireMethod(w, r, http.MethodPost) {
257 + return
258 + }
259 + req, ok := utils.DecodeJSONRequestAs[types.IPPolicyUpdate](w, r, controlBodyLimit, invalidRequestBody)
260 + if !ok {
261 + return
262 + }
263 + ip := strings.TrimSpace(req.IP)
264 + if net.ParseIP(ip) == nil {
265 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidIP, "invalid IP address")
266 + return
267 + }
268 + if req.IsBanned {
269 + runtime.IPFilter().BanIP(ip)
270 + } else {
271 + runtime.IPFilter().UnbanIP(ip)
272 + }
273 + savePolicyState(api.policyStatePath, runtime)
274 + utils.WriteAPIData(w, http.StatusOK, map[string]any{})
275 + default:
276 + http.NotFound(w, r)
277 }
278 +}
279
165 - w.Header().Set("Content-Type", contentType)
166 - w.Header().Set("Cache-Control", "public, max-age=300")
167 - w.WriteHeader(http.StatusOK)
168 - _, _ = w.Write(data)
280 +func (api *RelayAPI) policySettings(runtime *policy.Runtime) types.PolicySettings {
281 + return types.PolicySettings{
282 + ApprovalMode: string(runtime.Approver().Mode()),
283 + UDP: types.PolicyPortSettings{
284 + Enabled: runtime.IsUDPEnabled(),
285 + MaxLeases: runtime.UDPMaxLeases(),
286 + },
287 + TCPPort: types.PolicyPortSettings{
288 + Enabled: runtime.IsTCPPortEnabled(),
289 + MaxLeases: runtime.TCPPortMaxLeases(),
290 + },
291 + }
292 }
293
171 -func (api *RelayAPI) publicLeaseByHostname(hostname string) (types.Lease, bool) {
172 - hostname = utils.NormalizeHostname(hostname)
173 - if hostname == "" {
174 - return types.Lease{}, false
294 +func (api *RelayAPI) applyPolicySettings(w http.ResponseWriter, runtime *policy.Runtime, req types.PolicySettings) bool {
295 + if req.UDP.MaxLeases < 0 || req.TCPPort.MaxLeases < 0 {
296 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "max_leases must be non-negative")
297 + return false
298 }
176 - for _, lease := range api.server.PublicLeases() {
177 - if utils.HostnameMatchesPattern(lease.Hostname, hostname) {
178 - return lease, true
179 - }
299 + if err := runtime.Approver().SetMode(policy.Mode(strings.TrimSpace(req.ApprovalMode))); err != nil {
300 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "approval_mode must be 'auto' or 'manual'")
301 + return false
302 }
181 - return types.Lease{}, false
303 + api.server.SetUDPPolicy(req.UDP.Enabled, req.UDP.MaxLeases)
304 + api.server.SetTCPPortPolicy(req.TCPPort.Enabled, req.TCPPort.MaxLeases)
305 + savePolicyState(api.policyStatePath, runtime)
306 + return true
307 }
308
184 -func (api *RelayAPI) attachAutomaticThumbnails(leases []types.Lease) {
185 - for i := range leases {
186 - api.attachAutomaticThumbnail(leases[i].Hostname, &leases[i].Metadata)
309 +func normalizePolicyIdentityKey(w http.ResponseWriter, raw string) (string, bool) {
310 + raw = strings.TrimSpace(raw)
311 + name, address, ok := strings.Cut(raw, types.IdentityKeySeparator)
312 + if !ok {
313 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
314 + return "", false
315 + }
316 + normalizedIdentity, err := identity.NormalizeIdentity(types.Identity{Name: name, Address: address})
317 + if err != nil {
318 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
319 + return "", false
320 + }
321 + return normalizedIdentity.Key(), true
322 +}
323 +
324 +func applyLeasePolicyUpdate(w http.ResponseWriter, runtime *policy.Runtime, identityKey string, req types.LeasePolicyUpdate) bool {
325 + if req.IsBanned == nil && req.IsApproved == nil && req.IsDenied == nil && req.BPS == nil {
326 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "lease policy update is empty")
327 + return false
328 + }
329 + if req.IsApproved != nil && req.IsDenied != nil && *req.IsApproved && *req.IsDenied {
330 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "lease cannot be approved and denied")
331 + return false
332 + }
333 + if req.BPS != nil {
334 + if *req.BPS < 0 {
335 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "bps must be non-negative")
336 + return false
337 + }
338 + if *req.BPS == 0 {
339 + runtime.BPSManager().DeleteIdentityBPS(identityKey)
340 + } else {
341 + runtime.BPSManager().SetIdentityBPS(identityKey, *req.BPS)
342 + }
343 + }
344 + if req.IsBanned != nil {
345 + if *req.IsBanned {
346 + runtime.BanIdentity(identityKey)
347 + } else {
348 + runtime.UnbanIdentity(identityKey)
349 + }
350 }
351 + approver := runtime.Approver()
352 + if req.IsDenied != nil {
353 + if *req.IsDenied {
354 + approver.Deny(identityKey)
355 + approver.Revoke(identityKey)
356 + } else {
357 + approver.Undeny(identityKey)
358 + }
359 + }
360 + if req.IsApproved != nil {
361 + if *req.IsApproved {
362 + approver.Approve(identityKey)
363 + approver.Undeny(identityKey)
364 + } else {
365 + approver.Revoke(identityKey)
366 + }
367 + }
368 + return true
369 }
370
190 -func (api *RelayAPI) attachAutomaticAdminThumbnails(leases []types.AdminLease) {
191 - for i := range leases {
192 - api.attachAutomaticThumbnail(leases[i].Hostname, &leases[i].Metadata)
371 +func (api *RelayAPI) handleWalletChallenge(w http.ResponseWriter, r *http.Request) {
372 + req, ok := utils.DecodeJSONRequestAs[types.WalletAuthChallengeRequest](w, r, controlBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
373 + if !ok {
374 + return
375 }
376 + resp, err := api.auth.IssueChallenge(req, adminAuthDomain(r, api.server.RelayIdentity().Name), adminAuthURI(r, types.PathAdminAuthLogin), time.Now().UTC())
377 + if err != nil {
378 + writeWalletAuthError(w, err)
379 + return
380 + }
381 + utils.WriteAPIData(w, http.StatusCreated, resp)
382 }
383
196 -func (api *RelayAPI) attachAutomaticThumbnail(hostname string, metadata *types.LeaseMetadata) {
197 - if api.thumbnails == nil {
384 +func (api *RelayAPI) handleWalletLogin(w http.ResponseWriter, r *http.Request) {
385 + req, ok := utils.DecodeJSONRequestAs[types.WalletAuthLoginRequest](w, r, controlBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
386 + if !ok {
387 return
388 }
200 - if hostname == "" || metadata == nil || metadata.Thumbnail != "" {
389 + token, walletAddress, err := api.auth.Login(req, time.Now().UTC())
390 + if err != nil {
391 + writeWalletAuthError(w, err)
392 + return
393 + }
394 +
395 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{
396 + AccessToken: token,
397 + WalletAddress: walletAddress,
398 + })
399 +}
400 +
401 +func (api *RelayAPI) authenticatedWallet(r *http.Request) (string, bool) {
402 + return api.auth.ValidateSession(adminAccessToken(r))
403 +}
404 +
405 +func adminAuthDomain(r *http.Request, fallback string) string {
406 + domain := strings.TrimSpace(r.Host)
407 + if domain != "" {
408 + return domain
409 + }
410 + return strings.TrimSpace(fallback)
411 +}
412 +
413 +func adminAuthURI(r *http.Request, endpointPath string) string {
414 + scheme := "https"
415 + if r.TLS == nil {
416 + scheme = "http"
417 + }
418 + return (&url.URL{
419 + Scheme: scheme,
420 + Host: adminAuthDomain(r, "localhost"),
421 + Path: endpointPath,
422 + }).String()
423 +}
424 +
425 +func adminAccessToken(r *http.Request) string {
426 + parts := strings.Fields(r.Header.Get("Authorization"))
427 + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
428 + return ""
429 + }
430 + return strings.TrimSpace(parts[1])
431 +}
432 +
433 +func writeWalletAuthError(w http.ResponseWriter, err error) {
434 + switch {
435 + case errors.Is(err, auth.ErrWalletAuthUnauthorized):
436 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
437 + case errors.Is(err, auth.ErrWalletAuthChallengeNotFound), errors.Is(err, auth.ErrWalletAuthChallengeExpired), errors.Is(err, auth.ErrWalletAuthInvalidSignature):
438 + utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
439 + default:
440 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
441 + }
442 +}
443 +
444 +func savePolicyState(path string, runtime *policy.Runtime) {
445 + path = strings.TrimSpace(path)
446 + if path == "" {
447 return
448 }
203 - metadata.Thumbnail = types.PathThumbnailPrefix + hostname
204 - api.thumbnails.triggerAsync(hostname)
449 +
450 + approver := runtime.Approver()
451 + udpEnabled := runtime.IsUDPEnabled()
452 + udpMaxLeases := runtime.UDPMaxLeases()
453 + tcpPortEnabled := runtime.IsTCPPortEnabled()
454 + tcpPortMaxLeases := runtime.TCPPortMaxLeases()
455 + payload := persistedPolicyState{
456 + ApprovalMode: string(approver.Mode()),
457 + ApprovedIdentityKeys: approver.ApprovedKeys(),
458 + DeniedIdentityKeys: approver.DeniedKeys(),
459 + BannedIdentityKeys: runtime.BannedIdentityKeys(),
460 + BannedIPs: runtime.IPFilter().BannedIPs(),
461 + IdentityBPS: runtime.BPSManager().IdentityBPSLimits(),
462 + UDPEnabled: &udpEnabled,
463 + UDPMaxLeases: &udpMaxLeases,
464 + TCPPortEnabled: &tcpPortEnabled,
465 + TCPPortMaxLeases: &tcpPortMaxLeases,
466 + }
467 + _ = utils.WriteJSONFile(path, payload, 0o600)
468 +}
469 +
470 +type persistedPolicyState struct {
471 + ApprovalMode string `json:"approval_mode"`
472 + ApprovedIdentityKeys []string `json:"approved_identity_keys,omitempty"`
473 + DeniedIdentityKeys []string `json:"denied_identity_keys,omitempty"`
474 + BannedIdentityKeys []string `json:"banned_identity_keys,omitempty"`
475 + BannedIPs []string `json:"banned_ips,omitempty"`
476 + IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
477 + UDPEnabled *bool `json:"udp_enabled,omitempty"`
478 + UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
479 + TCPPortEnabled *bool `json:"tcp_port_enabled,omitempty"`
480 + TCPPortMaxLeases *int `json:"tcp_port_max_leases,omitempty"`
481 }
482
207 -func (api *RelayAPI) Close() {
208 - if api.thumbnails == nil {
483 +func applyOptionalPolicy(enabled *bool, maxLeases *int, getEnabled func() bool, getMax func() int, set func(bool, int)) {
484 + if enabled == nil && maxLeases == nil {
485 return
486 }
211 - api.thumbnails.close()
487 + e := getEnabled()
488 + m := getMax()
489 + if enabled != nil {
490 + e = *enabled
491 + }
492 + if maxLeases != nil {
493 + m = *maxLeases
494 + }
495 + set(e, m)
496 +}
497 +
498 +func (s persistedPolicyState) apply(server *portal.Server) error {
499 + if server == nil {
500 + return nil
501 + }
502 + runtime := server.PolicyRuntime()
503 + if runtime == nil {
504 + return nil
505 + }
506 + if mode := strings.TrimSpace(s.ApprovalMode); mode != "" {
507 + if err := runtime.Approver().SetMode(policy.Mode(mode)); err != nil {
508 + return err
509 + }
510 + }
511 + runtime.Approver().SetDecisions(
512 + identity.NormalizeIdentityKeys(s.ApprovedIdentityKeys),
513 + identity.NormalizeIdentityKeys(s.DeniedIdentityKeys),
514 + )
515 + runtime.SetBannedIdentityKeys(identity.NormalizeIdentityKeys(s.BannedIdentityKeys))
516 + runtime.IPFilter().SetBannedIPs(s.BannedIPs)
517 + runtime.BPSManager().SetIdentityBPSLimits(identity.NormalizeIdentityKeyBPS(s.IdentityBPS))
518 + applyOptionalPolicy(s.UDPEnabled, s.UDPMaxLeases, runtime.IsUDPEnabled, runtime.UDPMaxLeases, server.SetUDPPolicy)
519 + applyOptionalPolicy(s.TCPPortEnabled, s.TCPPortMaxLeases, runtime.IsTCPPortEnabled, runtime.TCPPortMaxLeases, server.SetTCPPortPolicy)
520 + return nil
521 }
522
523 func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
cmd/relay-server/main.go
+21 -29
@@ -34,27 +34,25 @@ func main() {
34 }
35
36 type relayServerConfig struct {
37 - PortalURL string
38 - IdentityPath string
39 - Bootstraps string
40 - DiscoveryEnabled bool
41 - WireGuardPort int
42 - APIPort int
43 - SNIPort int
44 - TrustProxyHeaders bool
45 - TrustedProxyCIDRs string
46 - UDPEnabled bool
47 - TCPEnabled bool
48 - MinPort int
49 - MaxPort int
50 - AdminWallets string
51 - LandingPageEnabled bool
52 - HeadlessShellURL string
53 - PProfEnabled bool
54 - PProfAddr string
55 - X402Enabled bool
56 - X402Network string
57 - X402RPCURL string
37 + PortalURL string
38 + IdentityPath string
39 + Bootstraps string
40 + DiscoveryEnabled bool
41 + WireGuardPort int
42 + APIPort int
43 + SNIPort int
44 + TrustProxyHeaders bool
45 + TrustedProxyCIDRs string
46 + UDPEnabled bool
47 + TCPEnabled bool
48 + MinPort int
49 + MaxPort int
50 + AdminWallets string
51 + PProfEnabled bool
52 + PProfAddr string
53 + X402Enabled bool
54 + X402Network string
55 + X402RPCURL string
56
57 ACMEDNSProvider string
58 ENSGaslessEnabled bool
@@ -77,7 +75,7 @@ func runServeCommand(args []string) error {
75 fs := utils.NewFlagSet("relay-server", printRootUsage)
76
77 utils.StringFlagEnv(fs, &cfg.PortalURL, "portal-url", "https://localhost:4017", "portal base URL", "PORTAL_URL")
80 - utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "./.portal-certs", "directory path for relay identity, admin state, and keyless materials", "IDENTITY_PATH")
78 + utils.StringFlagEnv(fs, &cfg.IdentityPath, "identity-path", "./.portal-certs", "directory path for relay identity, policy state, and keyless materials", "IDENTITY_PATH")
79 utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "bootstrap relay API URLs; merged with bootstrap relays when discovery is enabled", "BOOTSTRAPS")
80 utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
81 utils.IntFlagEnv(fs, &cfg.WireGuardPort, "wireguard-port", overlay.DefaultListenPort, utils.ParsePortNumber, "public and listen UDP port for relay overlay", "WIREGUARD_PORT")
@@ -93,8 +91,6 @@ func runServeCommand(args []string) error {
91 utils.IntFlagEnv(fs, &cfg.MaxPort, "max-port", 0, utils.ParseOptionalPortNumber, "inclusive maximum lease port shared by UDP and raw TCP transports (0=disabled)", "MAX_PORT")
92
93 utils.StringFlagEnv(fs, &cfg.AdminWallets, "admin-wallets", "", "admin wallet address allowlist, comma-separated; relay identity address is always allowed", "ADMIN_WALLETS")
96 - utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
97 - utils.StringFlagEnv(fs, &cfg.HeadlessShellURL, "headless-shell-url", "", "headless Chrome CDP WebSocket URL for thumbnail generation (e.g. ws://headless-shell:9222)", "HEADLESS_SHELL_URL")
94 utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
95 utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
96 utils.BoolFlagEnv(fs, &cfg.X402Enabled, "x402-facilitator-enabled", false, "enable relay-local x402 facilitator endpoints under /x402", "X402_FACILITATOR_ENABLED")
@@ -144,8 +140,6 @@ func runServeCommand(args []string) error {
140 Int("min_port", cfg.MinPort).
141 Int("max_port", cfg.MaxPort).
142 Bool("admin_wallets_configured", len(utils.SplitCSV(cfg.AdminWallets)) > 0).
147 - Bool("landing_page_enabled", cfg.LandingPageEnabled).
148 - Bool("headless_shell_enabled", strings.TrimSpace(cfg.HeadlessShellURL) != "").
143 Bool("pprof_enabled", cfg.PProfEnabled).
144 Str("pprof_addr", cfg.PProfAddr).
145 Bool("x402_facilitator_enabled", cfg.X402Enabled).
@@ -201,11 +195,10 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
195 return fmt.Errorf("create relay server: %w", err)
196 }
197
204 - relayAPI, err := NewRelayAPI(server, cfg.IdentityPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL, utils.SplitCSV(cfg.AdminWallets))
198 + relayAPI, err := NewRelayAPI(server, cfg.IdentityPath, utils.SplitCSV(cfg.AdminWallets))
199 if err != nil {
200 return fmt.Errorf("create relay api: %w", err)
201 }
208 - defer relayAPI.Close()
202
203 apiMux := relayAPI.Handler()
204 if cfg.X402Enabled {
@@ -263,7 +256,6 @@ func printRootUsage(w io.Writer) {
256 "relay-server --portal-url https://portal.example.com",
257 "relay-server --discovery --bootstraps https://bootstrap.example.com",
258 "relay-server --udp-enabled --min-port 40000 --max-port 40099",
266 - "relay-server --landing-page-enabled",
259 "relay-server help",
260 },
261 )
cmd/relay-server/thumbnail.go deleted
-256
@@ -1,256 +0,0 @@
1 -package main
2 -
3 -import (
4 - "context"
5 - "encoding/json"
6 - "fmt"
7 - "io"
8 - "net/http"
9 - "net/url"
10 - "strings"
11 - "sync"
12 - "time"
13 -
14 - "github.com/go-rod/rod"
15 - "github.com/go-rod/rod/lib/proto"
16 - "github.com/rs/zerolog/log"
17 -
18 - "github.com/gosuda/portal-tunnel/v2/utils"
19 -)
20 -
21 -var thumbnailHTTPClient = utils.NewHTTPClient(utils.WithHTTPTimeout(5 * time.Second))
22 -
23 -const (
24 - thumbnailViewportWidth = 1280
25 - thumbnailViewportHeight = 720
26 - thumbnailJPEGQuality = 80
27 - thumbnailMaxBytes = 256 << 10 // 256KB
28 - thumbnailCooldown = 30 * time.Second
29 - thumbnailPageTimeout = 15 * time.Second
30 - thumbnailQueueSize = 32
31 - thumbnailContentType = "image/jpeg"
32 -)
33 -
34 -type thumbnailEntry struct {
35 - data []byte
36 - fetchedAt time.Time
37 -}
38 -
39 -type thumbnailService struct {
40 - mu sync.RWMutex
41 - cache map[string]*thumbnailEntry
42 - pending map[string]bool
43 - queue chan string
44 - headlessShellURL string
45 - done chan struct{}
46 -}
47 -
48 -func newThumbnailService(headlessShellURL string) *thumbnailService {
49 - headlessShellURL = strings.TrimSpace(headlessShellURL)
50 - if headlessShellURL == "" {
51 - return nil
52 - }
53 - service := &thumbnailService{
54 - cache: make(map[string]*thumbnailEntry),
55 - pending: make(map[string]bool),
56 - queue: make(chan string, thumbnailQueueSize),
57 - headlessShellURL: headlessShellURL,
58 - done: make(chan struct{}),
59 - }
60 - go service.worker()
61 - return service
62 -}
63 -
64 -func (s *thumbnailService) worker() {
65 - for hostname := range s.queue {
66 - _, _ = s.captureAndStore(hostname)
67 - s.mu.Lock()
68 - delete(s.pending, hostname)
69 - s.mu.Unlock()
70 - }
71 - close(s.done)
72 -}
73 -
74 -func (s *thumbnailService) get(hostname string) ([]byte, string, bool) {
75 - if s == nil {
76 - return nil, "", false
77 - }
78 - s.mu.RLock()
79 - entry, ok := s.cache[hostname]
80 - s.mu.RUnlock()
81 - if !ok || len(entry.data) == 0 {
82 - return nil, "", false
83 - }
84 - return entry.data, thumbnailContentType, true
85 -}
86 -
87 -func (s *thumbnailService) load(hostname string) ([]byte, string, error) {
88 - if data, contentType, ok := s.get(hostname); ok {
89 - return data, contentType, nil
90 - }
91 - data, err := s.captureAndStore(hostname)
92 - if err != nil {
93 - return nil, "", err
94 - }
95 - return data, thumbnailContentType, nil
96 -}
97 -
98 -func (s *thumbnailService) triggerAsync(hostname string) {
99 - if s == nil || hostname == "" {
100 - return
101 - }
102 -
103 - s.mu.Lock()
104 - defer s.mu.Unlock()
105 -
106 - if entry, ok := s.cache[hostname]; ok {
107 - if len(entry.data) > 0 || time.Since(entry.fetchedAt) < thumbnailCooldown {
108 - return
109 - }
110 - }
111 - if s.pending[hostname] {
112 - return
113 - }
114 -
115 - s.pending[hostname] = true
116 - select {
117 - case s.queue <- hostname:
118 - default:
119 - delete(s.pending, hostname)
120 - }
121 -}
122 -
123 -func (s *thumbnailService) captureAndStore(hostname string) ([]byte, error) {
124 - store := func(data []byte) {
125 - s.mu.Lock()
126 - s.cache[hostname] = &thumbnailEntry{data: data, fetchedAt: time.Now()}
127 - s.mu.Unlock()
128 - }
129 -
130 - data, err := s.screenshot(hostname)
131 - if err != nil {
132 - log.Warn().Err(err).Str("hostname", hostname).Msg("thumbnail capture failed")
133 - store(nil)
134 - return nil, err
135 - }
136 - if len(data) > thumbnailMaxBytes {
137 - err = fmt.Errorf("thumbnail too large: %d bytes", len(data))
138 - log.Warn().Err(err).Str("hostname", hostname).Int("size", len(data)).Msg("thumbnail capture failed")
139 - store(nil)
140 - return nil, err
141 - }
142 -
143 - store(data)
144 - log.Info().Str("hostname", hostname).Int("size", len(data)).Msg("thumbnail captured")
145 - return data, nil
146 -}
147 -
148 -func (s *thumbnailService) resolveCDPWebSocketURL() (string, error) {
149 - parsed, err := url.Parse(s.headlessShellURL)
150 - if err != nil {
151 - return "", fmt.Errorf("parse headless shell URL: %w", err)
152 - }
153 -
154 - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("http://%s/json/version", parsed.Host), nil)
155 - if err != nil {
156 - return "", fmt.Errorf("build /json/version request: %w", err)
157 - }
158 - req.Host = "127.0.0.1" // headless-shell rejects non-IP Host headers
159 -
160 - resp, err := thumbnailHTTPClient.Do(req)
161 - if err != nil {
162 - return "", fmt.Errorf("query /json/version: %w", err)
163 - }
164 - defer resp.Body.Close()
165 -
166 - body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
167 - if err != nil {
168 - return "", fmt.Errorf("read /json/version: %w", err)
169 - }
170 - if resp.StatusCode != http.StatusOK {
171 - return "", fmt.Errorf("/json/version status %d: %s", resp.StatusCode, body)
172 - }
173 -
174 - var info struct {
175 - WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
176 - }
177 - if err := json.Unmarshal(body, &info); err != nil {
178 - return "", fmt.Errorf("decode /json/version: %w", err)
179 - }
180 - if info.WebSocketDebuggerURL == "" {
181 - return "", fmt.Errorf("/json/version: empty webSocketDebuggerUrl")
182 - }
183 -
184 - wsURL, err := url.Parse(info.WebSocketDebuggerURL)
185 - if err != nil {
186 - return "", fmt.Errorf("parse debugger URL: %w", err)
187 - }
188 - wsURL.Host = parsed.Host // headless-shell returns 0.0.0.0 internally
189 - return wsURL.String(), nil
190 -}
191 -
192 -func (s *thumbnailService) screenshot(hostname string) ([]byte, error) {
193 - cdpURL, err := s.resolveCDPWebSocketURL()
194 - if err != nil {
195 - return nil, err
196 - }
197 -
198 - browser := rod.New().ControlURL(cdpURL)
199 - if err := browser.Connect(); err != nil {
200 - return nil, err
201 - }
202 -
203 - incognito, err := browser.Incognito()
204 - if err != nil {
205 - return nil, err
206 - }
207 - defer incognito.Close()
208 -
209 - page, err := incognito.Page(proto.TargetCreateTarget{URL: "about:blank"})
210 - if err != nil {
211 - return nil, err
212 - }
213 - defer page.Close()
214 -
215 - _ = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
216 - Width: thumbnailViewportWidth,
217 - Height: thumbnailViewportHeight,
218 - })
219 - _ = browser.IgnoreCertErrors(true)
220 -
221 - if err := page.Navigate("https://" + hostname); err != nil {
222 - return nil, err
223 - }
224 - if err := page.Timeout(thumbnailPageTimeout).WaitLoad(); err != nil {
225 - return nil, err
226 - }
227 - time.Sleep(1 * time.Second)
228 -
229 - quality := thumbnailJPEGQuality
230 - return page.Screenshot(false, &proto.PageCaptureScreenshot{
231 - Format: proto.PageCaptureScreenshotFormatJpeg,
232 - Quality: &quality,
233 - })
234 -}
235 -
236 -func (s *thumbnailService) remove(hostname string) {
237 - if s == nil {
238 - return
239 - }
240 - s.mu.Lock()
241 - delete(s.cache, hostname)
242 - delete(s.pending, hostname)
243 - s.mu.Unlock()
244 -}
245 -
246 -func (s *thumbnailService) close() {
247 - if s == nil {
248 - return
249 - }
250 - close(s.queue)
251 - <-s.done
252 - s.mu.Lock()
253 - s.cache = make(map[string]*thumbnailEntry)
254 - s.pending = make(map[string]bool)
255 - s.mu.Unlock()
256 -}
docker-compose.yml
+35 -17
@@ -5,13 +5,45 @@ services:
5 # image: chromedp/headless-shell:stable
6 # restart: unless-stopped
7
8 + portal-api:
9 + image: ghcr.io/gosuda/portal-api:latest
10 + build:
11 + context: ./frontend
12 + dockerfile: Dockerfile
13 + target: api
14 + depends_on:
15 + - portal
16 + # Uncomment with the headless-shell service above to enable generated screenshots.
17 + # - headless-shell
18 + environment:
19 + PORT: 8081
20 + PORTAL_API_BASE_URL: https://portal:4017
21 + LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
22 + PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
23 + # Leave empty to disable generated screenshots without removing the service.
24 + HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
25 + # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
26 + volumes:
27 + - ./.portal-frontend-state:/portal-frontend-state
28 + restart: unless-stopped
29 +
30 + portal-frontend:
31 + image: ghcr.io/gosuda/portal-frontend:latest
32 + build:
33 + context: ./frontend
34 + dockerfile: Dockerfile
35 + depends_on:
36 + - portal
37 + - portal-api
38 + ports:
39 + - "${FRONTEND_PORT:-8080}:8080"
40 + restart: unless-stopped
41 +
42 portal:
43 image: ghcr.io/gosuda/portal:latest
44 build:
45 context: .
46 dockerfile: Dockerfile
13 - # depends_on:
14 - # - headless-shell
47 stop_grace_period: 30s
48 ports:
49 - "${API_PORT:-4017}:4017"
@@ -41,7 +73,7 @@ services:
73 TCP_ENABLED: ${TCP_ENABLED:-false}
74
75 # Admin/auth configuration
44 - LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
76 + ADMIN_WALLETS: ${ADMIN_WALLETS:-}
77 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
78 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
79
@@ -50,9 +82,6 @@ services:
82 X402_NETWORK: ${X402_NETWORK:-eip155:84532}
83 X402_RPC_URL: ${X402_RPC_URL:-https://base-sepolia-rpc.publicnode.com}
84
53 - # Optional: auto-generated thumbnails (requires headless-shell sidecar above)
54 - # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
55 -
85 # Optional diagnostics; keep loopback unless the pprof port is protected.
86 PPROF_ENABLED: ${PPROF_ENABLED:-false}
87 PPROF_ADDR: ${PPROF_ADDR:-127.0.0.1:6060}
@@ -79,14 +108,3 @@ services:
108 # Uncomment when using a Google Cloud service account file for gcloud automation.
109 # - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
110 restart: unless-stopped
82 -
83 - portal-frontend:
84 - image: ghcr.io/gosuda/portal-frontend:latest
85 - build:
86 - context: ./frontend
87 - dockerfile: Dockerfile
88 - depends_on:
89 - - portal
90 - ports:
91 - - "${FRONTEND_PORT:-8080}:8080"
92 - restart: unless-stopped
docs/src/routes/api-reference/+page.md
+46 -15
@@ -40,7 +40,6 @@ The envelope does not apply to streaming or delegated endpoints:
40 |------|--------|
41 | `/sdk/connect` | HTTP/1.1 connection hijack |
42 | `/v1/sign` | keyless TLS signer protocol |
43 -| `/thumbnail/{hostname}` | image bytes |
43 | `/install.sh`, `/install.ps1`, `/install/bin/*` | script or binary bytes |
44 | `/x402/*` | x402 facilitator API |
45
@@ -70,11 +69,23 @@ interchangeable.
69 | `GET` | `/` | None | service identity |
70 | `GET` | `/healthz` | None | `{ "status": "ok" }` |
71 | `GET` | `/state` | None | `PublicStateResponse` |
73 -| `GET` | `/service/status?hostname=...` | None | `ServiceStatusResponse` |
74 -| `GET` | `/thumbnail/{hostname}` | None | image bytes |
72 | `GET`/`HEAD` | `/install.sh`, `/install.ps1` | None | install script |
73 | `GET`/`HEAD` | `/install/bin/{slug}` | None | install binary or redirect |
74
75 +### Frontend Presentation API
76 +
77 +These paths are served by the TypeScript API service when the static frontend stack is
78 +enabled. They are derived from relay APIs plus frontend-owned presentation state.
79 +
80 +| Method | Path | Auth | Response |
81 +|--------|------|------|----------|
82 +| `GET` | `/state` | None | `PublicStateResponse` plus `landing_page_enabled` |
83 +| `GET` | `/service/status?hostname=...` | None | `ServiceStatusResponse` |
84 +| `GET` | `/policy/state` | Admin bearer | `PolicyStateResponse` plus `landing_page_enabled` in `policy` |
85 +| `GET`/`POST` | `/policy` | Admin bearer | `PolicySettings` plus `landing_page_enabled` |
86 +| `POST` | `/policy/leases`, `/policy/ips` | Admin bearer | relay policy update response |
87 +| `GET` | `/thumbnail/{hostname}` | None | generated image |
88 +
89 ### SDK
90
91 | Method | Path | Auth | Body | Response |
@@ -97,13 +108,19 @@ SDK clients.
108 | `POST` | `/admin/auth/login` | SIWE signature body | `WalletAuthLoginRequest` | `WalletAuthLoginResponse` |
109 | `GET` | `/admin/auth/status` | Optional admin bearer | none | `WalletAuthStatusResponse` |
110 | `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` | `{}` |
111
112 `/admin` itself is a frontend route, not a relay API endpoint.
113
114 +### Policy
115 +
116 +| Method | Path | Auth | Body | Response |
117 +|--------|------|------|------|----------|
118 +| `GET` | `/policy` | Admin bearer | none | `PolicySettings` |
119 +| `POST` | `/policy` | Admin bearer | `PolicySettings` | `PolicySettings` |
120 +| `GET` | `/policy/state` | Admin bearer | none | `PolicyStateResponse` |
121 +| `POST` | `/policy/leases` | Admin bearer | `LeasePolicyUpdate` | `{}` |
122 +| `POST` | `/policy/ips` | Admin bearer | `IPPolicyUpdate` | `{}` |
123 +
124 ### Relay And Payment
125
126 | Method | Path | Auth | Response |
@@ -146,7 +163,7 @@ Timestamps are JSON-encoded Go `time.Time` values.
163 | `metadata` | `LeaseMetadata` |
164 | `ready` | `number` |
165
149 -`AdminLease` extends `Lease` with:
166 +`PolicyLease` extends `Lease` with:
167
168 | Field | Type |
169 |-------|------|
@@ -155,23 +172,37 @@ Timestamps are JSON-encoded Go `time.Time` values.
172 | `client_ip`, `reported_ip` | `string` |
173 | `is_approved`, `is_banned`, `is_denied`, `is_ip_banned` | `boolean` |
174
158 -`AdminPortSettings`:
175 +`ServiceStatusResponse`:
176 +
177 +| Field | Type |
178 +|-------|------|
179 +| `hostname` | `string` |
180 +| `registered` | `boolean` |
181 +| `service_alive` | `boolean` |
182 +
183 +`PolicyStateResponse`:
184 +
185 +| Field | Type |
186 +|-------|------|
187 +| `policy` | `PolicySettings` |
188 +| `leases` | `PolicyLease[]` |
189 +
190 +`PolicyPortSettings`:
191
192 | Field | Type | Notes |
193 |-------|------|-------|
194 | `enabled` | `boolean` | enables the transport |
195 | `max_leases` | `number` | `0` means unlimited |
196
165 -`AdminSettings`:
197 +`PolicySettings`:
198
199 | Field | Type |
200 |-------|------|
201 | `approval_mode` | `"auto"` or `"manual"` |
170 -| `landing_page_enabled` | `boolean` |
171 -| `udp` | `AdminPortSettings` |
172 -| `tcp_port` | `AdminPortSettings` |
202 +| `udp` | `PolicyPortSettings` |
203 +| `tcp_port` | `PolicyPortSettings` |
204
174 -`AdminLeasePolicy`:
205 +`LeasePolicyUpdate`:
206
207 | Field | Type | Notes |
208 |-------|------|-------|
@@ -181,7 +212,7 @@ Timestamps are JSON-encoded Go `time.Time` values.
212 | `is_denied` | `boolean` | optional; `true` also revokes approval |
213 | `bps` | `number` | optional; `0` removes the limit |
214
184 -`AdminIPPolicy`:
215 +`IPPolicyUpdate`:
216
217 | Field | Type |
218 |-------|------|
docs/src/routes/api-reference/admin/+page.md
+20 -20
@@ -1,16 +1,16 @@
1 ---
2 -title: Admin API
3 -description: Portal relay admin endpoints for auth, state, settings, and access control.
2 +title: Admin And Policy API
3 +description: Portal relay operator endpoints for auth and policy control.
4 ---
5
6 -# Admin API
6 +# Admin And Policy API
7
8 -Admin endpoints are the operator control surface for a relay. They all return
8 +Operator endpoints are the 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
12 -`/admin` is reserved for the frontend route. The relay API begins under the
13 -specific paths listed below.
12 +`/admin` is reserved for the frontend route and wallet auth endpoints. Relay
13 +enforcement settings live under `/policy`.
14
15 ## Auth Flow
16
@@ -30,10 +30,11 @@ Admin bearer tokens are separate from SDK lease tokens.
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` | `{}` |
33 +| `GET` | `/policy` | Bearer | none | `PolicySettings` |
34 +| `POST` | `/policy` | Bearer | `PolicySettings` | `PolicySettings` |
35 +| `GET` | `/policy/state` | Bearer | none | `PolicyStateResponse` |
36 +| `POST` | `/policy/leases` | Bearer | `LeasePolicyUpdate` | `{}` |
37 +| `POST` | `/policy/ips` | Bearer | `IPPolicyUpdate` | `{}` |
38
39 ## Auth Payloads
40
@@ -75,14 +76,14 @@ Admin bearer tokens are separate from SDK lease tokens.
76
77 ## State
78
78 -`GET /admin/state` returns the full operator view:
79 +`GET /policy/state` returns the full policy view:
80
81 | Field | Type |
82 |-------|------|
82 -| `settings` | `AdminSettings` |
83 -| `leases` | `AdminLease[]` |
83 +| `policy` | `PolicySettings` |
84 +| `leases` | `PolicyLease[]` |
85
85 -`AdminLease` uses the shared `Lease` fields from [API Reference](/api-reference#shared-types)
86 +`PolicyLease` uses the shared `Lease` fields from [API Reference](/api-reference#shared-types)
87 and adds:
88
89 | Field | Type | Notes |
@@ -97,15 +98,14 @@ and adds:
98 | `is_denied` | `boolean` | identity is denied |
99 | `is_ip_banned` | `boolean` | observed client IP is banned |
100
100 -## Settings
101 +## Policy
102
102 -Settings are written as one object through `POST /admin/settings` and returned
103 +Policy settings are written as one object through `POST /policy` and returned
104 in the same shape:
105
106 ```json
107 {
108 "approval_mode": "manual",
108 - "landing_page_enabled": true,
109 "udp": {
110 "enabled": true,
111 "max_leases": 10
@@ -128,7 +128,7 @@ Supported modes:
128
129 ## Lease Policy
130
131 -`POST /admin/lease-policy` accepts a partial policy update for one identity:
131 +`POST /policy/leases` accepts a partial policy update for one identity:
132
133 | Field | Type | Effect |
134 |-------|------|--------|
@@ -138,11 +138,11 @@ Supported modes:
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
141 -Lease policy updates persist to the admin state file and return `{}` on success.
141 +Lease policy updates persist to `policy.json` and return `{}` on success.
142
143 ## IP Policy
144
145 -`POST /admin/ip-policy` accepts:
145 +`POST /policy/ips` accepts:
146
147 ```json
148 { "ip": "203.0.113.10", "is_banned": true }
docs/src/routes/architecture/+page.md
+2 -2
@@ -199,7 +199,7 @@ UDP client
199 - For non-localhost deployments, relay TLS can run from manual certificate files in the relay `IDENTITY_PATH` directory or from managed ACME.
200 - When managed ACME is enabled, supported DNS providers are `cloudflare`, `gcloud`, `hetzner`, `njalla`, `route53`, and `vultr`.
201 - ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync when the selected provider supports DNSSEC.
202 -- Relay stores its state under `IDENTITY_PATH`, including `identity.json`, `admin_settings.json`, and certificate material. Tunnel and demo-app identities still use `IDENTITY_PATH` / `--identity-path` as a direct JSON file path.
202 +- Relay stores its state under `IDENTITY_PATH`, including `identity.json`, `policy.json`, and certificate material. Tunnel and demo-app identities still use `IDENTITY_PATH` / `--identity-path` as a direct JSON file path.
203 - Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
204 - Relay certificate material lives under `IDENTITY_PATH` as `fullchain.pem` and `privatekey.pem`.
205 - Localhost uses the development certificate path instead of public managed/manual certificate setup.
@@ -346,7 +346,7 @@ Notes:
346
347 ## Admin API Surface
348
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`.
349 +The relay server is intentionally API-only: public state endpoints, relay policy endpoints, public status endpoints, installer endpoints, and a small set of admin 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
+19 -12
@@ -16,7 +16,7 @@ The relay server (`relay-server`) reads configuration from environment variables
16 | Variable | Default | Type | Description |
17 |----------|---------|------|-------------|
18 | `PORTAL_URL` | `https://localhost:4017` | string | Public base URL of this relay server |
19 -| `IDENTITY_PATH` | `./.portal-certs` | string | Directory path for relay identity, admin state, and TLS materials |
19 +| `IDENTITY_PATH` | `./.portal-certs` | string | Directory path for relay identity, policy state, and TLS materials |
20 | `API_PORT` | `4017` | int | Admin/API server listen port |
21 | `SNI_PORT` | `443` | int | TCP SNI router listen port |
22 | `WIREGUARD_PORT` | `51820` | int | Public and listen UDP port for relay discovery overlay |
@@ -34,7 +34,6 @@ The relay server (`relay-server`) reads configuration from environment variables
34
35 | Variable | Default | Type | Description |
36 |----------|---------|------|-------------|
37 -| `LANDING_PAGE_ENABLED` | `false` | bool | Enable the landing page by default when no admin setting has been saved yet |
37 | `DISCOVERY` | `false` | bool | Serve relay discovery endpoints and poll discovery peers |
38 | `BOOTSTRAPS` | `""` | string | Additional bootstrap relay API URLs used for discovery expansion (comma-separated) |
39
@@ -52,12 +51,6 @@ The relay server (`relay-server`) reads configuration from environment variables
51 | `ACME_DNS_PROVIDER` | `""` | string | DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (`cloudflare` \| `gcloud` \| `hetzner` \| `njalla` \| `route53` \| `vultr`); leave empty to use manual `fullchain.pem`/`privatekey.pem` from `IDENTITY_PATH` |
52 | `ENS_GASLESS_ENABLED` | `false` | bool | Enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames |
53
55 -### Admin
56 -
57 -| Variable | Default | Type | Description |
58 -|----------|---------|------|-------------|
59 -| `HEADLESS_SHELL_URL` | `""` | string | Headless Chrome CDP WebSocket URL for thumbnail generation (e.g. `ws://headless-shell:9222`) |
60 -
54 ### Diagnostics
55
56 | Variable | Default | Type | Description |
@@ -77,6 +70,19 @@ The relay-local facilitator uses the relay identity private key from
70 `IDENTITY_PATH/identity.json`. `/sdk/domain` exposes only the public facilitator
71 URL and network; `X402_RPC_URL` is not returned to clients.
72
73 +### Frontend API Service
74 +
75 +The TypeScript API service reads these environment
76 +variables:
77 +
78 +| Variable | Default | Type | Description |
79 +|----------|---------|------|-------------|
80 +| `PORT` | `8081` | int | Frontend API HTTP listen port |
81 +| `PORTAL_API_BASE_URL` | `https://portal:4017` | string | Relay API base URL used to compose frontend-owned state |
82 +| `LANDING_PAGE_ENABLED` | `false` | bool | Default landing page flag when no frontend state has been saved yet |
83 +| `PORTAL_FRONTEND_STATE_PATH` | `""` | string | Optional JSON file path for persisted frontend-owned state |
84 +| `HEADLESS_SHELL_URL` | `""` | string | Headless Chrome CDP WebSocket URL; leave empty to disable generated thumbnails |
85 +
86 ### Cloudflare
87
88 | Variable | Default | Type | Description |
@@ -209,7 +215,7 @@ description = "Managed web tunnel"
215 tags = ["web"]
216
217 [[tunnels]]
212 -id = "frontend-api"
218 +id = "api"
219 name = "myapp"
220
221 [[tunnels.http_routes]]
@@ -308,11 +314,12 @@ and preserves the mnemonic form when rewriting `identity.json`. The same
314 identity file or state directory can be reused across restarts to keep a stable
315 address.
316
311 -### `admin_settings.json`
317 +### `policy.json`
318
313 -Persists admin-panel state for the relay server. Managed automatically by the relay on write; do not edit manually while the server is running.
319 +Persists relay policy state. Managed automatically by the relay on write; do not edit manually while the server is running.
320
315 -Relay admin settings are stored at `IDENTITY_PATH/admin_settings.json`.
321 +Relay policy settings are stored at `IDENTITY_PATH/policy.json`. Older
322 +`admin_settings.json` files are read once and migrated to `policy.json`.
323
324 ---
325
docs/src/routes/deployment/+page.md
+260 -502
@@ -5,419 +5,248 @@ priority: P1
5 ---
6
7 <div class="not-prose mb-8 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
8 - <strong>Advanced Documentation</strong> — This page covers production relay deployment for operators.
8 + <strong>Advanced Documentation</strong> - This page covers production relay deployment for operators.
9 </div>
10
11 # Portal Relay Deployment Guide
12
13 -This guide covers the production steps for running Portal Relay on a public domain.
13 +This guide starts from the production topology. Read this as the source of truth for how the split relay, frontend, and presentation API are expected to be deployed.
14
15 -## 1. Prerequisites
15 +## 1. Production Topology
16
17 -You need:
18 -
19 -- A public domain, for example `example.com`
20 -- A public Linux server with a static public IPv4
21 -- Docker and Docker Compose
22 -- Optional for managed ACME DNS-01 automation and Portal-managed ECH HTTPS records: a supported DNS provider account for `cloudflare`, `gcloud`, `hetzner`, `njalla`, `route53`, or `vultr`
23 -- Open inbound ports:
24 - - `443/tcp`
25 - - `4017/tcp`
26 - - optional for UDP transport:
27 - - `SNI_PORT/udp`
28 - - `MIN_PORT-MAX_PORT/udp` (see section 5)
29 - - optional for raw TCP port transport:
30 - - `MIN_PORT-MAX_PORT/tcp` (see section 5)
31 -
32 -## 2. Certificate and DNS Mode
33 -
34 -Choose one of these modes:
35 -
36 -- Manual certificate mode
37 - - Leave `ACME_DNS_PROVIDER` empty.
38 - - Place `fullchain.pem` and `privatekey.pem` in `IDENTITY_PATH`.
39 - - Portal uses the files as-is and does not modify DNS or renew the certificate.
40 -- Manual certificate + gasless mode
41 - - Place `fullchain.pem` and `privatekey.pem` in `IDENTITY_PATH`.
42 - - Set `ACME_DNS_PROVIDER` to a DNSSEC-capable provider.
43 - - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for ECH HTTPS records and DNSSEC + ENS TXT automation.
44 -- Managed ACME mode
45 - - Set `ACME_DNS_PROVIDER` to `cloudflare`, `gcloud`, `hetzner`, `njalla`, `route53`, or `vultr`.
46 - - Portal manages root/wildcard A records, ECH HTTPS records, and certificate renewal.
47 - - ENS gasless additionally requires a DNSSEC-capable provider.
48 -
49 -If you only need a relay and do not need Portal-managed DNS or automatic renewal, manual certificate mode is the simplest option.
50 -
51 -## 3. Managed ACME Provider Setup
52 -
53 -### 3.1 Choose ACME DNS provider
54 -
55 -Set `ACME_DNS_PROVIDER` to one of:
56 -
57 -- `cloudflare`
58 -- `gcloud`
59 -- `hetzner`
60 -- `njalla`
61 -- `route53`
62 -- `vultr`
63 -
64 -For a focused explanation of wallet auth and ENS gasless DNS behavior, see
65 -[Wallet and ENS](/wallet-and-ens).
66 -
67 -### 3.2 Cloudflare setup
68 -
69 -#### Add domain to Cloudflare
70 -
71 -1. Cloudflare Dashboard -> `Websites` -> `Add a Site`
72 -2. Enter your domain, for example `example.com`
73 -3. Complete onboarding and apply Cloudflare nameservers at your registrar
74 -4. Wait until zone status is `Active`
75 -
76 -#### Create DNS records
77 -
78 -If `PORTAL_URL=https://example.com`, create:
79 -
80 -- `example.com -> <server-ip>`
81 -- `*.example.com -> <server-ip>`
82 -
83 -If you deploy on a non-apex host such as `PORTAL_URL=https://portal.example.com:8443`, create:
84 -
85 -- `portal.example.com -> <server-ip>`
86 -- `*.portal.example.com -> <server-ip>`
87 -
88 -Set both records as:
89 -
90 -- Type: `A`
91 -- Proxy status: `DNS only`
92 -
93 -#### Create Cloudflare API token
94 -
95 -Cloudflare Dashboard -> `My Profile` -> `API Tokens` -> `Create Token`
96 -
97 -Required permissions:
98 -
99 -- `Zone:Read`
100 -- `DNS:Edit`
101 -- optional when `ENS_GASLESS_ENABLED=true` and `ACME_DNS_PROVIDER=cloudflare`:
102 - - `Zone Settings:Edit`
103 -
104 -Scope:
17 +The production deployment has four roles:
18
106 -- Limit the token to the target zone
19 +| Role | Service or image | Publicly exposed | Owns |
20 +|---|---|---|---|
21 +| Public edge | `nginx` | yes, `443/tcp` | Public TLS termination, path routing, wildcard SNI passthrough |
22 +| Relay | `portal`, `ghcr.io/gosuda/portal` | no direct public API port | Relay API, wallet auth, policy enforcement, tunnel ingress |
23 +| Static frontend | `portal-frontend`, `ghcr.io/gosuda/portal-frontend` | no direct public port | SPA assets and same-origin frontend proxy |
24 +| Presentation API | `portal-api`, `ghcr.io/gosuda/portal-api` | no direct public port | Frontend-owned state, policy composition, service status, thumbnails |
25
108 -Save the token for `CLOUDFLARE_TOKEN`.
26 +Traffic should flow through one public HTTPS origin:
27
110 -### 3.3 Route53 setup
111 -
112 -Create or select a public hosted zone that covers your relay host.
113 -
114 -Provide Route53 write access through either:
115 -
116 -- static AWS credentials, or
117 -- ambient AWS credentials such as an instance role
118 -
119 -Static credential environment variables:
120 -
121 -- `AWS_ACCESS_KEY_ID`
122 -- `AWS_SECRET_ACCESS_KEY`
123 -- optional `AWS_SESSION_TOKEN`
124 -- `AWS_REGION`, for example `us-east-1`
125 -
126 -Optional:
127 -
128 -- `AWS_HOSTED_ZONE_ID`
129 -
130 -Equivalent relay flags:
131 -
132 -- `--aws-access-key-id`
133 -- `--aws-secret-access-key`
134 -- `--aws-session-token`
135 -- `--aws-region`
136 -- `--aws-hosted-zone-id`
137 -
138 -When `ENS_GASLESS_ENABLED=true` and `ACME_DNS_PROVIDER=route53` and the hosted zone does not already have an active Route53 key-signing key (KSK), also provide:
139 -
140 -- `AWS_DNSSEC_KMS_KEY_ARN`
141 -
142 -### 3.4 Google Cloud DNS setup
143 -
144 -Create or select a public Cloud DNS managed zone that covers your relay host.
145 -
146 -Portal uses standard Google Application Default Credentials (ADC) for both Cloud DNS API access and lego DNS-01. Examples:
147 -
148 -- `GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/gcp-dns.json` with a mounted service account JSON file
149 -- an attached service account or workload identity on GCE, GKE, or Cloud Run
150 -
151 -Optional environment variables:
152 -
153 -- `GCP_PROJECT_ID`
154 -- `GCP_MANAGED_ZONE`
155 -- `GOOGLE_APPLICATION_CREDENTIALS`
156 -
157 -Equivalent relay flags:
158 -
159 -- `--gcp-project-id`
160 -- `--gcp-managed-zone`
161 -
162 -Notes:
163 -
164 -- `GCP_PROJECT_ID` is optional when ADC or GCE metadata already exposes the project id.
165 -- `GCP_MANAGED_ZONE` is optional, but useful when the credentials can edit a specific managed zone without permission to list all zones.
166 -- `GOOGLE_APPLICATION_CREDENTIALS` should point to the in-container path when you run Portal in Docker with a mounted service account JSON file.
167 -- Portal only targets public Cloud DNS managed zones.
168 -
169 -### 3.5 Hetzner DNS setup
170 -
171 -Create or select a Hetzner DNS zone that covers your relay host in Hetzner Console.
172 -
173 -Required environment variable:
174 -
175 -- `HETZNER_API_TOKEN`
28 +```text
29 +Browser
30 + -> https://portal.example.com
31 + -> nginx public TLS edge
32 + -> portal-frontend for SPA routes and assets
33 + -> portal for relay-owned API paths
34 + -> portal-frontend -> portal-api for presentation-owned API paths
35
177 -Equivalent relay flag:
36 +Tunnel clients and public app visitors
37 + -> https://*.portal.example.com
38 + -> nginx TCP passthrough
39 + -> portal SNI listener
40 +```
41
179 -- `--hetzner-api-token`
42 +### Public Routing
43
181 -Notes:
44 +| Public request | nginx behavior | Upstream |
45 +|---|---|---|
46 +| `portal.example.com/`, `/admin`, SPA assets | Terminate TLS, HTTP proxy | `portal-frontend:8080` |
47 +| `/admin/auth/*`, `/sdk/*`, `/install.*`, `/discovery`, `/healthz`, `/x402/*` | Terminate TLS, HTTP proxy | `portal:4017` over HTTPS |
48 +| `/state`, `/policy/*`, `/service/status`, `/thumbnail/*` | Terminate TLS, HTTP proxy | `portal-frontend:8080`, then `portal-api:8081` |
49 +| `*.portal.example.com` | Raw TCP passthrough with `ssl_preread` | `portal` SNI listener |
50
183 -- The token needs permission to list DNS zones and edit RRSets for the target zone.
184 -- Hetzner uses `@` for apex records and relative names such as `www` or `*` for subdomains.
185 -- Hetzner DNS does not support provider-side DNSSEC signing, so ENS gasless automation is not supported with `ACME_DNS_PROVIDER=hetzner`.
51 +The root relay host needs HTTP path routing, so it is not TCP-passthrough. Wildcard app hosts need TCP passthrough, so nginx must not terminate TLS for them.
52
187 -### 3.6 Vultr DNS setup
53 +### Migration From Embedded Frontend
54
189 -Create or select a Vultr DNS domain that covers your relay host.
55 +Older deployments could run only `ghcr.io/gosuda/portal` because the relay served frontend assets. Current production deployment separates that into:
56
191 -Required environment variable:
57 +- `portal` for relay API and tunnel ingress
58 +- `portal-frontend` for static SPA assets
59 +- `portal-api` for frontend-owned dynamic behavior
60 +- `nginx` as the public TLS edge
61
193 -- `VULTR_API_KEY`
62 +Operators upgrading from the embedded frontend must deploy all three Portal images and route them through nginx. `PORTAL_URL` remains the browser-facing HTTPS origin, for example `https://portal.example.com`; do not set it to `localhost` or an internal Docker hostname for a public relay.
63
195 -Equivalent relay flag:
64 +### Security Boundary
65
197 -- `--vultr-api-key`
66 +To keep the same practical security level as the embedded frontend deployment:
67
199 -Notes:
68 +- Public users reach the dashboard only through `https://portal.example.com`.
69 +- `portal:4017`, `portal-frontend:8080`, and `portal-api:8081` are not exposed directly to the internet.
70 +- Root-host API paths are HTTP reverse-proxied by nginx to the relay API upstream.
71 +- Wildcard app hosts are TCP-passthrough to the relay SNI listener.
72 +- The nginx browser certificate and the relay API certificate are separate operational concerns unless you intentionally share the same certificate files.
73
201 -- The API key needs permission to list DNS domains, edit DNS records, and update DNSSEC for the target domain.
202 -- Vultr uses `@` for apex records and relative names such as `www` or `*` for subdomains.
74 +It is fine for nginx to terminate public TLS and then proxy to the relay API over HTTPS internally. That is two TLS legs. TCP passthrough is only for wildcard tunnel app hosts.
75
204 -### 3.7 Njalla DNS setup
76 +## 2. Prerequisites
77
206 -Create or select a Njalla DNS domain that covers your relay host.
78 +You need:
79
208 -Required environment variable:
80 +- A public domain, for example `portal.example.com`.
81 +- A public Linux server with a static public IPv4.
82 +- Docker and Docker Compose.
83 +- DNS `A` records for the relay host and wildcard host:
84
210 -- `NJALLA_TOKEN`
85 +```text
86 +portal.example.com -> <server-ip>
87 +*.portal.example.com -> <server-ip>
88 +```
89
212 -Equivalent relay flag:
90 +If you use Cloudflare, keep these records `DNS only`. Proxied records break the raw wildcard TCP passthrough path.
91
214 -- `--njalla-token`
92 +Open only the public ports that match the topology:
93
216 -Notes:
94 +| Port | Required | Purpose |
95 +|---|---|---|
96 +| `80/tcp` | optional | HTTP to HTTPS redirect in the bundled nginx example |
97 +| `443/tcp` | yes | Public nginx edge for dashboard, relay API path routing, and wildcard TCP passthrough |
98 +| `WIREGUARD_PORT/udp` | when `DISCOVERY=true` | Relay discovery WireGuard transport |
99 +| `SNI_PORT/udp` | when UDP transport is enabled | QUIC tunnel ingress |
100 +| `MIN_PORT-MAX_PORT/udp` | when UDP lease transport is enabled | Public UDP lease ports |
101 +| `MIN_PORT-MAX_PORT/tcp` | when raw TCP lease transport is enabled | Public raw TCP lease ports |
102
218 -- The token needs permission to list and edit DNS records for the target domain.
219 -- Njalla uses `@` for apex records and relative names such as `www` or `*` for subdomains.
220 -- Portal does not automate Njalla DNSSEC signing, so ENS gasless automation is not supported with `ACME_DNS_PROVIDER=njalla`.
103 +Keep these ports private or loopback-only in the recommended topology:
104
222 -### 3.8 Optional ENS Gasless Automation
105 +| Port | Owner |
106 +|---|---|
107 +| `4017/tcp` | `portal` relay API |
108 +| `8080/tcp` | `portal-frontend` static server |
109 +| `8081/tcp` | `portal-api` presentation API |
110
224 -Portal can optionally enable ENS gasless DNS import for the base domain and lease hostnames.
111 +Certificate files are also split by owner:
112
226 -- This is not required for normal Portal deployment.
227 -- Enable it only when you specifically need ENS gasless DNS import.
228 -- ENS gasless automation requires `ACME_DNS_PROVIDER`.
229 -- Portal uses that provider for both DNSSEC automation and ENS TXT create/delete.
230 -- If valid manual certificate files already exist in `IDENTITY_PATH`, Portal keeps using them and does not force ACME certificate issuance just because `ACME_DNS_PROVIDER` is set.
231 -- Cloudflare can enable zone signing directly, but some registrars still require publishing the returned DS record.
232 -- Google Cloud DNS can enable zone signing directly, but the registrar may still require publishing the returned DS record.
233 -- Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
234 -- Vultr can enable zone signing directly, but the registrar may still require publishing the returned DS record.
235 -- Hetzner and Njalla are supported for managed ACME DNS automation, but not for ENS gasless automation.
236 -- New lease hostnames such as `app.portal.example.com` are published automatically when they register and are cleaned up on unregister or expiry.
237 -- ENS gasless import still depends on DNSSEC being valid for the domain.
238 -- By default Portal writes `ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 <address>`.
239 -- The address is derived automatically from the relay identity for the base domain and from each lease identity for lease hostnames.
240 -- This enables offchain gasless DNSSEC usage in ENS-aware clients. It does not perform an onchain ENS claim transaction.
241 -- Portal can automate provider-side DNS changes, but registrar-side DS publication is not always automatable. Expect a manual registrar step unless your registrar publishes DS records automatically.
242 -- Keep `ENS_GASLESS_ENABLED=false` unless you intend to use ENS gasless DNS import.
113 +| Certificate | Default path in the example | Used by |
114 +|---|---|---|
115 +| Browser-facing HTTPS certificate | `./certs/fullchain.pem`, `./certs/privkey.pem` | nginx public edge |
116 +| Relay API and SNI certificate | `./.portal-certs/fullchain.pem`, `./.portal-certs/privatekey.pem` | `portal` unless managed ACME is configured |
117
244 -Typical rollout:
118 +Portal-managed ACME can manage the relay certificate and relay DNS records. The bundled nginx example still expects a browser-facing certificate in `./certs`; manage that with your normal edge certificate process.
119
246 -1. Set `ACME_DNS_PROVIDER` and the provider credentials.
247 -2. Set `ENS_GASLESS_ENABLED=true`.
248 -3. Start Portal and confirm the log contains both `dnssec configured` and `ens gasless dns import configured`.
249 -4. If the DNSSEC state is `pending` or the provider returns a `DS` record, publish the returned `DS` record at your registrar and wait for propagation.
250 -5. Re-check until the provider DNSSEC state becomes `active` or `enabled`.
251 -6. Verify external resolution with an ENS-aware client after DNSSEC is active.
120 +## 3. Deploy the Recommended Stack
121
253 -Registrar DS publication:
122 +Start from the single-domain nginx example:
123
255 -- Cloudflare, Google Cloud DNS, Route53, and Vultr can sign the zone and return the DS record, but they do not control your registrar unless the domain is registered with the same provider.
256 -- If your registrar is separate, you must copy the DS values from the provider into the registrar's DNSSEC or DS configuration screen.
257 -- Example: if the domain is registered at Namecheap and delegated to Cloudflare nameservers, enable DNSSEC in Cloudflare first, then add the Cloudflare DS record in Namecheap under the domain's `Advanced DNS` DNSSEC section.
258 -- Until the registrar publishes the DS record at the parent zone, provider status typically stays `pending` and ENS gasless resolution may fail even though Portal already wrote the `ENS1 ...` TXT record.
124 +```bash
125 +mkdir -p portal-deploy
126 +cd portal-deploy
127
260 -Verification checklist:
128 +cp <repo>/docs/static/examples/nginx-proxy/docker-compose.yaml ./docker-compose.yaml
129 +cp <repo>/docs/static/examples/nginx-proxy/nginx.conf ./nginx.conf
130 +cp <repo>/docs/static/examples/nginx-proxy/.env.example ./.env
131 +cp <repo>/docs/static/examples/nginx-proxy/deploy_portal.sh ./deploy_portal.sh
132 +cp <repo>/docs/static/examples/nginx-proxy/watch_and_deploy.sh ./watch_and_deploy.sh
133 +cp <repo>/docs/static/examples/nginx-proxy/nginx_deploy.sh ./nginx_deploy.sh
134 +chmod +x deploy_portal.sh watch_and_deploy.sh nginx_deploy.sh
135 +```
136
262 -- Provider DNSSEC status is `active` or `enabled`.
263 -- `dig +short DS example.com` returns the DS record from the parent zone.
264 -- `dig +short TXT example.com` returns the `ENS1 ...` TXT record.
265 -- ENS-aware resolution returns the expected address for the base domain and each lease hostname.
137 +Replace every `portal.example.com` in `nginx.conf` and `.env`.
138
267 -## 4. Run Relay Server
139 +For deployments with multiple additional services behind the same edge nginx, use `docs/static/examples/nginx-proxy-multi-service` instead. The same Portal routing rules apply.
140
269 -### 4.1 Create `.env` at repository root
141 +### Configure `.env`
142
271 -Manual certificate example:
143 +Minimal production baseline:
144
145 ```bash
274 -PORTAL_URL=https://example.com
275 -BOOTSTRAPS=https://bootstrap.example.com
146 +PORTAL_URL=https://portal.example.com
147 +BOOTSTRAPS=
148 DISCOVERY=true
277 -WIREGUARD_PORT=51820
149 IDENTITY_PATH=/portal-certs
150 +
151 +API_PORT=4017
152 SNI_PORT=443
153 +WIREGUARD_PORT=51820
154 +MIN_PORT=0
155 +MAX_PORT=0
156 +UDP_ENABLED=false
157 +TCP_ENABLED=false
158 +
159 ACME_DNS_PROVIDER=
160 ENS_GASLESS_ENABLED=false
282 -```
161
284 -Place these files in `IDENTITY_PATH` before startup:
162 +TRUST_PROXY_HEADERS=true
163 +TRUSTED_PROXY_CIDRS=
164
286 -```text
287 -/portal-certs/fullchain.pem
288 -/portal-certs/privatekey.pem
165 +LANDING_PAGE_ENABLED=false
166 ```
167
291 -Manual certificate + gasless example:
168 +Keep `SNI_PORT=443` with the bundled nginx example because this is the public SNI port advertised to tunnel clients. The single-domain Compose example maps the relay container's SNI listener to `127.0.0.1:4443` on the host so nginx can own public `443/tcp` and still pass wildcard TCP traffic to the relay. Do not open `4443/tcp` publicly; it is only a host-local upstream in that example.
169
293 -```bash
294 -PORTAL_URL=https://example.com
295 -BOOTSTRAPS=https://bootstrap.example.com
296 -DISCOVERY=true
297 -WIREGUARD_PORT=51820
298 -IDENTITY_PATH=/portal-certs
299 -SNI_PORT=443
300 -ACME_DNS_PROVIDER=cloudflare
301 -CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
302 -ENS_GASLESS_ENABLED=true
303 -```
170 +If the relay joins public discovery, set `BOOTSTRAPS` to at least one reachable relay URL and keep `WIREGUARD_PORT/udp` open.
171
305 -In this mode, Portal keeps the manual certificate files but still manages DNSSEC and `ENS1 ...` TXT records through Cloudflare.
172 +The relay identity wallet can always sign in through admin auth. Set `ADMIN_WALLETS` only when you need additional admin wallets.
173
307 -Managed Cloudflare example:
174 +Leave `TRUSTED_PROXY_CIDRS` empty for the default private and loopback proxy ranges. Set it only when you need a stricter proxy source allowlist.
175
309 -```bash
310 -PORTAL_URL=https://example.com
311 -BOOTSTRAPS=https://bootstrap.example.com
312 -DISCOVERY=true
313 -WIREGUARD_PORT=51820
314 -IDENTITY_PATH=/portal-certs
315 -SNI_PORT=443
316 -ACME_DNS_PROVIDER=cloudflare
317 -CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
318 -ENS_GASLESS_ENABLED=false
319 -```
176 +### Prepare Certificates and State
177
321 -Route53 example:
178 +Create the state directories:
179
180 ```bash
324 -IDENTITY_PATH=/portal-certs
325 -ACME_DNS_PROVIDER=route53
326 -AWS_ACCESS_KEY_ID=AKIA...
327 -AWS_SECRET_ACCESS_KEY=...
328 -AWS_SESSION_TOKEN=...
329 -AWS_REGION=us-east-1
330 -# Optional override
331 -AWS_HOSTED_ZONE_ID=Z1234567890ABC
332 -# Required only for ENS gasless automation when no ACTIVE KSK already exists.
333 -AWS_DNSSEC_KMS_KEY_ARN=arn:aws:kms:...
334 -ENS_GASLESS_ENABLED=false
181 +mkdir -p ./.portal-certs ./.portal-frontend-state ./certs
182 +sudo chown 65532:65532 ./.portal-certs
183 +chmod 755 ./.portal-certs
184 ```
185
337 -Google Cloud DNS example:
186 +Place the nginx browser certificate here:
187
339 -```bash
340 -IDENTITY_PATH=/portal-certs
341 -ACME_DNS_PROVIDER=gcloud
342 -# Optional when ADC does not expose the project id directly.
343 -GCP_PROJECT_ID=my-gcp-project
344 -# Optional override when the credentials cannot list managed zones.
345 -GCP_MANAGED_ZONE=portal-example-com
346 -# Standard ADC when using a mounted service account file.
347 -GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/gcp-dns.json
348 -ENS_GASLESS_ENABLED=false
188 +```text
189 +./certs/fullchain.pem
190 +./certs/privkey.pem
191 ```
192
351 -Vultr example:
193 +In manual relay certificate mode, also place the relay certificate here before startup:
194
353 -```bash
354 -IDENTITY_PATH=/portal-certs
355 -ACME_DNS_PROVIDER=vultr
356 -VULTR_API_KEY=...
357 -ENS_GASLESS_ENABLED=false
195 +```text
196 +./.portal-certs/fullchain.pem
197 +./.portal-certs/privatekey.pem
198 ```
199
360 -Njalla example:
200 +You may use the same certificate material for nginx and the relay when it covers both `portal.example.com` and `*.portal.example.com`; keep the filenames expected by each service.
201
362 -```bash
363 -IDENTITY_PATH=/portal-certs
364 -ACME_DNS_PROVIDER=njalla
365 -NJALLA_TOKEN=...
366 -ENS_GASLESS_ENABLED=false
367 -```
202 +When `ACME_DNS_PROVIDER` is configured, Portal can create and renew the relay certificate under `IDENTITY_PATH`. That does not remove nginx's need for its own browser-facing certificate under `./certs`.
203
369 -Hetzner example:
204 +### Start and Verify
205 +
206 +Start the stack:
207
208 ```bash
372 -IDENTITY_PATH=/portal-certs
373 -ACME_DNS_PROVIDER=hetzner
374 -HETZNER_API_TOKEN=...
375 -ENS_GASLESS_ENABLED=false
209 +docker compose up -d
210 ```
211
378 -Notes:
379 -
380 -- For non-apex deployments, set `PORTAL_URL` to the non-apex host value, for example `https://portal.example.com:8443`
381 -- Portal uses the `PORTAL_URL` host for public lease hostnames
382 -- `IDENTITY_PATH` stores the relay state directory inside the container
383 -- Portal stores `identity.json`, `admin_settings.json`, `fullchain.pem`, and `privatekey.pem` under `IDENTITY_PATH`
384 -- The Docker Compose stack stores relay state under `./.portal-certs` on the host
385 -
386 -Discovery settings:
212 +Verify the public edge:
213
214 ```bash
389 -DISCOVERY=true
390 -BOOTSTRAPS=https://bootstrap.example.com
391 -WIREGUARD_PORT=51820
215 +curl -I https://portal.example.com
216 +docker compose ps
217 ```
218
394 -- Open `WIREGUARD_PORT/udp` on the host or VM when discovery is enabled.
395 -- The relay always advertises the `PORTAL_URL` host for WireGuard discovery.
396 -- The relay identity address can sign in through the admin API by default; use `ADMIN_WALLETS` to allow additional admin wallets.
397 -- The relay stores its WireGuard keypair in `IDENTITY_PATH/identity.json`. If that file has no WireGuard key yet, Portal generates one on first discovery startup and saves it back to that file.
398 -- `BOOTSTRAPS` should point at at least one existing relay when you want discovery to join a multi-relay mesh.
219 +Expected service names in the recommended stack:
220
400 -If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
221 +- `nginx`
222 +- `portal`
223 +- `portal-frontend`
224 +- `portal-api`
225
402 -```bash
403 -TRUST_PROXY_HEADERS=true
404 -```
226 +If `https://portal.example.com` loads the dashboard and tunnel app hosts under `*.portal.example.com` reach the relay, the topology is correct.
227
406 -If your proxy source addresses are public or you want a stricter allowlist, also set `TRUSTED_PROXY_CIDRS`.
228 +## 4. Certificate and DNS Automation
229
408 -### 4.2 Start Relay
230 +Choose one certificate and DNS mode for the relay.
231
410 -When using the published Docker image, create the bind-mount directory first and make it writable by UID `65532` (`nonroot` in the distroless image):
232 +| Mode | `ACME_DNS_PROVIDER` | Relay cert source | DNS automation |
233 +|---|---|---|---|
234 +| Manual certificate | empty | `IDENTITY_PATH/fullchain.pem` and `IDENTITY_PATH/privatekey.pem` | none |
235 +| Manual certificate plus gasless DNS | DNSSEC-capable provider | manual files | ENS TXT and DNSSEC automation |
236 +| Managed ACME | supported provider | Portal-managed ACME DNS-01 | root/wildcard A records, ECH HTTPS records, relay cert renewal |
237
412 -```bash
413 -mkdir -p ./.portal-certs
414 -sudo chown 65532:65532 ./.portal-certs
415 -chmod 755 ./.portal-certs
416 -```
238 +Supported provider values:
239
418 -If you use manual certificate mode, make sure `fullchain.pem` and `privatekey.pem` already exist in `./.portal-certs` before startup.
240 +| Provider | Required environment | ENS gasless support |
241 +|---|---|---|
242 +| `cloudflare` | `CLOUDFLARE_TOKEN` | yes |
243 +| `gcloud` | Google ADC, optionally `GCP_PROJECT_ID`, `GCP_MANAGED_ZONE`, `GOOGLE_APPLICATION_CREDENTIALS` | yes |
244 +| `route53` | AWS credentials or instance role, optionally `AWS_HOSTED_ZONE_ID` | yes, needs an active KSK or `AWS_DNSSEC_KMS_KEY_ARN` |
245 +| `vultr` | `VULTR_API_KEY` | yes |
246 +| `hetzner` | `HETZNER_API_TOKEN` | no |
247 +| `njalla` | `NJALLA_TOKEN` | no |
248
420 -If you use `ACME_DNS_PROVIDER=gcloud` with a service account JSON file under Docker Compose, mount the file into the container and set `GOOGLE_APPLICATION_CREDENTIALS` to the in-container path. Example:
249 +For `gcloud` with a service account file under Docker Compose, mount the file and point `GOOGLE_APPLICATION_CREDENTIALS` at the in-container path:
250
251 ```yaml
252 services:
@@ -429,54 +258,57 @@ services:
258 - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
259 ```
260
432 -Then start the stack:
433 -
434 -```bash
435 -docker compose up -d
436 -```
261 +### ENS Gasless Automation
262
438 -## 5. Optional UDP and Raw TCP Port Setup
263 +ENS gasless DNS import is optional and not required for normal relay operation.
264
440 -UDP transport and raw TCP port transport are disabled by default.
265 +Enable it only when you need ENS-aware clients to resolve Portal domains through gasless DNSSEC import:
266
442 -### 5.1 Open transport ports on your VM or host
267 +```bash
268 +ACME_DNS_PROVIDER=cloudflare
269 +CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
270 +ENS_GASLESS_ENABLED=true
271 +```
272
444 -Open these ports in your cloud security group or firewall:
273 +Operational notes:
274
446 -- `WIREGUARD_PORT/udp` when discovery is enabled
447 -- `SNI_PORT/udp`
448 -- `MIN_PORT-MAX_PORT/udp` when UDP transport is enabled
449 -- `MIN_PORT-MAX_PORT/tcp` when raw TCP port transport is enabled
275 +- ENS gasless requires `ACME_DNS_PROVIDER`.
276 +- Portal writes `ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 <address>` TXT records.
277 +- The base domain uses the relay identity address; lease hostnames use each lease identity address.
278 +- Provider-side DNSSEC automation is not the same as registrar-side DS publication.
279 +- If the provider returns a `DS` record or reports DNSSEC as pending, publish the DS record at your registrar and wait for parent-zone propagation.
280 +- Keep `ENS_GASLESS_ENABLED=false` unless you intentionally use this feature.
281
451 -Example with `MIN_PORT=40000` and `MAX_PORT=40009`:
282 +Verification checklist:
283
284 ```bash
454 -sudo ufw allow 443/udp
455 -sudo ufw allow 40000:40009/udp
456 -sudo ufw allow 40000:40009/tcp
285 +dig +short DS portal.example.com
286 +dig +short TXT portal.example.com
287 ```
288
459 -### 5.2 Expose transport ports in Docker
289 +Provider DNSSEC should be active, and the TXT response should include the `ENS1 ...` value.
290
461 -If you use `network_mode: host`, the container uses host transport ports directly.
291 +## 5. Optional UDP and Raw TCP Transport
292
463 -If you use bridge networking, map the ports explicitly in `docker-compose.yaml`:
293 +UDP transport and raw TCP lease transport are disabled by default.
294
465 -```yaml
466 -ports:
467 - - "443:443/udp"
468 - - "40000-40009:40000-40009/udp"
469 - - "40000-40009:40000-40009"
470 -```
295 +Open these ports in your cloud security group or host firewall only when the matching feature is enabled:
296
472 -Map `SNI_PORT/udp` on the host to the relay's UDP QUIC listener port in the container.
473 -UDP and raw TCP use the same numeric lease range independently, so when both transports are enabled you publish the same `MIN_PORT-MAX_PORT` range once for UDP and once for TCP.
297 +- `WIREGUARD_PORT/udp` when discovery is enabled.
298 +- `SNI_PORT/udp` when UDP tunnel ingress is enabled.
299 +- `MIN_PORT-MAX_PORT/udp` when UDP lease transport is enabled.
300 +- `MIN_PORT-MAX_PORT/tcp` when raw TCP lease transport is enabled.
301
475 -### 5.3 Configure Relay Transport Ports
302 +Example with `MIN_PORT=40000`, `MAX_PORT=40009`, and `SNI_PORT=443`:
303
477 -Set the shared lease range in `.env`, then enable the transports you want.
304 +```bash
305 +sudo ufw allow 51820/udp
306 +sudo ufw allow 443/udp
307 +sudo ufw allow 40000:40009/udp
308 +sudo ufw allow 40000:40009/tcp
309 +```
310
479 -Example:
311 +Configure the shared lease range in `.env`:
312
313 ```bash
314 MIN_PORT=40000
@@ -485,22 +317,19 @@ UDP_ENABLED=true
317 TCP_ENABLED=true
318 ```
319
488 -That allocates lease ports `40000-40009` for both UDP and raw TCP. The protocols are independent, so the same numeric port may be used on both transports at the same time.
489 -The SDK datagram backhaul always uses the relay `SNI_PORT`, even if `PORTAL_URL` uses `:4017` for the API.
490 -
491 -| Variable | Default | Description |
492 -|---|---|---|
493 -| `MIN_PORT` | `0` | Inclusive minimum lease port shared by UDP and raw TCP (`0` disables the range) |
494 -| `MAX_PORT` | `0` | Inclusive maximum lease port shared by UDP and raw TCP (`0` disables the range) |
495 -| `UDP_ENABLED` | `false` | Enable UDP relay transport |
496 -| `TCP_ENABLED` | `false` | Enable raw TCP port transport |
497 -| `SNI_PORT` | `443` | Public TCP SNI port and QUIC UDP port for relay ingress |
320 +When using bridge networking, publish the same range in `docker-compose.yaml`:
321
499 -### 5.4 Enable transports in the admin panel
322 +```yaml
323 +ports:
324 + - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
325 + - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
326 + - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
327 + - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
328 +```
329
501 -After the relay starts, open `/admin`, enable UDP transport and/or TCP port transport, and set any lease limits you want to enforce.
330 +UDP and raw TCP use the same numeric range independently, so the same number can be allocated once for UDP and once for TCP.
331
503 -### 5.5 Optional Linux UDP buffer tuning
332 +After startup, enable UDP or raw TCP policy in the admin UI and set any lease limits you want to enforce.
333
334 For better QUIC performance on Linux:
335
@@ -509,128 +338,63 @@ sudo sysctl -w net.core.rmem_max=7500000
338 sudo sysctl -w net.core.wmem_max=7500000
339 ```
340
512 -To persist this across reboots, add the values to `/etc/sysctl.conf` or a file in `/etc/sysctl.d/`.
513 -
514 -## 6. Optional Thumbnail Screenshots
515 -
516 -Portal can automatically generate thumbnail screenshots for tunnel apps that don't provide their own. When a tunnel app registers without a `thumbnail` in its metadata, the relay captures a screenshot of the app's public page and serves it as a card background on the dashboard.
517 -
518 -This feature is **disabled by default** and entirely optional. Without it, apps without a thumbnail simply show a gradient background.
519 -
520 -### 6.1 When to enable
521 -
522 -Enable this feature when:
523 -
524 -- You want richer visual previews on the relay dashboard
525 -- Most of your tunnel apps don't set a custom thumbnail in their metadata
526 -
527 -Skip this feature when:
528 -
529 -- You want the smallest possible deployment footprint
530 -- Tunnel apps already provide their own thumbnails
531 -- You're running on resource-constrained servers
532 -
533 -### 6.2 How it works
341 +Persist those values in `/etc/sysctl.conf` or a file under `/etc/sysctl.d/` if needed.
342
535 -The relay uses a headless Chromium sidecar (`chromedp/headless-shell`, ~200 MB) to render tunnel app pages and capture screenshots. When a tunnel app registers:
343 +## 6. Frontend Presentation API
344
537 -1. If the app has no thumbnail and `HEADLESS_SHELL_URL` is configured, the relay queues a screenshot job.
538 -2. A single background worker connects to the headless Chromium via Chrome DevTools Protocol (CDP).
539 -3. The worker navigates to the app's public HTTPS URL, waits for the page to load, and captures a 1280×720 screenshot.
540 -4. The screenshot is JPEG-encoded and cached in memory (max 256 KB per image).
541 -5. On the next dashboard page load, the cached thumbnail is injected into the app's card.
345 +`portal-api` is a small TypeScript service owned by the frontend deployment. It keeps frontend-specific behavior out of the Go relay.
346
543 -Screenshots are evicted when the lease expires or the app disconnects.
347 +It owns:
348
545 -### 6.3 Enable thumbnail screenshots
349 +- `/state` composition with frontend-owned fields.
350 +- `/policy/*` composition, while relay-enforced policy changes are still forwarded to `portal`.
351 +- `/service/status`, derived from relay state for quick-start UI checks.
352 +- `/thumbnail/<hostname>`, when optional screenshot generation is enabled.
353 +- The landing-page flag persisted at `PORTAL_FRONTEND_STATE_PATH`.
354
547 -**Step 1**: Uncomment the headless-shell service in `docker-compose.yml`:
355 +The Go relay remains the owner of authentication, policy enforcement, lease state, tunnel ingress, install scripts, discovery, and x402 facilitator paths.
356
549 -```yaml
550 -services:
551 - headless-shell:
552 - image: chromedp/headless-shell:stable
553 - restart: unless-stopped
554 -```
555 -
556 -**Step 2**: Uncomment the `depends_on` in the portal service:
557 -
558 -```yaml
559 - portal:
560 - depends_on:
561 - - headless-shell
562 -```
563 -
564 -**Step 3**: Uncomment and set `HEADLESS_SHELL_URL` in the portal environment:
565 -
566 -```yaml
567 - environment:
568 - HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
569 -```
357 +### Thumbnail Screenshots
358
571 -Or set it in `.env`:
359 +Generated thumbnails are optional and disabled by default. Without this feature, apps without a custom thumbnail simply use the default card background.
360
573 -```bash
574 -HEADLESS_SHELL_URL=ws://headless-shell:9222
575 -```
576 -
577 -**Step 4**: Restart the stack:
578 -
579 -```bash
580 -docker compose up -d
581 -```
361 +To enable generated thumbnails:
362
583 -### 6.4 Verify
363 +1. Uncomment the `headless-shell` service in `docker-compose.yaml`.
364 +2. Add `headless-shell` to `portal-api.depends_on`.
365 +3. Set `HEADLESS_SHELL_URL=ws://headless-shell:9222`.
366 +4. Restart with `docker compose up -d`.
367
585 -After a tunnel app connects, check the relay logs for:
368 +Expected log when a thumbnail is captured:
369
370 +```text
371 +thumbnail captured hostname=myapp.portal.example.com size=36209
372 ```
588 -INF thumbnail captured hostname=myapp.portal.example.com size=36209
589 -```
590 -
591 -The thumbnail is then served at `/thumbnail/<hostname>` and displayed on the dashboard card.
373
593 -### 6.5 Disable
594 -
595 -Remove or comment out `HEADLESS_SHELL_URL` from `.env` or the docker-compose environment. The headless-shell container can also be removed. Without this variable, the feature is completely inactive with zero overhead.
596 -
597 -| Variable | Default | Description |
598 -|---|---|---|
599 -| `HEADLESS_SHELL_URL` | _(empty, disabled)_ | CDP WebSocket URL for headless Chromium sidecar (e.g. `ws://headless-shell:9222`) |
374 +Disable the feature by removing `HEADLESS_SHELL_URL` and stopping the `headless-shell` container.
375
376 ## 7. Auto-Update
377
603 -Automatically redeploy when new `ghcr.io/gosuda/portal:latest` or `ghcr.io/gosuda/portal-frontend:latest` images are pushed.
378 +Auto-update must pull all production images together:
379
605 -### 7.1 Deploy script
380 +- `ghcr.io/gosuda/portal:latest`
381 +- `ghcr.io/gosuda/portal-frontend:latest`
382 +- `ghcr.io/gosuda/portal-api:latest`
383
607 -Create `deploy_portal.sh` in your project directory:
384 +The bundled `deploy_portal.sh` pulls all Portal images together and reloads nginx after the services are updated:
385
386 ```bash
610 -#!/usr/bin/env bash
611 -set -euo pipefail
387 +#!/bin/bash
388 +set -e
389
613 -cd "$(dirname "$0")"
614 -
615 -docker compose pull
616 -docker compose up -d
390 +docker compose pull portal portal-frontend portal-api
391 +docker compose up -d portal portal-frontend portal-api
392 +bash nginx_deploy.sh
393 ```
394
619 -### 7.2 Watcher script
620 -
621 -The repository includes `watch_and_deploy.sh`, which polls the remote image digest and runs the deploy script on change.
622 -
623 -Environment variables:
624 -
625 -| Variable | Default | Description |
626 -|---|---|---|
627 -| `INTERVAL` | `60` | Poll interval in seconds |
628 -| `DEPLOY_SCRIPT` | `deploy_portal.sh` | Path to deploy script |
629 -| `DIGEST_FILE` | `.portal_image_digest` | File storing the last known digest |
630 -
631 -### 7.3 Register as systemd service
395 +The bundled `watch_and_deploy.sh` polls remote image digests and runs the deploy script when any watched image changes.
396
633 -Set `WorkingDirectory` and `ExecStart` to the directory where `watch_and_deploy.sh` and `deploy_portal.sh` are located:
397 +Systemd example:
398
399 ```bash
400 sudo tee /etc/systemd/system/portal-watcher.service << 'EOF'
@@ -658,50 +422,38 @@ sudo systemctl daemon-reload
422 sudo systemctl enable --now portal-watcher
423 ```
424
661 -Adjust `User` to match your environment. Ensure the user belongs to the `docker` group:
425 +Adjust `User` and paths to match your server. The service user must be able to run Docker.
426
663 -```bash
664 -sudo usermod -aG docker opc
665 -```
666 -
667 -### 7.4 Verify and monitor
427 +Monitor it with:
428
429 ```bash
430 sudo systemctl status portal-watcher
431 sudo journalctl -u portal-watcher -f
672 -sudo journalctl -u portal-watcher --since today
432 ```
433
434 ## 8. Troubleshooting
435
677 -### 8.1 Ports blocked
436 +### `4017` Shows Only API
437
679 -Required inbound ports:
438 +That is expected. `4017/tcp` is the relay API, not the dashboard. Use `https://portal.example.com` through nginx for the production UI.
439
681 -- `443/tcp`
682 -- `4017/tcp`
683 -- optional for UDP:
684 - - `SNI_PORT/udp`
685 - - `MIN_PORT-MAX_PORT/udp`
686 -- optional for raw TCP:
687 - - `MIN_PORT-MAX_PORT/tcp`
440 +### Frontend Logs Show Binary TLS Bytes and `400`
441
689 -UFW example with `MIN_PORT=40000` and `MAX_PORT=40009`:
442 +Logs like `"\x16\x03\x01..." 400` mean a client sent HTTPS to the plain HTTP `portal-frontend:8080` listener. Do not expose `8080` publicly. Put nginx with TLS in front of it.
443
691 -```bash
692 -sudo ufw allow 443/tcp
693 -sudo ufw allow 4017/tcp
694 -sudo ufw allow 443/udp
695 -sudo ufw allow 40000:40009/udp
696 -sudo ufw allow 40000:40009/tcp
697 -sudo ufw status
698 -```
444 +### Relay Logs Show `tls: unknown certificate`
445 +
446 +This usually means a browser or proxy hit the relay API certificate directly instead of the public nginx certificate, or an upstream proxy tried to verify the relay's internal certificate. In the bundled nginx example, public browsers verify nginx's certificate, while nginx proxies to the relay API over internal HTTPS.
447 +
448 +### Root Host Works but Wildcard Apps Fail
449
700 -### 8.2 QUIC UDP buffer warnings
450 +Check that `portal.example.com` is HTTP-proxied after TLS termination and that `*.portal.example.com` is TCP-passthrough to the relay SNI listener. Do not terminate TLS for wildcard app hosts in nginx.
451
702 -If relay logs show `failed to sufficiently increase receive buffer size`, apply the sysctl settings from section 5.5.
452 +### Discovery Announce Is Rejected as Local-Only
453
704 -### 8.3 Docker DNS resolution fails
454 +Public discovery rejects `PORTAL_URL` hosts such as `localhost`, `127.0.0.1`, `::1`, or other local-only names. Set `PORTAL_URL` to a publicly reachable HTTPS hostname.
455 +
456 +### Docker DNS Resolution Fails
457
458 If logs show `discover bootstraps failed`, `sync dns records`, or `lookup <host> on 127.0.0.11:53: write: operation not permitted`, Docker is usually using the wrong host resolver config.
459
@@ -716,11 +468,17 @@ docker compose up -d
468 Verify from the container:
469
470 ```bash
719 -docker exec -it portal-1 nslookup api4.ipify.org
471 +docker run --rm --network container:portal busybox nslookup api4.ipify.org
472 ```
473
722 -### 8.4 Discovery announce warnings
474 +### Ports Are Blocked
475 +
476 +Confirm the required public ports are open:
477
724 -If logs show `relay discovery announce failed` with `404 page not found`, the target bootstrap relay is running an older release or does not serve `/discovery/announce`. This is warning-only: direct `/discovery` polling and explicit relay URLs can still work. The warnings stop once bootstrap relays are upgraded or removed from `BOOTSTRAPS`.
478 +```bash
479 +sudo ufw allow 443/tcp
480 +sudo ufw allow 51820/udp
481 +sudo ufw status
482 +```
483
726 -Discovery announce is relay-to-relay only. A relay whose `PORTAL_URL` host is `localhost`, `127.0.0.1`, `::1`, or another loopback/local host is rejected by `/discovery/announce` because other relays and users cannot route to it. To join public discovery, set `PORTAL_URL` to a publicly reachable HTTPS hostname and expose the required TCP/UDP ports.
484 +Only add UDP and raw TCP lease ranges when those transports are enabled.
docs/src/routes/self-hosting/+page.md
+1 -1
@@ -73,7 +73,7 @@ docker compose up -d
73 | `PORTAL_URL` | `https://localhost:4017` | Public base URL of your relay. Tunnels use this to register. |
74 | `API_PORT` | `4017` | Admin/API server port. |
75 | `SNI_PORT` | `443` | TCP SNI router port for tunnel traffic. |
76 -| `IDENTITY_PATH` | `./.portal-certs` | Relay state directory containing `identity.json`, `admin_settings.json`, and TLS materials. |
76 +| `IDENTITY_PATH` | `./.portal-certs` | Relay state directory containing `identity.json`, `policy.json`, and TLS materials. |
77
78 ## Connecting Your Tunnel
79
docs/src/routes/wallet-and-ens/+page.md
+1 -1
@@ -237,6 +237,6 @@ A lease hostname has no ENS TXT record:
237
238 ## Next Steps
239
240 -- [Deployment](/deployment#38-optional-ens-gasless-automation): production setup
240 +- [Deployment](/deployment#ens-gasless-automation): production setup
241 - [Security Model](/security-model): identity and TLS trust boundaries
242 - [Portal Agent](/portal-agent): local durable tunnel management
docs/static/examples/nginx-proxy-multi-service/.env.example
+16 -7
@@ -9,7 +9,7 @@ IDENTITY_PATH=/portal-certs
9
10 # Listener ports
11 API_PORT=4017
12 -SNI_PORT=4443
12 +SNI_PORT=443
13 WIREGUARD_PORT=51820
14 # Set when enabling public UDP or raw TCP lease ports.
15 MIN_PORT=0
@@ -19,7 +19,7 @@ TCP_ENABLED=false
19
20 # TLS/ACME materials live under IDENTITY_PATH as fullchain.pem/privatekey.pem.
21
22 -# Supported managed values: cloudflare, gcloud, route53, vultr
22 +# Supported managed values: cloudflare, gcloud, hetzner, njalla, route53, vultr
23 ACME_DNS_PROVIDER=
24
25 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
@@ -30,6 +30,9 @@ GCP_PROJECT_ID=
30 GCP_MANAGED_ZONE=
31 GOOGLE_APPLICATION_CREDENTIALS=
32
33 +# Hetzner DNS settings (required when ACME_DNS_PROVIDER=hetzner)
34 +HETZNER_API_TOKEN=
35 +
36 # Route53 settings (required when ACME_DNS_PROVIDER=route53)
37 AWS_ACCESS_KEY_ID=
38 AWS_SECRET_ACCESS_KEY=
@@ -43,18 +46,24 @@ AWS_DNSSEC_KMS_KEY_ARN=
46 # Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr)
47 VULTR_API_KEY=
48
49 +# Njalla DNS settings (required when ACME_DNS_PROVIDER=njalla)
50 +NJALLA_TOKEN=
51 +
52 # ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
53 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
54 ENS_GASLESS_ENABLED=false
55
50 -# Admin/auth configuration. The admin secret is generated and stored in IDENTITY_PATH/identity.json.
51 -LANDING_PAGE_ENABLED=false
56 +# Admin/auth configuration. The relay identity wallet is always allowed.
57 +ADMIN_WALLETS=
58 # Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
59 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
60 TRUST_PROXY_HEADERS=true
55 -TRUSTED_PROXY_CIDRS=127.0.0.0/8
61 +TRUSTED_PROXY_CIDRS=
62 +
63 +# Frontend-owned presentation state.
64 +LANDING_PAGE_ENABLED=false
65
66 # Optional: auto-generated thumbnail screenshots for tunnel apps without a thumbnail.
58 -# Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
59 -# Leave empty or remove to disable. See docs/src/routes/deployment/+page.md.
67 +# Used by the portal-api service. Requires the headless-shell sidecar.
68 +# Leave empty to keep generated screenshots disabled. See docs/src/routes/deployment/+page.md.
69 # HEADLESS_SHELL_URL=ws://headless-shell:9222
docs/static/examples/nginx-proxy-multi-service/docker-compose.yaml
+27 -14
@@ -5,9 +5,9 @@
5 # Architecture:
6 # nginx:443 (L4 stream, ssl_preread)
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)
8 +# - relay-owned API paths -> portal:4017 (portal, HTTPS, Docker network only)
9 +# - frontend/UI paths -> portal-frontend:8080 (HTTP)
10 +# - *.portal.example.com -> portal:443 (portal SNI passthrough, Docker network only)
11 # - everything else -> nginx:8443 (L7 for other apps)
12 #
13 # Prerequisites:
@@ -31,14 +31,13 @@ services:
31 ports:
32 - "80:80"
33 - "443:443"
34 - extra_hosts:
35 - - "host.docker.internal:host-gateway"
34 volumes:
35 - ./nginx.conf:/etc/nginx/nginx.conf:ro
36 - ./certs:/etc/certs:ro
37 depends_on:
38 - portal
39 - portal-frontend
40 + - portal-api
41 - app-a-api
42 - app-a-frontend
43 restart: unless-stopped
@@ -56,14 +55,10 @@ services:
55 portal:
56 image: ghcr.io/gosuda/portal:latest
57 container_name: portal
59 - # depends_on:
60 - # - headless-shell
58 ports:
62 - - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
63 - - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
59 - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
60 # Uncomment when enabling UDP transport:
66 - # - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
61 + # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
62 # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
63 # Uncomment when enabling raw TCP port transport:
64 # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
@@ -74,16 +69,15 @@ services:
69 DISCOVERY: ${DISCOVERY:-true}
70 WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
71 API_PORT: ${API_PORT:-4017}
77 - SNI_PORT: ${SNI_PORT:-4443}
72 + SNI_PORT: ${SNI_PORT:-443}
73 IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
74 MIN_PORT: ${MIN_PORT:-0}
75 MAX_PORT: ${MAX_PORT:-0}
76 UDP_ENABLED: ${UDP_ENABLED:-false}
77 TCP_ENABLED: ${TCP_ENABLED:-false}
83 - LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
84 - # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
78 + ADMIN_WALLETS: ${ADMIN_WALLETS:-}
79 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
86 - TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
80 + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
81 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
82 ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
83 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
@@ -111,6 +105,25 @@ services:
105 container_name: portal-frontend
106 depends_on:
107 - portal
108 + - portal-api
109 + restart: unless-stopped
110 +
111 + portal-api:
112 + image: ghcr.io/gosuda/portal-api:latest
113 + container_name: portal-api
114 + depends_on:
115 + - portal
116 + # Uncomment with the headless-shell service above to enable generated screenshots.
117 + # - headless-shell
118 + environment:
119 + PORT: 8081
120 + PORTAL_API_BASE_URL: https://portal:4017
121 + LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
122 + PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
123 + HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
124 + # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
125 + volumes:
126 + - ./.portal-frontend-state:/portal-frontend-state
127 restart: unless-stopped
128
129 app-a-api:
docs/static/examples/nginx-proxy-multi-service/nginx.conf
+39 -4
@@ -33,7 +33,7 @@ stream {
33
34 upstream portal_sni {
35 # Portal SNI listener. TLS is not terminated here.
36 - server host.docker.internal:4443;
36 + server portal:443;
37 }
38
39 upstream local_https {
@@ -73,6 +73,12 @@ http {
73 keepalive 16;
74 }
75
76 + upstream portal_api {
77 + # Portal API listener is HTTPS even though it is reached only internally.
78 + server portal:4017;
79 + keepalive 16;
80 + }
81 +
82 upstream app_a_backend {
83 server app-a-api:8000;
84 keepalive 32;
@@ -116,7 +122,7 @@ http {
122 text/xml application/xml;
123
124 location = /sdk/connect {
119 - proxy_pass https://host.docker.internal:4017;
125 + proxy_pass https://portal_api;
126 proxy_ssl_verify off;
127 proxy_ssl_server_name on;
128 proxy_ssl_name $host;
@@ -135,8 +141,37 @@ http {
141 proxy_send_timeout 86400s;
142 }
143
138 - location ~ ^/(state$|admin/|sdk/|service/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
139 - proxy_pass https://host.docker.internal:4017;
144 + # Presentation-owned paths enter portal-frontend. Its internal nginx
145 + # forwards dynamic API requests to portal-api.
146 + location = /state {
147 + proxy_pass http://portal_frontend;
148 + proxy_http_version 1.1;
149 +
150 + proxy_set_header Host $host;
151 + proxy_set_header X-Real-IP $remote_addr;
152 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
153 + proxy_set_header X-Forwarded-Proto https;
154 + proxy_set_header Connection "";
155 +
156 + proxy_read_timeout 60s;
157 + }
158 +
159 + location ~ ^/(policy($|/)|service/status$|thumbnail/) {
160 + proxy_pass http://portal_frontend;
161 + proxy_http_version 1.1;
162 +
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;
166 + proxy_set_header X-Forwarded-Proto https;
167 + proxy_set_header Authorization $http_authorization;
168 + proxy_set_header Connection "";
169 +
170 + proxy_read_timeout 60s;
171 + }
172 +
173 + location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
174 + proxy_pass https://portal_api;
175 proxy_ssl_verify off;
176 proxy_ssl_server_name on;
177 proxy_ssl_name $host;
docs/static/examples/nginx-proxy/.env.example
+16 -7
@@ -9,7 +9,7 @@ IDENTITY_PATH=/portal-certs
9
10 # Listener ports
11 API_PORT=4017
12 -SNI_PORT=4443
12 +SNI_PORT=443
13 WIREGUARD_PORT=51820
14 # Set when enabling public UDP or raw TCP lease ports.
15 MIN_PORT=0
@@ -19,7 +19,7 @@ TCP_ENABLED=false
19
20 # TLS/ACME materials live under IDENTITY_PATH as fullchain.pem/privatekey.pem.
21
22 -# Supported managed values: cloudflare, gcloud, route53, vultr
22 +# Supported managed values: cloudflare, gcloud, hetzner, njalla, route53, vultr
23 ACME_DNS_PROVIDER=
24
25 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
@@ -30,6 +30,9 @@ GCP_PROJECT_ID=
30 GCP_MANAGED_ZONE=
31 GOOGLE_APPLICATION_CREDENTIALS=
32
33 +# Hetzner DNS settings (required when ACME_DNS_PROVIDER=hetzner)
34 +HETZNER_API_TOKEN=
35 +
36 # Route53 settings (required when ACME_DNS_PROVIDER=route53)
37 AWS_ACCESS_KEY_ID=
38 AWS_SECRET_ACCESS_KEY=
@@ -43,18 +46,24 @@ AWS_DNSSEC_KMS_KEY_ARN=
46 # Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr)
47 VULTR_API_KEY=
48
49 +# Njalla DNS settings (required when ACME_DNS_PROVIDER=njalla)
50 +NJALLA_TOKEN=
51 +
52 # ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
53 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
54 ENS_GASLESS_ENABLED=false
55
50 -# Admin/auth configuration. The admin secret is generated and stored in IDENTITY_PATH/identity.json.
51 -LANDING_PAGE_ENABLED=false
56 +# Admin/auth configuration. The relay identity wallet is always allowed.
57 +ADMIN_WALLETS=
58 # Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
59 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
60 TRUST_PROXY_HEADERS=true
55 -TRUSTED_PROXY_CIDRS=127.0.0.0/8
61 +TRUSTED_PROXY_CIDRS=
62 +
63 +# Frontend-owned presentation state.
64 +LANDING_PAGE_ENABLED=false
65
66 # Optional: auto-generated thumbnail screenshots for tunnel apps without a thumbnail.
58 -# Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
59 -# Leave empty or remove to disable. See docs/src/routes/deployment/+page.md.
67 +# Used by the portal-api service. Requires the headless-shell sidecar.
68 +# Leave empty to keep generated screenshots disabled. See docs/src/routes/deployment/+page.md.
69 # HEADLESS_SHELL_URL=ws://headless-shell:9222
docs/static/examples/nginx-proxy/deploy_portal.sh
+2 -2
@@ -1,6 +1,6 @@
1 #!/bin/bash
2 set -e
3
4 -docker compose pull portal portal-frontend
5 -docker compose up -d portal portal-frontend
4 +docker compose pull portal portal-frontend portal-api
5 +docker compose up -d portal portal-frontend portal-api
6 bash nginx_deploy.sh
docs/static/examples/nginx-proxy/docker-compose.yaml
+30 -13
@@ -4,10 +4,10 @@
4 # Architecture:
5 # nginx:443/tcp (L4 stream, ssl_preread)
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)
7 +# - relay-owned API paths -> 127.0.0.1:4017 (portal, HTTPS, loopback only)
8 +# - frontend/UI paths -> 127.0.0.1:8080 (portal-frontend, HTTP, loopback only)
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)
10 +# portal:443/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 #
@@ -35,6 +35,7 @@ services:
35 depends_on:
36 - portal
37 - portal-frontend
38 + - portal-api
39 restart: unless-stopped
40
41 # Optional: uncomment to enable auto-generated thumbnails for tunnel apps.
@@ -46,14 +47,12 @@ services:
47 portal:
48 image: ghcr.io/gosuda/portal:latest
49 container_name: portal
49 - # depends_on:
50 - # - headless-shell
50 ports:
52 - - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
53 - - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
51 + - "127.0.0.1:${API_PORT:-4017}:${API_PORT:-4017}/tcp"
52 + - "127.0.0.1:4443:${SNI_PORT:-443}/tcp"
53 - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
54 # Uncomment when enabling UDP transport:
56 - # - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
55 + # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
56 # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
57 # Uncomment when enabling raw TCP port transport:
58 # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
@@ -64,16 +63,15 @@ services:
63 DISCOVERY: ${DISCOVERY:-true}
64 WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
65 API_PORT: ${API_PORT:-4017}
67 - SNI_PORT: ${SNI_PORT:-4443}
66 + SNI_PORT: ${SNI_PORT:-443}
67 IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
68 MIN_PORT: ${MIN_PORT:-0}
69 MAX_PORT: ${MAX_PORT:-0}
70 UDP_ENABLED: ${UDP_ENABLED:-false}
71 TCP_ENABLED: ${TCP_ENABLED:-false}
73 - LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
74 - # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
72 + ADMIN_WALLETS: ${ADMIN_WALLETS:-}
73 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
76 - TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
74 + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
75 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
76 ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
77 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
@@ -101,6 +99,25 @@ services:
99 container_name: portal-frontend
100 depends_on:
101 - portal
102 + - portal-api
103 ports:
105 - - "${FRONTEND_PORT:-8080}:8080"
104 + - "127.0.0.1:${FRONTEND_PORT:-8080}:8080"
105 + restart: unless-stopped
106 +
107 + portal-api:
108 + image: ghcr.io/gosuda/portal-api:latest
109 + container_name: portal-api
110 + depends_on:
111 + - portal
112 + # Uncomment with the headless-shell service above to enable generated screenshots.
113 + # - headless-shell
114 + environment:
115 + PORT: 8081
116 + PORTAL_API_BASE_URL: https://portal:4017
117 + LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
118 + PORTAL_FRONTEND_STATE_PATH: /portal-frontend-state/state.json
119 + HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-}
120 + # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
121 + volumes:
122 + - ./.portal-frontend-state:/portal-frontend-state
123 restart: unless-stopped
docs/static/examples/nginx-proxy/nginx.conf
+35 -5
@@ -8,8 +8,8 @@
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)
11 +# Relay API paths -> https://127.0.0.1:4017 (portal)
12 +# Frontend/UI paths -> http://127.0.0.1:8080 (portal-frontend)
13
14 events {
15 worker_connections 4096;
@@ -103,9 +103,39 @@ http {
103 proxy_send_timeout 86400s;
104 }
105
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(/|$)) {
106 + # Presentation-owned paths enter portal-frontend. Its internal nginx
107 + # forwards dynamic API requests to portal-api.
108 + location = /state {
109 + proxy_pass http://portal_frontend;
110 + proxy_http_version 1.1;
111 +
112 + proxy_set_header Host $host;
113 + proxy_set_header X-Real-IP $remote_addr;
114 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
115 + proxy_set_header X-Forwarded-Proto https;
116 + proxy_set_header Connection "";
117 +
118 + proxy_read_timeout 60s;
119 + }
120 +
121 + location ~ ^/(policy($|/)|service/status$|thumbnail/) {
122 + proxy_pass http://portal_frontend;
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 Authorization $http_authorization;
130 + proxy_set_header Connection "";
131 +
132 + proxy_read_timeout 60s;
133 + }
134 +
135 + # Relay API/control endpoints belong to portal. Exact /admin is
136 + # intentionally not matched so the React admin route can be served by
137 + # portal-frontend.
138 + location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
139 proxy_pass https://portal_api;
140 proxy_ssl_verify off;
141 proxy_ssl_server_name on;
docs/static/examples/nginx-proxy/watch_and_deploy.sh
+1 -1
@@ -1,7 +1,7 @@
1 #!/usr/bin/env bash
2 set -euo pipefail
3
4 -IMAGES="${IMAGES:-ghcr.io/gosuda/portal:latest ghcr.io/gosuda/portal-frontend:latest}"
4 +IMAGES="${IMAGES:-ghcr.io/gosuda/portal:latest ghcr.io/gosuda/portal-frontend:latest ghcr.io/gosuda/portal-api:latest}"
5 DIGEST_FILE="${DIGEST_FILE:-.portal_image_digest}"
6 INTERVAL="${INTERVAL:-60}"
7 DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-deploy_portal.sh}"
frontend/.dockerignore
+1
@@ -1,4 +1,5 @@
1 dist/
2 +dist-api/
3 node_modules/
4 coverage/
5 *.log
frontend/.gitignore
+1
@@ -9,6 +9,7 @@ lerna-debug.log*
9
10 node_modules
11 dist
12 +dist-api
13 dist-ssr
14 *.local
15
frontend/AGENTS.md
+13 -9
@@ -5,11 +5,11 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
5 ## Frontend-Backend Contracts
6
7 1. **Public list data comes from `/state`.**
8 - Go shape is `types.PublicStateResponse`; TS shape is `src/types/api.ts`.
8 + Go relay returns leases only; `api/server.ts` adds frontend-owned presentation fields mirrored in `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.**
12 - Go definitions live in `../types/paths.go`; TS duplicates live in `src/lib/apiPaths.ts`.
12 + Go relay definitions live in `../types/paths.go`; frontend facade paths live in `api/server.ts`, `nginx.conf`, and `src/lib/apiPaths.ts`.
13 - Why: no codegen. A path mismatch produces 404s.
14
15 3. **API envelope shape must match across Go and TS.**
@@ -18,30 +18,34 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
18 - Why: backend responses that skip the envelope surface as `invalid_envelope` in the frontend.
19
20 4. **Admin auth uses bearer tokens returned by `/admin/auth/login`.**
21 - `src/hooks/useAuth.ts` stores the token through `src/lib/adminAuthToken.ts`; `src/lib/apiClient.ts` adds it to `/admin/*` requests as `Authorization: Bearer ...`.
21 + `src/hooks/useAuth.ts` stores the token through `src/lib/adminAuthToken.ts`; `src/lib/apiClient.ts` adds it to `/admin/*` and `/policy/*` requests as `Authorization: Bearer ...`.
22 - Why: the relay admin API must be usable by any separately hosted frontend without credentialed cookie CORS state.
23
24 5. **`VITE_PORTAL_API_BASE_URL` is the only built-in API origin knob.**
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/state`.**
29 - `src/hooks/useAdmin.ts` expects `{ settings, leases }`; all setting writes go through `/admin/settings` with the full settings object.
28 +6. **Policy state reads are aggregated through `/policy/state`.**
29 + `src/hooks/useAdmin.ts` expects `{ policy, leases }`; policy settings writes go through `/policy`, while lease/IP actions use `/policy/leases` and `/policy/ips`.
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 snake_case.**
33 - Go `Lease`/`AdminLease` JSON tags live in `../types/identity.go`; TS mirrors the wire shape in `src/types/api.ts`.
32 +7. **Lease/policy lease JSON casing is snake_case.**
33 + Go `Lease`/`PolicyLease` 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 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`.
37 + Lease policy writes use `/policy/leases` with `identity_key`; IP policy writes use `/policy/ips` with `ip`.
38 - Why: path encoding rules add a second contract surface and are easy to drift across Go and TS.
39
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"`.**
44 +10. **Presentation support is frontend-owned.**
45 + `api/server.ts` serves `/state`, `/policy/*`, `/service/status`, and `/thumbnail/{hostname}` by composing relay data with frontend-owned state.
46 + - Why: landing-page flags, quick-start status, and generated screenshots are presentation support and should not add state or routes to the Go relay API.
47 +
48 +11. **ApprovalMode is a closed two-value enum: `"auto"` | `"manual"`.**
49 TS `normalizeApprovalMode()` (`src/hooks/useAdmin.ts`) collapses any non-`"manual"` value to `"auto"`.
50 - Why: adding a third mode in Go without updating the TS normalizer silently collapses it to "auto".
51
frontend/Dockerfile
+17 -2
@@ -1,17 +1,32 @@
1 # syntax=docker/dockerfile:1
2
3 -FROM --platform=$BUILDPLATFORM node:22-slim AS builder
3 +FROM --platform=$BUILDPLATFORM node:22-slim AS deps
4 WORKDIR /src
5
6 COPY package.json package-lock.json ./
7 RUN --mount=type=cache,target=/root/.npm npm ci
8
9 +FROM deps AS frontend-builder
10 COPY . .
11 RUN npm run build
12
13 +FROM deps AS api-builder
14 +COPY . .
15 +RUN npm run build:api
16 +
17 +FROM node:22-slim AS api
18 +WORKDIR /app
19 +
20 +COPY --from=api-builder /src/dist-api ./dist-api
21 +
22 +ENV PORT=8081
23 +EXPOSE 8081
24 +
25 +CMD ["node", "dist-api/api/server.js"]
26 +
27 FROM nginx:1.27-alpine AS frontend
28
29 COPY nginx.conf /etc/nginx/conf.d/default.conf
15 -COPY --from=builder /src/dist /usr/share/nginx/html
30 +COPY --from=frontend-builder /src/dist /usr/share/nginx/html
31
32 EXPOSE 8080
frontend/README.md
+23 -11
@@ -20,9 +20,9 @@ the relay over the JSON API and does not receive server-side injected lease
20 data.
21
22 - Public relay state is loaded from `/state`.
23 -- Admin state is loaded from `/admin/state`.
23 +- Operator policy state is loaded from `/policy/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`.
25 +- `VITE_PORTAL_API_BASE_URL` points the frontend at the same API surface exposed by the frontend nginx/API service. Admin auth uses a bearer token returned by `/admin/auth/login`.
26
27 ## Project Structure
28
@@ -48,9 +48,12 @@ frontend/
48 App.tsx
49 main.tsx
50 index.css
51 + api/
52 + server.ts
53 index.html
54 package.json
55 tsconfig.json
56 + tsconfig.api.json
57 vite.config.ts
58 ```
59
@@ -73,16 +76,17 @@ npm run dev
76
77 Default dev URL: `http://localhost:5173`.
78
76 -To run against a relay server on another origin, build or run the frontend with the public relay API URL:
79 +To run against another origin, build or run the frontend with the public frontend/API URL:
80
81 ```bash
79 -VITE_PORTAL_API_BASE_URL=https://relay.example.com npm run dev
82 +VITE_PORTAL_API_BASE_URL=https://portal.example.com npm run dev
83 ```
84
85 ## Docker
86
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.
87 +The frontend Docker image serves the built Vite app with nginx over HTTP,
88 +proxies relay-owned API paths to the HTTPS relay at `portal:4017`, and proxies
89 +presentation-owned paths to `portal-api:8081` in Docker Compose.
90 TLS for public domains should live in the outer reverse proxy. The app uses
91 same-origin relative API paths, so it does not need runtime config file
92 generation.
@@ -97,6 +101,7 @@ docker compose up -d portal-frontend
101 | --- | --- |
102 | `npm run dev` | Start the Vite development server. |
103 | `npm run build` | Type-check and build production assets. |
104 +| `npm run build:api` | Build the TypeScript API service. |
105 | `npm run lint` | Run ESLint. |
106 | `npm run typecheck` | Run TypeScript checking. |
107 | `npm test` | Run Vitest. |
@@ -107,16 +112,23 @@ docker compose up -d portal-frontend
112 Relay server exposes:
113
114 - `/` - relay API identity response
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
115 +- `/state` - public leases
116 - `/install.sh` and `/install.ps1` - CLI installers
114 -- `/admin/*` - admin API/control endpoints
117 +- `/admin/auth/*` - admin wallet auth endpoints
118 +- `/policy/*` - relay policy endpoints
119 - `/sdk/*` - SDK/control endpoints
120 - `/discovery` - relay discovery when enabled
121
122 +The TypeScript API service composes frontend-owned presentation
123 +state on top of relay data:
124 +
125 +- `/state` - relay leases plus `landing_page_enabled`
126 +- `/policy/*` - relay policy, with `landing_page_enabled` composed into `/policy` and `/policy/state`
127 +- `/service/status` - hostname and service readiness derived from relay `/state`
128 +- `/thumbnail/{hostname}` - generated screenshots, disabled when `HEADLESS_SHELL_URL` is empty
129 +
130 ## Notes
131
120 -- API path constants are duplicated in Go (`types/paths.go`) and TS (`src/lib/apiPaths.ts`).
132 +- Relay path constants live in Go (`types/paths.go`); frontend facade paths also need matching entries in `api/server.ts`, `nginx.conf`, and `src/lib/apiPaths.ts`.
133 - Frontend API wire types live in `src/types/api.ts`.
134 - Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
frontend/api/server.ts new
+909
@@ -0,0 +1,909 @@
1 +import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2 +import {
3 + createServer,
4 + request as httpRequest,
5 + type IncomingMessage,
6 + type RequestOptions as HTTPRequestOptions,
7 + type ServerResponse,
8 +} from "node:http";
9 +import { request as httpsRequest, type RequestOptions as HTTPSRequestOptions } from "node:https";
10 +import { dirname } from "node:path";
11 +import { setTimeout as delay } from "node:timers/promises";
12 +import { URL } from "node:url";
13 +import { API_PATHS } from "../src/lib/apiPaths.js";
14 +import { parseLeaseMetadata } from "../src/lib/metadata.js";
15 +
16 +const PORT = parseIntegerEnv("PORT", 8081);
17 +const PORTAL_API_BASE_URL = normalizeBaseURL(
18 + process.env.PORTAL_API_BASE_URL || "https://portal:4017"
19 +);
20 +const HEADLESS_SHELL_URL = (process.env.HEADLESS_SHELL_URL || "").trim();
21 +const FRONTEND_STATE_PATH = (process.env.PORTAL_FRONTEND_STATE_PATH || "").trim();
22 +const DEFAULT_LANDING_PAGE_ENABLED = parseBooleanEnv("LANDING_PAGE_ENABLED", false);
23 +
24 +const VIEWPORT_WIDTH = 1280;
25 +const VIEWPORT_HEIGHT = 720;
26 +const JPEG_QUALITY = 80;
27 +const MAX_BYTES = 256 << 10;
28 +const BODY_LIMIT = 1 << 16;
29 +const COOLDOWN_MS = 30_000;
30 +const PAGE_TIMEOUT_MS = 15_000;
31 +const CDP_TIMEOUT_MS = 5_000;
32 +const HTTP_TIMEOUT_MS = 5_000;
33 +const JSON_LIMIT = 1 << 20;
34 +const THUMBNAIL_CONTENT_TYPE = "image/jpeg";
35 +
36 +type APIEnvelope<T> =
37 + | { ok: true; data: T }
38 + | { ok: false; error?: { code?: string; message?: string }; data?: unknown };
39 +
40 +interface RelayPublicStateResponse {
41 + leases?: Lease[];
42 +}
43 +
44 +interface FrontendPublicStateResponse extends RelayPublicStateResponse {
45 + landing_page_enabled: boolean;
46 +}
47 +
48 +interface Lease {
49 + hostname?: string;
50 + metadata?: unknown;
51 + ready?: number;
52 +}
53 +
54 +interface PolicyPortSettings {
55 + enabled: boolean;
56 + max_leases: number;
57 +}
58 +
59 +interface RelayPolicySettings {
60 + approval_mode?: string;
61 + udp?: PolicyPortSettings;
62 + tcp_port?: PolicyPortSettings;
63 +}
64 +
65 +interface FrontendPolicySettings extends RelayPolicySettings {
66 + landing_page_enabled: boolean;
67 + udp: PolicyPortSettings;
68 + tcp_port: PolicyPortSettings;
69 +}
70 +
71 +interface RelayPolicyStateResponse {
72 + policy?: RelayPolicySettings;
73 + leases?: unknown[];
74 +}
75 +
76 +interface FrontendPolicyStateResponse {
77 + policy: FrontendPolicySettings;
78 + leases?: unknown[];
79 +}
80 +
81 +interface ServiceStatusResponse {
82 + hostname: string;
83 + registered: boolean;
84 + service_alive: boolean;
85 +}
86 +
87 +interface FrontendState {
88 + landing_page_enabled: boolean;
89 +}
90 +
91 +interface ThumbnailEntry {
92 + data?: Buffer;
93 + fetchedAt: number;
94 +}
95 +
96 +interface CDPReply {
97 + id?: number;
98 + sessionId?: string;
99 + method?: string;
100 + params?: unknown;
101 + result?: Record<string, unknown>;
102 + error?: { message?: string };
103 +}
104 +
105 +interface CDPWaiter {
106 + method: string;
107 + sessionId?: string;
108 + resolve: (message: CDPReply) => void;
109 +}
110 +
111 +const thumbnailCache = new Map<string, ThumbnailEntry>();
112 +const pendingThumbnails = new Map<string, Promise<Buffer>>();
113 +let captureChain: Promise<unknown> = Promise.resolve();
114 +let frontendState = loadFrontendState();
115 +
116 +function parseIntegerEnv(name: string, fallback: number): number {
117 + const parsed = Number.parseInt(process.env[name] || "", 10);
118 + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
119 +}
120 +
121 +function parseBooleanEnv(name: string, fallback: boolean): boolean {
122 + const raw = (process.env[name] || "").trim().toLowerCase();
123 + if (["1", "true", "yes", "on"].includes(raw)) {
124 + return true;
125 + }
126 + if (["0", "false", "no", "off"].includes(raw)) {
127 + return false;
128 + }
129 + return fallback;
130 +}
131 +
132 +function normalizeBaseURL(raw: string): string {
133 + const trimmed = raw.trim();
134 + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
135 +}
136 +
137 +function normalizeHostname(raw: string): string {
138 + return raw.trim().toLowerCase().replace(/\.$/, "");
139 +}
140 +
141 +function isRecord(value: unknown): value is Record<string, unknown> {
142 + return typeof value === "object" && value !== null && !Array.isArray(value);
143 +}
144 +
145 +function loadFrontendState(): FrontendState {
146 + if (!FRONTEND_STATE_PATH) {
147 + return { landing_page_enabled: DEFAULT_LANDING_PAGE_ENABLED };
148 + }
149 + try {
150 + const parsed = JSON.parse(readFileSync(FRONTEND_STATE_PATH, "utf8")) as unknown;
151 + if (isRecord(parsed) && typeof parsed.landing_page_enabled === "boolean") {
152 + return { landing_page_enabled: parsed.landing_page_enabled };
153 + }
154 + } catch (error) {
155 + if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
156 + console.warn("failed to read frontend state", error);
157 + }
158 + }
159 + return { landing_page_enabled: DEFAULT_LANDING_PAGE_ENABLED };
160 +}
161 +
162 +function saveFrontendState(): void {
163 + if (!FRONTEND_STATE_PATH) {
164 + return;
165 + }
166 + try {
167 + mkdirSync(dirname(FRONTEND_STATE_PATH), { recursive: true });
168 + writeFileSync(FRONTEND_STATE_PATH, `${JSON.stringify(frontendState, null, 2)}\n`, {
169 + mode: 0o600,
170 + });
171 + } catch (error) {
172 + console.warn("failed to write frontend state", error);
173 + }
174 +}
175 +
176 +function hostnameMatchesPattern(pattern: string, hostname: string): boolean {
177 + const normalizedPattern = normalizeHostname(pattern);
178 + const normalizedHostname = normalizeHostname(hostname);
179 + if (!normalizedPattern || !normalizedHostname) {
180 + return false;
181 + }
182 + if (normalizedPattern === normalizedHostname) {
183 + return true;
184 + }
185 + if (!normalizedPattern.startsWith("*.")) {
186 + return false;
187 + }
188 + const suffix = normalizedPattern.slice(2);
189 + if (!suffix.includes(".")) {
190 + return false;
191 + }
192 + const dotIndex = normalizedHostname.indexOf(".");
193 + return dotIndex > 0 && normalizedHostname.slice(dotIndex + 1) === suffix;
194 +}
195 +
196 +function requestJSON<T>(
197 + rawURL: string,
198 + options: { hostHeader?: string } = {}
199 +): Promise<T> {
200 + return new Promise((resolve, reject) => {
201 + const parsed = new URL(rawURL);
202 + const requestOptions: HTTPRequestOptions = {
203 + method: "GET",
204 + hostname: parsed.hostname,
205 + port: parsed.port,
206 + path: `${parsed.pathname}${parsed.search}`,
207 + headers: options.hostHeader ? { Host: options.hostHeader } : undefined,
208 + timeout: HTTP_TIMEOUT_MS,
209 + };
210 +
211 + const handleResponse = (res: IncomingMessage) => {
212 + collectResponseBody(res, JSON_LIMIT)
213 + .then((body) => {
214 + if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
215 + reject(new Error(`${rawURL} status ${res.statusCode}: ${body}`));
216 + return;
217 + }
218 + resolve(JSON.parse(body.toString("utf8")) as T);
219 + })
220 + .catch(reject);
221 + };
222 +
223 + const req =
224 + parsed.protocol === "https:"
225 + ? httpsRequest(requestOptions as HTTPSRequestOptions, handleResponse)
226 + : httpRequest(requestOptions, handleResponse);
227 +
228 + req.on("timeout", () => req.destroy(new Error(`${rawURL} timed out`)));
229 + req.on("error", reject);
230 + req.end();
231 + });
232 +}
233 +
234 +function collectResponseBody(res: IncomingMessage, limit: number): Promise<Buffer> {
235 + return new Promise((resolve, reject) => {
236 + const chunks: Buffer[] = [];
237 + let size = 0;
238 + res.on("data", (chunk: Buffer) => {
239 + size += chunk.length;
240 + if (size > limit) {
241 + res.destroy(new Error("response too large"));
242 + return;
243 + }
244 + chunks.push(chunk);
245 + });
246 + res.on("error", reject);
247 + res.on("end", () => resolve(Buffer.concat(chunks)));
248 + });
249 +}
250 +
251 +function readRequestBody(req: IncomingMessage, limit: number): Promise<Buffer> {
252 + return new Promise((resolve, reject) => {
253 + const chunks: Buffer[] = [];
254 + let size = 0;
255 + req.on("data", (chunk: Buffer) => {
256 + size += chunk.length;
257 + if (size > limit) {
258 + req.destroy(new Error("request body too large"));
259 + return;
260 + }
261 + chunks.push(chunk);
262 + });
263 + req.on("error", reject);
264 + req.on("end", () => resolve(Buffer.concat(chunks)));
265 + });
266 +}
267 +
268 +function readJSONRequest(req: IncomingMessage): Promise<Record<string, unknown>> {
269 + return readRequestBody(req, BODY_LIMIT).then((body) => {
270 + const parsed = JSON.parse(body.toString("utf8")) as unknown;
271 + return isRecord(parsed) ? parsed : {};
272 + });
273 +}
274 +
275 +function relayURL(path: string): string {
276 + return `${PORTAL_API_BASE_URL}${path}`;
277 +}
278 +
279 +function requestRelay<T>(
280 + path: string,
281 + options: { method?: string; body?: unknown; authorization?: string } = {}
282 +): Promise<{ statusCode: number; envelope: APIEnvelope<T> }> {
283 + return new Promise((resolve, reject) => {
284 + const parsed = new URL(relayURL(path));
285 + const body =
286 + options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body));
287 + const headers: Record<string, string> = {};
288 + if (options.authorization) {
289 + headers.Authorization = options.authorization;
290 + }
291 + if (body) {
292 + headers["Content-Type"] = "application/json";
293 + headers["Content-Length"] = String(body.length);
294 + }
295 +
296 + const requestOptions: HTTPRequestOptions = {
297 + method: options.method || "GET",
298 + hostname: parsed.hostname,
299 + port: parsed.port,
300 + path: `${parsed.pathname}${parsed.search}`,
301 + headers,
302 + timeout: HTTP_TIMEOUT_MS,
303 + };
304 +
305 + const handleResponse = (relayRes: IncomingMessage) => {
306 + collectResponseBody(relayRes, JSON_LIMIT)
307 + .then((responseBody) => {
308 + try {
309 + resolve({
310 + statusCode: relayRes.statusCode || 500,
311 + envelope: JSON.parse(responseBody.toString("utf8")) as APIEnvelope<T>,
312 + });
313 + } catch (error) {
314 + reject(error);
315 + }
316 + })
317 + .catch(reject);
318 + };
319 +
320 + const req =
321 + parsed.protocol === "https:"
322 + ? httpsRequest(
323 + {
324 + ...requestOptions,
325 + rejectUnauthorized: false,
326 + } satisfies HTTPSRequestOptions,
327 + handleResponse
328 + )
329 + : httpRequest(requestOptions, handleResponse);
330 +
331 + req.on("timeout", () => req.destroy(new Error(`${path} timed out`)));
332 + req.on("error", reject);
333 + if (body) {
334 + req.write(body);
335 + }
336 + req.end();
337 + });
338 +}
339 +
340 +function mergePublicState(state: RelayPublicStateResponse): FrontendPublicStateResponse {
341 + return {
342 + ...state,
343 + landing_page_enabled: frontendState.landing_page_enabled,
344 + };
345 +}
346 +
347 +function mergePolicySettings(settings: RelayPolicySettings = {}): FrontendPolicySettings {
348 + return {
349 + approval_mode: settings.approval_mode || "auto",
350 + udp: settings.udp || { enabled: false, max_leases: 0 },
351 + tcp_port: settings.tcp_port || { enabled: false, max_leases: 0 },
352 + landing_page_enabled: frontendState.landing_page_enabled,
353 + };
354 +}
355 +
356 +function writeEnvelope<T>(res: ServerResponse, status: number, envelope: APIEnvelope<T>): void {
357 + const body = Buffer.from(JSON.stringify(envelope));
358 + res.writeHead(status, {
359 + "Content-Type": "application/json",
360 + "Content-Length": String(body.length),
361 + });
362 + res.end(body);
363 +}
364 +
365 +function writeData<T>(res: ServerResponse, status: number, data: T): void {
366 + writeEnvelope(res, status, { ok: true, data });
367 +}
368 +
369 +function writeError(res: ServerResponse, status: number, code: string, message: string): void {
370 + writeEnvelope(res, status, { ok: false, error: { code, message } });
371 +}
372 +
373 +function writeRelayEnvelope<T>(
374 + res: ServerResponse,
375 + relayResponse: { statusCode: number; envelope: APIEnvelope<T> }
376 +): void {
377 + writeEnvelope(res, relayResponse.statusCode, relayResponse.envelope);
378 +}
379 +
380 +function authorizationHeader(req: IncomingMessage): string {
381 + const authorization = req.headers.authorization;
382 + return typeof authorization === "string" ? authorization : "";
383 +}
384 +
385 +async function servePublicState(res: ServerResponse): Promise<void> {
386 + const relayResponse = await requestRelay<RelayPublicStateResponse>(API_PATHS.public.state);
387 + if (!relayResponse.envelope.ok) {
388 + writeRelayEnvelope(res, relayResponse);
389 + return;
390 + }
391 + writeData(res, relayResponse.statusCode, mergePublicState(relayResponse.envelope.data));
392 +}
393 +
394 +async function publicLeases(): Promise<Lease[]> {
395 + const relayResponse = await requestRelay<RelayPublicStateResponse>(API_PATHS.public.state);
396 + if (!relayResponse.envelope.ok) {
397 + return [];
398 + }
399 + return Array.isArray(relayResponse.envelope.data.leases)
400 + ? relayResponse.envelope.data.leases
401 + : [];
402 +}
403 +
404 +async function publicLeaseByHostname(hostname: string): Promise<Lease | undefined> {
405 + const normalizedHostname = normalizeHostname(hostname);
406 + if (!normalizedHostname) {
407 + return undefined;
408 + }
409 + const leases = await publicLeases();
410 + return leases.find((lease) => {
411 + const leaseHostname = typeof lease.hostname === "string" ? lease.hostname : "";
412 + return hostnameMatchesPattern(leaseHostname, normalizedHostname);
413 + });
414 +}
415 +
416 +async function serveServiceStatus(req: IncomingMessage, res: ServerResponse): Promise<void> {
417 + const url = new URL(req.url || "/", "http://api.local");
418 + const hostname = normalizeHostname(url.searchParams.get("hostname") || "");
419 + if (!hostname) {
420 + writeError(res, 400, "invalid_request", "hostname is required");
421 + return;
422 + }
423 +
424 + const lease = await publicLeaseByHostname(hostname);
425 + writeData<ServiceStatusResponse>(res, 200, {
426 + hostname: typeof lease?.hostname === "string" ? lease.hostname : hostname,
427 + registered: Boolean(lease),
428 + service_alive: Boolean(lease && typeof lease.ready === "number" && lease.ready > 0),
429 + });
430 +}
431 +
432 +async function servePolicyState(req: IncomingMessage, res: ServerResponse): Promise<void> {
433 + const relayResponse = await requestRelay<RelayPolicyStateResponse>(API_PATHS.policy.state, {
434 + authorization: authorizationHeader(req),
435 + });
436 + if (!relayResponse.envelope.ok) {
437 + writeRelayEnvelope(res, relayResponse);
438 + return;
439 + }
440 + writeData<FrontendPolicyStateResponse>(res, relayResponse.statusCode, {
441 + leases: relayResponse.envelope.data.leases,
442 + policy: mergePolicySettings(relayResponse.envelope.data.policy),
443 + });
444 +}
445 +
446 +async function servePolicy(req: IncomingMessage, res: ServerResponse): Promise<void> {
447 + if (req.method === "GET") {
448 + const relayResponse = await requestRelay<RelayPolicySettings>(API_PATHS.policy.root, {
449 + authorization: authorizationHeader(req),
450 + });
451 + if (!relayResponse.envelope.ok) {
452 + writeRelayEnvelope(res, relayResponse);
453 + return;
454 + }
455 + writeData(res, relayResponse.statusCode, mergePolicySettings(relayResponse.envelope.data));
456 + return;
457 + }
458 +
459 + let body: Record<string, unknown>;
460 + try {
461 + body = await readJSONRequest(req);
462 + } catch {
463 + writeError(res, 400, "invalid_json", "invalid request body");
464 + return;
465 + }
466 +
467 + const nextLandingPageEnabled =
468 + typeof body.landing_page_enabled === "boolean"
469 + ? body.landing_page_enabled
470 + : frontendState.landing_page_enabled;
471 + let relayBody = { ...body };
472 + delete relayBody.landing_page_enabled;
473 + if (!("approval_mode" in relayBody) || !("udp" in relayBody) || !("tcp_port" in relayBody)) {
474 + const current = await requestRelay<RelayPolicyStateResponse>(API_PATHS.policy.state, {
475 + authorization: authorizationHeader(req),
476 + });
477 + if (!current.envelope.ok) {
478 + writeRelayEnvelope(res, current);
479 + return;
480 + }
481 + const currentSettings = mergePolicySettings(current.envelope.data.policy);
482 + relayBody = {
483 + approval_mode: relayBody.approval_mode ?? currentSettings.approval_mode,
484 + udp: relayBody.udp ?? currentSettings.udp,
485 + tcp_port: relayBody.tcp_port ?? currentSettings.tcp_port,
486 + };
487 + }
488 + const relayResponse = await requestRelay<RelayPolicySettings>(API_PATHS.policy.root, {
489 + method: "POST",
490 + body: relayBody,
491 + authorization: authorizationHeader(req),
492 + });
493 + if (!relayResponse.envelope.ok) {
494 + writeRelayEnvelope(res, relayResponse);
495 + return;
496 + }
497 +
498 + frontendState = { landing_page_enabled: nextLandingPageEnabled };
499 + saveFrontendState();
500 + writeData(res, relayResponse.statusCode, mergePolicySettings(relayResponse.envelope.data));
501 +}
502 +
503 +async function forwardPolicyUpdate(
504 + req: IncomingMessage,
505 + res: ServerResponse,
506 + path: string
507 +): Promise<void> {
508 + let body: Record<string, unknown>;
509 + try {
510 + body = await readJSONRequest(req);
511 + } catch {
512 + writeError(res, 400, "invalid_json", "invalid request body");
513 + return;
514 + }
515 +
516 + writeRelayEnvelope(
517 + res,
518 + await requestRelay<unknown>(path, {
519 + method: "POST",
520 + body,
521 + authorization: authorizationHeader(req),
522 + })
523 + );
524 +}
525 +
526 +async function leaseAllowsThumbnail(hostname: string): Promise<boolean> {
527 + const lease = await publicLeaseByHostname(hostname);
528 + return Boolean(lease && parseLeaseMetadata(lease.metadata).thumbnail.trim() === "");
529 +}
530 +
531 +async function resolveCDPWebSocketURL(): Promise<string> {
532 + if (!HEADLESS_SHELL_URL) {
533 + throw new Error("HEADLESS_SHELL_URL is not configured");
534 + }
535 +
536 + const parsed = new URL(HEADLESS_SHELL_URL);
537 + const versionURL = `http://${parsed.host}/json/version`;
538 + const info = await requestJSON<{ webSocketDebuggerUrl?: string }>(versionURL, {
539 + hostHeader: "127.0.0.1",
540 + });
541 + if (!info.webSocketDebuggerUrl) {
542 + throw new Error("/json/version did not return webSocketDebuggerUrl");
543 + }
544 +
545 + const wsURL = new URL(info.webSocketDebuggerUrl);
546 + wsURL.host = parsed.host;
547 + return wsURL.toString();
548 +}
549 +
550 +class CDPClient {
551 + private nextID = 1;
552 + private readonly pendingReplies = new Map<
553 + number,
554 + { resolve: (result: Record<string, unknown>) => void; reject: (error: Error) => void }
555 + >();
556 + private readonly waiters: CDPWaiter[] = [];
557 + private readonly socket: WebSocket;
558 +
559 + constructor(url: string) {
560 + this.socket = new WebSocket(url);
561 + this.socket.addEventListener("message", (event) => this.handleMessage(event));
562 + }
563 +
564 + connect(): Promise<void> {
565 + if (this.socket.readyState === WebSocket.OPEN) {
566 + return Promise.resolve();
567 + }
568 + return new Promise((resolve, reject) => {
569 + const timeout = setTimeout(() => {
570 + reject(new Error("connect to headless shell timed out"));
571 + }, CDP_TIMEOUT_MS);
572 +
573 + this.socket.addEventListener(
574 + "open",
575 + () => {
576 + clearTimeout(timeout);
577 + resolve();
578 + },
579 + { once: true }
580 + );
581 + this.socket.addEventListener(
582 + "error",
583 + () => {
584 + clearTimeout(timeout);
585 + reject(new Error("connect to headless shell failed"));
586 + },
587 + { once: true }
588 + );
589 + });
590 + }
591 +
592 + close(): void {
593 + this.socket.close();
594 + }
595 +
596 + send(
597 + method: string,
598 + params: Record<string, unknown> = {},
599 + sessionId = "",
600 + timeoutMs = CDP_TIMEOUT_MS
601 + ): Promise<Record<string, unknown>> {
602 + const id = this.nextID++;
603 + const payload: Record<string, unknown> = { id, method, params };
604 + if (sessionId) {
605 + payload.sessionId = sessionId;
606 + }
607 +
608 + return new Promise((resolve, reject) => {
609 + const timeout = setTimeout(() => {
610 + this.pendingReplies.delete(id);
611 + reject(new Error(`${method} timed out`));
612 + }, timeoutMs);
613 + this.pendingReplies.set(id, {
614 + resolve: (result) => {
615 + clearTimeout(timeout);
616 + resolve(result);
617 + },
618 + reject: (error) => {
619 + clearTimeout(timeout);
620 + reject(error);
621 + },
622 + });
623 + this.socket.send(JSON.stringify(payload));
624 + });
625 + }
626 +
627 + waitForEvent(method: string, sessionId: string, timeoutMs: number): Promise<CDPReply> {
628 + return new Promise((resolve, reject) => {
629 + const waiter: CDPWaiter = { method, sessionId, resolve };
630 + this.waiters.push(waiter);
631 + const timeout = setTimeout(() => {
632 + const index = this.waiters.indexOf(waiter);
633 + if (index >= 0) {
634 + this.waiters.splice(index, 1);
635 + }
636 + reject(new Error(`${method} timed out`));
637 + }, timeoutMs);
638 +
639 + waiter.resolve = (message) => {
640 + clearTimeout(timeout);
641 + resolve(message);
642 + };
643 + });
644 + }
645 +
646 + private handleMessage(event: MessageEvent): void {
647 + const message = JSON.parse(String(event.data)) as CDPReply;
648 + if (typeof message.id === "number") {
649 + const pendingReply = this.pendingReplies.get(message.id);
650 + if (!pendingReply) {
651 + return;
652 + }
653 + this.pendingReplies.delete(message.id);
654 + if (message.error) {
655 + pendingReply.reject(new Error(message.error.message || "CDP command failed"));
656 + return;
657 + }
658 + pendingReply.resolve(message.result || {});
659 + return;
660 + }
661 +
662 + const waiter = this.waiters.find(
663 + (candidate) =>
664 + candidate.method === message.method &&
665 + (!candidate.sessionId || candidate.sessionId === message.sessionId)
666 + );
667 + if (!waiter) {
668 + return;
669 + }
670 + this.waiters.splice(this.waiters.indexOf(waiter), 1);
671 + waiter.resolve(message);
672 + }
673 +}
674 +
675 +function stringResultField(result: Record<string, unknown>, field: string): string {
676 + const value = result[field];
677 + return typeof value === "string" ? value : "";
678 +}
679 +
680 +async function captureScreenshot(hostname: string): Promise<Buffer> {
681 + const cdpURL = await resolveCDPWebSocketURL();
682 + const client = new CDPClient(cdpURL);
683 +
684 + let browserContextID = "";
685 + let targetID = "";
686 +
687 + try {
688 + await client.connect();
689 + await client.send("Security.setIgnoreCertificateErrors", { ignore: true }).catch(() => ({}));
690 +
691 + const context = await client.send("Target.createBrowserContext", {
692 + disposeOnDetach: true,
693 + });
694 + browserContextID = stringResultField(context, "browserContextId");
695 +
696 + const target = await client.send("Target.createTarget", {
697 + url: "about:blank",
698 + browserContextId: browserContextID,
699 + });
700 + targetID = stringResultField(target, "targetId");
701 +
702 + const attached = await client.send("Target.attachToTarget", {
703 + targetId: targetID,
704 + flatten: true,
705 + });
706 + const sessionID = stringResultField(attached, "sessionId");
707 + if (!sessionID) {
708 + throw new Error("CDP target attach did not return sessionId");
709 + }
710 +
711 + await client.send("Page.enable", {}, sessionID);
712 + await client.send(
713 + "Emulation.setDeviceMetricsOverride",
714 + {
715 + width: VIEWPORT_WIDTH,
716 + height: VIEWPORT_HEIGHT,
717 + deviceScaleFactor: 1,
718 + mobile: false,
719 + },
720 + sessionID
721 + );
722 +
723 + const loadEvent = client.waitForEvent("Page.loadEventFired", sessionID, PAGE_TIMEOUT_MS);
724 + await client.send("Page.navigate", { url: `https://${hostname}` }, sessionID);
725 + await loadEvent;
726 + await delay(1_000);
727 +
728 + const screenshot = await client.send(
729 + "Page.captureScreenshot",
730 + { format: "jpeg", quality: JPEG_QUALITY, fromSurface: true },
731 + sessionID
732 + );
733 + const data = stringResultField(screenshot, "data");
734 + if (!data) {
735 + throw new Error("CDP screenshot returned empty data");
736 + }
737 + return Buffer.from(data, "base64");
738 + } finally {
739 + if (targetID) {
740 + await client.send("Target.closeTarget", { targetId: targetID }).catch(() => ({}));
741 + }
742 + if (browserContextID) {
743 + await client
744 + .send("Target.disposeBrowserContext", { browserContextId: browserContextID })
745 + .catch(() => ({}));
746 + }
747 + client.close();
748 + }
749 +}
750 +
751 +async function captureAndStore(hostname: string): Promise<Buffer> {
752 + try {
753 + const data = await captureScreenshot(hostname);
754 + if (data.length > MAX_BYTES) {
755 + throw new Error(`thumbnail too large: ${data.length} bytes`);
756 + }
757 + thumbnailCache.set(hostname, { data, fetchedAt: Date.now() });
758 + console.log(`thumbnail captured hostname=${hostname} size=${data.length}`);
759 + return data;
760 + } catch (error) {
761 + thumbnailCache.set(hostname, { fetchedAt: Date.now() });
762 + console.warn(`thumbnail capture failed hostname=${hostname}`, error);
763 + throw error;
764 + }
765 +}
766 +
767 +function loadThumbnail(hostname: string): Promise<Buffer> {
768 + const entry = thumbnailCache.get(hostname);
769 + if (entry) {
770 + if (entry.data && entry.data.length > 0) {
771 + return Promise.resolve(entry.data);
772 + }
773 + if (Date.now() - entry.fetchedAt < COOLDOWN_MS) {
774 + return Promise.reject(new Error("thumbnail capture is cooling down"));
775 + }
776 + }
777 +
778 + const existing = pendingThumbnails.get(hostname);
779 + if (existing) {
780 + return existing;
781 + }
782 +
783 + const capture = captureChain.then(() => captureAndStore(hostname));
784 + captureChain = capture.catch(() => undefined);
785 + pendingThumbnails.set(hostname, capture);
786 + capture.then(
787 + () => pendingThumbnails.delete(hostname),
788 + () => pendingThumbnails.delete(hostname)
789 + );
790 + return capture;
791 +}
792 +
793 +function requestedThumbnailHostname(req: IncomingMessage): string {
794 + const url = new URL(req.url || "/", "http://api.local");
795 + if (!url.pathname.startsWith(API_PATHS.thumbnail.prefix)) {
796 + return "";
797 + }
798 + try {
799 + const hostname = normalizeHostname(
800 + decodeURIComponent(url.pathname.slice(API_PATHS.thumbnail.prefix.length))
801 + );
802 + return hostname.includes("*") ? "" : hostname;
803 + } catch {
804 + return "";
805 + }
806 +}
807 +
808 +function writeNotFound(res: ServerResponse): void {
809 + res.writeHead(404, { "Cache-Control": "no-store" });
810 + res.end();
811 +}
812 +
813 +function writeMethodNotAllowed(res: ServerResponse, allow = "GET"): void {
814 + res.setHeader("Allow", allow);
815 + writeError(res, 405, "method_not_allowed", "method not allowed");
816 +}
817 +
818 +async function serveThumbnail(req: IncomingMessage, res: ServerResponse): Promise<void> {
819 + if (req.method !== "GET") {
820 + writeMethodNotAllowed(res);
821 + return;
822 + }
823 + const hostname = requestedThumbnailHostname(req);
824 + if (!hostname || !HEADLESS_SHELL_URL) {
825 + writeNotFound(res);
826 + return;
827 + }
828 + if (!(await leaseAllowsThumbnail(hostname))) {
829 + writeNotFound(res);
830 + return;
831 + }
832 +
833 + const data = await loadThumbnail(hostname);
834 + res.writeHead(200, {
835 + "Content-Type": THUMBNAIL_CONTENT_TYPE,
836 + "Cache-Control": "public, max-age=300",
837 + "Content-Length": String(data.length),
838 + });
839 + res.end(data);
840 +}
841 +
842 +const server = createServer((req, res) => {
843 + void (async () => {
844 + const url = new URL(req.url || "/", "http://api.local");
845 + if (url.pathname === "/healthz") {
846 + writeData(res, 200, { status: "ok" });
847 + return;
848 + }
849 + if (url.pathname === API_PATHS.public.state) {
850 + if (req.method !== "GET") {
851 + writeMethodNotAllowed(res);
852 + return;
853 + }
854 + await servePublicState(res);
855 + return;
856 + }
857 + if (url.pathname === API_PATHS.service.status) {
858 + if (req.method !== "GET") {
859 + writeMethodNotAllowed(res);
860 + return;
861 + }
862 + await serveServiceStatus(req, res);
863 + return;
864 + }
865 + if (url.pathname === API_PATHS.policy.state) {
866 + if (req.method !== "GET") {
867 + writeMethodNotAllowed(res);
868 + return;
869 + }
870 + await servePolicyState(req, res);
871 + return;
872 + }
873 + if (url.pathname === API_PATHS.policy.root) {
874 + if (req.method !== "GET" && req.method !== "POST") {
875 + writeMethodNotAllowed(res, "GET, POST");
876 + return;
877 + }
878 + await servePolicy(req, res);
879 + return;
880 + }
881 + if (url.pathname === API_PATHS.policy.leases || url.pathname === API_PATHS.policy.ips) {
882 + if (req.method !== "POST") {
883 + writeMethodNotAllowed(res, "POST");
884 + return;
885 + }
886 + await forwardPolicyUpdate(req, res, url.pathname);
887 + return;
888 + }
889 + if (url.pathname.startsWith(API_PATHS.thumbnail.prefix)) {
890 + await serveThumbnail(req, res);
891 + return;
892 + }
893 + writeNotFound(res);
894 + })().catch((error) => {
895 + console.warn("portal api request failed", error);
896 + const url = new URL(req.url || "/", "http://api.local");
897 + if (url.pathname.startsWith(API_PATHS.thumbnail.prefix)) {
898 + writeNotFound(res);
899 + return;
900 + }
901 + writeError(res, 502, "upstream_error", "upstream request failed");
902 + });
903 +});
904 +
905 +server.listen(PORT, () => {
906 + console.log(
907 + `portal api listening on :${PORT} headless=${HEADLESS_SHELL_URL ? "enabled" : "disabled"} landing_page=${frontendState.landing_page_enabled}`
908 + );
909 +});
frontend/nginx.conf
+12 -1
@@ -5,9 +5,20 @@ server {
5 root /usr/share/nginx/html;
6 index index.html;
7
8 - location ~ ^/(state$|admin/|sdk/|service/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
8 + location ~ ^/(state$|service/status$|policy(/|$)|thumbnail/) {
9 proxy_http_version 1.1;
10 proxy_set_header Host $host;
11 + proxy_set_header Authorization $http_authorization;
12 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
13 + proxy_set_header X-Forwarded-Host $host;
14 + proxy_set_header X-Forwarded-Proto $scheme;
15 + proxy_pass http://portal-api:8081;
16 + }
17 +
18 + location ~ ^/(admin/|sdk/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
19 + proxy_http_version 1.1;
20 + proxy_set_header Host $host;
21 + proxy_set_header Authorization $http_authorization;
22 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23 proxy_set_header X-Forwarded-Host $host;
24 proxy_set_header X-Forwarded-Proto $scheme;
frontend/package.json
+1
@@ -6,6 +6,7 @@
6 "scripts": {
7 "dev": "vite",
8 "build": "tsc && vite build",
9 + "build:api": "tsc -p tsconfig.api.json",
10 "lint": "eslint . --max-warnings 0",
11 "lint:fix": "eslint . --fix",
12 "typecheck": "tsc --noEmit",
frontend/src/components/ServerCard.tsx
+21 -5
@@ -1,4 +1,4 @@
1 -import { useMemo, useState } from "react";
1 +import { useEffect, useMemo, useState } from "react";
2 import { Link } from "react-router-dom";
3 import clsx from "clsx";
4 import {
@@ -82,6 +82,12 @@ export function ServerCard({
82 }: ServerCardProps) {
83 const [showBPSModal, setShowBPSModal] = useState(false);
84 const [bpsInput, setBpsInput] = useState(bps.toString());
85 + const [thumbnailFailed, setThumbnailFailed] = useState(false);
86 + const effectiveThumbnail = thumbnailFailed ? "" : thumbnail;
87 +
88 + useEffect(() => {
89 + setThumbnailFailed(false);
90 + }, [thumbnail]);
91
92 const bpsSteps = [0, 10, 100, 1000, 10000, 100000, 1000000, 10000000];
93
@@ -233,11 +239,20 @@ export function ServerCard({
239 <div
240 className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
241 style={{
236 - backgroundImage: thumbnail
237 - ? `url(${thumbnail})`
242 + backgroundImage: effectiveThumbnail
243 + ? `url(${effectiveThumbnail})`
244 : "linear-gradient(135deg, var(--card) 0%, var(--background) 100%)",
245 }}
246 />
247 + {effectiveThumbnail && (
248 + <img
249 + alt=""
250 + aria-hidden="true"
251 + className="hidden"
252 + src={effectiveThumbnail}
253 + onError={() => setThumbnailFailed(true)}
254 + />
255 + )}
256
257 <div className="absolute inset-0 bg-linear-to-t from-black via-black/60 to-transparent" />
258
@@ -351,13 +366,14 @@ export function ServerCard({
366 )}
367 </div>
368
354 - {!showAdminControls && thumbnail && (
369 + {!showAdminControls && effectiveThumbnail && (
370 <div className="shrink-0">
371 <div className="size-10 overflow-hidden rounded-xl border border-white/20 shadow-lg">
372 <img
373 alt={`${name} avatar`}
374 className="h-full w-full object-cover"
360 - src={thumbnail}
375 + src={effectiveThumbnail}
376 + onError={() => setThumbnailFailed(true)}
377 />
378 </div>
379 </div>
frontend/src/components/ServerListView.tsx
+1 -1
@@ -182,7 +182,7 @@ export function ServerListView({
182 isAdmin = false,
183 banFilter = "all",
184 approvalMode = "auto",
185 - landingPageEnabled = true,
185 + landingPageEnabled = false,
186 onBanFilterChange,
187 onBanStatusChange,
188 onBPSChange,
frontend/src/hooks/useAdmin.test.ts
+23 -23
@@ -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 { AdminLease, AdminSettings } from "@/types/api";
4 +import type { PolicyLease, PolicySettings } from "@/types/api";
5 import { useAdmin } from "@/hooks/useAdmin";
6 import { API_PATHS } from "@/lib/apiPaths";
7 import { APIClientError, apiClient } from "@/lib/apiClient";
8
9 -type DeferredAdminState = {
10 - leases: AdminLease[];
11 - settings: AdminSettings;
9 +type DeferredPolicyState = {
10 + leases: PolicyLease[];
11 + policy: PolicySettings;
12 };
13
14 vi.mock("@/hooks/useList", () => ({
@@ -42,16 +42,16 @@ vi.mock("@/lib/apiClient", async () => {
42 };
43 });
44
45 -function buildSettings(approvalMode: "auto" | "manual" = "auto"): AdminSettings {
45 +function buildSettings(approvalMode: "auto" | "manual" = "auto"): PolicySettings {
46 return {
47 approval_mode: approvalMode,
48 - landing_page_enabled: true,
48 + landing_page_enabled: false,
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 {
54 +function buildLease(address: string, name: string = "relay-1"): PolicyLease {
55 return {
56 expires_at: "2026-03-04T00:00:00Z",
57 first_seen_at: "2026-03-02T00:00:00Z",
@@ -91,17 +91,17 @@ describe("useAdmin", () => {
91 vi.clearAllMocks();
92
93 mockGet.mockImplementation(async (path: string) => {
94 - if (path === API_PATHS.admin.state) {
94 + if (path === API_PATHS.policy.state) {
95 return {
96 leases: [buildLease("0x00000000000000000000000000000000000000A1")],
97 - settings: { ...buildSettings(), approval_mode: "not-a-mode" },
97 + policy: { ...buildSettings(), approval_mode: "not-a-mode" },
98 } as never;
99 }
100 throw new Error(`Unexpected GET path: ${path}`);
101 });
102
103 mockPost.mockImplementation(async <T,>(path: string, body?: unknown): Promise<T> => {
104 - if (path === API_PATHS.admin.settings) {
104 + if (path === API_PATHS.policy.root) {
105 return body as T;
106 }
107 return {} as T;
@@ -122,7 +122,7 @@ describe("useAdmin", () => {
122
123 it("surfaces fetchData API errors", async () => {
124 mockGet.mockImplementation(async (path: string) => {
125 - if (path === API_PATHS.admin.state) {
125 + if (path === API_PATHS.policy.state) {
126 throw new APIClientError("failed to load leases", 500, "server_error");
127 }
128 throw new Error(`Unexpected GET path: ${path}`);
@@ -180,8 +180,8 @@ describe("useAdmin", () => {
180 });
181
182 const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
183 - expect(calledPaths).toContain(API_PATHS.admin.leasePolicy);
184 - expect(mockPost).toHaveBeenCalledWith(API_PATHS.admin.leasePolicy, {
183 + expect(calledPaths).toContain(API_PATHS.policy.leases);
184 + expect(mockPost).toHaveBeenCalledWith(API_PATHS.policy.leases, {
185 identity_key: identityKey,
186 is_approved: true,
187 });
@@ -199,7 +199,7 @@ describe("useAdmin", () => {
199 });
200
201 expect(mockPost).toHaveBeenCalledWith(
202 - API_PATHS.admin.leasePolicy,
202 + API_PATHS.policy.leases,
203 {
204 identity_key: "relay-1:0x00000000000000000000000000000000000000a1",
205 bps: 4096,
@@ -210,21 +210,21 @@ describe("useAdmin", () => {
210 it("keeps loading false while refreshing bps in the background", async () => {
211 let getCalls = 0;
212 let resolveRefresh:
213 - | ((value: DeferredAdminState | PromiseLike<DeferredAdminState>) => void)
213 + | ((value: DeferredPolicyState | PromiseLike<DeferredPolicyState>) => void)
214 | undefined;
215
216 mockGet.mockImplementation((path: string) => {
217 - if (path !== API_PATHS.admin.state) {
217 + if (path !== API_PATHS.policy.state) {
218 throw new Error(`Unexpected GET path: ${path}`);
219 }
220 getCalls++;
221 if (getCalls === 1) {
222 return Promise.resolve({
223 leases: [buildLease("0x00000000000000000000000000000000000000A1")],
224 - settings: buildSettings(),
224 + policy: buildSettings(),
225 } as never);
226 }
227 - return new Promise<DeferredAdminState>((resolve) => {
227 + return new Promise<DeferredPolicyState>((resolve) => {
228 resolveRefresh = resolve;
229 }) as never;
230 });
@@ -242,7 +242,7 @@ describe("useAdmin", () => {
242 expect(result.current.loading).toBe(false);
243 resolveRefresh?.({
244 leases: [{ ...buildLease("0x00000000000000000000000000000000000000A1"), bps: 2048 }],
245 - settings: buildSettings(),
245 + policy: buildSettings(),
246 });
247 await pending;
248 });
@@ -252,13 +252,13 @@ describe("useAdmin", () => {
252
253 it("bulk deny posts deduped identity keys in lease policy bodies", async () => {
254 mockGet.mockImplementation(async (path: string) => {
255 - if (path === API_PATHS.admin.state) {
255 + if (path === API_PATHS.policy.state) {
256 return {
257 leases: [
258 buildLease("0x00000000000000000000000000000000000000A1", "relay-1"),
259 buildLease("0x00000000000000000000000000000000000000B2", "relay-2"),
260 ],
261 - settings: buildSettings(),
261 + policy: buildSettings(),
262 } as never;
263 }
264 throw new Error(`Unexpected GET path: ${path}`);
@@ -283,8 +283,8 @@ describe("useAdmin", () => {
283 expect(denyCalls).toHaveLength(2);
284 expect(denyCalls).toEqual(
285 expect.arrayContaining([
286 - [API_PATHS.admin.leasePolicy, { identity_key: identityKeyA, is_denied: true }],
287 - [API_PATHS.admin.leasePolicy, { identity_key: identityKeyB, is_denied: true }],
286 + [API_PATHS.policy.leases, { identity_key: identityKeyA, is_denied: true }],
287 + [API_PATHS.policy.leases, { identity_key: identityKeyB, is_denied: true }],
288 ]),
289 );
290 });
frontend/src/hooks/useAdmin.ts
+54 -54
@@ -3,15 +3,15 @@ import { useList, type BaseServer } from "@/hooks/useList";
3 import type { BanFilter } from "@/types/filters";
4 import { API_PATHS } from "@/lib/apiPaths";
5 import { APIClientError, apiClient } from "@/lib/apiClient";
6 -import { parseLeaseMetadata } from "@/lib/metadata";
6 +import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
7 import type {
8 - AdminIPPolicy,
9 - AdminLease,
10 - AdminLeasePolicy,
11 - AdminPortSettings,
12 - AdminSettings,
13 - AdminStateResponse,
8 ApprovalMode,
9 + IPPolicyUpdate,
10 + LeasePolicyUpdate,
11 + PolicyLease,
12 + PolicyPortSettings,
13 + PolicySettings,
14 + PolicyStateResponse,
15 } from "@/types/api";
16
17 export type { ApprovalMode } from "@/types/api";
@@ -40,9 +40,9 @@ export interface TCPPortSettings {
40 maxLeases: number;
41 }
42
43 -const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
43 +const DEFAULT_POLICY_SETTINGS: PolicySettings = {
44 approval_mode: "auto",
45 - landing_page_enabled: true,
45 + landing_page_enabled: false,
46 udp: { enabled: false, max_leases: 0 },
47 tcp_port: { enabled: false, max_leases: 0 },
48 };
@@ -84,7 +84,7 @@ function toAdminErrorMessage(error: unknown, fallback: string): string {
84 }
85
86 function toAdminServer(
87 - row: AdminLease,
87 + row: PolicyLease,
88 ): AdminServer {
89 const metadata = parseLeaseMetadata(row.metadata);
90 const hostname = row.hostname || "";
@@ -96,7 +96,7 @@ function toAdminServer(
96 name: serviceName || hostname || "(unnamed)",
97 description: metadata.description,
98 tags: metadata.tags,
99 - thumbnail: metadata.thumbnail,
99 + thumbnail: resolveLeaseThumbnail(metadata, hostname),
100 owner: metadata.owner,
101 online: (row.ready || 0) > 0,
102 dns: hostname,
@@ -119,55 +119,55 @@ function normalizeApprovalMode(value: string | undefined): ApprovalMode {
119 return value === "manual" ? "manual" : "auto";
120 }
121
122 -function normalizeAdminSettings(settings: AdminSettings | undefined): AdminSettings {
122 +function normalizePolicySettings(settings: PolicySettings | undefined): PolicySettings {
123 return {
124 approval_mode: normalizeApprovalMode(settings?.approval_mode),
125 landing_page_enabled:
126 - settings?.landing_page_enabled ?? DEFAULT_ADMIN_SETTINGS.landing_page_enabled,
126 + settings?.landing_page_enabled ?? DEFAULT_POLICY_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,
128 + enabled: settings?.udp?.enabled ?? DEFAULT_POLICY_SETTINGS.udp.enabled,
129 + max_leases: settings?.udp?.max_leases ?? DEFAULT_POLICY_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,
132 + enabled: settings?.tcp_port?.enabled ?? DEFAULT_POLICY_SETTINGS.tcp_port.enabled,
133 + max_leases: settings?.tcp_port?.max_leases ?? DEFAULT_POLICY_SETTINGS.tcp_port.max_leases,
134 },
135 };
136 }
137
138 -interface AdminState {
139 - serverData: AdminLease[];
140 - settings: AdminSettings;
138 +interface PolicyViewState {
139 + serverData: PolicyLease[];
140 + settings: PolicySettings;
141 }
142
143 -async function loadAdminState(): Promise<AdminState> {
144 - const state = await apiClient.get<AdminStateResponse>(API_PATHS.admin.state);
143 +async function loadPolicyState(): Promise<PolicyViewState> {
144 + const state = await apiClient.get<PolicyStateResponse>(API_PATHS.policy.state);
145 const normalizedLeases = Array.isArray(state?.leases) ? state.leases : [];
146
147 return {
148 serverData: normalizedLeases,
149 - settings: normalizeAdminSettings(state?.settings),
149 + settings: normalizePolicySettings(state?.policy),
150 };
151 }
152
153 export function useAdmin(enabled = true) {
154 - const [serverData, setServerData] = useState<AdminLease[]>([]);
155 - const [adminSettings, setAdminSettings] = useState<AdminSettings>(DEFAULT_ADMIN_SETTINGS);
154 + const [serverData, setServerData] = useState<PolicyLease[]>([]);
155 + const [policySettings, setPolicySettings] = useState<PolicySettings>(DEFAULT_POLICY_SETTINGS);
156 const [loading, setLoading] = useState(true);
157 const [error, setError] = useState("");
158
159 const [banFilter, setBanFilter] = useState<BanFilter>("all");
160
161 - const applyAdminState = (state: AdminState) => {
161 + const applyPolicyState = (state: PolicyViewState) => {
162 setServerData(state.serverData);
163 - setAdminSettings(state.settings);
163 + setPolicySettings(state.settings);
164 };
165
166 const fetchData = async () => {
167 setError("");
168
169 try {
170 - applyAdminState(await loadAdminState());
170 + applyPolicyState(await loadPolicyState());
171 } catch (err: unknown) {
172 setError(toAdminErrorMessage(err, "Failed to load admin data"));
173 }
@@ -187,11 +187,11 @@ export function useAdmin(enabled = true) {
187 setError("");
188 setLoading(true);
189 try {
190 - const state = await loadAdminState();
190 + const state = await loadPolicyState();
191 if (!mounted) {
192 return;
193 }
194 - applyAdminState(state);
194 + applyPolicyState(state);
195 } catch (err: unknown) {
196 if (!mounted) {
197 return;
@@ -244,27 +244,27 @@ export function useAdmin(enabled = true) {
244 }
245 };
246
247 - const postAdminSettings = async (settings: AdminSettings) => {
248 - const response = await apiClient.post<AdminSettings>(API_PATHS.admin.settings, settings);
249 - setAdminSettings(normalizeAdminSettings(response));
247 + const postPolicySettings = async (settings: PolicySettings) => {
248 + const response = await apiClient.post<PolicySettings>(API_PATHS.policy.root, settings);
249 + setPolicySettings(normalizePolicySettings(response));
250 };
251
252 - const currentAdminSettings = (overrides: Partial<AdminSettings> = {}): AdminSettings => ({
253 - ...adminSettings,
252 + const currentPolicySettings = (overrides: Partial<PolicySettings> = {}): PolicySettings => ({
253 + ...policySettings,
254 ...overrides,
255 });
256
257 const updateLeasePolicy = async (
258 identityKey: string,
259 - policy: Omit<AdminLeasePolicy, "identity_key">,
259 + policy: Omit<LeasePolicyUpdate, "identity_key">,
260 ) => {
261 if (!identityKey) {
262 throw new Error("Missing lease identity");
263 }
264 - await apiClient.post<unknown>(API_PATHS.admin.leasePolicy, {
264 + await apiClient.post<unknown>(API_PATHS.policy.leases, {
265 identity_key: identityKey,
266 ...policy,
267 - } satisfies AdminLeasePolicy);
267 + } satisfies LeasePolicyUpdate);
268 };
269
270 const handleBanFilterChange = (value: BanFilter) => {
@@ -313,22 +313,22 @@ export function useAdmin(enabled = true) {
313
314 const handleApprovalModeChange = async (mode: ApprovalMode) => {
315 await runAdminAction(async () => {
316 - await postAdminSettings(currentAdminSettings({ approval_mode: mode }));
316 + await postPolicySettings(currentPolicySettings({ approval_mode: mode }));
317 });
318 };
319
320 const handleSettingsChange = (key: "udp" | "tcp_port") =>
321 async (settings: { enabled: boolean; maxLeases: number }) => {
322 await runAdminAction(async () => {
323 - const nextPortSettings: AdminPortSettings = {
323 + const nextPortSettings: PolicyPortSettings = {
324 enabled: settings.enabled,
325 max_leases: settings.maxLeases,
326 };
327 const nextSettings =
328 key === "udp"
329 - ? currentAdminSettings({ udp: nextPortSettings })
330 - : currentAdminSettings({ tcp_port: nextPortSettings });
331 - await postAdminSettings(nextSettings);
329 + ? currentPolicySettings({ udp: nextPortSettings })
330 + : currentPolicySettings({ tcp_port: nextPortSettings });
331 + await postPolicySettings(nextSettings);
332 });
333 };
334
@@ -337,7 +337,7 @@ export function useAdmin(enabled = true) {
337
338 const handleLandingPageEnabledChange = async (enabled: boolean) => {
339 await runAdminAction(async () => {
340 - await postAdminSettings(currentAdminSettings({ landing_page_enabled: enabled }));
340 + await postPolicySettings(currentPolicySettings({ landing_page_enabled: enabled }));
341 });
342 };
343
@@ -353,10 +353,10 @@ export function useAdmin(enabled = true) {
353 if (!normalizedIP) {
354 throw new Error("Missing IP address");
355 }
356 - await apiClient.post<unknown>(API_PATHS.admin.ipPolicy, {
356 + await apiClient.post<unknown>(API_PATHS.policy.ips, {
357 ip: normalizedIP,
358 is_banned: isBan,
359 - } satisfies AdminIPPolicy);
359 + } satisfies IPPolicyUpdate);
360 });
361
362 const runBulkLeaseAction = async (identityKeys: string[], action: LeaseAction) => {
@@ -369,13 +369,13 @@ export function useAdmin(enabled = true) {
369
370 const results = await Promise.allSettled(
371 normalizedIdentityKeys.map((identityKey) => {
372 - const policy: AdminLeasePolicy =
372 + const policy: LeasePolicyUpdate =
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);
378 + return apiClient.post<unknown>(API_PATHS.policy.leases, policy);
379 })
380 );
381
@@ -401,15 +401,15 @@ 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;
404 + const approvalMode = normalizeApprovalMode(policySettings.approval_mode);
405 + const landingPageEnabled = policySettings.landing_page_enabled;
406 const udpSettings: UDPSettings = {
407 - enabled: adminSettings.udp.enabled,
408 - maxLeases: adminSettings.udp.max_leases,
407 + enabled: policySettings.udp.enabled,
408 + maxLeases: policySettings.udp.max_leases,
409 };
410 const tcpPortSettings: TCPPortSettings = {
411 - enabled: adminSettings.tcp_port.enabled,
412 - maxLeases: adminSettings.tcp_port.max_leases,
411 + enabled: policySettings.tcp_port.enabled,
412 + maxLeases: policySettings.tcp_port.max_leases,
413 };
414
415 return {
frontend/src/hooks/useServerList.ts
+5 -5
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
2 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";
5 +import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
6 import type { Lease, PublicStateResponse } from "@/types/api";
7
8 type PublicState = {
@@ -21,7 +21,7 @@ function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
21 name: serviceName || hostname || "(unnamed)",
22 description: metadata.description || "",
23 tags: metadata.tags,
24 - thumbnail: metadata.thumbnail || "",
24 + thumbnail: resolveLeaseThumbnail(metadata, hostname),
25 owner: metadata.owner || "",
26 online: (row.ready || 0) > 0,
27 dns: hostname,
@@ -35,7 +35,7 @@ function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
35 export function useServerList() {
36 const [publicState, setPublicState] = useState<PublicState>({
37 leases: [],
38 - landingPageEnabled: true,
38 + landingPageEnabled: false,
39 });
40
41 useEffect(() => {
@@ -51,12 +51,12 @@ export function useServerList() {
51 }
52 setPublicState({
53 leases: Array.isArray(data?.leases) ? data.leases : [],
54 - landingPageEnabled: data?.landing_page_enabled ?? true,
54 + landingPageEnabled: data?.landing_page_enabled ?? false,
55 });
56 } catch (error) {
57 console.error("Failed to load public relay state", error);
58 if (!cancelled) {
59 - setPublicState({ leases: [], landingPageEnabled: true });
59 + setPublicState({ leases: [], landingPageEnabled: false });
60 }
61 }
62 })();
frontend/src/lib/apiClient.test.ts
+17
@@ -159,4 +159,21 @@ describe("apiClient", () => {
159 Authorization: "Bearer admin-token",
160 });
161 });
162 +
163 + it("sends bearer token for policy API calls", async () => {
164 + writeAdminAuthToken("admin-token");
165 + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
166 +
167 + await apiClient.post("/policy/leases", {
168 + identity_key: "relay:0x1",
169 + is_approved: true,
170 + });
171 +
172 + const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
173 + expect(init.headers).toEqual({
174 + Accept: "application/json",
175 + Authorization: "Bearer admin-token",
176 + "Content-Type": "application/json",
177 + });
178 + });
179 });
frontend/src/lib/apiClient.ts
+3 -1
@@ -97,8 +97,10 @@ async function request<T>(path: string, init: RequestInit): Promise<T> {
97 ...((init.headers as Record<string, string> | undefined) ?? {}),
98 };
99 const pathname = new URL(path, window.location.origin).pathname;
100 + const requiresAdminAuth =
101 + pathname === "/policy" || pathname.startsWith("/policy/") || pathname.startsWith("/admin/");
102 if (
101 - pathname.startsWith("/admin/") &&
103 + requiresAdminAuth &&
104 pathname !== API_PATHS.admin.authChallenge &&
105 pathname !== API_PATHS.admin.authLogin
106 ) {
frontend/src/lib/apiPaths.ts
+9 -4
@@ -3,14 +3,16 @@ export const API_PATHS = {
3 state: "/state",
4 },
5 admin: {
6 - state: "/admin/state",
6 authChallenge: "/admin/auth/challenge",
7 authLogin: "/admin/auth/login",
8 logout: "/admin/auth/logout",
9 authStatus: "/admin/auth/status",
11 - settings: "/admin/settings",
12 - leasePolicy: "/admin/lease-policy",
13 - ipPolicy: "/admin/ip-policy",
10 + },
11 + policy: {
12 + root: "/policy",
13 + state: "/policy/state",
14 + leases: "/policy/leases",
15 + ips: "/policy/ips",
16 },
17 sdk: {
18 domain: "/sdk/domain",
@@ -18,6 +20,9 @@ export const API_PATHS = {
20 service: {
21 status: "/service/status",
22 },
23 + thumbnail: {
24 + prefix: "/thumbnail/",
25 + },
26 discovery: "/discovery",
27 install: {
28 shell: "/install.sh",
frontend/src/lib/metadata.ts
+15
@@ -1,3 +1,5 @@
1 +import { API_PATHS } from "./apiPaths.js";
2 +
3 interface Metadata {
4 description: string;
5 tags: string[];
@@ -53,3 +55,16 @@ export function parseLeaseMetadata(metadataValue: unknown): Metadata {
55 return EMPTY_METADATA;
56 }
57 }
58 +
59 +export function resolveLeaseThumbnail(metadata: Metadata, hostname: string): string {
60 + const configuredThumbnail = metadata.thumbnail.trim();
61 + if (configuredThumbnail !== "") {
62 + return configuredThumbnail;
63 + }
64 +
65 + const normalizedHostname = hostname.trim().toLowerCase().replace(/\.$/, "");
66 + if (normalizedHostname === "" || normalizedHostname.startsWith("*.")) {
67 + return "";
68 + }
69 + return `${API_PATHS.thumbnail.prefix}${encodeURIComponent(normalizedHostname)}`;
70 +}
frontend/src/types/api.ts
+10 -10
@@ -38,7 +38,7 @@ export interface Lease {
38 ready: number;
39 }
40
41 -export interface AdminLease extends Lease {
41 +export interface PolicyLease extends Lease {
42 identity_key: string;
43 address: string;
44 bps: number;
@@ -55,21 +55,21 @@ export interface PublicStateResponse {
55 landing_page_enabled: boolean;
56 }
57
58 -export interface AdminPortSettings {
58 +export interface PolicyPortSettings {
59 enabled: boolean;
60 max_leases: number;
61 }
62
63 -export interface AdminStateResponse {
64 - settings: AdminSettings;
65 - leases?: AdminLease[];
63 +export interface PolicyStateResponse {
64 + policy: PolicySettings;
65 + leases?: PolicyLease[];
66 }
67
68 -export interface AdminSettings {
68 +export interface PolicySettings {
69 approval_mode: ApprovalMode;
70 landing_page_enabled: boolean;
71 - udp: AdminPortSettings;
72 - tcp_port: AdminPortSettings;
71 + udp: PolicyPortSettings;
72 + tcp_port: PolicyPortSettings;
73 }
74
75 export interface WalletAuthStatusResponse {
@@ -128,7 +128,7 @@ export interface ServiceStatusResponse {
128 service_alive: boolean;
129 }
130
131 -export interface AdminLeasePolicy {
131 +export interface LeasePolicyUpdate {
132 identity_key: string;
133 bps?: number;
134 is_approved?: boolean;
@@ -136,7 +136,7 @@ export interface AdminLeasePolicy {
136 is_denied?: boolean;
137 }
138
139 -export interface AdminIPPolicy {
139 +export interface IPPolicyUpdate {
140 ip: string;
141 is_banned: boolean;
142 }
frontend/tsconfig.api.json new
+16
@@ -0,0 +1,16 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": ["ES2022", "DOM"],
5 + "module": "NodeNext",
6 + "moduleResolution": "NodeNext",
7 + "outDir": "dist-api",
8 + "rootDir": ".",
9 + "strict": true,
10 + "skipLibCheck": true,
11 + "types": ["node"],
12 + "noUnusedLocals": true,
13 + "noUnusedParameters": true
14 + },
15 + "include": ["api/**/*.ts", "src/lib/apiPaths.ts", "src/lib/metadata.ts"]
16 +}
go.mod
-6
@@ -15,7 +15,6 @@ require (
15 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
16 github.com/go-acme/lego/v4 v4.34.0
17 github.com/go-jose/go-jose/v4 v4.1.4
18 - github.com/go-rod/rod v0.116.2
18 github.com/gosuda/keyless_tls v0.0.2-0.20260507061030-5128be6b5008
19 github.com/gosuda/x402-facilitator v0.0.0-20260413025142-cb6c4794b9a5
20 github.com/hashicorp/yamux v0.1.2
@@ -139,11 +138,6 @@ require (
138 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
139 github.com/xeipuuv/gojsonschema v1.2.0 // indirect
140 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
142 - github.com/ysmood/fetchup v0.2.3 // indirect
143 - github.com/ysmood/goob v0.4.0 // indirect
144 - github.com/ysmood/got v0.40.0 // indirect
145 - github.com/ysmood/gson v0.7.3 // indirect
146 - github.com/ysmood/leakless v0.9.0 // indirect
141 github.com/yusufpapurcu/wmi v1.2.4 // indirect
142 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
143 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.sum
-16
@@ -170,8 +170,6 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7
170 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
171 github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
172 github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
173 -github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
174 -github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
173 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
174 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
175 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -405,20 +403,6 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC
403 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
404 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
405 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
408 -github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
409 -github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
410 -github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
411 -github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
412 -github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
413 -github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
414 -github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
415 -github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
416 -github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
417 -github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
418 -github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
419 -github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
420 -github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
421 -github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
406 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
407 github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
408 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
portal/identity/store.go
+11 -3
@@ -114,7 +114,7 @@ func ResolveRelayStateDir(path string) string {
114 return ""
115 }
116 switch strings.ToLower(filepath.Base(trimmed)) {
117 - case types.RelayIdentityFilename, types.RelayAdminSettingsFilename:
117 + case types.RelayIdentityFilename, types.RelayPolicyFilename, types.LegacyRelayAdminSettingsFilename:
118 return filepath.Dir(trimmed)
119 default:
120 return trimmed
@@ -129,12 +129,20 @@ func resolveRelayIdentityPath(path string) string {
129 return filepath.Join(stateDir, types.RelayIdentityFilename)
130 }
131
132 -func ResolveRelayAdminSettingsPath(path string) string {
132 +func ResolveRelayPolicyPath(path string) string {
133 stateDir := ResolveRelayStateDir(path)
134 if stateDir == "" {
135 return ""
136 }
137 - return filepath.Join(stateDir, types.RelayAdminSettingsFilename)
137 + return filepath.Join(stateDir, types.RelayPolicyFilename)
138 +}
139 +
140 +func ResolveLegacyRelayPolicyPath(path string) string {
141 + stateDir := ResolveRelayStateDir(path)
142 + if stateDir == "" {
143 + return ""
144 + }
145 + return filepath.Join(stateDir, types.LegacyRelayAdminSettingsFilename)
146 }
147
148 func normalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
portal/lease.go
+3 -3
@@ -893,18 +893,18 @@ func (r *leaseRegistry) PublicLeases(now time.Time) []types.Lease {
893 return leases
894 }
895
896 -func (r *leaseRegistry) AdminLeases(now time.Time) []types.AdminLease {
896 +func (r *leaseRegistry) PolicyLeases(now time.Time) []types.PolicyLease {
897 r.mu.RLock()
898 defer r.mu.RUnlock()
899
900 - leases := make([]types.AdminLease, 0, len(r.records))
900 + leases := make([]types.PolicyLease, 0, len(r.records))
901 for _, record := range r.records {
902 if record == nil || record.stream == nil || record.isExpired(now) {
903 continue
904 }
905 clientIP := record.ClientIP
906 identityKey := record.Key()
907 - leases = append(leases, types.AdminLease{
907 + leases = append(leases, types.PolicyLease{
908 Lease: r.publicLease(record),
909 IdentityKey: identityKey,
910 Address: record.Address,
portal/lease_test.go
+12 -12
@@ -130,12 +130,12 @@ func TestLeaseRegistryAutomaticECHRouteFallsBackToPlainSNI(t *testing.T) {
130 t.Fatalf("PublicLeases()[0].Hostname = %q, want %q", leases[0].Hostname, publicHostname)
131 }
132
133 - adminLeases := registry.AdminLeases(time.Now())
134 - if len(adminLeases) != 1 {
135 - t.Fatalf("AdminLeases() length = %d, want 1", len(adminLeases))
133 + policyLeases := registry.PolicyLeases(time.Now())
134 + if len(policyLeases) != 1 {
135 + t.Fatalf("PolicyLeases() length = %d, want 1", len(policyLeases))
136 }
137 - if adminLeases[0].Hostname != publicHostname {
138 - t.Fatalf("AdminLeases()[0] hostname = %q, want %q", adminLeases[0].Hostname, publicHostname)
137 + if policyLeases[0].Hostname != publicHostname {
138 + t.Fatalf("PolicyLeases()[0] hostname = %q, want %q", policyLeases[0].Hostname, publicHostname)
139 }
140
141 if _, _, err := registry.Register(types.RegisterChallengeRequest{
@@ -254,7 +254,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
254 }
255 }
256
257 -func TestLeaseRegistryAdminLeasesAndRoutableUsePolicy(t *testing.T) {
257 +func TestLeaseRegistryPolicyLeasesAndRoutableUsePolicy(t *testing.T) {
258 t.Parallel()
259
260 registry := newTestRegistry(t)
@@ -273,12 +273,12 @@ func TestLeaseRegistryAdminLeasesAndRoutableUsePolicy(t *testing.T) {
273 t.Fatal("policy.IsIdentityRoutable() = true, want false before approval")
274 }
275
276 - leases := registry.AdminLeases(time.Now())
276 + leases := registry.PolicyLeases(time.Now())
277 if len(leases) != 1 {
278 - t.Fatalf("AdminLeases() length = %d, want 1", len(leases))
278 + t.Fatalf("PolicyLeases() length = %d, want 1", len(leases))
279 }
280 if leases[0].IsApproved {
281 - t.Fatal("AdminLeases()[0].IsApproved = true, want false before approval")
281 + t.Fatal("PolicyLeases()[0].IsApproved = true, want false before approval")
282 }
283 if got := runtime.IPFilter().IdentityIP(record.Key()); got != "203.0.113.20" {
284 t.Fatalf("Register() lease IP = %q, want %q", got, "203.0.113.20")
@@ -289,12 +289,12 @@ func TestLeaseRegistryAdminLeasesAndRoutableUsePolicy(t *testing.T) {
289 t.Fatal("policy.IsIdentityRoutable() = false, want true after approval")
290 }
291
292 - leases = registry.AdminLeases(time.Now())
292 + leases = registry.PolicyLeases(time.Now())
293 if len(leases) != 1 {
294 - t.Fatalf("AdminLeases() length = %d, want 1", len(leases))
294 + t.Fatalf("PolicyLeases() length = %d, want 1", len(leases))
295 }
296 if !leases[0].IsApproved {
297 - t.Fatal("AdminLeases()[0].IsApproved = false, want true after approval")
297 + t.Fatal("PolicyLeases()[0].IsApproved = false, want true after approval")
298 }
299 }
300
portal/server.go
+2 -2
@@ -414,11 +414,11 @@ func (s *Server) PublicLeases() []types.Lease {
414 return s.registry.PublicLeases(time.Now())
415 }
416
417 -func (s *Server) AdminLeases() []types.AdminLease {
417 +func (s *Server) PolicyLeases() []types.PolicyLease {
418 if s == nil || s.registry == nil {
419 return nil
420 }
421 - return s.registry.AdminLeases(time.Now())
421 + return s.registry.PolicyLeases(time.Now())
422 }
423
424 func (s *Server) RelayIdentity() types.RelayIdentity {
types/api.go
+11 -19
@@ -201,15 +201,8 @@ type ENSStatus struct {
201 LastError string `json:"last_error,omitempty"`
202 }
203
204 -type ServiceStatusResponse struct {
205 - Hostname string `json:"hostname"`
206 - Registered bool `json:"registered"`
207 - ServiceAlive bool `json:"service_alive"`
208 -}
209 -
204 type PublicStateResponse struct {
211 - Leases []Lease `json:"leases,omitempty"`
212 - LandingPageEnabled bool `json:"landing_page_enabled"`
205 + Leases []Lease `json:"leases,omitempty"`
206 }
207
208 type WalletAuthChallengeRequest struct {
@@ -238,19 +231,18 @@ type WalletAuthStatusResponse struct {
231 WalletAddress string `json:"wallet_address,omitempty"`
232 }
233
241 -type AdminStateResponse struct {
242 - Settings AdminSettings `json:"settings"`
243 - Leases []AdminLease `json:"leases,omitempty"`
234 +type PolicyStateResponse struct {
235 + Policy PolicySettings `json:"policy"`
236 + Leases []PolicyLease `json:"leases,omitempty"`
237 }
238
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"`
239 +type PolicySettings struct {
240 + ApprovalMode string `json:"approval_mode"`
241 + UDP PolicyPortSettings `json:"udp"`
242 + TCPPort PolicyPortSettings `json:"tcp_port"`
243 }
244
253 -type AdminLeasePolicy struct {
245 +type LeasePolicyUpdate struct {
246 IdentityKey string `json:"identity_key"`
247 BPS *int64 `json:"bps,omitempty"`
248 IsApproved *bool `json:"is_approved,omitempty"`
@@ -258,12 +250,12 @@ type AdminLeasePolicy struct {
250 IsDenied *bool `json:"is_denied,omitempty"`
251 }
252
261 -type AdminPortSettings struct {
253 +type PolicyPortSettings struct {
254 Enabled bool `json:"enabled"`
255 MaxLeases int `json:"max_leases"`
256 }
257
266 -type AdminIPPolicy struct {
258 +type IPPolicyUpdate struct {
259 IP string `json:"ip"`
260 IsBanned bool `json:"is_banned"`
261 }
types/identity.go
+5 -4
@@ -7,9 +7,10 @@ import (
7 )
8
9 const (
10 - IdentityKeySeparator = ":"
11 - RelayIdentityFilename = "identity.json"
12 - RelayAdminSettingsFilename = "admin_settings.json"
10 + IdentityKeySeparator = ":"
11 + RelayIdentityFilename = "identity.json"
12 + RelayPolicyFilename = "policy.json"
13 + LegacyRelayAdminSettingsFilename = "admin_settings.json"
14 )
15
16 type Identity struct {
@@ -90,7 +91,7 @@ type Lease struct {
91 Ready int `json:"ready"`
92 }
93
93 -type AdminLease struct {
94 +type PolicyLease struct {
95 Lease
96 IdentityKey string `json:"identity_key,omitempty"`
97 Address string `json:"address,omitempty"`
types/paths.go
+8 -9
@@ -1,29 +1,28 @@
1 package types
2
3 const (
4 + PathRoot = "/"
5 PathV1Sign = "/v1/sign"
6 PathHealthz = "/healthz"
6 - PathRoot = "/"
7 + PathState = "/state"
8
9 PathAdmin = "/admin"
10 PathAdminPrefix = "/admin/"
10 - PathAdminState = "/admin/state"
11 - PathAdminSettings = "/admin/settings"
12 - PathAdminLeasePolicy = "/admin/lease-policy"
13 - PathAdminIPPolicy = "/admin/ip-policy"
11 PathAdminAuthChallenge = "/admin/auth/challenge"
12 PathAdminAuthLogin = "/admin/auth/login"
13 PathAdminLogout = "/admin/auth/logout"
14 PathAdminAuthStatus = "/admin/auth/status"
18 - PathPublicState = "/state"
15 +
16 + PathPolicy = "/policy"
17 + PathPolicyPrefix = "/policy/"
18 + PathPolicyState = PathPolicy + "/state"
19 + PathPolicyLeases = PathPolicy + "/leases"
20 + PathPolicyIPs = PathPolicy + "/ips"
21
22 PathInstallShell = "/install.sh"
23 PathInstallPowerShell = "/install.ps1"
24 PathInstallBinPrefix = "/install/bin/"
25
24 - PathServiceStatus = "/service/status"
25 - PathThumbnailPrefix = "/thumbnail/"
26 -
26 PathSDKDomain = "/sdk/domain"
27 PathSDKRegisterChallenge = "/sdk/register/challenge"
28 PathSDKRegister = "/sdk/register"