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,