feat: add Docker support for frontend with nginx and build process

Kim committed May 29, 2026 at 12:29 UTC 13a693b22617e2461f2f3ba1f14e0a8bad7d618d
49 files changed +886 -1057
.dockerignore
+5
@@ -11,6 +11,10 @@ bin/
11 docs/
12 *.md
13
14 +# Local relay/tunnel state
15 +.portal-certs/
16 +*identity.json
17 +
18 # IDE
19 .vscode/
20 .idea/
@@ -34,5 +38,6 @@ coverage.html
38 vendor/
39
40 # Frontend deps/build artifacts
41 +frontend/
42 node_modules/
43 **/node_modules/
.env.example
+4 -2
@@ -5,6 +5,7 @@ DISCOVERY=true
5 IDENTITY_PATH=/portal-certs
6
7 # Listener ports
8 +FRONTEND_PORT=8080
9 API_PORT=4017
10 SNI_PORT=443
11 WIREGUARD_PORT=51820
@@ -46,7 +47,8 @@ VULTR_API_KEY=
47 # for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
48 ENS_GASLESS_ENABLED=false
49
49 -# Admin/auth configuration. The admin secret is generated and stored in IDENTITY_PATH/identity.json.
50 +# Admin/auth configuration. The relay identity wallet is always allowed.
51 +ADMIN_WALLETS=
52 LANDING_PAGE_ENABLED=false
53 # Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
54 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
@@ -60,4 +62,4 @@ TRUSTED_PROXY_CIDRS=
62
63 X402_FACILITATOR_ENABLED=false
64 X402_NETWORK=eip155:84532
63 -X402_RPC_URL=https://base-sepolia-rpc.publicnode.com
\ No newline at end of file
65 +X402_RPC_URL=https://base-sepolia-rpc.publicnode.com
.github/workflows/branch-artifacts.yml
+29 -9
@@ -14,7 +14,6 @@ on:
14 env:
15 BUILD_REF: ${{ inputs.ref || github.ref_name }}
16 REGISTRY: ghcr.io
17 - IMAGE_NAME: gosuda/portal
17 PLATFORMS: linux/amd64,linux/arm64
18
19 concurrency:
@@ -23,7 +22,7 @@ concurrency:
22
23 jobs:
24 build:
26 - name: Build Binaries
25 + name: Build Artifacts
26 runs-on: ubuntu-latest
27
28 steps:
@@ -52,9 +51,13 @@ jobs:
51 echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
52 echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
53 {
55 - echo "image_tags<<EOF"
56 - echo "${REGISTRY}/${IMAGE_NAME}:branch-${safe_ref}"
57 - echo "${REGISTRY}/${IMAGE_NAME}:branch-${safe_ref}-${short_sha}"
54 + echo "api_image_tags<<EOF"
55 + echo "${REGISTRY}/gosuda/portal:branch-${safe_ref}"
56 + echo "${REGISTRY}/gosuda/portal:branch-${safe_ref}-${short_sha}"
57 + echo "EOF"
58 + echo "frontend_image_tags<<EOF"
59 + echo "${REGISTRY}/gosuda/portal-frontend:branch-${safe_ref}"
60 + echo "${REGISTRY}/gosuda/portal-frontend:branch-${safe_ref}-${short_sha}"
61 echo "EOF"
62 } >> "$GITHUB_OUTPUT"
63
@@ -71,18 +74,35 @@ jobs:
74 - name: Set up Docker Buildx
75 uses: docker/setup-buildx-action@v3
76
74 - - name: Build and push Docker image
77 + - name: Build and push API Docker image
78 + uses: docker/build-push-action@v6
79 + with:
80 + context: .
81 + file: Dockerfile
82 + platforms: ${{ env.PLATFORMS }}
83 + push: true
84 + tags: ${{ steps.meta.outputs.api_image_tags }}
85 + labels: |
86 + org.opencontainers.image.source=https://github.com/${{ github.repository }}
87 + org.opencontainers.image.revision=${{ steps.meta.outputs.commit_sha }}
88 + org.opencontainers.image.ref.name=${{ env.BUILD_REF }}
89 + cache-from: type=gha,scope=api
90 + cache-to: type=gha,mode=max,scope=api
91 +
92 + - name: Build and push frontend Docker image
93 uses: docker/build-push-action@v6
94 with:
95 + context: ./frontend
96 + file: ./frontend/Dockerfile
97 platforms: ${{ env.PLATFORMS }}
98 push: true
79 - tags: ${{ steps.meta.outputs.image_tags }}
99 + tags: ${{ steps.meta.outputs.frontend_image_tags }}
100 labels: |
101 org.opencontainers.image.source=https://github.com/${{ github.repository }}
102 org.opencontainers.image.revision=${{ steps.meta.outputs.commit_sha }}
103 org.opencontainers.image.ref.name=${{ env.BUILD_REF }}
84 - cache-from: type=gha,mode=max
85 - cache-to: type=gha,mode=max
104 + cache-from: type=gha,scope=frontend
105 + cache-to: type=gha,mode=max,scope=frontend
106
107 - name: Build binaries
108 shell: bash
.github/workflows/cd.yml
+18 -5
@@ -7,7 +7,6 @@ on:
7
8 env:
9 REGISTRY: ghcr.io
10 - IMAGE_NAME: gosuda/portal
10 PLATFORMS: linux/amd64,linux/arm64
11
12 concurrency:
@@ -16,11 +15,23 @@ concurrency:
15
16 jobs:
17 build-and-push:
19 - name: Build and Push Docker Image
18 + name: Build and Push Docker Images
19 runs-on: ubuntu-latest
20 permissions:
21 contents: read
22 packages: write
23 + strategy:
24 + fail-fast: false
25 + matrix:
26 + include:
27 + - image: gosuda/portal
28 + context: .
29 + file: Dockerfile
30 + cache_scope: api
31 + - image: gosuda/portal-frontend
32 + context: ./frontend
33 + file: ./frontend/Dockerfile
34 + cache_scope: frontend
35
36 steps:
37 - name: Checkout code
@@ -43,7 +54,7 @@ jobs:
54 id: meta
55 uses: docker/metadata-action@v5
56 with:
46 - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
57 + images: ${{ env.REGISTRY }}/${{ matrix.image }}
58 tags: |
59 type=raw,value=latest,enable={{is_default_branch}}
60 type=semver,pattern={{version}}
@@ -54,12 +65,14 @@ jobs:
65 - name: Build and push Docker image
66 uses: docker/build-push-action@v6
67 with:
68 + context: ${{ matrix.context }}
69 + file: ${{ matrix.file }}
70 platforms: ${{ env.PLATFORMS }}
71 push: true
72 tags: ${{ steps.meta.outputs.tags }}
73 labels: ${{ steps.meta.outputs.labels }}
61 - cache-from: type=gha,mode=max
62 - cache-to: type=gha,mode=max
74 + cache-from: type=gha,scope=${{ matrix.cache_scope }}
75 + cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }}
76
77 release-binaries:
78 name: Build and Release Tunnel Binaries
.gitignore
+4 -2
@@ -11,9 +11,7 @@
11 bin/
12 chat
13
14 -cmd/relay-server/dist/app
14 data/
16 -.portal-certs
15
16 # Test binary, built with `go test -c`
17 *.test
@@ -146,3 +144,7 @@ fabric.properties
144
145 # Nested workspace (separate keyless_tls project; not part of the portal module)
146 keyless_tls/
147 +
148 +# Local relay/tunnel state
149 +.portal-certs/
150 +*identity.json
Dockerfile
+2 -18
@@ -1,20 +1,5 @@
1 # syntax=docker/dockerfile:1
2
3 -# Stage 1: Build frontend (Node.js)
4 -FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-builder
5 -WORKDIR /src
6 -
7 -RUN apt-get update && apt-get install -y --no-install-recommends \
8 - make && rm -rf /var/lib/apt/lists/*
9 -
10 -COPY frontend ./frontend
11 -COPY utils ./utils
12 -COPY Makefile ./
13 -
14 -RUN --mount=type=cache,target=/root/.npm \
15 - make build-frontend
16 -
17 -# Stage 2: Build Go artifacts
3 FROM --platform=$BUILDPLATFORM golang:1 AS go-builder
4 WORKDIR /src
5
@@ -25,8 +10,7 @@ COPY go.mod go.sum ./
10 RUN --mount=type=cache,target=/go/pkg/mod go mod download
11
12 COPY . .
28 -RUN rm -rf bin/
29 -COPY --from=frontend-builder /src/cmd/relay-server/dist/app ./cmd/relay-server/dist/app
13 +RUN rm -rf bin/ cmd/relay-server/dist && mkdir -p cmd/relay-server/dist && touch cmd/relay-server/dist/.gitkeep
14
15 ARG TARGETOS
16 ARG TARGETARCH
@@ -35,7 +19,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
19 make build-tunnel && \
20 GOOS=${TARGETOS} GOARCH=${TARGETARCH} make build-server
21
38 -FROM gcr.io/distroless/static-debian12:nonroot
22 +FROM gcr.io/distroless/static-debian12:nonroot AS api
23
24 COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
25
Makefile
+4 -5
@@ -16,11 +16,11 @@ help:
16 @echo " make fmt - Apply gofmt/goimports"
17 @echo " make lint-auto - Run autofix lint/format pipeline"
18 @echo " make test - Run Go tests"
19 - @echo " make build - Build everything (frontend, tunnel, server)"
19 + @echo " make build - Build Go tunnel and relay server artifacts"
20 @echo " make build-frontend - Build React frontend (Tailwind CSS 4)"
21 @echo " make build-docs - Build documentation site (SvelteKit)"
22 @echo " make build-tunnel - Build portal-tunnel binaries"
23 - @echo " make build-server - Build Go relay server (frontend built separately)"
23 + @echo " make build-server - Build Go relay server"
24 @echo " make run - Run relay server"
25 @echo " make clean - Remove build artifacts"
26
@@ -61,12 +61,11 @@ run:
61 ./bin/relay-server
62
63 # Convenience target
64 -build: build-frontend build-tunnel build-server
64 +build: build-tunnel build-server
65
66 # Build React frontend with Tailwind CSS 4
67 build-frontend:
68 @echo "[frontend] building React frontend..."
69 - @mkdir -p cmd/relay-server/dist/app
69 @cd frontend && npm i && npm run build
70 @echo "[frontend] build complete"
71
@@ -97,8 +96,8 @@ build-server:
96
97 clean:
98 rm -rf bin
100 - rm -rf cmd/relay-server/dist/app
99 rm -rf cmd/relay-server/dist/tunnel
100 + rm -rf frontend/dist
101
102 # Run the uniformity probe. Extra flags are passed through after the target name:
103 # make load-test -- -clients 1000 -relays 5
cmd/relay-server/admin.go
+41 -71
@@ -18,7 +18,6 @@ import (
18 )
19
20 const (
21 - cookieName = "portal_admin"
21 adminBodyLimit = 1 << 16
22 )
23
@@ -38,7 +37,7 @@ func loadAdminState(path string, server *portal.Server) (persistedAdminState, er
37 return payload, nil
38 }
39
41 -func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
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
@@ -46,47 +45,32 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
45
46 switch path {
47 case types.PathAdmin:
49 - if r.Method == http.MethodGet {
50 - f.ServeAppStatic(w, r, "")
51 - return
52 - }
48 http.NotFound(w, r)
49 return
50 case types.PathAdminAuthChallenge:
51 if !utils.RequireMethod(w, r, http.MethodPost) {
52 return
53 }
59 - f.handleWalletChallenge(w, r)
54 + api.handleWalletChallenge(w, r)
55 return
56 case types.PathAdminAuthLogin:
57 if !utils.RequireMethod(w, r, http.MethodPost) {
58 return
59 }
65 - f.handleWalletLogin(w, r)
60 + api.handleWalletLogin(w, r)
61 return
62 case types.PathAdminLogout:
63 if !utils.RequireMethod(w, r, http.MethodPost) {
64 return
65 }
71 - if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value != "" {
72 - f.auth.DeleteSession(cookie.Value)
73 - }
74 - http.SetCookie(w, &http.Cookie{
75 - Name: cookieName,
76 - Value: "",
77 - Path: types.PathAdmin,
78 - HttpOnly: true,
79 - Secure: true,
80 - SameSite: http.SameSiteStrictMode,
81 - MaxAge: -1,
82 - })
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 }
89 - walletAddress, authenticated := f.authenticatedWallet(r)
73 + walletAddress, authenticated := api.authenticatedWallet(r)
74 utils.WriteAPIData(w, http.StatusOK, types.WalletAuthStatusResponse{
75 Authenticated: authenticated,
76 WalletAddress: walletAddress,
@@ -94,12 +78,12 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
78 return
79 }
80
97 - if !f.isAuthenticated(r) {
81 + if _, ok := api.authenticatedWallet(r); !ok {
82 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
83 return
84 }
85
102 - runtime := f.server.PolicyRuntime()
86 + runtime := api.server.PolicyRuntime()
87 methodNotAllowed := utils.MethodNotAllowedError()
88 invalidRequestBody := utils.InvalidRequestError(errors.New("invalid request body"))
89
@@ -111,11 +95,11 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
95 if !utils.RequireMethod(w, r, http.MethodGet) {
96 return
97 }
114 - leases := f.server.AdminLeases()
115 - f.attachAutomaticAdminThumbnails(leases)
98 + leases := api.server.AdminLeases()
99 + api.attachAutomaticAdminThumbnails(leases)
100 utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
101 ApprovalMode: string(runtime.Approver().Mode()),
118 - LandingPageEnabled: f.isLandingPageEnabled(),
102 + LandingPageEnabled: api.landingPageEnabled.Load(),
103 Leases: leases,
104 UDP: types.AdminUDPSettingsResponse{
105 Enabled: runtime.IsUDPEnabled(),
@@ -134,21 +118,22 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
118 if !ok {
119 return
120 }
137 - f.setLandingPageEnabled(req.Enabled)
138 - saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
121 + api.landingPageEnabled.Store(req.Enabled)
122 + landingPageEnabled := api.landingPageEnabled.Load()
123 + saveAdminState(api.adminSettingsPath, runtime, landingPageEnabled)
124 utils.WriteAPIData(w, http.StatusOK, types.AdminLandingPageSettingsResponse{
140 - Enabled: f.isLandingPageEnabled(),
125 + Enabled: landingPageEnabled,
126 })
127 case types.PathAdminUDP:
143 - f.handlePortSettings(w, r, invalidRequestBody, runtime,
144 - f.server.SetUDPPolicy,
128 + api.handlePortSettings(w, r, invalidRequestBody, runtime,
129 + api.server.SetUDPPolicy,
130 func() any {
131 return types.AdminUDPSettingsResponse{Enabled: runtime.IsUDPEnabled(), MaxLeases: runtime.UDPMaxLeases()}
132 },
133 )
134 case types.PathAdminTCPPort:
150 - f.handlePortSettings(w, r, invalidRequestBody, runtime,
151 - f.server.SetTCPPortPolicy,
135 + api.handlePortSettings(w, r, invalidRequestBody, runtime,
136 + api.server.SetTCPPortPolicy,
137 func() any {
138 return types.AdminTCPPortSettingsResponse{Enabled: runtime.IsTCPPortEnabled(), MaxLeases: runtime.TCPPortMaxLeases()}
139 },
@@ -165,7 +150,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
150 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
151 return
152 }
168 - saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
153 + saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
154 utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
155 ApprovalMode: string(runtime.Approver().Mode()),
156 })
@@ -250,7 +235,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
235 methodNotAllowed.Write(w)
236 return
237 }
253 - saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
238 + saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
239 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
240 case strings.HasPrefix(path, types.PathAdminIPsPrefix):
241 if !strings.HasSuffix(path, "/ban") {
@@ -275,7 +260,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
260 methodNotAllowed.Write(w)
261 return
262 }
278 - saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
263 + saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
264 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
265 default:
266 http.NotFound(w, r)
@@ -283,12 +268,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
268 }
269 }
270
286 -type portSettingsRequest struct {
287 - Enabled bool `json:"enabled"`
288 - MaxLeases int `json:"max_leases"`
289 -}
290 -
291 -func (f *Frontend) handlePortSettings(
271 +func (api *RelayAPI) handlePortSettings(
272 w http.ResponseWriter,
273 r *http.Request,
274 invalidBody utils.APIErrorResponse,
@@ -299,7 +279,7 @@ func (f *Frontend) handlePortSettings(
279 if !utils.RequireMethod(w, r, http.MethodPost) {
280 return
281 }
302 - req, ok := utils.DecodeJSONRequestAs[portSettingsRequest](w, r, 1<<16, invalidBody)
282 + req, ok := utils.DecodeJSONRequestAs[types.AdminPortSettingsRequest](w, r, 1<<16, invalidBody)
283 if !ok {
284 return
285 }
@@ -308,16 +288,16 @@ func (f *Frontend) handlePortSettings(
288 return
289 }
290 setPolicy(req.Enabled, req.MaxLeases)
311 - saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
291 + saveAdminState(api.adminSettingsPath, runtime, api.landingPageEnabled.Load())
292 utils.WriteAPIData(w, http.StatusOK, buildResponse())
293 }
294
315 -func (f *Frontend) handleWalletChallenge(w http.ResponseWriter, r *http.Request) {
295 +func (api *RelayAPI) handleWalletChallenge(w http.ResponseWriter, r *http.Request) {
296 req, ok := utils.DecodeJSONRequestAs[types.WalletAuthChallengeRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
297 if !ok {
298 return
299 }
320 - resp, err := f.auth.IssueChallenge(req, adminAuthDomain(r, f.server.RelayIdentity().Name), adminAuthURI(r, types.PathAdminAuthLogin), time.Now().UTC())
300 + resp, err := api.auth.IssueChallenge(req, adminAuthDomain(r, api.server.RelayIdentity().Name), adminAuthURI(r, types.PathAdminAuthLogin), time.Now().UTC())
301 if err != nil {
302 writeWalletAuthError(w, err)
303 return
@@ -325,43 +305,25 @@ func (f *Frontend) handleWalletChallenge(w http.ResponseWriter, r *http.Request)
305 utils.WriteAPIData(w, http.StatusCreated, resp)
306 }
307
328 -func (f *Frontend) handleWalletLogin(w http.ResponseWriter, r *http.Request) {
308 +func (api *RelayAPI) handleWalletLogin(w http.ResponseWriter, r *http.Request) {
309 req, ok := utils.DecodeJSONRequestAs[types.WalletAuthLoginRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
310 if !ok {
311 return
312 }
333 - token, walletAddress, err := f.auth.Login(req, time.Now().UTC())
313 + token, walletAddress, err := api.auth.Login(req, time.Now().UTC())
314 if err != nil {
315 writeWalletAuthError(w, err)
316 return
317 }
318
339 - http.SetCookie(w, &http.Cookie{
340 - Name: cookieName,
341 - Value: token,
342 - Path: types.PathAdmin,
343 - HttpOnly: true,
344 - Secure: true,
345 - SameSite: http.SameSiteStrictMode,
346 - MaxAge: 86400,
319 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{
320 + AccessToken: token,
321 + WalletAddress: walletAddress,
322 })
348 - utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{WalletAddress: walletAddress})
349 -}
350 -
351 -func (f *Frontend) isAuthenticated(r *http.Request) bool {
352 - _, ok := f.authenticatedWallet(r)
353 - return ok
323 }
324
356 -func (f *Frontend) authenticatedWallet(r *http.Request) (string, bool) {
357 - if f.auth == nil {
358 - return "", false
359 - }
360 - cookie, err := r.Cookie(cookieName)
361 - if err != nil {
362 - return "", false
363 - }
364 - return f.auth.ValidateSession(cookie.Value)
325 +func (api *RelayAPI) authenticatedWallet(r *http.Request) (string, bool) {
326 + return api.auth.ValidateSession(adminAccessToken(r))
327 }
328
329 func adminAuthDomain(r *http.Request, fallback string) string {
@@ -384,6 +346,14 @@ func adminAuthURI(r *http.Request, endpointPath string) string {
346 }).String()
347 }
348
349 +func adminAccessToken(r *http.Request) string {
350 + parts := strings.Fields(r.Header.Get("Authorization"))
351 + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
352 + return ""
353 + }
354 + return strings.TrimSpace(parts[1])
355 +}
356 +
357 func writeWalletAuthError(w http.ResponseWriter, err error) {
358 switch {
359 case errors.Is(err, auth.ErrWalletAuthUnauthorized):
cmd/relay-server/api.go new
+278
@@ -0,0 +1,278 @@
1 +package main
2 +
3 +import (
4 + "crypto/sha256"
5 + "embed"
6 + "encoding/hex"
7 + "errors"
8 + "fmt"
9 + "net/http"
10 + "strings"
11 + "sync/atomic"
12 +
13 + "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
14 + "github.com/gosuda/portal-tunnel/v2/portal"
15 + "github.com/gosuda/portal-tunnel/v2/portal/auth"
16 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
17 + "github.com/gosuda/portal-tunnel/v2/types"
18 + "github.com/gosuda/portal-tunnel/v2/utils"
19 +)
20 +
21 +//go:embed dist/*
22 +var embeddedDistFS embed.FS
23 +
24 +type RelayAPI struct {
25 + server *portal.Server
26 + auth *auth.WalletAuthenticator
27 + adminSettingsPath string
28 + thumbnails *thumbnailService
29 +
30 + landingPageEnabled atomic.Bool
31 +}
32 +
33 +func NewRelayAPI(server *portal.Server, identityPath string, defaultLandingPageEnabled bool, headlessShellURL string, adminWallets []string) (*RelayAPI, error) {
34 + if server == nil {
35 + return nil, errors.New("relay api requires portal server")
36 + }
37 + runtime := server.PolicyRuntime()
38 + if runtime == nil {
39 + return nil, errors.New("relay api requires policy runtime")
40 + }
41 + adminSettingsPath := identity.ResolveRelayAdminSettingsPath(identityPath)
42 + if adminSettingsPath == "" {
43 + return nil, errors.New("relay api requires identity path")
44 + }
45 + state, err := loadAdminState(adminSettingsPath, server)
46 + if err != nil {
47 + return nil, err
48 + }
49 + relayIdentity := server.RelayIdentity()
50 + allowedWallets := append([]string{relayIdentity.Address}, adminWallets...)
51 + authenticator, err := auth.NewWalletAuthenticator(auth.WalletAuthConfig{
52 + AllowedAddresses: allowedWallets,
53 + Statement: "Sign in to Portal relay admin",
54 + })
55 + if err != nil {
56 + return nil, err
57 + }
58 +
59 + api := &RelayAPI{
60 + server: server,
61 + auth: authenticator,
62 + adminSettingsPath: strings.TrimSpace(adminSettingsPath),
63 + thumbnails: newThumbnailService(headlessShellURL),
64 + }
65 + landingPageEnabled := defaultLandingPageEnabled
66 + if state.LandingPageEnabled != nil {
67 + landingPageEnabled = *state.LandingPageEnabled
68 + }
69 + api.landingPageEnabled.Store(landingPageEnabled)
70 + return api, nil
71 +}
72 +
73 +func (api *RelayAPI) Handler() *http.ServeMux {
74 + mux := http.NewServeMux()
75 +
76 + mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
77 + if !utils.RequireMethod(w, r, http.MethodGet) {
78 + return
79 + }
80 + utils.WriteAPIData(w, http.StatusOK, map[string]any{
81 + "service": "portal-relay",
82 + "root": api.server.RelayIdentity().Name,
83 + })
84 + })
85 + mux.HandleFunc(types.PathAdmin, api.serveAdmin)
86 + mux.HandleFunc(types.PathAdminPrefix, api.serveAdmin)
87 + mux.HandleFunc(types.PathPublicSnapshot, api.servePublicSnapshot)
88 + mux.HandleFunc(types.PathTunnelStatus, api.serveTunnelStatus)
89 + mux.HandleFunc(types.PathThumbnailPrefix, api.serveThumbnail)
90 + mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
91 + serveInstallScript(w, r, api.server.PortalURL(), false)
92 + })
93 + mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
94 + serveInstallScript(w, r, api.server.PortalURL(), true)
95 + })
96 + mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
97 +
98 + return mux
99 +}
100 +
101 +func (api *RelayAPI) servePublicSnapshot(w http.ResponseWriter, r *http.Request) {
102 + if !utils.RequireMethod(w, r, http.MethodGet) {
103 + return
104 + }
105 +
106 + leases := api.server.PublicLeases()
107 + api.attachAutomaticThumbnails(leases)
108 + utils.WriteAPIData(w, http.StatusOK, types.PublicSnapshotResponse{
109 + Leases: leases,
110 + LandingPageEnabled: api.landingPageEnabled.Load(),
111 + })
112 +}
113 +
114 +func (api *RelayAPI) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
115 + if !utils.RequireMethod(w, r, http.MethodGet) {
116 + return
117 + }
118 +
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
123 + }
124 +
125 + resp := types.TunnelStatusResponse{
126 + Hostname: hostname,
127 + }
128 + if lease, ok := api.publicLeaseByHostname(hostname); ok {
129 + resp.Hostname = lease.Hostname
130 + resp.Registered = true
131 + resp.ServiceAlive = lease.Ready > 0
132 + }
133 + utils.WriteAPIData(w, http.StatusOK, resp)
134 +}
135 +
136 +func (api *RelayAPI) serveThumbnail(w http.ResponseWriter, r *http.Request) {
137 + if !utils.RequireMethod(w, r, http.MethodGet) {
138 + return
139 + }
140 +
141 + hostname := strings.TrimPrefix(r.URL.Path, types.PathThumbnailPrefix)
142 + hostname = strings.TrimSpace(strings.ToLower(hostname))
143 + if hostname == "" || api.thumbnails == nil {
144 + http.NotFound(w, r)
145 + return
146 + }
147 +
148 + lease, ok := api.publicLeaseByHostname(hostname)
149 + if !ok || lease.Metadata.Thumbnail != "" {
150 + api.thumbnails.remove(hostname)
151 + http.NotFound(w, r)
152 + return
153 + }
154 +
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)
161 + return
162 + }
163 + }
164 +
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)
169 +}
170 +
171 +func (api *RelayAPI) publicLeaseByHostname(hostname string) (types.Lease, bool) {
172 + hostname = utils.NormalizeHostname(hostname)
173 + if hostname == "" {
174 + return types.Lease{}, false
175 + }
176 + for _, lease := range api.server.PublicLeases() {
177 + if utils.HostnameMatchesPattern(lease.Hostname, hostname) {
178 + return lease, true
179 + }
180 + }
181 + return types.Lease{}, false
182 +}
183 +
184 +func (api *RelayAPI) attachAutomaticThumbnails(leases []types.Lease) {
185 + for i := range leases {
186 + api.attachAutomaticThumbnail(leases[i].Hostname, &leases[i].Metadata)
187 + }
188 +}
189 +
190 +func (api *RelayAPI) attachAutomaticAdminThumbnails(leases []types.AdminLease) {
191 + for i := range leases {
192 + api.attachAutomaticThumbnail(leases[i].Hostname, &leases[i].Metadata)
193 + }
194 +}
195 +
196 +func (api *RelayAPI) attachAutomaticThumbnail(hostname string, metadata *types.LeaseMetadata) {
197 + if api.thumbnails == nil {
198 + return
199 + }
200 + if hostname == "" || metadata == nil || metadata.Thumbnail != "" {
201 + return
202 + }
203 + metadata.Thumbnail = types.PathThumbnailPrefix + hostname
204 + api.thumbnails.triggerAsync(hostname)
205 +}
206 +
207 +func (api *RelayAPI) Close() {
208 + if api.thumbnails == nil {
209 + return
210 + }
211 + api.thumbnails.close()
212 +}
213 +
214 +func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
215 + if r.Method != http.MethodGet && r.Method != http.MethodHead {
216 + w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
217 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
218 + return
219 + }
220 +
221 + slug := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathInstallBinPrefix), "/")
222 + checksumRequest := strings.HasSuffix(slug, ".sha256")
223 + if checksumRequest {
224 + slug = strings.TrimSuffix(slug, ".sha256")
225 + }
226 +
227 + filename, ok := installer.AssetFilename(slug)
228 + if !ok {
229 + http.NotFound(w, r)
230 + return
231 + }
232 + data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename)
233 + if err != nil {
234 + redirectURL := types.OfficialReleaseBaseURL + "/latest/download/" + filename
235 + if checksumRequest {
236 + redirectURL += ".sha256"
237 + }
238 + http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
239 + return
240 + }
241 + sum := sha256.Sum256(data)
242 + checksumHex := hex.EncodeToString(sum[:])
243 +
244 + if checksumRequest {
245 + w.Header().Set("Content-Type", "text/plain; charset=utf-8")
246 + if r.Method == http.MethodGet {
247 + _, _ = fmt.Fprintf(w, "%s %s\n", checksumHex, filename)
248 + }
249 + return
250 + }
251 +
252 + w.Header().Set("Content-Type", "application/octet-stream")
253 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
254 + w.Header().Set("X-Checksum-Sha256", checksumHex)
255 + if r.Method == http.MethodGet {
256 + _, _ = w.Write(data)
257 + }
258 +}
259 +
260 +func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string, isWindows bool) {
261 + if r.Method != http.MethodGet && r.Method != http.MethodHead {
262 + w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
263 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
264 + return
265 + }
266 +
267 + script, filename, contentType, err := installer.RelayScript(portalURL, isWindows)
268 + if err != nil {
269 + http.Error(w, "failed to render install script", http.StatusInternalServerError)
270 + return
271 + }
272 +
273 + w.Header().Set("Content-Type", contentType)
274 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
275 + if r.Method == http.MethodGet {
276 + _, _ = w.Write([]byte(script))
277 + }
278 +}
cmd/relay-server/frontend.go deleted
-444
@@ -1,444 +0,0 @@
1 -package main
2 -
3 -import (
4 - "crypto/sha256"
5 - "embed"
6 - "encoding/hex"
7 - "encoding/json"
8 - "errors"
9 - "fmt"
10 - "html"
11 - "io/fs"
12 - "mime"
13 - "net/http"
14 - "path"
15 - "strconv"
16 - "strings"
17 - "sync"
18 - "sync/atomic"
19 -
20 - "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
21 - "github.com/gosuda/portal-tunnel/v2/portal"
22 - "github.com/gosuda/portal-tunnel/v2/portal/auth"
23 - "github.com/gosuda/portal-tunnel/v2/portal/identity"
24 - "github.com/gosuda/portal-tunnel/v2/types"
25 - "github.com/gosuda/portal-tunnel/v2/utils"
26 -)
27 -
28 -type readDirFileFS interface {
29 - fs.ReadFileFS
30 - fs.ReadDirFS
31 -}
32 -
33 -//go:embed dist/*
34 -var embeddedDistFS embed.FS
35 -
36 -type Frontend struct {
37 - distFS readDirFileFS
38 - server *portal.Server
39 - auth *auth.WalletAuthenticator
40 - adminSettingsPath string
41 - thumbnails *thumbnailService
42 -
43 - cachedPortalHTML []byte
44 - cachedPortalHTMLOnce sync.Once
45 - landingPageEnabled atomic.Bool
46 -}
47 -
48 -func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageEnabled bool, headlessShellURL string, adminWallets []string) (*Frontend, error) {
49 - if server == nil {
50 - return nil, errors.New("frontend requires portal server")
51 - }
52 - runtime := server.PolicyRuntime()
53 - if runtime == nil {
54 - return nil, errors.New("frontend requires policy runtime")
55 - }
56 - adminSettingsPath := identity.ResolveRelayAdminSettingsPath(identityPath)
57 - if adminSettingsPath == "" {
58 - return nil, errors.New("frontend requires identity path")
59 - }
60 - state, err := loadAdminState(adminSettingsPath, server)
61 - if err != nil {
62 - return nil, err
63 - }
64 - relayIdentity := server.RelayIdentity()
65 - allowedWallets := append([]string{relayIdentity.Address}, adminWallets...)
66 - authenticator, err := auth.NewWalletAuthenticator(auth.WalletAuthConfig{
67 - AllowedAddresses: allowedWallets,
68 - Statement: "Sign in to Portal relay admin",
69 - })
70 - if err != nil {
71 - return nil, err
72 - }
73 -
74 - frontend := &Frontend{
75 - distFS: embeddedDistFS,
76 - server: server,
77 - auth: authenticator,
78 - adminSettingsPath: strings.TrimSpace(adminSettingsPath),
79 - thumbnails: newThumbnailService(headlessShellURL),
80 - }
81 - landingPageEnabled := defaultLandingPageEnabled
82 - if state.LandingPageEnabled != nil {
83 - landingPageEnabled = *state.LandingPageEnabled
84 - }
85 - frontend.setLandingPageEnabled(landingPageEnabled)
86 - return frontend, nil
87 -}
88 -
89 -func (f *Frontend) Handler() *http.ServeMux {
90 - mux := http.NewServeMux()
91 -
92 - mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
93 - f.ServeAppStatic(w, r, "")
94 - })
95 - mux.HandleFunc(types.PathApp, func(w http.ResponseWriter, r *http.Request) {
96 - f.ServeAppStatic(w, r, "")
97 - })
98 - mux.HandleFunc(types.PathAppPrefix, func(w http.ResponseWriter, r *http.Request) {
99 - f.ServeAppStatic(w, r, strings.TrimPrefix(r.URL.Path, types.PathAppPrefix))
100 - })
101 - mux.HandleFunc(types.PathAssetsPrefix, func(w http.ResponseWriter, r *http.Request) {
102 - f.ServeAsset(w, r, strings.TrimPrefix(r.URL.Path, "/"), "")
103 - })
104 - for _, assetPath := range []string{
105 - "/favicon.ico",
106 - "/favicon.svg",
107 - "/favicon-96x96.png",
108 - "/apple-touch-icon.png",
109 - "/web-app-manifest-192x192.png",
110 - "/web-app-manifest-512x512.png",
111 - } {
112 - mux.HandleFunc(assetPath, func(w http.ResponseWriter, r *http.Request) {
113 - f.ServeAsset(w, r, strings.TrimPrefix(assetPath, "/"), "")
114 - })
115 - }
116 -
117 - mux.HandleFunc(types.PathAdmin, f.serveAdmin)
118 - mux.HandleFunc(types.PathAdminPrefix, f.serveAdmin)
119 - mux.HandleFunc(types.PathTunnelStatus, f.serveTunnelStatus)
120 - mux.HandleFunc(types.PathThumbnailPrefix, f.serveThumbnail)
121 - mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
122 - serveInstallScript(w, r, f.server.PortalURL(), false)
123 - })
124 - mux.HandleFunc(types.PathInstallPowerShell, func(w http.ResponseWriter, r *http.Request) {
125 - serveInstallScript(w, r, f.server.PortalURL(), true)
126 - })
127 - mux.HandleFunc(types.PathInstallBinPrefix, serveInstallBinary)
128 -
129 - return mux
130 -}
131 -
132 -func (f *Frontend) ServeAsset(w http.ResponseWriter, r *http.Request, assetPath, contentType string) {
133 - assetPath, ok := cleanFrontendPath(assetPath)
134 - if !ok {
135 - http.NotFound(w, r)
136 - return
137 - }
138 -
139 - fullPath := path.Join("dist", "app", assetPath)
140 - data, err := f.distFS.ReadFile(fullPath)
141 - if err != nil {
142 - http.NotFound(w, r)
143 - return
144 - }
145 - if contentType == "" {
146 - contentType = getContentType(path.Ext(assetPath))
147 - }
148 - if contentType != "" {
149 - w.Header().Set("Content-Type", contentType)
150 - }
151 - w.Header().Set("Cache-Control", "public, max-age=3600")
152 - w.WriteHeader(http.StatusOK)
153 - _, _ = w.Write(data)
154 -}
155 -
156 -func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPath string) {
157 - appPath, ok := cleanFrontendPath(appPath)
158 - if !ok {
159 - http.NotFound(w, r)
160 - return
161 - }
162 - if appPath == "" {
163 - f.servePortalHTMLWithSSR(w)
164 - return
165 - }
166 -
167 - fullPath := path.Join("dist", "app", appPath)
168 - data, err := f.distFS.ReadFile(fullPath)
169 - if err != nil {
170 - if path.Ext(appPath) != "" {
171 - http.NotFound(w, r)
172 - return
173 - }
174 - f.servePortalHTMLWithSSR(w)
175 - return
176 - }
177 -
178 - if contentType := getContentType(path.Ext(appPath)); contentType != "" {
179 - w.Header().Set("Content-Type", contentType)
180 - }
181 - w.Header().Set("Cache-Control", "public, max-age=3600")
182 - w.WriteHeader(http.StatusOK)
183 - _, _ = w.Write(data)
184 -}
185 -
186 -func cleanFrontendPath(raw string) (string, bool) {
187 - raw = strings.TrimSpace(raw)
188 - if raw == "" {
189 - return "", true
190 - }
191 -
192 - cleaned := strings.TrimPrefix(path.Clean("/"+raw), "/")
193 - if cleaned == "." || cleaned == "" {
194 - return "", true
195 - }
196 - if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
197 - return "", false
198 - }
199 - return cleaned, true
200 -}
201 -
202 -func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter) {
203 - f.cachedPortalHTMLOnce.Do(func() {
204 - f.cachedPortalHTML, _ = f.distFS.ReadFile("dist/app/portal.html")
205 - })
206 -
207 - if len(f.cachedPortalHTML) == 0 {
208 - http.NotFound(w, nil)
209 - return
210 - }
211 -
212 - htmlContent := string(f.cachedPortalHTML)
213 - htmlContent = f.injectServerData(htmlContent)
214 - htmlContent = f.injectOGMetadata(htmlContent, "", "")
215 -
216 - w.Header().Set("Content-Type", "text/html; charset=utf-8")
217 - w.Header().Set("Cache-Control", "no-cache, must-revalidate")
218 - w.WriteHeader(http.StatusOK)
219 - _, _ = w.Write([]byte(htmlContent))
220 -}
221 -
222 -func (f *Frontend) injectServerData(htmlContent string) string {
223 - var leases []types.Lease
224 - if f.server != nil {
225 - leases = f.server.PublicLeases()
226 - f.attachAutomaticThumbnails(leases)
227 - }
228 - jsonData, err := json.Marshal(leases)
229 - if err != nil {
230 - jsonData = []byte("[]")
231 - }
232 - ssrScript := `<script id="__SSR_DATA__" type="application/json">` + string(jsonData) + `</script>`
233 - return strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
234 -}
235 -
236 -func (f *Frontend) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
237 - if !utils.RequireMethod(w, r, http.MethodGet) {
238 - return
239 - }
240 -
241 - hostname := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hostname")))
242 - if hostname == "" {
243 - utils.InvalidRequestError(errors.New("hostname is required")).Write(w)
244 - return
245 - }
246 -
247 - resp := types.TunnelStatusResponse{
248 - Hostname: hostname,
249 - }
250 - if lease, ok := f.publicLeaseByHostname(hostname); ok {
251 - resp.Hostname = lease.Hostname
252 - resp.Registered = true
253 - resp.ServiceAlive = lease.Ready > 0
254 - }
255 - utils.WriteAPIData(w, http.StatusOK, resp)
256 -}
257 -
258 -func (f *Frontend) serveThumbnail(w http.ResponseWriter, r *http.Request) {
259 - if !utils.RequireMethod(w, r, http.MethodGet) {
260 - return
261 - }
262 -
263 - hostname := strings.TrimPrefix(r.URL.Path, types.PathThumbnailPrefix)
264 - hostname = strings.TrimSpace(strings.ToLower(hostname))
265 - if hostname == "" || f.server == nil || f.thumbnails == nil {
266 - http.NotFound(w, r)
267 - return
268 - }
269 -
270 - lease, ok := f.publicLeaseByHostname(hostname)
271 - if !ok || lease.Metadata.Thumbnail != "" {
272 - f.thumbnails.remove(hostname)
273 - http.NotFound(w, r)
274 - return
275 - }
276 -
277 - data, contentType, ok := f.thumbnails.get(hostname)
278 - if !ok {
279 - var err error
280 - data, contentType, err = f.thumbnails.load(hostname)
281 - if err != nil {
282 - http.NotFound(w, r)
283 - return
284 - }
285 - }
286 -
287 - w.Header().Set("Content-Type", contentType)
288 - w.Header().Set("Cache-Control", "public, max-age=300")
289 - w.WriteHeader(http.StatusOK)
290 - _, _ = w.Write(data)
291 -}
292 -
293 -func (f *Frontend) publicLeaseByHostname(hostname string) (types.Lease, bool) {
294 - if f == nil || f.server == nil {
295 - return types.Lease{}, false
296 - }
297 - hostname = utils.NormalizeHostname(hostname)
298 - if hostname == "" {
299 - return types.Lease{}, false
300 - }
301 - for _, lease := range f.server.PublicLeases() {
302 - if utils.HostnameMatchesPattern(lease.Hostname, hostname) {
303 - return lease, true
304 - }
305 - }
306 - return types.Lease{}, false
307 -}
308 -
309 -func (f *Frontend) attachAutomaticThumbnails(leases []types.Lease) {
310 - for i := range leases {
311 - f.attachAutomaticThumbnail(leases[i].Hostname, &leases[i].Metadata)
312 - }
313 -}
314 -
315 -func (f *Frontend) attachAutomaticAdminThumbnails(leases []types.AdminLease) {
316 - for i := range leases {
317 - f.attachAutomaticThumbnail(leases[i].Hostname, &leases[i].Metadata)
318 - }
319 -}
320 -
321 -func (f *Frontend) attachAutomaticThumbnail(hostname string, metadata *types.LeaseMetadata) {
322 - if f == nil || f.thumbnails == nil {
323 - return
324 - }
325 - if hostname == "" || metadata == nil || metadata.Thumbnail != "" {
326 - return
327 - }
328 - metadata.Thumbnail = types.PathThumbnailPrefix + hostname
329 - f.thumbnails.triggerAsync(hostname)
330 -}
331 -
332 -func (f *Frontend) injectOGMetadata(htmlContent, title, description string) string {
333 - if title == "" {
334 - title = "Portal Proxy Gateway"
335 - }
336 - if description == "" {
337 - description = "Transform your local services into web-accessible endpoints. Instant access from anywhere."
338 - }
339 -
340 - replacer := strings.NewReplacer(
341 - "[%OG_TITLE%]", html.EscapeString(title),
342 - "[%OG_DESCRIPTION%]", html.EscapeString(description),
343 - "[%LANDING_PAGE_ENABLED%]", html.EscapeString(strconv.FormatBool(f.isLandingPageEnabled())),
344 - "[%RELEASE_VERSION%]", html.EscapeString(types.ReleaseVersion),
345 - )
346 - return replacer.Replace(htmlContent)
347 -}
348 -
349 -func (f *Frontend) isLandingPageEnabled() bool {
350 - if f == nil {
351 - return false
352 - }
353 - return f.landingPageEnabled.Load()
354 -}
355 -
356 -func (f *Frontend) setLandingPageEnabled(enabled bool) {
357 - if f == nil {
358 - return
359 - }
360 - f.landingPageEnabled.Store(enabled)
361 -}
362 -
363 -func (f *Frontend) Close() {
364 - if f == nil || f.thumbnails == nil {
365 - return
366 - }
367 - f.thumbnails.close()
368 -}
369 -
370 -func getContentType(ext string) string {
371 - if ct := mime.TypeByExtension(ext); ct != "" {
372 - return ct
373 - }
374 - if ext == ".webmanifest" {
375 - return "application/json; charset=utf-8"
376 - }
377 - return ""
378 -}
379 -
380 -func serveInstallBinary(w http.ResponseWriter, r *http.Request) {
381 - if r.Method != http.MethodGet && r.Method != http.MethodHead {
382 - w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
383 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
384 - return
385 - }
386 -
387 - slug := strings.Trim(strings.TrimPrefix(r.URL.Path, types.PathInstallBinPrefix), "/")
388 - checksumRequest := strings.HasSuffix(slug, ".sha256")
389 - if checksumRequest {
390 - slug = strings.TrimSuffix(slug, ".sha256")
391 - }
392 -
393 - filename, ok := installer.AssetFilename(slug)
394 - if !ok {
395 - http.NotFound(w, r)
396 - return
397 - }
398 - data, err := embeddedDistFS.ReadFile("dist/tunnel/" + filename)
399 - if err != nil {
400 - redirectURL := types.OfficialReleaseBaseURL + "/latest/download/" + filename
401 - if checksumRequest {
402 - redirectURL += ".sha256"
403 - }
404 - http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
405 - return
406 - }
407 - sum := sha256.Sum256(data)
408 - checksumHex := hex.EncodeToString(sum[:])
409 -
410 - if checksumRequest {
411 - w.Header().Set("Content-Type", "text/plain; charset=utf-8")
412 - if r.Method == http.MethodGet {
413 - _, _ = fmt.Fprintf(w, "%s %s\n", checksumHex, filename)
414 - }
415 - return
416 - }
417 -
418 - w.Header().Set("Content-Type", "application/octet-stream")
419 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
420 - w.Header().Set("X-Checksum-Sha256", checksumHex)
421 - if r.Method == http.MethodGet {
422 - _, _ = w.Write(data)
423 - }
424 -}
425 -
426 -func serveInstallScript(w http.ResponseWriter, r *http.Request, portalURL string, isWindows bool) {
427 - if r.Method != http.MethodGet && r.Method != http.MethodHead {
428 - w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
429 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
430 - return
431 - }
432 -
433 - script, filename, contentType, err := installer.RelayScript(portalURL, isWindows)
434 - if err != nil {
435 - http.Error(w, "failed to render install script", http.StatusInternalServerError)
436 - return
437 - }
438 -
439 - w.Header().Set("Content-Type", contentType)
440 - w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
441 - if r.Method == http.MethodGet {
442 - _, _ = w.Write([]byte(script))
443 - }
444 -}
cmd/relay-server/main.go
+4 -4
@@ -201,13 +201,13 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
201 return fmt.Errorf("create relay server: %w", err)
202 }
203
204 - frontend, err := NewFrontend(server, cfg.IdentityPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL, utils.SplitCSV(cfg.AdminWallets))
204 + relayAPI, err := NewRelayAPI(server, cfg.IdentityPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL, utils.SplitCSV(cfg.AdminWallets))
205 if err != nil {
206 - return fmt.Errorf("create frontend: %w", err)
206 + return fmt.Errorf("create relay api: %w", err)
207 }
208 - defer frontend.Close()
208 + defer relayAPI.Close()
209
210 - apiMux := frontend.Handler()
210 + apiMux := relayAPI.Handler()
211 if cfg.X402Enabled {
212 relayIdentity := server.RelayIdentity()
213 if err := portalx402.MountFacilitator(apiMux, portalx402.FacilitatorConfig{
docker-compose.yml
+14 -4
@@ -5,7 +5,7 @@ services:
5 # image: chromedp/headless-shell:stable
6 # restart: unless-stopped
7
8 - portal:
8 + portal-api:
9 image: ghcr.io/gosuda/portal:latest
10 build:
11 context: .
@@ -14,7 +14,7 @@ services:
14 # - headless-shell
15 stop_grace_period: 30s
16 ports:
17 - - "${API_PORT:-4017}:${API_PORT:-4017}"
17 + - "${API_PORT:-4017}:4017"
18 - "${SNI_PORT:-443}:${SNI_PORT:-443}"
19 - "${WIREGUARD_PORT:-51820}:${WIREGUARD_PORT:-51820}/udp"
20 # Uncomment for UDP backhaul, public UDP lease ports, and raw TCP lease ports as needed.
@@ -30,8 +30,7 @@ services:
30 DISCOVERY: ${DISCOVERY:-true}
31 IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs}
32
33 - # Listener ports (published to the host below)
34 - API_PORT: ${API_PORT:-4017}
33 + API_PORT: 4017
34 SNI_PORT: ${SNI_PORT:-443}
35 WIREGUARD_PORT: ${WIREGUARD_PORT:-51820}
36
@@ -80,3 +79,14 @@ services:
79 # Uncomment when using a Google Cloud service account file for gcloud automation.
80 # - ./gcp-dns.json:/run/secrets/gcp-dns.json:ro
81 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-api
90 + ports:
91 + - "${FRONTEND_PORT:-8080}:8080"
92 + restart: unless-stopped
docs/src/routes/api-reference/+page.md
+39 -21
@@ -54,16 +54,16 @@ SDK clients authenticate using Sign-In with Ethereum (SIWE):
54 3. POST the signed message to `/sdk/register` to receive a JWT access token
55 4. Include the access token in subsequent requests via the `X-Portal-Access-Token` header or in the JSON request body
56
57 -### Admin Authentication (Wallet Session)
57 +### Admin Authentication (Wallet Bearer Token)
58
59 Admin clients authenticate with a wallet signature:
60
61 1. POST to `/admin/auth/challenge` with `{ "address": "<wallet-address>" }`
62 2. Sign the returned SIWE message with the wallet
63 3. POST the signed message to `/admin/auth/login`
64 -4. The server sets a `portal_admin` session cookie (HttpOnly, Secure, SameSite=Strict)
65 -5. Include the cookie in subsequent admin requests
66 -6. Sessions expire after 24 hours
64 +4. Store the returned `access_token`
65 +5. Include it in subsequent admin requests as `Authorization: Bearer <access_token>`
66 +6. Tokens expire after 24 hours
67
68 The local agent has its own loopback wallet auth endpoints under
69 `/v1/agent/auth/*`. Agent wallet sessions can read `/v1/agent/status`; mutating
@@ -89,23 +89,23 @@ agent actions require the bearer token stored in the agent state directory. See
89 |--------|------|-------------|------|
90 | `POST` | [`/admin/auth/challenge`](/api-reference/admin#post-adminauthchallenge) | Request wallet login challenge | None |
91 | `POST` | [`/admin/auth/login`](/api-reference/admin#post-adminauthlogin) | Complete wallet login | None |
92 -| `POST` | [`/admin/logout`](/api-reference/admin#post-adminlogout) | End admin session | Session Cookie |
92 +| `POST` | [`/admin/logout`](/api-reference/admin#post-adminlogout) | Invalidate admin token | Bearer Token |
93 | `GET` | [`/admin/auth/status`](/api-reference/admin#get-adminauthstatus) | Check authentication status | None |
94 -| `GET` | [`/admin/snapshot`](/api-reference/admin#get-adminsnapshot) | Get full relay state snapshot | Session Cookie |
95 -| `POST` | [`/admin/settings/landing-page`](/api-reference/admin#post-adminsettingslanding-page) | Toggle landing page | Session Cookie |
96 -| `POST` | [`/admin/settings/udp`](/api-reference/admin#post-adminsettingsudp) | Configure UDP settings | Session Cookie |
97 -| `POST` | [`/admin/settings/tcp-port`](/api-reference/admin#post-adminsettingstcp-port) | Configure TCP port settings | Session Cookie |
98 -| `POST` | [`/admin/settings/approval-mode`](/api-reference/admin#post-adminsettingsapproval-mode) | Set approval mode | Session Cookie |
99 -| `POST` | [`/admin/leases/{name}/{addr}/ban`](/api-reference/admin#lease-management) | Ban a lease identity | Session Cookie |
100 -| `DELETE` | [`/admin/leases/{name}/{addr}/ban`](/api-reference/admin#lease-management) | Unban a lease identity | Session Cookie |
101 -| `POST` | [`/admin/leases/{name}/{addr}/bps`](/api-reference/admin#lease-management) | Set bandwidth limit for a lease | Session Cookie |
102 -| `DELETE` | [`/admin/leases/{name}/{addr}/bps`](/api-reference/admin#lease-management) | Remove bandwidth limit | Session Cookie |
103 -| `POST` | [`/admin/leases/{name}/{addr}/approve`](/api-reference/admin#lease-management) | Approve a lease | Session Cookie |
104 -| `DELETE` | [`/admin/leases/{name}/{addr}/approve`](/api-reference/admin#lease-management) | Revoke lease approval | Session Cookie |
105 -| `POST` | [`/admin/leases/{name}/{addr}/deny`](/api-reference/admin#lease-management) | Deny a lease | Session Cookie |
106 -| `DELETE` | [`/admin/leases/{name}/{addr}/deny`](/api-reference/admin#lease-management) | Remove lease denial | Session Cookie |
107 -| `POST` | [`/admin/ips/{ip}/ban`](/api-reference/admin#ip-management) | Ban an IP address | Session Cookie |
108 -| `DELETE` | [`/admin/ips/{ip}/ban`](/api-reference/admin#ip-management) | Unban an IP address | Session Cookie |
94 +| `GET` | [`/admin/snapshot`](/api-reference/admin#get-adminsnapshot) | Get full relay state snapshot | Bearer Token |
95 +| `POST` | [`/admin/settings/landing-page`](/api-reference/admin#post-adminsettingslanding-page) | Toggle landing page | Bearer Token |
96 +| `POST` | [`/admin/settings/udp`](/api-reference/admin#post-adminsettingsudp) | Configure UDP settings | Bearer Token |
97 +| `POST` | [`/admin/settings/tcp-port`](/api-reference/admin#post-adminsettingstcp-port) | Configure TCP port settings | Bearer Token |
98 +| `POST` | [`/admin/settings/approval-mode`](/api-reference/admin#post-adminsettingsapproval-mode) | Set approval mode | Bearer Token |
99 +| `POST` | [`/admin/leases/{name}/{addr}/ban`](/api-reference/admin#lease-management) | Ban a lease identity | Bearer Token |
100 +| `DELETE` | [`/admin/leases/{name}/{addr}/ban`](/api-reference/admin#lease-management) | Unban a lease identity | Bearer Token |
101 +| `POST` | [`/admin/leases/{name}/{addr}/bps`](/api-reference/admin#lease-management) | Set bandwidth limit for a lease | Bearer Token |
102 +| `DELETE` | [`/admin/leases/{name}/{addr}/bps`](/api-reference/admin#lease-management) | Remove bandwidth limit | Bearer Token |
103 +| `POST` | [`/admin/leases/{name}/{addr}/approve`](/api-reference/admin#lease-management) | Approve a lease | Bearer Token |
104 +| `DELETE` | [`/admin/leases/{name}/{addr}/approve`](/api-reference/admin#lease-management) | Revoke lease approval | Bearer Token |
105 +| `POST` | [`/admin/leases/{name}/{addr}/deny`](/api-reference/admin#lease-management) | Deny a lease | Bearer Token |
106 +| `DELETE` | [`/admin/leases/{name}/{addr}/deny`](/api-reference/admin#lease-management) | Remove lease denial | Bearer Token |
107 +| `POST` | [`/admin/ips/{ip}/ban`](/api-reference/admin#ip-management) | Ban an IP address | Bearer Token |
108 +| `DELETE` | [`/admin/ips/{ip}/ban`](/api-reference/admin#ip-management) | Unban an IP address | Bearer Token |
109
110 ### System Endpoints
111
@@ -115,8 +115,9 @@ agent actions require the bearer token stored in the agent state directory. See
115 | `GET` | `/discovery` | Relay discovery | None |
116 | `POST` | `/discovery/announce` | Relay discovery self-announce | Signed Descriptor |
117 | `POST` | `/v1/sign` | Keyless TLS signing | Access Token |
118 +| `GET` | `/api/public/snapshot` | Public frontend snapshot | None |
119 | `GET` | `/thumbnail/{hostname}` | Cached thumbnail screenshot | None |
119 -| `GET` | `/tunnel/status` | Tunnel connection status | Access Token |
120 +| `GET` | `/tunnel/status` | Tunnel connection status | None |
121
122 ## System Endpoints
123
@@ -207,6 +208,23 @@ Only available when the API server is configured with a TLS private key.
208
209 Returns `404 Not Found` if signing is not configured.
210
211 +### `GET /api/public/snapshot`
212 +
213 +Returns the public relay dashboard snapshot used by frontends.
214 +
215 +**Response fields:**
216 +
217 +| Field | Type | Description |
218 +|-------|------|-------------|
219 +| `leases` | `Lease[]` | Public lease rows visible on the relay index |
220 +| `landing_page_enabled` | `boolean` | Whether the public landing hero should be shown |
221 +
222 +**Example:**
223 +
224 +```bash
225 +curl https://relay.example.com/api/public/snapshot
226 +```
227 +
228 ### `GET /thumbnail/{hostname}`
229
230 Returns a cached thumbnail screenshot for a registered tunnel hostname.
docs/src/routes/api-reference/admin/+page.md
+38 -42
@@ -13,8 +13,9 @@ const adminWorkflowDiagram = `sequenceDiagram
13 Relay->>Admin: SIWE message
14 Admin->>Relay: POST /admin/auth/login
15 Note right of Admin: wallet signature in body
16 - Relay->>Admin: Set-Cookie session token
16 + Relay->>Admin: access_token
17 Admin->>Relay: GET /admin/snapshot
18 + Note right of Admin: Authorization: Bearer ...
19 Relay->>Admin: Full relay state
20 Note left of Relay: leases, settings, bans
21 alt Manage Leases
@@ -26,12 +27,12 @@ const adminWorkflowDiagram = `sequenceDiagram
27 Relay->>Admin: Updated settings
28 end
29 Admin->>Relay: POST /admin/logout
29 - Relay->>Admin: Session cleared`
30 + Relay->>Admin: Token invalidated`
31 </script>
32
33 # Admin API
34
34 -These endpoints allow relay operators to manage leases, configure settings, and control access. All endpoints (except authentication) require a valid admin session cookie.
35 +These endpoints allow relay operators to manage leases, configure settings, and control access. All endpoints (except authentication) require a valid admin bearer token.
36
37 ## Admin Workflow
38
@@ -73,7 +74,7 @@ curl -X POST https://relay.example.com/admin/auth/challenge \
74
75 ### `POST /admin/auth/login`
76
76 -Complete wallet login with the signed SIWE message. On success, sets a session cookie used for subsequent admin requests.
77 +Complete wallet login with the signed SIWE message. On success, returns an access token used for subsequent admin requests.
78
79 **Auth:** None
80
@@ -89,14 +90,9 @@ Complete wallet login with the signed SIWE message. On success, sets a session c
90
91 | Field | Type | Description |
92 |-------|------|-------------|
93 +| `access_token` | `string` | Bearer token for admin API requests |
94 | `wallet_address` | `string` | Authenticated wallet address |
95
94 -**Response cookies:**
95 -
96 -| Cookie | Value | Attributes |
97 -|--------|-------|------------|
98 -| `portal_admin` | Session token | `Path=/admin; HttpOnly; Secure; SameSite=Strict; MaxAge=86400` |
99 -
96 **Error codes:**
97
98 | Code | Status | Description |
@@ -108,7 +104,6 @@ Complete wallet login with the signed SIWE message. On success, sets a session c
104 ```bash
105 curl -X POST https://relay.example.com/admin/auth/login \
106 -H "Content-Type: application/json" \
111 - -c cookies.txt \
107 -d '{ "challenge_id": "...", "siwe_message": "...", "siwe_signature": "0x..." }'
108 ```
109
@@ -118,6 +113,7 @@ curl -X POST https://relay.example.com/admin/auth/login \
113 {
114 "ok": true,
115 "data": {
116 + "access_token": "...",
117 "wallet_address": "0x1234567890abcdef1234567890abcdef12345678"
118 }
119 }
@@ -127,9 +123,9 @@ curl -X POST https://relay.example.com/admin/auth/login \
123
124 ### `POST /admin/logout`
125
130 -End the current admin session and clear the session cookie.
126 +Invalidate the current admin bearer token.
127
132 -**Auth:** Session Cookie
128 +**Auth:** Bearer Token
129
130 **Request body:** None
131
@@ -139,14 +135,14 @@ End the current admin session and clear the session cookie.
135
136 ```bash
137 curl -X POST https://relay.example.com/admin/logout \
142 - -b cookies.txt
138 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
139 ```
140
141 ---
142
143 ### `GET /admin/auth/status`
144
149 -Check the current wallet session. Can be called without a session.
145 +Check the current wallet login state. Can be called without a token.
146
147 **Auth:** None (returns status regardless)
148
@@ -154,14 +150,14 @@ Check the current wallet session. Can be called without a session.
150
151 | Field | Type | Description |
152 |-------|------|-------------|
157 -| `authenticated` | `bool` | `true` if the request has a valid session |
153 +| `authenticated` | `bool` | `true` if the request has a valid bearer token |
154 | `wallet_address` | `string` | Authenticated wallet address, when logged in |
155
156 **Example:**
157
158 ```bash
159 curl https://relay.example.com/admin/auth/status \
164 - -b cookies.txt
160 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
161 ```
162
163 **Response:**
@@ -184,7 +180,7 @@ curl https://relay.example.com/admin/auth/status \
180
181 Get a full snapshot of the relay's current state including all active leases, approval mode, and transport settings.
182
187 -**Auth:** Session Cookie
183 +**Auth:** Bearer Token
184
185 **Response fields:**
186
@@ -223,7 +219,7 @@ Get a full snapshot of the relay's current state including all active leases, ap
219
220 ```bash
221 curl https://relay.example.com/admin/snapshot \
226 - -b cookies.txt
222 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
223 ```
224
225 **Response:**
@@ -268,7 +264,7 @@ curl https://relay.example.com/admin/snapshot \
264
265 Enable or disable the relay landing page.
266
271 -**Auth:** Session Cookie
267 +**Auth:** Bearer Token
268
269 **Request body:**
270
@@ -287,7 +283,7 @@ Enable or disable the relay landing page.
283 ```bash
284 curl -X POST https://relay.example.com/admin/settings/landing-page \
285 -H "Content-Type: application/json" \
290 - -b cookies.txt \
286 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
287 -d '{ "enabled": true }'
288 ```
289
@@ -297,7 +293,7 @@ curl -X POST https://relay.example.com/admin/settings/landing-page \
293
294 Configure UDP (QUIC) transport settings.
295
300 -**Auth:** Session Cookie
296 +**Auth:** Bearer Token
297
298 **Request body:**
299
@@ -324,7 +320,7 @@ Configure UDP (QUIC) transport settings.
320 ```bash
321 curl -X POST https://relay.example.com/admin/settings/udp \
322 -H "Content-Type: application/json" \
327 - -b cookies.txt \
323 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
324 -d '{ "enabled": true, "max_leases": 10 }'
325 ```
326
@@ -334,7 +330,7 @@ curl -X POST https://relay.example.com/admin/settings/udp \
330
331 Configure dedicated TCP port transport settings.
332
337 -**Auth:** Session Cookie
333 +**Auth:** Bearer Token
334
335 **Request body:**
336
@@ -361,7 +357,7 @@ Configure dedicated TCP port transport settings.
357 ```bash
358 curl -X POST https://relay.example.com/admin/settings/tcp-port \
359 -H "Content-Type: application/json" \
364 - -b cookies.txt \
360 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
361 -d '{ "enabled": true, "max_leases": 5 }'
362 ```
363
@@ -371,7 +367,7 @@ curl -X POST https://relay.example.com/admin/settings/tcp-port \
367
368 Set the lease approval mode. In `auto` mode, all leases are automatically approved. In `manual` mode, leases must be explicitly approved before they can route traffic.
369
374 -**Auth:** Session Cookie
370 +**Auth:** Bearer Token
371
372 **Request body:**
373
@@ -396,7 +392,7 @@ Set the lease approval mode. In `auto` mode, all leases are automatically approv
392 ```bash
393 curl -X POST https://relay.example.com/admin/settings/approval-mode \
394 -H "Content-Type: application/json" \
399 - -b cookies.txt \
395 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
396 -d '{ "mode": "manual" }'
397 ```
398
@@ -429,7 +425,7 @@ All lease management endpoints return an empty data object on success. All chang
425
426 Ban or unban a lease identity. Banned identities cannot register new leases or renew existing ones.
427
432 -**Auth:** Session Cookie
428 +**Auth:** Bearer Token
429
430 | Method | Description |
431 |--------|-------------|
@@ -441,11 +437,11 @@ Ban or unban a lease identity. Banned identities cannot register new leases or r
437 ```bash
438 # Ban an identity
439 curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/ban \
444 - -b cookies.txt
440 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
441
442 # Unban an identity
443 curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/ban \
448 - -b cookies.txt
444 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
445 ```
446
447 ---
@@ -454,7 +450,7 @@ curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/ban \
450
451 Set or remove a bandwidth limit (bytes per second) for a specific lease identity.
452
457 -**Auth:** Session Cookie
453 +**Auth:** Bearer Token
454
455 | Method | Description |
456 |--------|-------------|
@@ -479,12 +475,12 @@ Set or remove a bandwidth limit (bytes per second) for a specific lease identity
475 # Set 1 MB/s bandwidth limit
476 curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/bps \
477 -H "Content-Type: application/json" \
482 - -b cookies.txt \
478 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \
479 -d '{ "bps": 1048576 }'
480
481 # Remove bandwidth limit
482 curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/bps \
487 - -b cookies.txt
483 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
484 ```
485
486 ---
@@ -493,7 +489,7 @@ curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/bps \
489
490 Approve or revoke approval for a lease identity. Only relevant when approval mode is `manual`.
491
496 -**Auth:** Session Cookie
492 +**Auth:** Bearer Token
493
494 | Method | Description |
495 |--------|-------------|
@@ -505,11 +501,11 @@ Approve or revoke approval for a lease identity. Only relevant when approval mod
501 ```bash
502 # Approve an identity
503 curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/approve \
508 - -b cookies.txt
504 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
505
506 # Revoke approval
507 curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/approve \
512 - -b cookies.txt
508 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
509 ```
510
511 ---
@@ -518,7 +514,7 @@ curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/approve
514
515 Deny or remove denial for a lease identity. Denied identities are blocked from routing even in `auto` mode.
516
521 -**Auth:** Session Cookie
517 +**Auth:** Bearer Token
518
519 | Method | Description |
520 |--------|-------------|
@@ -530,11 +526,11 @@ Deny or remove denial for a lease identity. Denied identities are blocked from r
526 ```bash
527 # Deny an identity
528 curl -X POST https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/deny \
533 - -b cookies.txt
529 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
530
531 # Remove denial
532 curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/deny \
537 - -b cookies.txt
533 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
534 ```
535
536 ---
@@ -545,7 +541,7 @@ curl -X DELETE https://relay.example.com/admin/leases/bXktYXBw/MHgxMjM0/deny \
541
542 Ban or unban an IP address. Banned IPs are rejected at the SDK registration and renewal endpoints.
543
548 -**Auth:** Session Cookie
544 +**Auth:** Bearer Token
545
546 | Method | Description |
547 |--------|-------------|
@@ -563,11 +559,11 @@ Ban or unban an IP address. Banned IPs are rejected at the SDK registration and
559 ```bash
560 # Ban an IP
561 curl -X POST https://relay.example.com/admin/ips/203.0.113.50/ban \
566 - -b cookies.txt
562 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
563
564 # Unban an IP
565 curl -X DELETE https://relay.example.com/admin/ips/203.0.113.50/ban \
570 - -b cookies.txt
566 + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN"
567 ```
568
569 **Response:**
docs/src/routes/architecture/+page.md
+2 -2
@@ -344,9 +344,9 @@ Notes:
344 - The exact root host is never served by the wildcard route.
345 - For non-apex `PORTAL_URL` values such as `https://portal.example.com:8443/admin`, a lease named `demo` is published at `demo.portal.example.com`.
346
347 -## Admin and Frontend Surface
347 +## Admin API Surface
348
349 -The admin surface is intentionally small: an HTML index, one JSON snapshot endpoint, 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: one JSON snapshot endpoint, public snapshot/status endpoints, installer endpoints, and a small set of admin action/auth routes. Route paths are enumerated in `types/paths.go` and `cmd/relay-server`.
350
351 ## Keyless TLS Trust Model
352
docs/src/routes/concepts/+page.md
+2 -2
@@ -160,8 +160,8 @@ datagram authentication.
160
161 Reusing the same identity path keeps the same tunnel identity across runs.
162
163 -Browser wallet login is separate from tunnel registration. Wallet sessions are
164 -used for relay admin access and optional local agent status access. See
163 +Browser wallet login is separate from tunnel registration. It is used for relay
164 +admin access and optional local agent status access. See
165 [Wallet and ENS](/wallet-and-ens) for the distinction.
166
167 ## Domain Boundary
docs/src/routes/deployment/+page.md
+2 -2
@@ -393,7 +393,7 @@ WIREGUARD_PORT=51820
393
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 to the admin UI by default; use `ADMIN_WALLETS` to allow additional admin wallets.
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.
399
@@ -600,7 +600,7 @@ Remove or comment out `HEADLESS_SHELL_URL` from `.env` or the docker-compose env
600
601 ## 7. Auto-Update
602
603 -Automatically redeploy when a new `ghcr.io/gosuda/portal:latest` image is pushed.
603 +Automatically redeploy when new `ghcr.io/gosuda/portal:latest` or `ghcr.io/gosuda/portal-frontend:latest` images are pushed.
604
605 ### 7.1 Deploy script
606
docs/src/routes/siwe-authentication/+page.md
+4 -4
@@ -1,6 +1,6 @@
1 ---
2 title: SIWE Authentication
3 -description: How Portal uses SIWE for tunnel registration and wallet sessions.
3 +description: How Portal uses SIWE for tunnel registration and wallet login.
4 ---
5
6 # SIWE Authentication
@@ -8,7 +8,7 @@ description: How Portal uses SIWE for tunnel registration and wallet sessions.
8 Portal uses Sign-In with Ethereum (SIWE) in two places:
9
10 - tunnel registration, signed automatically by the local tunnel identity
11 -- browser wallet sessions for relay admin and optional local agent status access
11 +- browser wallet login for relay admin and optional local agent status access
12
13 For the full operational guide, see [Wallet and ENS](/wallet-and-ens).
14
@@ -32,14 +32,14 @@ portal expose 3000 --name myapp
32 There is no `--auth siwe` flag. SIWE is part of the normal registration
33 protocol.
34
35 -## Wallet Sessions
35 +## Wallet Login
36
37 The relay admin UI uses browser wallet login:
38
39 1. request `/admin/auth/challenge`
40 2. sign the returned SIWE message with the connected wallet
41 3. submit `/admin/auth/login`
42 -4. use the resulting `portal_admin` session cookie
42 +4. use the returned `access_token` as `Authorization: Bearer <access_token>`
43
44 The relay identity address is allowed by default. Add more admin wallets with
45 `ADMIN_WALLETS`.
docs/src/routes/wallet-and-ens/+page.md
+6 -6
@@ -14,7 +14,7 @@ related, but they do not all mean "connect a browser wallet".
14 |---------|--------------|---------|
15 | Tunnel identity | Local `identity.json` secp256k1 private key, or BIP-39 mnemonic plus derivation path | Signs SIWE lease registration challenges |
16 | Relay identity | Relay `IDENTITY_PATH/identity.json` secp256k1 private key, or BIP-39 mnemonic plus derivation path | Signs relay descriptors, admin default wallet, lease access tokens, and ENS base-domain address |
17 -| Relay admin wallet | Browser wallet address allowlist | Signs in to `/admin` with a SIWE wallet session |
17 +| Relay admin wallet | Browser wallet address allowlist | Signs in to `/admin` and receives an admin bearer token |
18 | Agent wallet | Optional browser wallet allowlist | Reads loopback agent status through `/v1/agent/status` |
19 | ENS gasless DNS | DNSSEC plus `ENS1 ...` TXT records | Lets ENS-aware clients resolve the relay domain and lease hostnames to Portal identities |
20
@@ -56,8 +56,8 @@ name such as `alice.eth`.
56
57 ## Relay Admin Wallet Login
58
59 -The relay admin UI uses browser wallet login. The relay creates a SIWE challenge
60 -for the connected wallet and sets a `portal_admin` session cookie after the
59 +The relay admin API uses browser wallet login. The relay creates a SIWE
60 +challenge for the connected wallet and returns an admin bearer token after the
61 signature verifies.
62
63 Allowed admin wallets:
@@ -83,10 +83,10 @@ Admin wallet flow:
83 2. Sign the returned `siwe_message` in the browser wallet.
84 3. `POST /admin/auth/login` with the challenge id, exact SIWE message, and
85 signature.
86 -4. The relay sets an HttpOnly, Secure, SameSite=Strict session cookie.
87 -5. Admin endpoints require that session cookie.
86 +4. The relay returns an `access_token`.
87 +5. Admin endpoints require `Authorization: Bearer <access_token>`.
88
89 -Challenges expire after two minutes. Sessions expire after 24 hours.
89 +Challenges expire after two minutes. Admin bearer tokens expire after 24 hours.
90
91 ## Agent Wallet Login
92
frontend/.dockerignore new
+4
@@ -0,0 +1,4 @@
1 +dist/
2 +node_modules/
3 +coverage/
4 +*.log
frontend/AGENTS.md
+23 -23
@@ -2,48 +2,48 @@
2
3 High-signal constraints for the relay-server frontend. Only items expensive to rediscover.
4
5 -## Frontend-Backend Contracts (Manually Synced)
5 +## Frontend-Backend Contracts
6
7 -1. **SSR data shape is a 4-way contract.**
8 - Go lease contracts (`../types/lease.go`) + portal snapshot producer (`../portal/lease.go`) + relay-server frontend filtering/injection (`../cmd/relay-server/frontend.go`) -> TS `ServerData` (`src/hooks/useSSRData.ts`).
9 - - Why: no shared schema or codegen. Field drift silently breaks SSR hydration. The script tag ID `__SSR_DATA__` is hardcoded in all three locations.
7 +1. **Public list data comes from `/api/public/snapshot`.**
8 + Go shape is `types.PublicSnapshotResponse`; TS shape is `src/types/lease.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`.
13 - - Why: no codegen. A path mismatch produces same-origin 404s.
12 + Go definitions live in `../types/paths.go`; TS duplicates live in `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.**
16 All JSON control-plane responses use `{ ok, data?, error?: { code, message } }`.
17 - Go shape is `types.APIEnvelope` in `types/api.go`; Go writers live in `portal/api.go`; TS parser lives in `src/lib/apiClient.ts`.
17 + Go shape is `types.APIEnvelope` in `../types/api.go`; Go writers live in `../utils/api.go`; TS parser lives in `src/lib/apiClient.ts`.
18 - Why: backend responses that skip the envelope surface as `invalid_envelope` in the frontend.
19
20 -4. **Build output renames `index.html` to `portal.html`.**
21 - Vite plugin `rename-index` (`vite.config.ts`) performs this post-build. Go backend serves `portal.html`, not `index.html`. The rename is skipped when `VITEST` is set.
22 - - Why: any tooling or script assuming `index.html` post-build will fail.
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 ...`.
22 + - Why: the relay admin API must be usable by any separately hosted frontend without credentialed cookie CORS state.
23
24 -5. **HTML metadata placeholders must match between HTML and Go.**
25 - `index.html` (renamed to `portal.html`) contains `[%..%]` placeholders substituted server-side in `cmd/relay-server/frontend.go`.
26 - - Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML.
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/snapshot`.**
29 - `src/hooks/useAdmin.ts` expects one payload carrying `leases` and `approval_mode`.
29 + `src/hooks/useAdmin.ts` expects one payload carrying `leases`, settings, and `approval_mode`.
30 - Why: splitting those reads across multiple endpoints reintroduces extra request coordination and drift in the admin bootstrap path.
31
32 7. **Lease/AdminLease JSON casing is a mixed implicit/explicit contract.**
33 - `Lease` (`../types/identity.go`): `Name` has `json:"name"` tag, but `ExpiresAt`, `FirstSeenAt`, `LastSeenAt`, `Hostname`, `Ready` have NO tags — Go defaults to PascalCase. `AdminLease`: `IdentityKey` and `Address` have snake_case json tags, but `BPS`, `ClientIP`, `ReportedIP`, `IsApproved`, `IsBanned`, `IsDenied`, `IsIPBanned` have NO tags — also PascalCase. TS `PublicLeaseData` and `AdminLeaseData` (`src/hooks/useSSRData.ts`) consume a subset of these fields and match this mixed casing.
34 - - Why: adding a `json:"..."` tag to any currently untagged field silently changes the wire name and breaks the TS consumer. Go also sends `UDPEnabled`, `TCPEnabled`, `TCPAddr` on `Lease` which the frontend does not consume — these are not part of the frontend contract.
33 + `Lease` (`../types/identity.go`): `Name` has `json:"name"`, while `FirstSeenAt`, `LastSeenAt`, `Hostname`, `Ready`, and `Metadata` use Go's default PascalCase names. `AdminLease`: `IdentityKey` and `Address` have snake_case json tags, while `BPS`, `ClientIP`, `ReportedIP`, `IsApproved`, `IsBanned`, `IsDenied`, and `IsIPBanned` use PascalCase. TS types in `src/types/lease.ts` include the fields currently rendered or used by actions.
34 + - Why: adding a `json:"..."` tag to any currently untagged field silently changes the wire name and breaks the TS consumer.
35
36 8. **Admin lease paths use base64-url encoding with URI-component escaping.**
37 - TS `encodePathPart()` (`src/lib/apiPaths.ts`): `btoa(value)` → replace `+/=` with `-/_/""` → `encodeURIComponent()`. Go decodes via `utils.DecodeBase64URLString()`.
37 + TS `encodePathPart()` (`src/lib/apiPaths.ts`) does `btoa(value)` then replaces `+/=` with `-/_/""` before `encodeURIComponent()`. Go decodes via `utils.DecodeBase64URLString()`.
38 - Why: two-layer codec. Changing either side silently produces 400s on admin lease actions.
39
40 9. **`Metadata` is typed `unknown` in TS but has a concrete Go struct.**
41 - Go `LeaseMetadata` (`../types/identity.go`): `description`, `owner`, `thumbnail`, `tags`, `hide` — all with json tags. TS declares `Metadata: unknown` in `PublicLeaseData`, then runtime-parses in `src/lib/metadata.ts`.
42 - - Why: adding or renaming a Go metadata field silently drops data in the frontend. No compile-time contract exists.
41 + Go `LeaseMetadata` (`../types/identity.go`) has more fields than the frontend renders. TS parses only rendered fields at runtime in `src/lib/metadata.ts`.
42 + - Why: adding or renaming a rendered Go metadata field silently drops data in the frontend. No compile-time contract exists.
43
44 10. **ApprovalMode is a closed two-value enum: `"auto"` | `"manual"`.**
45 - TS `normalizeApprovalMode()` (`src/hooks/useAdmin.ts`) collapses any non-`"manual"` value to `"auto"`.
46 - - Why: adding a third mode in Go without updating the TS normalizer silently collapses it to "auto".
45 + TS `normalizeApprovalMode()` (`src/hooks/useAdmin.ts`) collapses any non-`"manual"` value to `"auto"`.
46 + - Why: adding a third mode in Go without updating the TS normalizer silently collapses it to "auto".
47
48 ## Frontend Conventions
49
@@ -52,9 +52,9 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
52 - Why: manual `useCallback` is redundant with the compiler and adds noise.
53
54 2. **Feature state lives in page-level hooks and is prop-drilled. No global state library.**
55 - `useServerList`, `useAdmin`, `useAuth` own feature state at the page level. Theme is the exception — it uses a dedicated `ThemeProvider` context (`src/components/ThemeProvider.tsx`). `localStorage` for persistence (favorites, theme, tunnel seed) with silent fallback on errors.
55 + `useServerList`, `useAdmin`, and `useAuth` own feature state at the page level. Theme is the exception; it uses `ThemeProvider`. `localStorage` persistence should silently fall back on errors.
56 - Why: the prop-drilling pattern for feature state is intentional. Adding shared state providers for feature data changes the data flow architecture.
57
58 3. **Only `handleBPSChange` uses optimistic update with rollback.**
59 - All other admin actions use `runAdminAction()` which awaits the API call then refreshes via `fetchData()`. BPS is the exception: it mutates local state immediately and rolls back on error (`src/hooks/useAdmin.ts`).
59 + All other admin actions use `runAdminAction()` which awaits the API call then refreshes via `fetchData()`.
60 - Why: treating other admin handlers as optimistic will skip the server-refresh step and show stale data.
frontend/Dockerfile new
+17
@@ -0,0 +1,17 @@
1 +# syntax=docker/dockerfile:1
2 +
3 +FROM --platform=$BUILDPLATFORM node:22-slim AS builder
4 +WORKDIR /src
5 +
6 +COPY package.json package-lock.json ./
7 +RUN --mount=type=cache,target=/root/.npm npm ci
8 +
9 +COPY . .
10 +RUN npm run build
11 +
12 +FROM nginx:1.27-alpine AS frontend
13 +
14 +COPY nginx.conf /etc/nginx/conf.d/default.conf
15 +COPY --from=builder /src/dist /usr/share/nginx/html
16 +
17 +EXPOSE 8080
frontend/README.md
+72 -144
@@ -10,183 +10,111 @@ React + TypeScript frontend for relay server discovery and onboarding.
10 - Tailwind CSS 4
11 - shadcn/ui (Radix-based)
12 - Lucide React
13 -- @ssgoi/react (page transitions)
14 -- React Compiler (`babel-plugin-react-compiler`, enabled in `vite.config.ts`) — do not use `useCallback` in new code
15 -
16 -## Project Structure
17 -
18 -```text
19 -frontend/
20 -├── src/
21 -│ ├── components/
22 -│ │ ├── ui/ # shadcn/ui base components
23 -│ │ ├── Header.tsx # Header + add-server entry point
24 -│ │ ├── SearchBar.tsx # Search + filters (status, sort, tags)
25 -│ │ ├── ServerCard.tsx # Server list card
26 -│ │ ├── ServerListView.tsx # Shared server/admin list view
27 -│ │ ├── TagCombobox.tsx # Tag filter control
28 -│ │ └── FloatingActionBar.tsx # Admin bulk actions
29 -│ ├── hooks/
30 -│ │ ├── useSSRData.ts # Reads __SSR_DATA__ injected by Go backend
31 -│ │ ├── useServerList.ts # Converts SSR payload into list models
32 -│ │ ├── useAdmin.ts # Admin API integration and actions
33 -│ │ ├── useList.ts # Shared list filtering/sorting state
34 -│ │ └── useAuth.ts # Wallet auth helper hooks
35 -│ ├── lib/
36 -│ │ ├── apiClient.ts
37 -│ │ ├── apiPaths.ts
38 -│ │ ├── testUtils.ts # Optional test fixtures
39 -│ │ └── utils.ts
40 -│ ├── pages/
41 -│ │ ├── Admin.tsx # Admin area shell
42 -│ │ ├── ServerDetail.tsx # Server detail view with page transition
43 -│ │ └── ServerList.tsx # Listing pages and route assembly
44 -│ ├── App.tsx
45 -│ ├── main.tsx
46 -│ └── index.css
47 -├── index.html
48 -├── package.json
49 -├── tsconfig.json
50 -└── vite.config.ts
51 -```
13 +- @ssgoi/react
14 +- React Compiler (`babel-plugin-react-compiler`, enabled in `vite.config.ts`)
15
16 ## Core Behavior
17
55 -### Server-Side Data Bootstrap
18 +The Go relay is API-only. This frontend is a standalone Vite app that talks to
19 +the relay over the JSON API and does not receive server-side injected lease
20 +data.
21
57 -1. Go backend injects lease data into `portal.html` using `<script id="__SSR_DATA__">`.
58 -2. Frontend reads it with `useSSRData()`.
59 -3. UI renders server list immediately without an initial fetch.
60 -4. Admin pages load state through `/admin/snapshot`, then call `/admin/*` action endpoints through `apiClient` for changes (approve, deny, ban, settings).
22 +- Public relay state is loaded from `/api/public/snapshot`.
23 +- Admin state is loaded from `/admin/snapshot`.
24 +- All JSON API responses use the `{ ok, data?, error? }` envelope parsed by `src/lib/apiClient.ts`.
25 +- `VITE_PORTAL_API_BASE_URL` points the frontend at a relay API origin. Admin auth uses a bearer token returned by `/admin/auth/login`.
26
62 -### List Filtering and Sort
63 -
64 -- List logic is centralized in `useList` and shared across admin and public server views.
65 -- Search fields include server name, description, and tags.
66 -- Filters include status and tag selection.
67 -- Sort options include default, description, tags, owner, and timestamp ordering.
68 -
69 -## Tailwind CSS v4 Notes
27 +## Project Structure
28
71 -- Uses CSS-first config (`@theme` in `index.css`)
72 -- Uses `@tailwindcss/vite` plugin
73 -- Uses `@import "tailwindcss"` syntax
29 +```text
30 +frontend/
31 + src/
32 + components/
33 + hooks/
34 + useServerList.ts
35 + useAdmin.ts
36 + useList.ts
37 + useAuth.ts
38 + lib/
39 + apiClient.ts
40 + apiPaths.ts
41 + metadata.ts
42 + pages/
43 + Admin.tsx
44 + ServerDetail.tsx
45 + ServerList.tsx
46 + types/
47 + lease.ts
48 + App.tsx
49 + main.tsx
50 + index.css
51 + index.html
52 + package.json
53 + tsconfig.json
54 + vite.config.ts
55 +```
56
57 ## Install and Build
58
77 -### Install
78 -
59 ```bash
60 cd frontend
61 npm install
62 +npm run build
63 ```
64
84 -### Development
65 +Build output goes to `frontend/dist/`.
66 +
67 +## Development
68
69 ```bash
70 +cd frontend
71 npm run dev
72 ```
73
74 Default dev URL: `http://localhost:5173`.
75
92 -### Production Build
76 +To run against a relay server on another origin, build or run the frontend with the public relay API URL:
77
78 ```bash
95 -npm run build
79 +VITE_PORTAL_API_BASE_URL=https://relay.example.com npm run dev
80 ```
81
98 -Build output:
99 -
100 -- `dist/portal.html` (entry HTML served by Go server)
101 -- `dist/assets/` (bundled JS/CSS)
102 -
103 -### NPM Scripts
104 -
105 -| Script | Purpose |
106 -| --- | --- |
107 -| `npm run dev` | Start the Vite development server (`http://localhost:5173`). |
108 -| `npm run build` | Type-check and build production assets into `dist/`. |
109 -| `npm run lint` | Run ESLint with warnings treated as errors. |
110 -| `npm run typecheck` | Run TypeScript checking with `--noEmit`. |
111 -| `npm test` | Run the frontend test suite with `vitest run`. |
112 -| `npm run test:watch` | Run Vitest in watch mode for local TDD cycles. |
113 -| `npm run test:coverage` | Run Vitest with coverage reporting. |
114 -| `npm run preview` | Preview the production bundle with Vite. |
115 -| `npm run build:go` | Build the relay server binary used by local serve flow. |
116 -| `npm run serve` | Build frontend + Go binary, then launch relay server on admin port `4017`. |
117 -
118 -## Relay Server Integration
119 -
120 -Relay server exposes:
82 +## Docker
83
122 -- `/` - React frontend with SSR bootstrap payload
123 -- `/app/` - Static frontend assets
124 -- `/install.sh` - Unix installer for the `portal` CLI
125 -- `/install.ps1` - PowerShell installer for the `portal` CLI
126 -- `/healthz` - Health endpoint
127 -- `/admin/*` - Admin API/control endpoints used by server management UI
128 -- `/sdk/*` - SDK/control endpoints (`/sdk/connect` opens the raw TCP reverse channel used by the relay)
129 -
130 -Admin endpoints use a JSON envelope contract (`{ ok, data, error }`) and reject malformed or non-JSON responses with explicit API client errors.
131 -
132 -Admin address contract:
133 -
134 -- `/admin/snapshot` returns `leases` and `approval_mode` in one envelope payload.
135 -- `leases` rows inside the admin snapshot include the normalized identity `address`; public SSR snapshots omit it.
136 -- Frontend only Base64URL-encodes addresses when constructing admin action routes (`/admin/leases/{encodedAddress}/{action}`).
137 -
138 -### SDK-Related Runtime Contract
139 -
140 -The relay enforces a consistent anti-abuse gate for both control APIs and reverse admission:
141 -
142 -- `/sdk/register`, `/sdk/unregister`, `/sdk/renew`, and `/sdk/domain` return JSON envelopes (`{ ok, data, error }`).
143 -- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` use token-based admission.
144 -- Control-plane admission order is deterministic: `IP -> Lease -> Token`.
145 -- `/sdk/connect` is additionally re-validated inside `ReverseHub` before pooling so token and IP authorization are applied at both admission layers.
146 -
147 -### Run with Relay Server
84 +The frontend Docker image serves the built Vite app with nginx and proxies API
85 +paths to `portal-api:4017` in Docker Compose. The app uses same-origin relative
86 +API paths, so it does not need runtime config file generation.
87
88 ```bash
150 -# Build frontend (output: ../cmd/relay-server/dist/app/)
151 -cd frontend
152 -npm run build
153 -
154 -# Run relay server (embeds dist/ at compile time)
155 -cd ..
156 -go run ./cmd/relay-server/*.go
89 +docker compose up -d portal-frontend
90 ```
91
159 -Or use the combined script:
92 +## NPM Scripts
93
161 -```bash
162 -cd frontend
163 -npm run serve
164 -```
165 -
166 -## Technical Notes
167 -
168 -- Backend relay/tunnel transport is raw TCP reverse-connect only.
169 -- SNI routing keeps exact `PORTAL_URL` host fallbacks on the admin/API listener to preserve portal dashboard control-plane locality.
170 -
171 -### Connection Responsibilities
172 -
173 -- Conn #1 (`browser -> app`) is the data plane and keeps existing tenant-facing TLS behavior.
174 -- Conn #2 (`relay -> tunnel`) is the control plane and enforces lease access token admission.
175 -
176 -### Breaking-Change Expectation
177 -
178 -- Clients with invalid lease access tokens are expected to fail admission.
179 -- Client certificates are not required for `/sdk/*` admission.
180 -
181 -### Radix Select Values
94 +| Script | Purpose |
95 +| --- | --- |
96 +| `npm run dev` | Start the Vite development server. |
97 +| `npm run build` | Type-check and build production assets. |
98 +| `npm run lint` | Run ESLint. |
99 +| `npm run typecheck` | Run TypeScript checking. |
100 +| `npm test` | Run Vitest. |
101 +| `npm run preview` | Preview the production bundle. |
102
183 -Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
103 +## Relay Integration
104
185 -### API-Response Edge Cases
105 +Relay server exposes:
106
187 -- API responses are validated via `APIClient` envelope decoding; malformed payloads are surfaced as explicit runtime errors.
188 -- Non-admin rendering still works using SSR bootstrap data when admin calls are unavailable.
107 +- `/` - relay API identity response
108 +- `/api/public/snapshot` - public leases and landing-page state
109 +- `/tunnel/status` - tunnel readiness check used by the command form
110 +- `/thumbnail/{hostname}` - cached generated screenshots
111 +- `/install.sh` and `/install.ps1` - CLI installers
112 +- `/admin/*` - admin API/control endpoints
113 +- `/sdk/*` - SDK/control endpoints
114 +- `/discovery` - relay discovery when enabled
115
190 -## License
116 +## Notes
117
192 -Part of `gosuda/portal-tunnel`.
118 +- API path constants are duplicated in Go (`types/paths.go`) and TS (`src/lib/apiPaths.ts`).
119 +- Lease JSON field casing is intentionally mixed to match Go's current wire output; see `src/types/lease.ts`.
120 +- Radix Select values cannot be empty strings. Use stable values such as `"all"` and `"default"`.
frontend/index.html
+10 -6
@@ -8,13 +8,17 @@
8 name="description"
9 content="Transform your local services into web-accessible endpoints. Instant access from anywhere."
10 />
11 - <meta property="og:title" content="[%OG_TITLE%]" />
12 - <meta property="og:description" content="[%OG_DESCRIPTION%]" />
11 + <meta property="og:title" content="Portal Proxy Gateway" />
12 + <meta
13 + property="og:description"
14 + content="Transform your local services into web-accessible endpoints. Instant access from anywhere."
15 + />
16 <meta name="twitter:card" content="summary" />
14 - <meta name="twitter:title" content="[%OG_TITLE%]" />
15 - <meta name="twitter:description" content="[%OG_DESCRIPTION%]" />
16 - <meta name="portal-landing-page-enabled" content="[%LANDING_PAGE_ENABLED%]" />
17 - <meta name="portal-release-version" content="[%RELEASE_VERSION%]" />
17 + <meta name="twitter:title" content="Portal Proxy Gateway" />
18 + <meta
19 + name="twitter:description"
20 + content="Transform your local services into web-accessible endpoints. Instant access from anywhere."
21 + />
22 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
23 <title>Portal - Local to web. Instant access.</title>
24 <script>
frontend/nginx.conf new
+20
@@ -0,0 +1,20 @@
1 +server {
2 + listen 8080;
3 + server_name _;
4 +
5 + root /usr/share/nginx/html;
6 + index index.html;
7 +
8 + location ~ ^/(api/|admin/|sdk/|tunnel/|thumbnail/|install\.sh$|install\.ps1$|install/bin/|discovery$|healthz$|x402(/|$)) {
9 + proxy_http_version 1.1;
10 + proxy_set_header Host $host;
11 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
12 + proxy_set_header X-Forwarded-Host $host;
13 + proxy_set_header X-Forwarded-Proto $scheme;
14 + proxy_pass http://portal-api:4017;
15 + }
16 +
17 + location / {
18 + try_files $uri $uri/ /index.html;
19 + }
20 +}
frontend/package.json
+1 -3
@@ -12,9 +12,7 @@
12 "test": "vitest run",
13 "test:watch": "vitest",
14 "test:coverage": "vitest run --coverage",
15 - "preview": "vite preview",
16 - "build:go": "cd .. && CGO_ENABLED=0 go build -o bin/relay-server ./cmd/relay-server/*.go",
17 - "serve": "npm run build && npm run build:go && STATIC_DIR=./cmd/relay-server/dist ../bin/relay-server"
15 + "preview": "vite preview"
16 },
17 "dependencies": {
18 "@radix-ui/react-dialog": "^1.1.15",
frontend/src/components/Header.tsx
+8 -2
@@ -11,7 +11,6 @@ import {
11 TooltipProvider,
12 TooltipTrigger,
13 } from "@/components/ui/tooltip";
14 -import { getReleaseVersion } from "@/lib/releaseVersion";
14
15 interface HeaderProps {
16 title?: string;
@@ -21,6 +20,7 @@ interface HeaderProps {
20 }
21
22 interface DomainStatusResponse {
23 + release_version?: string;
24 ens?: {
25 verified?: boolean;
26 };
@@ -55,7 +55,7 @@ export function Header({
55 onAuthChange,
56 showQuickStartLink = true,
57 }: HeaderProps) {
58 - const releaseVersion = getReleaseVersion();
58 + const [releaseVersion, setReleaseVersion] = useState("");
59 const [ensVerified, setENSVerified] = useState(false);
60 const [x402, setX402] = useState<X402FacilitatorInfo | null>(null);
61 const {
@@ -99,11 +99,17 @@ export function Header({
99 API_PATHS.sdk.domain
100 );
101 if (!cancelled) {
102 + setReleaseVersion(
103 + typeof status?.release_version === "string"
104 + ? status.release_version.trim()
105 + : ""
106 + );
107 setENSVerified(status?.ens?.verified === true);
108 setX402(status?.x402?.enabled === true ? status.x402 : null);
109 }
110 } catch {
111 if (!cancelled) {
112 + setReleaseVersion("");
113 setENSVerified(false);
114 setX402(null);
115 }
frontend/src/hooks/useAdmin.test.ts
+1 -3
@@ -1,7 +1,7 @@
1 import { act, renderHook, waitFor } from "@testing-library/react";
2 import { beforeEach, describe, expect, it, vi } from "vitest";
3
4 -import type { AdminLeaseData } from "@/hooks/useSSRData";
4 +import type { AdminLeaseData } from "@/types/lease";
5 import { useAdmin } from "@/hooks/useAdmin";
6 import { API_PATHS, adminLeasePath } from "@/lib/apiPaths";
7 import { APIClientError, apiClient } from "@/lib/apiClient";
@@ -45,7 +45,6 @@ vi.mock("@/lib/apiClient", async () => {
45
46 function buildLease(address: string, name: string = "relay-1"): AdminLeaseData {
47 return {
48 - ExpiresAt: "2026-03-03T01:00:00Z",
48 FirstSeenAt: "2026-03-02T00:00:00Z",
49 LastSeenAt: "2026-03-03T00:00:00Z",
50 identity_key: `${name.toLowerCase()}:${address.toLowerCase()}`,
@@ -60,7 +59,6 @@ function buildLease(address: string, name: string = "relay-1"): AdminLeaseData {
59 tags: ["core"],
60 thumbnail: "",
61 owner: "ops",
63 - hide: false,
62 },
63 Ready: 1,
64 IsApproved: true,
frontend/src/hooks/useAdmin.ts
+1 -1
@@ -1,5 +1,5 @@
1 import { useEffect, useMemo, useState } from "react";
2 -import type { AdminLeaseData } from "@/hooks/useSSRData";
2 +import type { AdminLeaseData } from "@/types/lease";
3 import { useList, type BaseServer } from "@/hooks/useList";
4 import type { BanFilter } from "@/types/filters";
5 import {
frontend/src/hooks/useAuth.ts
+16
@@ -8,6 +8,7 @@ import {
8 } from "wagmi";
9 import { API_PATHS } from "@/lib/apiPaths";
10 import { APIClientError, apiClient } from "@/lib/apiClient";
11 +import { writeAdminAuthToken } from "@/lib/adminAuthToken";
12
13 interface AuthState {
14 isAuthenticated: boolean;
@@ -32,6 +33,7 @@ interface WalletAuthChallengePayload {
33 }
34
35 interface WalletAuthLoginPayload {
36 + access_token?: string;
37 wallet_address?: string;
38 }
39
@@ -155,6 +157,14 @@ export function useAuth(target: AuthTarget = "admin") {
157 siwe_signature: signature,
158 }
159 );
160 + if (authTarget === "admin") {
161 + const accessToken = data.access_token?.trim() || "";
162 + if (!accessToken) {
163 + writeAdminAuthToken("");
164 + return { success: false, error: "Admin login did not return an access token." };
165 + }
166 + writeAdminAuthToken(accessToken);
167 + }
168 setAuthState((prev) => ({
169 ...prev,
170 isAuthenticated: true,
@@ -182,8 +192,14 @@ export function useAuth(target: AuthTarget = "admin") {
192 for (const candidate of candidates) {
193 try {
194 await apiClient.post<unknown>(authPaths[candidate].logout);
195 + if (candidate === "admin") {
196 + writeAdminAuthToken("");
197 + }
198 break;
199 } catch {
200 + if (candidate === "admin") {
201 + writeAdminAuthToken("");
202 + }
203 continue;
204 }
205 }
frontend/src/hooks/useSSRData.ts deleted
-56
@@ -1,56 +0,0 @@
1 -import { useState, useEffect } from "react";
2 -
3 -export interface Metadata {
4 - description: string;
5 - tags: string[];
6 - thumbnail: string;
7 - owner: string;
8 - hide: boolean;
9 -}
10 -
11 -export interface PublicLeaseData {
12 - ExpiresAt: string;
13 - FirstSeenAt: string;
14 - LastSeenAt: string;
15 - name?: string;
16 - Hostname: string;
17 - Metadata: unknown;
18 - Ready: number;
19 -}
20 -
21 -export interface AdminLeaseData extends PublicLeaseData {
22 - identity_key: string;
23 - address: string;
24 - BPS: number;
25 - ClientIP: string;
26 - ReportedIP: string;
27 - IsApproved: boolean;
28 - IsBanned: boolean;
29 - IsDenied: boolean;
30 - IsIPBanned: boolean;
31 -}
32 -
33 -/**
34 - * useSSRData hook reads server data injected by Go SSR
35 - * The data is embedded in a <script id="__SSR_DATA__"> tag in the HTML
36 - */
37 -export function useSSRData(): PublicLeaseData[] {
38 - const [data, setData] = useState<PublicLeaseData[]>([]);
39 -
40 - useEffect(() => {
41 - const ssrScript = document.getElementById("__SSR_DATA__");
42 - if (!ssrScript?.textContent) {
43 - return;
44 - }
45 -
46 - try {
47 - const parsed = JSON.parse(ssrScript.textContent);
48 - setData(Array.isArray(parsed) ? parsed : []);
49 - } catch (error) {
50 - console.error("Failed to parse SSR data", error);
51 - setData([]);
52 - }
53 - }, []);
54 -
55 - return data;
56 -}
frontend/src/hooks/useServerList.ts
+51 -9
@@ -1,11 +1,17 @@
1 -import { useMemo } from "react";
2 -import { useSSRData } from "@/hooks/useSSRData";
3 -import type { PublicLeaseData } from "@/hooks/useSSRData";
1 +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";
6 +import type { PublicLeaseData, PublicSnapshotResponse } from "@/types/lease";
7
7 -function convertSSRDataToServers(ssrData: PublicLeaseData[]): BaseServer[] {
8 - return ssrData.map((row) => {
8 +type PublicSnapshot = {
9 + leases: PublicLeaseData[];
10 + landingPageEnabled: boolean;
11 +};
12 +
13 +function convertPublicLeasesToServers(leases: PublicLeaseData[]): BaseServer[] {
14 + return leases.map((row) => {
15 const metadata = parseLeaseMetadata(row.Metadata);
16 const hostname = row.Hostname || "";
17 const serviceName = row.name || "";
@@ -27,15 +33,51 @@ function convertSSRDataToServers(ssrData: PublicLeaseData[]): BaseServer[] {
33 }
34
35 export function useServerList() {
30 - const ssrData = useSSRData();
36 + const [snapshot, setSnapshot] = useState<PublicSnapshot>({
37 + leases: [],
38 + landingPageEnabled: true,
39 + });
40 +
41 + useEffect(() => {
42 + let cancelled = false;
43 +
44 + void (async () => {
45 + try {
46 + const data = await apiClient.get<PublicSnapshotResponse>(
47 + API_PATHS.public.snapshot
48 + );
49 + if (cancelled) {
50 + return;
51 + }
52 + setSnapshot({
53 + leases: Array.isArray(data?.leases) ? data.leases : [],
54 + landingPageEnabled: data?.landing_page_enabled ?? true,
55 + });
56 + } catch (error) {
57 + console.error("Failed to load public relay snapshot", error);
58 + if (!cancelled) {
59 + setSnapshot({ leases: [], landingPageEnabled: true });
60 + }
61 + }
62 + })();
63 +
64 + return () => {
65 + cancelled = true;
66 + };
67 + }, []);
68
69 const servers: BaseServer[] = useMemo(
33 - () => convertSSRDataToServers(ssrData),
34 - [ssrData]
70 + () => convertPublicLeasesToServers(snapshot.leases),
71 + [snapshot.leases]
72 );
73
37 - return useList({
74 + const list = useList({
75 servers,
76 storageKey: "serverFavorites",
77 });
78 +
79 + return {
80 + ...list,
81 + landingPageEnabled: snapshot.landingPageEnabled,
82 + };
83 }
frontend/src/lib/adminAuthToken.ts new
+22
@@ -0,0 +1,22 @@
1 +const ADMIN_AUTH_TOKEN_STORAGE_KEY = "portal:admin_access_token";
2 +
3 +export function readAdminAuthToken(): string {
4 + try {
5 + return localStorage.getItem(ADMIN_AUTH_TOKEN_STORAGE_KEY)?.trim() || "";
6 + } catch {
7 + return "";
8 + }
9 +}
10 +
11 +export function writeAdminAuthToken(token: string) {
12 + const value = token.trim();
13 + try {
14 + if (value) {
15 + localStorage.setItem(ADMIN_AUTH_TOKEN_STORAGE_KEY, value);
16 + return;
17 + }
18 + localStorage.removeItem(ADMIN_AUTH_TOKEN_STORAGE_KEY);
19 + } catch {
20 + // localStorage can be unavailable in restricted browser contexts.
21 + }
22 +}
frontend/src/lib/apiClient.test.ts
+16
@@ -1,4 +1,5 @@
1 import { APIClientError, apiClient } from "@/lib/apiClient";
2 +import { writeAdminAuthToken } from "@/lib/adminAuthToken";
3 import { beforeEach, describe, expect, it, vi } from "vitest";
4
5 function jsonResponse(payload: unknown, init?: ResponseInit): Response {
@@ -15,6 +16,7 @@ describe("apiClient", () => {
16 beforeEach(() => {
17 fetchMock.mockReset();
18 vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
19 + localStorage.clear();
20 });
21
22 it("returns data when API envelope is ok", async () => {
@@ -152,4 +154,18 @@ describe("apiClient", () => {
154 Accept: "application/json",
155 });
156 });
157 +
158 + it("sends bearer token for admin API calls", async () => {
159 + writeAdminAuthToken("admin-token");
160 + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, data: {} }));
161 +
162 + await apiClient.post("/admin/logout");
163 +
164 + const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
165 + expect(init.credentials).toBe("same-origin");
166 + expect(init.headers).toEqual({
167 + Accept: "application/json",
168 + Authorization: "Bearer admin-token",
169 + });
170 + });
171 });
frontend/src/lib/apiClient.ts
+28 -11
@@ -1,3 +1,5 @@
1 +import { readAdminAuthToken } from "@/lib/adminAuthToken";
2 +
3 type APIErrorPayload = {
4 code?: string;
5 message?: string;
@@ -27,17 +29,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
29 return typeof value === "object" && value !== null && !Array.isArray(value);
30 }
31
30 -function headersToObject(headers?: HeadersInit): Record<string, string> {
31 - if (!headers) {
32 - return {};
33 - }
34 - if (headers instanceof Headers) {
35 - return Object.fromEntries(headers.entries());
32 +function resolveAPIURL(path: string): string {
33 + if (/^[a-z][a-z\d+\-.]*:/i.test(path)) {
34 + return path;
35 }
37 - if (Array.isArray(headers)) {
38 - return Object.fromEntries(headers);
36 +
37 + const baseURL = import.meta.env.VITE_PORTAL_API_BASE_URL?.trim();
38 + if (!baseURL) {
39 + return path;
40 }
40 - return { ...headers };
41 + return new URL(
42 + path,
43 + baseURL.endsWith("/") ? baseURL : `${baseURL}/`
44 + ).toString();
45 }
46
47 function ensureJsonEnvelope<T>(raw: unknown, path: string, status: number): APIEnvelope<T> {
@@ -89,8 +93,21 @@ async function decodeEnvelope<T>(path: string, response: Response): Promise<APIE
93 async function request<T>(path: string, init: RequestInit): Promise<T> {
94 let response: Response;
95 try {
92 - const requestHeaders = headersToObject(init.headers);
93 - response = await fetch(path, {
96 + const requestHeaders = {
97 + ...((init.headers as Record<string, string> | undefined) ?? {}),
98 + };
99 + const pathname = new URL(path, window.location.origin).pathname;
100 + if (
101 + pathname.startsWith("/admin/") &&
102 + pathname !== "/admin/auth/challenge" &&
103 + pathname !== "/admin/auth/login"
104 + ) {
105 + const token = readAdminAuthToken();
106 + if (token) {
107 + requestHeaders.Authorization = `Bearer ${token}`;
108 + }
109 + }
110 + response = await fetch(resolveAPIURL(path), {
111 credentials: "same-origin",
112 ...init,
113 headers: {
frontend/src/lib/apiPaths.test.ts
+3 -8
@@ -1,6 +1,6 @@
1 import { describe, expect, it } from "vitest";
2
3 -import { API_PATHS, adminLeasePath, encodePathPart } from "@/lib/apiPaths";
3 +import { adminLeasePath } from "@/lib/apiPaths";
4
5 describe("API_PATHS contract alignment", () => {
6 it("encodes lease identities as base64url path segments", () => {
@@ -16,14 +16,9 @@ describe("API_PATHS contract alignment", () => {
16 .replace(/\+/g, "-")
17 .replace(/\//g, "_")
18 .replace(/=+$/, "");
19 - const encodedName = encodePathPart(name);
20 - const encodedAddress = encodePathPart(address);
21 -
22 - expect(encodedName).toBe(expectedName);
23 - expect(encodedAddress).toBe(expectedAddress);
24 - expect(encodedAddress).not.toContain("=");
19 + expect(expectedAddress).not.toContain("=");
20 expect(adminLeasePath(name, address, "approve")).toBe(
26 - `${API_PATHS.admin.leases}/${encodeURIComponent(encodedName)}/${encodeURIComponent(encodedAddress)}/approve`
21 + `/admin/leases/${encodeURIComponent(expectedName)}/${encodeURIComponent(expectedAddress)}/approve`
22 );
23 });
24 });
frontend/src/lib/apiPaths.ts
+9 -12
@@ -1,12 +1,13 @@
1 export const API_PATHS = {
2 + public: {
3 + snapshot: "/api/public/snapshot",
4 + },
5 admin: {
3 - prefix: "/admin",
6 snapshot: "/admin/snapshot",
7 authChallenge: "/admin/auth/challenge",
8 authLogin: "/admin/auth/login",
9 logout: "/admin/logout",
10 authStatus: "/admin/auth/status",
9 - leases: "/admin/leases",
11
12 approvalMode: "/admin/settings/approval-mode",
13 landingPage: "/admin/settings/landing-page",
@@ -14,12 +15,7 @@ export const API_PATHS = {
15 tcpPortSettings: "/admin/settings/tcp-port",
16 },
17 sdk: {
17 - prefix: "/sdk",
18 - register: "/sdk/register",
19 - unregister: "/sdk/unregister",
20 - renew: "/sdk/renew",
18 domain: "/sdk/domain",
22 - connect: "/sdk/connect",
19 },
20 tunnel: {
21 status: "/tunnel/status",
@@ -31,12 +27,10 @@ export const API_PATHS = {
27 authStatus: "/v1/agent/auth/status",
28 },
29 discovery: "/discovery",
34 - healthz: "/healthz",
30 install: {
31 shell: "/install.sh",
32 powershell: "/install.ps1",
33 },
39 - appPrefix: "/app/",
34 } as const;
35
36 export const ROUTE_PATHS = {
@@ -45,7 +39,10 @@ export const ROUTE_PATHS = {
39 admin: "/admin",
40 } as const;
41
48 -export function encodePathPart(value: string): string {
42 +const ADMIN_LEASES_PATH = "/admin/leases";
43 +const ADMIN_IPS_PATH = "/admin/ips";
44 +
45 +function encodePathPart(value: string): string {
46 return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
47 }
48
@@ -56,9 +53,9 @@ export function adminLeasePath(
53 ): string {
54 const encodedName = encodePathPart(name);
55 const encodedAddress = encodePathPart(address);
59 - return `${API_PATHS.admin.leases}/${encodeURIComponent(encodedName)}/${encodeURIComponent(encodedAddress)}/${action}`;
56 + return `${ADMIN_LEASES_PATH}/${encodeURIComponent(encodedName)}/${encodeURIComponent(encodedAddress)}/${action}`;
57 }
58
59 export function adminIPBanPath(ip: string): string {
63 - return `${API_PATHS.admin.prefix}/ips/${encodeURIComponent(ip.trim())}/ban`;
60 + return `${ADMIN_IPS_PATH}/${encodeURIComponent(ip.trim())}/ban`;
61 }
frontend/src/lib/metadata.ts
+16 -27
@@ -1,34 +1,36 @@
1 -import type { Metadata } from "@/hooks/useSSRData";
1 +import type { Metadata } from "@/types/lease";
2
3 const EMPTY_METADATA: Metadata = {
4 description: "",
5 tags: [],
6 thumbnail: "",
7 owner: "",
8 - hide: false,
8 };
9
10 function isRecord(value: unknown): value is Record<string, unknown> {
11 return typeof value === "object" && value !== null && !Array.isArray(value);
12 }
13
14 +function metadataFromRecord(value: Record<string, unknown>): Metadata {
15 + return {
16 + description: typeof value.description === "string" ? value.description : "",
17 + tags: Array.isArray(value.tags)
18 + ? value.tags
19 + .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
20 + .filter(Boolean)
21 + : [],
22 + thumbnail: typeof value.thumbnail === "string" ? value.thumbnail : "",
23 + owner: typeof value.owner === "string" ? value.owner : "",
24 + };
25 +}
26 +
27 export function parseLeaseMetadata(metadataValue: unknown): Metadata {
28 if (!metadataValue) {
29 return EMPTY_METADATA;
30 }
31
32 if (isRecord(metadataValue)) {
21 - return {
22 - description: typeof metadataValue.description === "string" ? metadataValue.description : "",
23 - tags: Array.isArray(metadataValue.tags)
24 - ? metadataValue.tags
25 - .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
26 - .filter(Boolean)
27 - : [],
28 - thumbnail: typeof metadataValue.thumbnail === "string" ? metadataValue.thumbnail : "",
29 - owner: typeof metadataValue.owner === "string" ? metadataValue.owner : "",
30 - hide: typeof metadataValue.hide === "boolean" ? metadataValue.hide : false,
31 - };
33 + return metadataFromRecord(metadataValue);
34 }
35
36 if (typeof metadataValue !== "string") {
@@ -41,20 +43,7 @@ export function parseLeaseMetadata(metadataValue: unknown): Metadata {
43 return EMPTY_METADATA;
44 }
45
44 - const rawTags = parsed.tags;
45 - const tags = Array.isArray(rawTags)
46 - ? rawTags
47 - .map((tag) => (typeof tag === "string" ? tag.trim() : ""))
48 - .filter(Boolean)
49 - : [];
50 -
51 - return {
52 - description: typeof parsed.description === "string" ? parsed.description : "",
53 - tags,
54 - thumbnail: typeof parsed.thumbnail === "string" ? parsed.thumbnail : "",
55 - owner: typeof parsed.owner === "string" ? parsed.owner : "",
56 - hide: typeof parsed.hide === "boolean" ? parsed.hide : false,
57 - };
46 + return metadataFromRecord(parsed);
47 } catch {
48 return EMPTY_METADATA;
49 }
frontend/src/lib/releaseVersion.test.ts deleted
-21
@@ -1,21 +0,0 @@
1 -import { beforeEach, describe, expect, it } from "vitest";
2 -import {
3 - getReleaseVersion,
4 - RELEASE_VERSION_META_NAME,
5 -} from "@/lib/releaseVersion";
6 -
7 -describe("getReleaseVersion", () => {
8 - beforeEach(() => {
9 - document.head.innerHTML = "";
10 - });
11 -
12 - it("reads the release version from the portal meta tag", () => {
13 - document.head.innerHTML = `<meta name="${RELEASE_VERSION_META_NAME}" content=" v2.0.4 " />`;
14 -
15 - expect(getReleaseVersion(document)).toBe("v2.0.4");
16 - });
17 -
18 - it("returns an empty string when the version meta tag is missing", () => {
19 - expect(getReleaseVersion(document)).toBe("");
20 - });
21 -});
frontend/src/lib/releaseVersion.ts deleted
-17
@@ -1,17 +0,0 @@
1 -export const RELEASE_VERSION_META_NAME = "portal-release-version";
2 -
3 -export function getReleaseVersion(doc?: Document): string {
4 - const targetDoc =
5 - doc ?? (typeof document !== "undefined" ? document : undefined);
6 - if (!targetDoc) {
7 - return "";
8 - }
9 -
10 - return (
11 - targetDoc
12 - .querySelector<HTMLMetaElement>(
13 - `meta[name="${RELEASE_VERSION_META_NAME}"]`
14 - )
15 - ?.content.trim() || ""
16 - );
17 -}
frontend/src/pages/ServerDetail.tsx
+1 -1
@@ -3,7 +3,7 @@ import { useEffect } from "react";
3 import { useLocation, useNavigate } from "react-router-dom";
4
5 interface ServerDetailState {
6 - id: number;
6 + id: string;
7 name: string;
8 description: string;
9 tags: string[];
frontend/src/pages/ServerList.tsx
+1 -26
@@ -2,32 +2,7 @@ import { SsgoiTransition } from "@ssgoi/react";
2 import { useServerList } from "@/hooks/useServerList";
3 import { ServerListView } from "@/components/ServerListView";
4
5 -const LANDING_PAGE_ENABLED_META_NAME = "portal-landing-page-enabled";
6 -
7 -function readLandingPageEnabled(doc?: Document): boolean {
8 - const targetDoc =
9 - doc ?? (typeof document !== "undefined" ? document : undefined);
10 - if (!targetDoc) {
11 - return true;
12 - }
13 -
14 - const value =
15 - targetDoc
16 - .querySelector<HTMLMetaElement>(
17 - `meta[name="${LANDING_PAGE_ENABLED_META_NAME}"]`
18 - )
19 - ?.content.trim()
20 - .toLowerCase() || "";
21 -
22 - if (value === "" || value === "[%landing_page_enabled%]") {
23 - return true;
24 - }
25 -
26 - return value === "true" || value === "1" || value === "yes";
27 -}
28 -
5 export function ServerList() {
30 - // Controller: useServerList hook handles all server list logic
6 const {
7 searchQuery,
8 status,
@@ -41,8 +16,8 @@ export function ServerList() {
16 handleSortByChange,
17 handleTagToggle,
18 handleToggleFavorite,
19 + landingPageEnabled,
20 } = useServerList();
45 - const landingPageEnabled = readLandingPageEnabled();
21
22 return (
23 <SsgoiTransition id="/">
frontend/src/types/lease.ts new
+32
@@ -0,0 +1,32 @@
1 +export interface Metadata {
2 + description: string;
3 + tags: string[];
4 + thumbnail: string;
5 + owner: string;
6 +}
7 +
8 +export interface PublicLeaseData {
9 + FirstSeenAt: string;
10 + LastSeenAt: string;
11 + name?: string;
12 + Hostname: string;
13 + Metadata: unknown;
14 + Ready: number;
15 +}
16 +
17 +export interface AdminLeaseData extends PublicLeaseData {
18 + identity_key: string;
19 + address: string;
20 + BPS: number;
21 + ClientIP: string;
22 + ReportedIP: string;
23 + IsApproved: boolean;
24 + IsBanned: boolean;
25 + IsDenied: boolean;
26 + IsIPBanned: boolean;
27 +}
28 +
29 +export interface PublicSnapshotResponse {
30 + leases?: PublicLeaseData[];
31 + landing_page_enabled?: boolean;
32 +}
frontend/src/vite-env.d.ts
+8
@@ -1 +1,9 @@
1 /// <reference types="vite/client" />
2 +
3 +interface ImportMetaEnv {
4 + readonly VITE_PORTAL_API_BASE_URL?: string;
5 +}
6 +
7 +interface ImportMeta {
8 + readonly env: ImportMetaEnv;
9 +}
frontend/vite.config.ts
+5 -31
@@ -2,42 +2,16 @@ import { defineConfig } from "vite";
2 import react from "@vitejs/plugin-react";
3 import tailwindcss from "@tailwindcss/vite";
4 import { resolve } from "path";
5 -import { existsSync, renameSync } from "fs";
5
6 // https://vitejs.dev/config/
7 export default defineConfig({
8 plugins: [
9 react({
11 - babel: {
12 - plugins: [
13 - ["babel-plugin-react-compiler", {}]
14 - ]
15 - }
10 + babel: {
11 + plugins: [["babel-plugin-react-compiler", {}]],
12 + },
13 }),
14 tailwindcss(),
18 - {
19 - name: "rename-index",
20 - closeBundle() {
21 - if (process.env.VITEST) {
22 - return;
23 - }
24 -
25 - const appDir = resolve(process.cwd(), "../cmd/relay-server/dist/app");
26 - const indexPath = resolve(appDir, "index.html");
27 - const portalPath = resolve(appDir, "portal.html");
28 -
29 - if (!existsSync(indexPath)) {
30 - return;
31 - }
32 -
33 - try {
34 - renameSync(indexPath, portalPath);
35 - console.log("✓ Renamed index.html to portal.html");
36 - } catch (err) {
37 - console.error("Failed to rename index.html:", err);
38 - }
39 - },
40 - },
15 ],
16 resolve: {
17 alias: {
@@ -45,8 +19,8 @@ export default defineConfig({
19 },
20 },
21 build: {
48 - outDir: "../cmd/relay-server/dist/app",
49 - emptyOutDir: false,
22 + outDir: "dist",
23 + emptyOutDir: true,
24 rollupOptions: {
25 output: {
26 manualChunks: undefined,
portal/api_server.go
+3 -2
@@ -87,6 +87,9 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
87 }
88
89 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 + if utils.HandleAPICORS(w, r) {
91 + return
92 + }
93 switch strings.TrimSpace(r.URL.Path) {
94 case types.PathHealthz:
95 s.handleHealthz(w, r)
@@ -243,8 +246,6 @@ func (s *Server) handleRelayDiscoveryAnnounce(w http.ResponseWriter, r *http.Req
246 }
247
248 func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
246 - w.Header().Set("Access-Control-Allow-Origin", "*")
247 -
249 if !utils.RequireMethod(w, r, http.MethodGet) {
250 return
251 }
types/api.go
+7 -6
@@ -207,6 +207,11 @@ type TunnelStatusResponse struct {
207 ServiceAlive bool `json:"service_alive"`
208 }
209
210 +type PublicSnapshotResponse struct {
211 + Leases []Lease `json:"leases,omitempty"`
212 + LandingPageEnabled bool `json:"landing_page_enabled"`
213 +}
214 +
215 type WalletAuthChallengeRequest struct {
216 Address string `json:"address"`
217 }
@@ -224,6 +229,7 @@ type WalletAuthLoginRequest struct {
229 }
230
231 type WalletAuthLoginResponse struct {
232 + AccessToken string `json:"access_token,omitempty"`
233 WalletAddress string `json:"wallet_address,omitempty"`
234 }
235
@@ -260,7 +266,7 @@ type AdminBPSRequest struct {
266 BPS int64 `json:"bps"`
267 }
268
263 -type AdminUDPSettingsRequest struct {
269 +type AdminPortSettingsRequest struct {
270 Enabled bool `json:"enabled"`
271 MaxLeases int `json:"max_leases"`
272 }
@@ -270,11 +276,6 @@ type AdminUDPSettingsResponse struct {
276 MaxLeases int `json:"max_leases"`
277 }
278
273 -type AdminTCPPortSettingsRequest struct {
274 - Enabled bool `json:"enabled"`
275 - MaxLeases int `json:"max_leases"`
276 -}
277 -
279 type AdminTCPPortSettingsResponse struct {
280 Enabled bool `json:"enabled"`
281 MaxLeases int `json:"max_leases"`
types/paths.go
+2 -5
@@ -4,13 +4,9 @@ const (
4 PathV1Sign = "/v1/sign"
5 PathHealthz = "/healthz"
6 PathRoot = "/"
7 - PathAssetsPrefix = "/assets/"
8 - PathApp = "/app"
9 - PathAppPrefix = "/app/"
7 PathAdmin = "/admin"
8 PathAdminPrefix = "/admin/"
9 PathAdminSnapshot = "/admin/snapshot"
13 - PathAdminLeases = "/admin/leases"
10 PathAdminLeasesPrefix = "/admin/leases/"
11 PathAdminAuthChallenge = "/admin/auth/challenge"
12 PathAdminAuthLogin = "/admin/auth/login"
@@ -25,6 +21,8 @@ const (
21 PathInstallPowerShell = "/install.ps1"
22 PathInstallBinPrefix = "/install/bin/"
23
24 + PathPublicSnapshot = "/api/public/snapshot"
25 +
26 PathAgentPrefix = "/v1/agent"
27 PathAgentStatus = PathAgentPrefix + "/status"
28 PathAgentShutdown = PathAgentPrefix + "/shutdown"
@@ -38,7 +36,6 @@ const (
36 PathTunnelStatus = "/tunnel/status"
37 PathThumbnailPrefix = "/thumbnail/"
38
41 - PathSDKPrefix = "/sdk/"
39 PathSDKDomain = "/sdk/domain"
40 PathSDKRegisterChallenge = "/sdk/register/challenge"
41 PathSDKRegister = "/sdk/register"
utils/api.go
+13
@@ -39,6 +39,19 @@ func WriteAPIError(w http.ResponseWriter, status int, code, message string) {
39 })
40 }
41
42 +func HandleAPICORS(w http.ResponseWriter, r *http.Request) bool {
43 + header := w.Header()
44 + header.Set("Access-Control-Allow-Origin", "*")
45 + header.Set("Access-Control-Allow-Methods", "GET, HEAD, POST, DELETE, OPTIONS")
46 + header.Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, "+types.HeaderAccessToken)
47 + header.Set("Access-Control-Max-Age", "600")
48 + if r.Method != http.MethodOptions {
49 + return false
50 + }
51 + w.WriteHeader(http.StatusNoContent)
52 + return true
53 +}
54 +
55 func MethodNotAllowedError() APIErrorResponse {
56 return APIErrorResponse{
57 Status: http.StatusMethodNotAllowed,