feat: Implement wallet authentication and remove admin login

Kim committed May 13, 2026 at 19:00 UTC 3b63468143604fa9d846ccb364b0b14ead8a57a7
29 files changed +870 -422
cmd/portal-tunnel/agent/config.go
+12 -3
@@ -32,9 +32,10 @@ type Config struct {
32 }
33
34 type AgentConfig struct {
35 - StateDir string `koanf:"state_dir"`
36 - ControlAddr string `koanf:"control_addr"`
37 - ServiceName string `koanf:"service_name"`
35 + StateDir string `koanf:"state_dir"`
36 + ControlAddr string `koanf:"control_addr"`
37 + ServiceName string `koanf:"service_name"`
38 + AllowedWallets []string `koanf:"allowed_wallets"`
39 }
40
41 type TunnelConfig struct {
@@ -149,6 +150,7 @@ func configMap(cfg Config) map[string]any {
150 addStringDocumentField(agent, "state_dir", cfg.Agent.StateDir)
151 addStringDocumentField(agent, "control_addr", cfg.Agent.ControlAddr)
152 addStringDocumentField(agent, "service_name", cfg.Agent.ServiceName)
153 + addStringSliceDocumentField(agent, "allowed_wallets", cfg.Agent.AllowedWallets)
154
155 tunnels := make([]map[string]any, 0, len(cfg.Tunnels))
156 for _, tunnel := range cfg.Tunnels {
@@ -233,6 +235,13 @@ func (cfg *Config) ApplyDefaults(configPath string) error {
235 cfg.Agent.StateDir = strings.TrimSpace(cfg.Agent.StateDir)
236 cfg.Agent.ControlAddr = strings.TrimSpace(cfg.Agent.ControlAddr)
237 cfg.Agent.ServiceName = strings.TrimSpace(cfg.Agent.ServiceName)
238 + allowedWallets := cfg.Agent.AllowedWallets[:0]
239 + for _, wallet := range cfg.Agent.AllowedWallets {
240 + if wallet = strings.TrimSpace(wallet); wallet != "" {
241 + allowedWallets = append(allowedWallets, wallet)
242 + }
243 + }
244 + cfg.Agent.AllowedWallets = allowedWallets
245 if strings.TrimSpace(cfg.Agent.StateDir) == "" {
246 cfg.Agent.StateDir = service.DefaultDataDir()
247 } else if !filepath.IsAbs(cfg.Agent.StateDir) {
cmd/portal-tunnel/agent/control.go
+130 -2
@@ -9,6 +9,7 @@ import (
9 "strings"
10 "time"
11
12 + portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
13 "github.com/gosuda/portal-tunnel/v2/types"
14 "github.com/gosuda/portal-tunnel/v2/utils"
15 )
@@ -16,6 +17,7 @@ import (
17 const (
18 controlRequestBodyLimit = 8 << 10
19 endpointFilename = "agent-endpoint.json"
20 + agentCookieName = "portal_agent"
21 )
22
23 var controlHTTPClient = utils.NewHTTPClient(utils.WithHTTPTimeout(5 * time.Second))
@@ -28,12 +30,20 @@ type endpoint struct {
30 type controlHandler struct {
31 manager *manager
32 token string
33 + auth *portalauth.WalletAuthenticator
34 shutdown func()
35 }
36
37 func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
38 + if s.serveWalletAuth(w, r) {
39 + return
40 + }
41 +
42 auth := strings.TrimSpace(r.Header.Get("Authorization"))
36 - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) != s.token {
43 + bearerAuthenticated := strings.HasPrefix(auth, "Bearer ") && strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == s.token
44 + walletAddress, walletAuthenticated := s.authenticatedWallet(r)
45 + allowed := bearerAuthenticated || (walletAuthenticated && r.URL.Path == types.PathAgentStatus)
46 + if !allowed {
47 utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, "unauthorized")
48 return
49 }
@@ -43,7 +53,11 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
53 if !utils.RequireMethod(w, r, http.MethodGet) {
54 return
55 }
46 - utils.WriteAPIData(w, http.StatusOK, s.manager.Snapshot())
56 + status := s.manager.Snapshot()
57 + if walletAuthenticated {
58 + status.WalletAddress = walletAddress
59 + }
60 + utils.WriteAPIData(w, http.StatusOK, status)
61 case r.URL.Path == types.PathAgentShutdown:
62 if !utils.RequireMethod(w, r, http.MethodPost) {
63 return
@@ -139,6 +153,120 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
153 }
154 }
155
156 +func (s *controlHandler) serveWalletAuth(w http.ResponseWriter, r *http.Request) bool {
157 + switch r.URL.Path {
158 + case types.PathAgentAuthChallenge:
159 + if !utils.RequireMethod(w, r, http.MethodPost) {
160 + return true
161 + }
162 + req, ok := utils.DecodeJSONRequest[types.WalletAuthChallengeRequest](w, r, controlRequestBodyLimit)
163 + if !ok {
164 + return true
165 + }
166 + resp, err := s.auth.IssueChallenge(req, agentAuthDomain(r), agentAuthURI(r, types.PathAgentAuthLogin), time.Now().UTC())
167 + if err != nil {
168 + writeAgentWalletAuthError(w, err)
169 + return true
170 + }
171 + utils.WriteAPIData(w, http.StatusCreated, resp)
172 + return true
173 + case types.PathAgentAuthLogin:
174 + if !utils.RequireMethod(w, r, http.MethodPost) {
175 + return true
176 + }
177 + req, ok := utils.DecodeJSONRequest[types.WalletAuthLoginRequest](w, r, controlRequestBodyLimit)
178 + if !ok {
179 + return true
180 + }
181 + token, walletAddress, err := s.auth.Login(req, time.Now().UTC())
182 + if err != nil {
183 + writeAgentWalletAuthError(w, err)
184 + return true
185 + }
186 + http.SetCookie(w, &http.Cookie{
187 + Name: agentCookieName,
188 + Value: token,
189 + Path: types.PathAgentPrefix,
190 + HttpOnly: true,
191 + SameSite: http.SameSiteStrictMode,
192 + MaxAge: 86400,
193 + })
194 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{WalletAddress: walletAddress})
195 + return true
196 + case types.PathAgentAuthLogout:
197 + if !utils.RequireMethod(w, r, http.MethodPost) {
198 + return true
199 + }
200 + if cookie, err := r.Cookie(agentCookieName); err == nil && cookie.Value != "" {
201 + s.auth.DeleteSession(cookie.Value)
202 + }
203 + http.SetCookie(w, &http.Cookie{
204 + Name: agentCookieName,
205 + Value: "",
206 + Path: types.PathAgentPrefix,
207 + HttpOnly: true,
208 + SameSite: http.SameSiteStrictMode,
209 + MaxAge: -1,
210 + })
211 + utils.WriteAPIData(w, http.StatusOK, map[string]any{})
212 + return true
213 + case types.PathAgentAuthStatus:
214 + if !utils.RequireMethod(w, r, http.MethodGet) {
215 + return true
216 + }
217 + walletAddress, authenticated := s.authenticatedWallet(r)
218 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthStatusResponse{
219 + Authenticated: authenticated,
220 + WalletAddress: walletAddress,
221 + })
222 + return true
223 + default:
224 + return false
225 + }
226 +}
227 +
228 +func (s *controlHandler) authenticatedWallet(r *http.Request) (string, bool) {
229 + if s == nil || s.auth == nil {
230 + return "", false
231 + }
232 + cookie, err := r.Cookie(agentCookieName)
233 + if err != nil {
234 + return "", false
235 + }
236 + return s.auth.ValidateSession(cookie.Value)
237 +}
238 +
239 +func agentAuthDomain(r *http.Request) string {
240 + domain := strings.TrimSpace(r.Host)
241 + if domain != "" {
242 + return domain
243 + }
244 + return "localhost"
245 +}
246 +
247 +func agentAuthURI(r *http.Request, endpointPath string) string {
248 + scheme := "https"
249 + if r.TLS == nil {
250 + scheme = "http"
251 + }
252 + return (&url.URL{
253 + Scheme: scheme,
254 + Host: agentAuthDomain(r),
255 + Path: endpointPath,
256 + }).String()
257 +}
258 +
259 +func writeAgentWalletAuthError(w http.ResponseWriter, err error) {
260 + switch {
261 + case errors.Is(err, portalauth.ErrWalletAuthUnauthorized):
262 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
263 + case errors.Is(err, portalauth.ErrWalletAuthChallengeNotFound), errors.Is(err, portalauth.ErrWalletAuthChallengeExpired), errors.Is(err, portalauth.ErrWalletAuthInvalidSignature):
264 + utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
265 + default:
266 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
267 + }
268 +}
269 +
270 func Status(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
271 var status types.AgentStatusResponse
272 err := controlRequest(ctx, stateDir, http.MethodGet, types.PathAgentStatus, nil, &status)
cmd/portal-tunnel/agent/manager.go
+6
@@ -509,6 +509,9 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
509 MultiHop: append([]string(nil), cfg.MultiHop...),
510 }
511 if exposure == nil {
512 + if strings.TrimSpace(runtime.Address) != "" {
513 + status.Address = runtime.Address
514 + }
515 if strings.TrimSpace(runtime.TargetAddr) != "" {
516 status.TargetAddr = runtime.TargetAddr
517 }
@@ -522,6 +525,7 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
525 t.mu.Lock()
526 if t.exposure == exposure {
527 t.runtime = types.AgentTunnelStatus{
528 + Address: snapshot.Address,
529 TargetAddr: snapshot.TargetAddr,
530 MultiHop: append([]string(nil), snapshot.MultiHop...),
531 Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
@@ -529,6 +533,7 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
533 }
534 t.mu.Unlock()
535
536 + status.Address = snapshot.Address
537 status.TargetAddr = snapshot.TargetAddr
538 status.MultiHop = append([]string(nil), snapshot.MultiHop...)
539 status.Relays = append([]types.AgentRelayStatus(nil), snapshot.Relays...)
@@ -601,6 +606,7 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
606 t.mu.Lock()
607 t.exposure = exposure
608 t.runtime = types.AgentTunnelStatus{
609 + Address: snapshot.Address,
610 TargetAddr: snapshot.TargetAddr,
611 MultiHop: append([]string(nil), snapshot.MultiHop...),
612 Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
cmd/portal-tunnel/agent/run.go
+10
@@ -13,6 +13,7 @@ import (
13
14 "github.com/rs/zerolog/log"
15
16 + portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
17 "github.com/gosuda/portal-tunnel/v2/utils"
18 )
19
@@ -30,6 +31,14 @@ func Run(ctx context.Context, cfg Config) error {
31 defer cancel()
32
33 manager := newManager(cfg, "")
34 + walletAuth, err := portalauth.NewWalletAuthenticator(portalauth.WalletAuthConfig{
35 + AllowedAddresses: cfg.Agent.AllowedWallets,
36 + AllowAnyAddress: len(cfg.Agent.AllowedWallets) == 0,
37 + Statement: "Sign in to Portal agent",
38 + })
39 + if err != nil {
40 + return err
41 + }
42 controlAddr := strings.TrimSpace(cfg.Agent.ControlAddr)
43 if controlAddr == "" {
44 return errors.New("control address is required")
@@ -57,6 +66,7 @@ func Run(ctx context.Context, cfg Config) error {
66 Handler: &controlHandler{
67 manager: manager,
68 token: token,
69 + auth: walletAuth,
70 shutdown: cancel,
71 },
72 ReadHeaderTimeout: 5 * time.Second,
cmd/relay-server/admin.go
+65 -92
@@ -1,14 +1,14 @@
1 package main
2
3 import (
4 - "crypto/subtle"
4 "errors"
5 "net"
6 "net/http"
7 + "net/url"
8 "strings"
9 - "sync"
9 "time"
10
11 + portalauth "github.com/gosuda/portal-tunnel/v2/portal/auth"
12 "github.com/gosuda/portal-tunnel/v2/portal/identity"
13 "github.com/gosuda/portal-tunnel/v2/portal/policy"
14 "github.com/gosuda/portal-tunnel/v2/types"
@@ -21,72 +21,6 @@ const (
21 adminBodyLimit = 1 << 16
22 )
23
24 -type adminAuth struct {
25 - sessions map[string]time.Time
26 - secretKey string
27 - mu sync.RWMutex
28 -}
29 -
30 -func newAdminAuth(secretKey string) (*adminAuth, error) {
31 - secretKey = strings.TrimSpace(secretKey)
32 - if secretKey == "" {
33 - return nil, errors.New("admin secret key is required")
34 - }
35 -
36 - return &adminAuth{
37 - secretKey: secretKey,
38 - sessions: make(map[string]time.Time),
39 - }, nil
40 -}
41 -
42 -func (a *adminAuth) AuthEnabled() bool {
43 - return a != nil && a.secretKey != ""
44 -}
45 -
46 -func (a *adminAuth) ValidateKey(key string) bool {
47 - if !a.AuthEnabled() {
48 - return false
49 - }
50 - return subtle.ConstantTimeCompare([]byte(a.secretKey), []byte(key)) == 1
51 -}
52 -
53 -func (a *adminAuth) CreateSession() (string, error) {
54 - token := utils.RandomID("")
55 -
56 - a.mu.Lock()
57 - defer a.mu.Unlock()
58 - a.sessions[token] = time.Now().Add(24 * time.Hour)
59 - a.cleanupExpiredSessionsLocked()
60 - return token, nil
61 -}
62 -
63 -func (a *adminAuth) ValidateSession(token string) bool {
64 - if token == "" {
65 - return false
66 - }
67 -
68 - a.mu.RLock()
69 - defer a.mu.RUnlock()
70 -
71 - expiry, ok := a.sessions[token]
72 - return ok && time.Now().Before(expiry)
73 -}
74 -
75 -func (a *adminAuth) DeleteSession(token string) {
76 - a.mu.Lock()
77 - defer a.mu.Unlock()
78 - delete(a.sessions, token)
79 -}
80 -
81 -func (a *adminAuth) cleanupExpiredSessionsLocked() {
82 - now := time.Now()
83 - for token, expiry := range a.sessions {
84 - if now.After(expiry) {
85 - delete(a.sessions, token)
86 - }
87 - }
88 -}
89 -
24 func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState, error) {
25 path = strings.TrimSpace(path)
26 if path == "" {
@@ -117,16 +51,17 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
51 }
52 http.NotFound(w, r)
53 return
120 - case types.PathAdminLogin:
121 - if r.Method == http.MethodGet {
122 - f.ServeAppStatic(w, r, "")
54 + case types.PathAdminAuthChallenge:
55 + if !utils.RequireMethod(w, r, http.MethodPost) {
56 return
57 }
125 - if r.Method == http.MethodPost {
126 - f.handleLogin(w, r)
58 + f.handleWalletChallenge(w, r)
59 + return
60 + case types.PathAdminAuthLogin:
61 + if !utils.RequireMethod(w, r, http.MethodPost) {
62 return
63 }
129 - utils.MethodNotAllowedError().Write(w)
64 + f.handleWalletLogin(w, r)
65 return
66 case types.PathAdminLogout:
67 if !utils.RequireMethod(w, r, http.MethodPost) {
@@ -150,9 +85,10 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
85 if !utils.RequireMethod(w, r, http.MethodGet) {
86 return
87 }
153 - utils.WriteAPIData(w, http.StatusOK, types.AdminAuthStatusResponse{
154 - Authenticated: f.isAuthenticated(r),
155 - AuthEnabled: f.auth.AuthEnabled(),
88 + walletAddress, authenticated := f.authenticatedWallet(r)
89 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthStatusResponse{
90 + Authenticated: authenticated,
91 + WalletAddress: walletAddress,
92 })
93 return
94 }
@@ -375,26 +311,27 @@ func (f *Frontend) handlePortSettings(
311 utils.WriteAPIData(w, http.StatusOK, buildResponse())
312 }
313
378 -func (f *Frontend) handleLogin(w http.ResponseWriter, r *http.Request) {
379 - if !utils.RequireMethod(w, r, http.MethodPost) {
314 +func (f *Frontend) handleWalletChallenge(w http.ResponseWriter, r *http.Request) {
315 + req, ok := utils.DecodeJSONRequestAs[types.WalletAuthChallengeRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
316 + if !ok {
317 return
318 }
382 - if !f.auth.AuthEnabled() {
383 - utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeAuthDisabled, "admin authentication is not configured")
319 + resp, err := f.auth.IssueChallenge(req, adminAuthDomain(r, f.server.RelayIdentity().Name), adminAuthURI(r, types.PathAdminAuthLogin), time.Now().UTC())
320 + if err != nil {
321 + writeWalletAuthError(w, err)
322 return
323 }
324 + utils.WriteAPIData(w, http.StatusCreated, resp)
325 +}
326
387 - req, ok := utils.DecodeJSONRequestAs[types.AdminLoginRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
327 +func (f *Frontend) handleWalletLogin(w http.ResponseWriter, r *http.Request) {
328 + req, ok := utils.DecodeJSONRequestAs[types.WalletAuthLoginRequest](w, r, adminBodyLimit, utils.InvalidRequestError(errors.New("invalid request body")))
329 if !ok {
330 return
331 }
391 - if !f.auth.ValidateKey(req.Key) {
392 - utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeInvalidKey, "Invalid key")
393 - return
394 - }
395 - token, err := f.auth.CreateSession()
332 + token, walletAddress, err := f.auth.Login(req, time.Now().UTC())
333 if err != nil {
397 - utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeSessionCreateFailed, "failed to create admin session")
334 + writeWalletAuthError(w, err)
335 return
336 }
337
@@ -407,20 +344,56 @@ func (f *Frontend) handleLogin(w http.ResponseWriter, r *http.Request) {
344 SameSite: http.SameSiteStrictMode,
345 MaxAge: 86400,
346 })
410 - utils.WriteAPIData(w, http.StatusOK, types.AdminLoginResponse{Success: true})
347 + utils.WriteAPIData(w, http.StatusOK, types.WalletAuthLoginResponse{WalletAddress: walletAddress})
348 }
349
350 func (f *Frontend) isAuthenticated(r *http.Request) bool {
414 - if !f.auth.AuthEnabled() {
415 - return false
351 + _, ok := f.authenticatedWallet(r)
352 + return ok
353 +}
354 +
355 +func (f *Frontend) authenticatedWallet(r *http.Request) (string, bool) {
356 + if f.auth == nil {
357 + return "", false
358 }
359 cookie, err := r.Cookie(cookieName)
360 if err != nil {
419 - return false
361 + return "", false
362 }
363 return f.auth.ValidateSession(cookie.Value)
364 }
365
366 +func adminAuthDomain(r *http.Request, fallback string) string {
367 + domain := strings.TrimSpace(r.Host)
368 + if domain != "" {
369 + return domain
370 + }
371 + return strings.TrimSpace(fallback)
372 +}
373 +
374 +func adminAuthURI(r *http.Request, endpointPath string) string {
375 + scheme := "https"
376 + if r.TLS == nil {
377 + scheme = "http"
378 + }
379 + return (&url.URL{
380 + Scheme: scheme,
381 + Host: adminAuthDomain(r, "localhost"),
382 + Path: endpointPath,
383 + }).String()
384 +}
385 +
386 +func writeWalletAuthError(w http.ResponseWriter, err error) {
387 + switch {
388 + case errors.Is(err, portalauth.ErrWalletAuthUnauthorized):
389 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, err.Error())
390 + case errors.Is(err, portalauth.ErrWalletAuthChallengeNotFound), errors.Is(err, portalauth.ErrWalletAuthChallengeExpired), errors.Is(err, portalauth.ErrWalletAuthInvalidSignature):
391 + utils.WriteAPIError(w, http.StatusUnauthorized, types.APIErrorCodeUnauthorized, err.Error())
392 + default:
393 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
394 + }
395 +}
396 +
397 func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled bool) {
398 path = strings.TrimSpace(path)
399 if path == "" {
cmd/relay-server/frontend.go
+9 -4
@@ -19,6 +19,7 @@ import (
19
20 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
21 "github.com/gosuda/portal-tunnel/v2/portal"
22 + portalauth "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"
@@ -35,7 +36,7 @@ var embeddedDistFS embed.FS
36 type Frontend struct {
37 distFS readDirFileFS
38 server *portal.Server
38 - auth *adminAuth
39 + auth *portalauth.WalletAuthenticator
40 adminSettingsPath string
41 thumbnails *thumbnailService
42
@@ -44,7 +45,7 @@ type Frontend struct {
45 landingPageEnabled atomic.Bool
46 }
47
47 -func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageEnabled bool, headlessShellURL string) (*Frontend, error) {
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 }
@@ -61,7 +62,11 @@ func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageE
62 return nil, err
63 }
64 relayIdentity := server.RelayIdentity()
64 - auth, err := newAdminAuth(relayIdentity.AdminSecretKey)
65 + allowedWallets := append([]string{relayIdentity.Address}, adminWallets...)
66 + authenticator, err := portalauth.NewWalletAuthenticator(portalauth.WalletAuthConfig{
67 + AllowedAddresses: allowedWallets,
68 + Statement: "Sign in to Portal relay admin",
69 + })
70 if err != nil {
71 return nil, err
72 }
@@ -69,7 +74,7 @@ func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageE
74 frontend := &Frontend{
75 distFS: embeddedDistFS,
76 server: server,
72 - auth: auth,
77 + auth: authenticator,
78 adminSettingsPath: strings.TrimSpace(adminSettingsPath),
79 thumbnails: newThumbnailService(headlessShellURL),
80 }
cmd/relay-server/main.go
+4 -1
@@ -46,6 +46,7 @@ type relayServerConfig struct {
46 TCPEnabled bool
47 MinPort int
48 MaxPort int
49 + AdminWallets string
50 LandingPageEnabled bool
51 HeadlessShellURL string
52 PProfEnabled bool
@@ -84,6 +85,7 @@ func runServeCommand(args []string) error {
85 utils.IntFlagEnv(fs, &cfg.MinPort, "min-port", 0, utils.ParseOptionalPortNumber, "inclusive minimum lease port shared by UDP and raw TCP transports (0=disabled)", "MIN_PORT")
86 utils.IntFlagEnv(fs, &cfg.MaxPort, "max-port", 0, utils.ParseOptionalPortNumber, "inclusive maximum lease port shared by UDP and raw TCP transports (0=disabled)", "MAX_PORT")
87
88 + utils.StringFlagEnv(fs, &cfg.AdminWallets, "admin-wallets", "", "admin wallet address allowlist, comma-separated; relay identity address is always allowed", "ADMIN_WALLETS")
89 utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
90 utils.StringFlagEnv(fs, &cfg.HeadlessShellURL, "headless-shell-url", "", "headless Chrome CDP WebSocket URL for thumbnail generation (e.g. ws://headless-shell:9222)", "HEADLESS_SHELL_URL")
91 utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
@@ -128,6 +130,7 @@ func runServeCommand(args []string) error {
130 Bool("tcp_enabled", cfg.TCPEnabled).
131 Int("min_port", cfg.MinPort).
132 Int("max_port", cfg.MaxPort).
133 + Bool("admin_wallets_configured", len(utils.SplitCSV(cfg.AdminWallets)) > 0).
134 Bool("landing_page_enabled", cfg.LandingPageEnabled).
135 Bool("headless_shell_enabled", strings.TrimSpace(cfg.HeadlessShellURL) != "").
136 Bool("pprof_enabled", cfg.PProfEnabled).
@@ -178,7 +181,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
181 return fmt.Errorf("create relay server: %w", err)
182 }
183
181 - frontend, err := NewFrontend(server, cfg.IdentityPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL)
184 + frontend, err := NewFrontend(server, cfg.IdentityPath, cfg.LandingPageEnabled, cfg.HeadlessShellURL, utils.SplitCSV(cfg.AdminWallets))
185 if err != nil {
186 return fmt.Errorf("create frontend: %w", err)
187 }
docs/src/routes/api-reference/+page.md
+10 -10
@@ -54,14 +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 (Secret Key)
57 +### Admin Authentication (Wallet Session)
58
59 -Admin clients authenticate using a shared secret key:
59 +Admin clients authenticate with a wallet signature:
60
61 -1. POST to `/admin/login` with `{ "key": "<secret>" }`
62 -2. The server sets a `portal_admin` session cookie (HttpOnly, Secure, SameSite=Strict)
63 -3. Include the cookie in subsequent admin requests
64 -4. Sessions expire after 24 hours
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
67
68 ## Endpoint Summary
69
@@ -80,7 +82,8 @@ Admin clients authenticate using a shared secret key:
82
83 | Method | Path | Description | Auth |
84 |--------|------|-------------|------|
83 -| `POST` | [`/admin/login`](/api-reference/admin#post-adminlogin) | Authenticate with secret key | None |
85 +| `POST` | [`/admin/auth/challenge`](/api-reference/admin#post-adminauthchallenge) | Request wallet login challenge | None |
86 +| `POST` | [`/admin/auth/login`](/api-reference/admin#post-adminauthlogin) | Complete wallet login | None |
87 | `POST` | [`/admin/logout`](/api-reference/admin#post-adminlogout) | End admin session | Session Cookie |
88 | `GET` | [`/admin/auth/status`](/api-reference/admin#get-adminauthstatus) | Check authentication status | None |
89 | `GET` | [`/admin/snapshot`](/api-reference/admin#get-adminsnapshot) | Get full relay state snapshot | Session Cookie |
@@ -220,7 +223,6 @@ All error codes that may appear in the `error.code` field:
223
224 | Code | Description |
225 |------|-------------|
223 -| `auth_disabled` | Admin authentication is not configured |
226 | `feature_unavailable` | Requested feature is not available |
227 | `hijack_failed` | HTTP connection hijack failed |
228 | `hijack_unsupported` | HTTP connection hijack not supported |
@@ -229,7 +231,6 @@ All error codes that may appear in the `error.code` field:
231 | `invalid_address` | Invalid Ethereum address |
232 | `invalid_ip` | Invalid IP address format |
233 | `invalid_json` | Malformed JSON request body |
232 -| `invalid_key` | Invalid admin secret key |
234 | `invalid_mode` | Invalid approval mode value |
235 | `invalid_request` | General request validation failure |
236 | `internal` | Internal server error |
@@ -237,7 +238,6 @@ All error codes that may appear in the `error.code` field:
238 | `lease_not_found` | No lease found for the given identity |
239 | `lease_rejected` | Lease is not approved for routing |
240 | `method_not_allowed` | HTTP method not allowed for this endpoint |
240 -| `session_create_failed` | Failed to create admin session |
241 | `unauthorized` | Authentication required or token invalid |
242 | `udp_port_exhausted` | No UDP ports available |
243 | `udp_disabled` | UDP transport is disabled |
docs/src/routes/api-reference/admin/+page.md
+47 -14
@@ -9,8 +9,10 @@ import Mermaid from '$lib/components/Mermaid.svelte'
9 const adminWorkflowDiagram = `sequenceDiagram
10 participant Admin
11 participant Relay as Portal Relay
12 - Admin->>Relay: POST /admin/login
13 - Note right of Admin: secret key in body
12 + Admin->>Relay: POST /admin/auth/challenge
13 + Relay->>Admin: SIWE message
14 + Admin->>Relay: POST /admin/auth/login
15 + Note right of Admin: wallet signature in body
16 Relay->>Admin: Set-Cookie session token
17 Admin->>Relay: GET /admin/snapshot
18 Relay->>Admin: Full relay state
@@ -39,9 +41,9 @@ These endpoints allow relay operators to manage leases, configure settings, and
41
42 ## Authentication
43
42 -### `POST /admin/login`
44 +### `POST /admin/auth/challenge`
45
44 -Authenticate with the admin secret key. On success, sets a session cookie used for all subsequent admin requests.
46 +Request a SIWE message for wallet-based admin login.
47
48 **Auth:** None
49
@@ -49,13 +51,45 @@ Authenticate with the admin secret key. On success, sets a session cookie used f
51
52 | Field | Type | Required | Description |
53 |-------|------|----------|-------------|
52 -| `key` | `string` | Yes | Admin secret key |
54 +| `address` | `string` | Yes | Ethereum wallet address |
55
56 **Response fields:**
57
58 | Field | Type | Description |
59 |-------|------|-------------|
58 -| `success` | `bool` | `true` on successful login |
60 +| `challenge_id` | `string` | Challenge identifier |
61 +| `siwe_message` | `string` | Message to sign with the wallet |
62 +| `expires_at` | `string` | ISO 8601 challenge expiration |
63 +
64 +**Example:**
65 +
66 +```bash
67 +curl -X POST https://relay.example.com/admin/auth/challenge \
68 + -H "Content-Type: application/json" \
69 + -d '{ "address": "0x1234567890abcdef1234567890abcdef12345678" }'
70 +```
71 +
72 +---
73 +
74 +### `POST /admin/auth/login`
75 +
76 +Complete wallet login with the signed SIWE message. On success, sets a session cookie used for subsequent admin requests.
77 +
78 +**Auth:** None
79 +
80 +**Request body:**
81 +
82 +| Field | Type | Required | Description |
83 +|-------|------|----------|-------------|
84 +| `challenge_id` | `string` | Yes | Challenge identifier returned by `/admin/auth/challenge` |
85 +| `siwe_message` | `string` | Yes | Exact SIWE message returned by `/admin/auth/challenge` |
86 +| `siwe_signature` | `string` | Yes | Wallet signature for the SIWE message |
87 +
88 +**Response fields:**
89 +
90 +| Field | Type | Description |
91 +|-------|------|-------------|
92 +| `wallet_address` | `string` | Authenticated wallet address |
93
94 **Response cookies:**
95
@@ -67,16 +101,15 @@ Authenticate with the admin secret key. On success, sets a session cookie used f
101
102 | Code | Status | Description |
103 |------|--------|-------------|
70 -| `auth_disabled` | 503 | Admin authentication is not configured |
71 -| `invalid_key` | 401 | Incorrect secret key |
104 +| `unauthorized` | 401/403 | Signature, challenge, or wallet address is invalid |
105
106 **Example:**
107
108 ```bash
76 -curl -X POST https://relay.example.com/admin/login \
109 +curl -X POST https://relay.example.com/admin/auth/login \
110 -H "Content-Type: application/json" \
111 -c cookies.txt \
79 - -d '{ "key": "my-secret-key" }'
112 + -d '{ "challenge_id": "...", "siwe_message": "...", "siwe_signature": "0x..." }'
113 ```
114
115 **Response:**
@@ -85,7 +118,7 @@ curl -X POST https://relay.example.com/admin/login \
118 {
119 "ok": true,
120 "data": {
88 - "success": true
121 + "wallet_address": "0x1234567890abcdef1234567890abcdef12345678"
122 }
123 }
124 ```
@@ -113,7 +146,7 @@ curl -X POST https://relay.example.com/admin/logout \
146
147 ### `GET /admin/auth/status`
148
116 -Check the current authentication status. Can be called without a session to determine whether admin auth is enabled.
149 +Check the current wallet session. Can be called without a session.
150
151 **Auth:** None (returns status regardless)
152
@@ -122,7 +155,7 @@ Check the current authentication status. Can be called without a session to dete
155 | Field | Type | Description |
156 |-------|------|-------------|
157 | `authenticated` | `bool` | `true` if the request has a valid session |
125 -| `auth_enabled` | `bool` | `true` if admin auth is configured |
158 +| `wallet_address` | `string` | Authenticated wallet address, when logged in |
159
160 **Example:**
161
@@ -138,7 +171,7 @@ curl https://relay.example.com/admin/auth/status \
171 "ok": true,
172 "data": {
173 "authenticated": true,
141 - "auth_enabled": true
174 + "wallet_address": "0x1234567890abcdef1234567890abcdef12345678"
175 }
176 }
177 ```
docs/src/routes/configuration/+page.md
+1 -1
@@ -198,6 +198,7 @@ Agent fields:
198 | `state_dir` | Platform default state directory | Stores the local control endpoint token and runtime state |
199 | `control_addr` | `127.0.0.1:4018` | Loopback-only local control API address |
200 | `service_name` | `portal-agent` | OS service name |
201 +| `allowed_wallets` | empty | Wallet addresses allowed to sign in to the local agent UI; empty allows any wallet on the loopback UI |
202
203 Tunnel fields mirror `portal expose` flags:
204
@@ -224,7 +225,6 @@ Stores the secp256k1 identity used to sign tunnel sessions and relay descriptors
225 | `address` | string | Derived EVM address used for SIWE and identity ownership |
226 | `public_key` | string | Compressed secp256k1 public key hex |
227 | `private_key` | string | secp256k1 private key hex; keep secret |
227 -| `admin_secret_key` | string | Relay-only admin login secret, generated automatically when missing |
228 | `wireguard_public_key` | string | Relay-only WireGuard overlay public key when discovery is enabled |
229 | `wireguard_private_key` | string | Relay-only WireGuard overlay private key when discovery is enabled |
230 | `encrypted_client_hello_seed` | string | Relay-only HKDF salt for deriving the ECH HPKE private key; generated automatically when missing; keep secret |
docs/src/routes/deployment/+page.md
+1 -1
@@ -305,7 +305,7 @@ WIREGUARD_PORT=51820
305
306 - Open `WIREGUARD_PORT/udp` on the host or VM when discovery is enabled.
307 - The relay always advertises the `PORTAL_URL` host for WireGuard discovery.
308 -- The relay stores its admin secret key in `IDENTITY_PATH/identity.json` and generates one automatically on first startup if the file does not already contain it.
308 +- The relay identity address can sign in to the admin UI by default; use `ADMIN_WALLETS` to allow additional admin wallets.
309 - 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.
310 - `BOOTSTRAPS` should point at at least one existing relay when you want discovery to join a multi-relay mesh.
311
docs/src/routes/self-hosting/+page.md
+1 -1
@@ -36,7 +36,7 @@ docker run -d \
36 ghcr.io/gosuda/portal:latest
37 ```
38
39 -Replace `relay.example.com` with your domain. The admin secret is generated on first start and stored in `IDENTITY_PATH/identity.json`.
39 +Replace `relay.example.com` with your domain. The relay identity address is allowed to sign in to the admin UI by default.
40
41 ## Docker Compose Setup
42
frontend/README.md
+1 -2
@@ -31,7 +31,7 @@ frontend/
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 # Admin auth helper hooks
34 +│ │ └── useAuth.ts # Wallet auth helper hooks
35 │ ├── lib/
36 │ │ ├── apiClient.ts
37 │ │ ├── apiPaths.ts
@@ -39,7 +39,6 @@ frontend/
39 │ │ └── utils.ts
40 │ ├── pages/
41 │ │ ├── Admin.tsx # Admin area shell
42 -│ │ ├── AdminLogin.tsx # Login flow UI
42 │ │ ├── ServerDetail.tsx # Server detail view with page transition
43 │ │ └── ServerList.tsx # Listing pages and route assembly
44 │ ├── App.tsx
frontend/src/App.tsx
-2
@@ -1,5 +1,4 @@
1 import { Admin } from "@/pages/Admin";
2 -import { AdminLogin } from "@/pages/AdminLogin";
2 import { ServerDetail } from "@/pages/ServerDetail";
3 import { ServerList } from "@/pages/ServerList";
4 import { ROUTE_PATHS } from "@/lib/apiPaths";
@@ -10,7 +9,6 @@ function App() {
9 <Routes>
10 <Route path={ROUTE_PATHS.home} element={<ServerList />} />
11 <Route path={ROUTE_PATHS.serverDetail} element={<ServerDetail />} />
13 - <Route path={ROUTE_PATHS.adminLogin} element={<AdminLogin />} />
12 <Route path={ROUTE_PATHS.admin} element={<Admin />} />
13 </Routes>
14 );
frontend/src/components/Header.tsx
+77 -6
@@ -1,6 +1,8 @@
1 -import { LogOut } from "lucide-react";
1 +import { useState } from "react";
2 +import { Loader2, LogOut, Wallet } from "lucide-react";
3 import { Button } from "@/components/ui/button";
4 import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5 +import { useAuth } from "@/hooks/useAuth";
6 import {
7 Tooltip,
8 TooltipContent,
@@ -12,19 +14,58 @@ import { getReleaseVersion } from "@/lib/releaseVersion";
14 interface HeaderProps {
15 title?: string;
16 isAdmin?: boolean;
15 - onLogout?: () => void;
17 + onAuthChange?: () => void | Promise<void>;
18 showQuickStartLink?: boolean;
19 }
20
21 const repoURL = "https://github.com/gosuda/portal-tunnel";
22
23 +function formatWalletAddress(address: string): string {
24 + const trimmed = address.trim();
25 + if (trimmed.length <= 12) {
26 + return trimmed;
27 + }
28 + return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
29 +}
30 +
31 export function Header({
32 title = "PORTAL",
33 isAdmin,
24 - onLogout,
34 + onAuthChange,
35 showQuickStartLink = true,
36 }: HeaderProps) {
37 const releaseVersion = getReleaseVersion();
38 + const {
39 + isAuthenticated,
40 + isLoading,
41 + walletAddress,
42 + login,
43 + logout,
44 + } = useAuth(isAdmin ? "admin" : "auto");
45 + const [authError, setAuthError] = useState("");
46 +
47 + const handleWalletLogin = async () => {
48 + setAuthError("");
49 + const result = await login();
50 + if (!result.success) {
51 + setAuthError(result.error || "Wallet login failed.");
52 + return;
53 + }
54 + await onAuthChange?.();
55 + };
56 +
57 + const handleLogout = async () => {
58 + setAuthError("");
59 + await logout();
60 + await onAuthChange?.();
61 + };
62 +
63 + const walletLabel = isAuthenticated && walletAddress
64 + ? formatWalletAddress(walletAddress)
65 + : "Wallet";
66 + const walletTooltip = authError || (
67 + isAuthenticated && walletAddress ? walletAddress : "Connect wallet"
68 + );
69
70 return (
71 <header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 py-2 lg:flex-nowrap">
@@ -107,18 +148,48 @@ export function Header({
148
149 <ThemeToggleButton className="inline-flex shrink-0" />
150
110 - {isAdmin && onLogout && (
151 + <TooltipProvider>
152 + <Tooltip>
153 + <TooltipTrigger asChild>
154 + <Button
155 + variant={isAuthenticated ? "secondary" : "outline"}
156 + onClick={isAuthenticated ? undefined : handleWalletLogin}
157 + disabled={isLoading}
158 + className={`h-12 rounded-full border-border/70 bg-background/90 px-3 text-foreground shadow-sm transition-all hover:bg-background disabled:cursor-not-allowed sm:px-4 ${
159 + isAuthenticated
160 + ? "cursor-default"
161 + : "cursor-pointer hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary"
162 + }`}
163 + aria-label={isAuthenticated ? "Wallet connected" : "Connect wallet"}
164 + >
165 + {isLoading ? (
166 + <Loader2 className="h-5 w-5 animate-spin" />
167 + ) : (
168 + <Wallet className="h-5 w-5" />
169 + )}
170 + <span className="max-w-28 truncate font-mono text-xs sm:max-w-36">
171 + {walletLabel}
172 + </span>
173 + </Button>
174 + </TooltipTrigger>
175 + <TooltipContent>
176 + <p>{walletTooltip}</p>
177 + </TooltipContent>
178 + </Tooltip>
179 + </TooltipProvider>
180 +
181 + {isAuthenticated && (
182 <TooltipProvider>
183 <Tooltip>
184 <TooltipTrigger asChild>
185 <Button
186 variant="outline"
187 size="icon"
117 - onClick={onLogout}
188 + onClick={handleLogout}
189 className="h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-destructive/40 hover:bg-background hover:text-destructive"
190 aria-label="Logout"
191 >
121 - <LogOut className="h-5.5 w-5.5" />
192 + <LogOut className="h-5 w-5" />
193 </Button>
194 </TooltipTrigger>
195 <TooltipContent>
frontend/src/components/ServerListView.tsx
+8 -4
@@ -165,7 +165,7 @@ interface ServerListViewProps {
165 onBulkApprove?: (identityKeys: string[]) => void | Promise<void>;
166 onBulkDeny?: (identityKeys: string[]) => void | Promise<void>;
167 onBulkBan?: (identityKeys: string[]) => void | Promise<void>;
168 - onLogout?: () => void;
168 + onAuthChange?: () => void | Promise<void>;
169 }
170
171 function isAdminServer(server: ListServer): server is AdminServer {
@@ -209,7 +209,7 @@ export function ServerListView({
209 onBulkApprove,
210 onBulkDeny,
211 onBulkBan,
212 - onLogout,
212 + onAuthChange,
213 }: ServerListViewProps) {
214 const [showFilterModal, setShowFilterModal] = useState(false);
215 const [relayReleaseVersions, setRelayReleaseVersions] = useState<
@@ -719,7 +719,11 @@ export function ServerListView({
719 <>
720 <div className="sticky top-0 z-10 w-full bg-background pb-4 pt-5">
721 <div className="flex w-full flex-col px-4 sm:px-6 lg:px-8">
722 - <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
722 + <Header
723 + title={title}
724 + isAdmin={isAdmin}
725 + onAuthChange={onAuthChange}
726 + />
727 <div className="flex items-center gap-2">
728 <div className="flex-1">{searchBar}</div>
729 </div>
@@ -785,7 +789,7 @@ export function ServerListView({
789 <Header
790 title={title}
791 isAdmin={isAdmin}
788 - onLogout={onLogout}
792 + onAuthChange={onAuthChange}
793 showQuickStartLink={landingPageEnabled}
794 />
795 </div>
frontend/src/hooks/useAdmin.ts
+10 -2
@@ -166,7 +166,7 @@ async function loadAdminSnapshot(): Promise<AdminSnapshot> {
166 };
167 }
168
169 -export function useAdmin() {
169 +export function useAdmin(enabled = true) {
170 const [serverData, setServerData] = useState<AdminLeaseData[]>([]);
171 const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
172 const [landingPageEnabled, setLandingPageEnabled] = useState(true);
@@ -197,6 +197,14 @@ export function useAdmin() {
197
198 useEffect(() => {
199 let mounted = true;
200 + if (!enabled) {
201 + setError("");
202 + setLoading(false);
203 + return () => {
204 + mounted = false;
205 + };
206 + }
207 +
208 const loadInitialData = async () => {
209 setError("");
210 setLoading(true);
@@ -222,7 +230,7 @@ export function useAdmin() {
230 return () => {
231 mounted = false;
232 };
225 - }, []);
233 + }, [enabled]);
234
235 const servers: AdminServer[] = useMemo(() => {
236 return serverData.map((row) => toAdminServer(row));
frontend/src/hooks/useAuth.ts
+143 -38
@@ -5,7 +5,8 @@ import { APIClientError, apiClient } from "@/lib/apiClient";
5 interface AuthState {
6 isAuthenticated: boolean;
7 isLoading: boolean;
8 - authEnabled: boolean;
8 + authTarget: ResolvedAuthTarget | "";
9 + walletAddress: string;
10 }
11
12 interface LoginResult {
@@ -13,83 +14,187 @@ interface LoginResult {
14 error?: string;
15 }
16
16 -interface AdminAuthStatusPayload {
17 +interface WalletAuthStatusPayload {
18 authenticated: boolean;
18 - auth_enabled: boolean;
19 + wallet_address?: string;
20 }
21
21 -interface AdminLoginPayload {
22 - success?: boolean;
22 +interface WalletAuthChallengePayload {
23 + challenge_id: string;
24 + siwe_message: string;
25 }
26
25 -async function fetchAuthState(): Promise<AuthState> {
26 - try {
27 - const data = await apiClient.get<AdminAuthStatusPayload>(API_PATHS.admin.authStatus);
28 - return {
29 - isAuthenticated: data.authenticated,
30 - isLoading: false,
31 - authEnabled: data.auth_enabled,
32 - };
33 - } catch {
34 - return {
35 - isAuthenticated: false,
36 - isLoading: false,
37 - authEnabled: true,
38 - };
27 +interface WalletAuthLoginPayload {
28 + wallet_address?: string;
29 +}
30 +
31 +type AuthTarget = "admin" | "agent" | "auto";
32 +type ResolvedAuthTarget = "admin" | "agent";
33 +
34 +type EthereumProvider = {
35 + request<T>(args: { method: string; params?: unknown[] }): Promise<T>;
36 +};
37 +
38 +declare global {
39 + interface Window {
40 + ethereum?: EthereumProvider;
41 + }
42 +}
43 +
44 +const authPaths = {
45 + admin: {
46 + challenge: API_PATHS.admin.authChallenge,
47 + login: API_PATHS.admin.authLogin,
48 + logout: API_PATHS.admin.logout,
49 + status: API_PATHS.admin.authStatus,
50 + },
51 + agent: {
52 + challenge: API_PATHS.agent.authChallenge,
53 + login: API_PATHS.agent.authLogin,
54 + logout: API_PATHS.agent.authLogout,
55 + status: API_PATHS.agent.authStatus,
56 + },
57 +} as const;
58 +
59 +function authCandidates(target: AuthTarget, preferred?: ResolvedAuthTarget | ""): ResolvedAuthTarget[] {
60 + if (target === "admin" || target === "agent") {
61 + return [target];
62 + }
63 + if (preferred === "admin") {
64 + return ["admin", "agent"];
65 + }
66 + if (preferred === "agent") {
67 + return ["agent", "admin"];
68 + }
69 + return ["admin", "agent"];
70 +}
71 +
72 +function emptyAuthState(target: ResolvedAuthTarget | "" = ""): AuthState {
73 + return {
74 + isAuthenticated: false,
75 + isLoading: false,
76 + authTarget: target,
77 + walletAddress: "",
78 + };
79 +}
80 +
81 +async function fetchAuthState(target: AuthTarget, preferred?: ResolvedAuthTarget | ""): Promise<AuthState> {
82 + for (const candidate of authCandidates(target, preferred)) {
83 + try {
84 + const data = await apiClient.get<WalletAuthStatusPayload>(authPaths[candidate].status);
85 + return {
86 + isAuthenticated: data.authenticated,
87 + isLoading: false,
88 + authTarget: candidate,
89 + walletAddress: data.wallet_address || "",
90 + };
91 + } catch {
92 + continue;
93 + }
94 }
95 + return emptyAuthState(target === "admin" || target === "agent" ? target : "");
96 +}
97 +
98 +function ethereumProvider(): EthereumProvider | undefined {
99 + return window.ethereum;
100 }
101
42 -export function useAuth() {
102 +export function useAuth(target: AuthTarget = "admin") {
103 const [authState, setAuthState] = useState<AuthState>({
104 isAuthenticated: false,
105 isLoading: true,
46 - authEnabled: true,
106 + authTarget: "",
107 + walletAddress: "",
108 });
109
110 const checkAuth = async () => {
50 - setAuthState(await fetchAuthState());
111 + setAuthState(await fetchAuthState(target, authState.authTarget));
112 };
113
114 useEffect(() => {
115 void (async () => {
55 - setAuthState(await fetchAuthState());
116 + setAuthState(await fetchAuthState(target));
117 })();
57 - }, []);
118 + }, [target]);
119
59 - const login = async (key: string): Promise<LoginResult> => {
120 + const login = async (): Promise<LoginResult> => {
121 try {
61 - const data = await apiClient.post<AdminLoginPayload>(API_PATHS.admin.login, { key });
62 - if (data?.success) {
63 - setAuthState((prev) => ({ ...prev, isAuthenticated: true }));
64 - return { success: true };
122 + const provider = ethereumProvider();
123 + if (!provider) {
124 + return { success: false, error: "Wallet provider is unavailable." };
125 + }
126 + const accounts = await provider.request<string[]>({
127 + method: "eth_requestAccounts",
128 + });
129 + const address = accounts?.[0]?.trim();
130 + if (!address) {
131 + return { success: false, error: "Wallet account is unavailable." };
132 + }
133 + let authTarget = authState.authTarget;
134 + if (!authTarget) {
135 + const nextState = await fetchAuthState(target);
136 + setAuthState(nextState);
137 + authTarget = nextState.authTarget;
138 + }
139 + if (!authTarget) {
140 + return { success: false, error: "Wallet login is unavailable." };
141 }
66 - return { success: false, error: "Secret key is invalid." };
142 +
143 + const challenge = await apiClient.post<WalletAuthChallengePayload>(
144 + authPaths[authTarget].challenge,
145 + { address }
146 + );
147 + const signature = await provider.request<string>({
148 + method: "personal_sign",
149 + params: [challenge.siwe_message, address],
150 + });
151 + const data = await apiClient.post<WalletAuthLoginPayload>(
152 + authPaths[authTarget].login,
153 + {
154 + challenge_id: challenge.challenge_id,
155 + siwe_message: challenge.siwe_message,
156 + siwe_signature: signature,
157 + }
158 + );
159 + setAuthState((prev) => ({
160 + ...prev,
161 + isAuthenticated: true,
162 + authTarget,
163 + walletAddress: data.wallet_address || address,
164 + }));
165 + return { success: true };
166 } catch (err: unknown) {
167 if (err instanceof APIClientError) {
168 return {
169 success: false,
71 - error: err.message || "Secret key is invalid.",
170 + error: err.message || "Wallet login failed.",
171 };
172 }
173
174 return {
175 success: false,
77 - error: err instanceof Error ? err.message : "Could not sign in.",
176 + error: err instanceof Error ? err.message : "Wallet login failed.",
177 };
178 }
179 };
180
181 const logout = async () => {
83 - try {
84 - await apiClient.post<unknown>(API_PATHS.admin.logout);
85 - } catch {
86 - // Ignore errors
182 + const candidates = authCandidates(target, authState.authTarget);
183 + for (const candidate of candidates) {
184 + try {
185 + await apiClient.post<unknown>(authPaths[candidate].logout);
186 + break;
187 + } catch {
188 + continue;
189 + }
190 }
88 - setAuthState((prev) => ({ ...prev, isAuthenticated: false }));
191 + setAuthState((prev) => ({ ...prev, isAuthenticated: false, walletAddress: "" }));
192 };
193
194 return {
92 - ...authState,
195 + isAuthenticated: authState.isAuthenticated,
196 + isLoading: authState.isLoading,
197 + walletAddress: authState.walletAddress,
198 login,
199 logout,
200 checkAuth,
frontend/src/lib/apiPaths.ts
+8 -2
@@ -2,7 +2,8 @@ export const API_PATHS = {
2 admin: {
3 prefix: "/admin",
4 snapshot: "/admin/snapshot",
5 - login: "/admin/login",
5 + authChallenge: "/admin/auth/challenge",
6 + authLogin: "/admin/auth/login",
7 logout: "/admin/logout",
8 authStatus: "/admin/auth/status",
9 leases: "/admin/leases",
@@ -23,6 +24,12 @@ export const API_PATHS = {
24 tunnel: {
25 status: "/tunnel/status",
26 },
27 + agent: {
28 + authChallenge: "/v1/agent/auth/challenge",
29 + authLogin: "/v1/agent/auth/login",
30 + authLogout: "/v1/agent/auth/logout",
31 + authStatus: "/v1/agent/auth/status",
32 + },
33 discovery: "/discovery",
34 healthz: "/healthz",
35 install: {
@@ -36,7 +43,6 @@ export const ROUTE_PATHS = {
43 home: "/",
44 serverDetail: "/server/:id",
45 admin: "/admin",
39 - adminLogin: "/admin/login",
46 } as const;
47
48 export function encodePathPart(value: string): string {
frontend/src/pages/Admin.tsx
+30 -17
@@ -1,13 +1,15 @@
1 -import { useEffect } from "react";
2 -import { useNavigate } from "react-router-dom";
1 import { SsgoiTransition } from "@ssgoi/react";
2 +import { Header } from "@/components/Header";
3 import { useAdmin } from "@/hooks/useAdmin";
4 import { useAuth } from "@/hooks/useAuth";
5 import { ServerListView } from "@/components/ServerListView";
6
7 export function Admin() {
9 - const navigate = useNavigate();
10 - const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
8 + const {
9 + isAuthenticated,
10 + isLoading: authLoading,
11 + checkAuth,
12 + } = useAuth("admin");
13
14 const {
15 servers,
@@ -43,18 +45,10 @@ export function Admin() {
45 handleBulkApprove,
46 handleBulkDeny,
47 handleBulkBan,
46 - } = useAdmin();
47 -
48 - // Redirect to login if not authenticated
49 - useEffect(() => {
50 - if (!authLoading && !isAuthenticated) {
51 - navigate("/admin/login", { replace: true });
52 - }
53 - }, [authLoading, isAuthenticated, navigate]);
48 + } = useAdmin(isAuthenticated);
49
55 - const handleLogout = async () => {
56 - await logout();
57 - navigate("/admin/login", { replace: true });
50 + const handleAuthChange = async () => {
51 + await checkAuth();
52 };
53
54 if (authLoading) {
@@ -62,7 +56,26 @@ export function Admin() {
56 }
57
58 if (!isAuthenticated) {
65 - return null; // Will redirect
59 + return (
60 + <SsgoiTransition id="admin">
61 + <div className="relative flex min-h-screen w-full flex-col bg-background">
62 + <div className="sticky top-0 z-10 w-full bg-background pb-4 pt-5">
63 + <div className="flex w-full flex-col px-4 sm:px-6 lg:px-8">
64 + <Header
65 + title="PORTAL ADMIN"
66 + isAdmin={true}
67 + onAuthChange={handleAuthChange}
68 + />
69 + </div>
70 + </div>
71 + <main className="mx-auto flex w-full max-w-6xl flex-1 items-center justify-center px-6 py-16">
72 + <div className="rounded-lg border border-border bg-card px-6 py-5 text-center text-sm text-muted-foreground shadow-sm">
73 + Connect a wallet from the header to view admin controls.
74 + </div>
75 + </main>
76 + </div>
77 + </SsgoiTransition>
78 + );
79 }
80
81 if (loading && servers.length === 0) {
@@ -108,7 +121,7 @@ export function Admin() {
121 onBulkApprove={handleBulkApprove}
122 onBulkDeny={handleBulkDeny}
123 onBulkBan={handleBulkBan}
111 - onLogout={handleLogout}
124 + onAuthChange={handleAuthChange}
125 />
126 </SsgoiTransition>
127 );
frontend/src/pages/AdminLogin.tsx deleted
-171
@@ -1,171 +0,0 @@
1 -import { useState, useEffect, FormEvent } from "react";
2 -import { useNavigate } from "react-router-dom";
3 -import { KeyRound, ShieldCheck } from "lucide-react";
4 -import { ThemeToggleButton } from "@/components/ThemeToggleButton";
5 -import { getReleaseVersion } from "@/lib/releaseVersion";
6 -import { useAuth } from "@/hooks/useAuth";
7 -
8 -export function AdminLogin() {
9 - const navigate = useNavigate();
10 - const {
11 - isAuthenticated,
12 - isLoading,
13 - authEnabled,
14 - login,
15 - } = useAuth();
16 -
17 - const [key, setKey] = useState("");
18 - const [error, setError] = useState("");
19 - const [submitting, setSubmitting] = useState(false);
20 - const releaseVersion = getReleaseVersion();
21 -
22 - // Redirect if already authenticated
23 - useEffect(() => {
24 - if (!isLoading && isAuthenticated) {
25 - navigate("/admin", { replace: true });
26 - }
27 - }, [isAuthenticated, isLoading, navigate]);
28 -
29 - // Show auth not enabled message
30 - useEffect(() => {
31 - if (!isLoading && !authEnabled) {
32 - setError("Admin authentication is unavailable on this relay.");
33 - }
34 - }, [isLoading, authEnabled]);
35 -
36 - const handleSubmit = async (e: FormEvent) => {
37 - e.preventDefault();
38 - if (!key.trim() || submitting) return;
39 -
40 - setSubmitting(true);
41 - setError("");
42 -
43 - const result = await login(key);
44 -
45 - setSubmitting(false);
46 -
47 - if (result.success) {
48 - navigate("/admin", { replace: true });
49 - } else {
50 - setError(result.error || "Login failed");
51 - }
52 - };
53 -
54 - if (isLoading) {
55 - return (
56 - <div className="min-h-screen bg-background flex items-center justify-center">
57 - <div className="text-muted-foreground">Loading...</div>
58 - </div>
59 - );
60 - }
61 -
62 - return (
63 - <div className="relative flex h-auto min-h-screen w-full flex-col">
64 - <div className="flex h-full grow flex-col">
65 - <div className="flex flex-1 justify-center py-5">
66 - <div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
67 - {/* Header */}
68 - <header className="flex items-center justify-between whitespace-nowrap px-4 sm:px-6 py-3">
69 - <div className="flex items-center gap-4 text-foreground">
70 - <div className="text-primary size-6">
71 - <svg
72 - xmlns="http://www.w3.org/2000/svg"
73 - width="24"
74 - height="24"
75 - viewBox="0 0 906.26 1457.543"
76 - >
77 - <path
78 - fill="currentColor"
79 - d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
80 - ></path>
81 - </svg>
82 - </div>
83 - <div className="flex flex-wrap items-center gap-2">
84 - <h2 className="text-foreground text-lg font-bold leading-tight tracking-[0.3em]">
85 - PORTAL ADMIN
86 - </h2>
87 - {releaseVersion && (
88 - <span className="rounded-full border border-border bg-secondary px-2 py-0.5 text-xs font-medium text-text-muted">
89 - {releaseVersion}
90 - </span>
91 - )}
92 - </div>
93 - </div>
94 - <ThemeToggleButton />
95 - </header>
96 -
97 - {/* Main Content */}
98 - <main className="flex flex-1 flex-col items-center justify-center py-16">
99 - <div className="flex w-full max-w-md flex-col items-center gap-8 rounded-xl bg-card p-8 shadow-lg">
100 - {/* Icon and Title */}
101 - <div className="flex flex-col items-center gap-2 text-center">
102 - <ShieldCheck className="w-10 h-10 text-primary" />
103 - <h1 className="text-2xl font-bold text-foreground">
104 - Admin Access
105 - </h1>
106 - <p className="text-muted-foreground">
107 - Enter your secret key to manage servers.
108 - </p>
109 - </div>
110 -
111 - {/* Form */}
112 - <form
113 - onSubmit={handleSubmit}
114 - className="flex w-full flex-col gap-6"
115 - >
116 - <div className="flex flex-col gap-2">
117 - <label
118 - className="text-sm font-medium text-muted-foreground"
119 - htmlFor="admin-key"
120 - >
121 - Admin Secret Key
122 - </label>
123 - <div className="relative">
124 - <KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
125 - <input
126 - id="admin-key"
127 - type="password"
128 - placeholder="Enter your secret key"
129 - value={key}
130 - onChange={(e) => setKey(e.target.value)}
131 - disabled={submitting || !authEnabled}
132 - autoFocus
133 - className="h-12 w-full rounded-lg border-none bg-secondary pl-10 pr-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
134 - />
135 - </div>
136 - </div>
137 -
138 - {/* Error Message */}
139 - {error && (
140 - <div className="text-destructive text-sm text-center bg-destructive/10 p-3 rounded-md">
141 - {error}
142 - </div>
143 - )}
144 -
145 - {/* Submit Button */}
146 - <button
147 - type="submit"
148 - disabled={!key.trim() || submitting || !authEnabled}
149 - className="flex h-12 w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-primary text-base font-bold text-white transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
150 - >
151 - <span className="truncate">
152 - {submitting ? "Authenticating..." : "Login"}
153 - </span>
154 - </button>
155 - </form>
156 -
157 - {/* Back Link */}
158 - <a
159 - href="/"
160 - className="text-sm text-muted-foreground hover:text-foreground transition-colors"
161 - >
162 - Back to Home
163 - </a>
164 - </div>
165 - </main>
166 - </div>
167 - </div>
168 - </div>
169 - </div>
170 - );
171 -}
portal/auth/wallet_auth.go new
+245
@@ -0,0 +1,245 @@
1 +package auth
2 +
3 +import (
4 + "errors"
5 + "fmt"
6 + "strings"
7 + "sync"
8 + "time"
9 +
10 + "github.com/spruceid/siwe-go"
11 +
12 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
13 + "github.com/gosuda/portal-tunnel/v2/types"
14 + "github.com/gosuda/portal-tunnel/v2/utils"
15 +)
16 +
17 +const (
18 + defaultWalletAuthChallengeTTL = 2 * time.Minute
19 + defaultWalletAuthSessionTTL = 24 * time.Hour
20 +)
21 +
22 +var (
23 + ErrWalletAuthUnauthorized = errors.New("wallet is not allowed")
24 + ErrWalletAuthChallengeNotFound = errors.New("wallet auth challenge not found")
25 + ErrWalletAuthChallengeExpired = errors.New("wallet auth challenge expired")
26 + ErrWalletAuthInvalidSignature = errors.New("wallet auth signature is invalid")
27 +)
28 +
29 +type WalletAuthConfig struct {
30 + AllowedAddresses []string
31 + AllowAnyAddress bool
32 + Statement string
33 +}
34 +
35 +type WalletAuthenticator struct {
36 + allowed map[string]struct{}
37 + allowAny bool
38 + statement string
39 +
40 + mu sync.Mutex
41 + challenges map[string]walletAuthChallenge
42 + sessions map[string]walletAuthSession
43 +}
44 +
45 +type walletAuthChallenge struct {
46 + Address string
47 + Domain string
48 + ExpiresAt time.Time
49 + Nonce string
50 + SIWEMessage string
51 +}
52 +
53 +type walletAuthSession struct {
54 + Address string
55 + ExpiresAt time.Time
56 +}
57 +
58 +func NewWalletAuthenticator(cfg WalletAuthConfig) (*WalletAuthenticator, error) {
59 + allowed := make(map[string]struct{}, len(cfg.AllowedAddresses))
60 + for _, raw := range cfg.AllowedAddresses {
61 + if strings.TrimSpace(raw) == "" {
62 + continue
63 + }
64 + address, err := identity.NormalizeEVMAddress(raw)
65 + if err != nil {
66 + return nil, fmt.Errorf("wallet address: %w", err)
67 + }
68 + allowed[strings.ToLower(address)] = struct{}{}
69 + }
70 + if !cfg.AllowAnyAddress && len(allowed) == 0 {
71 + return nil, errors.New("wallet auth requires at least one allowed address")
72 + }
73 +
74 + statement := strings.TrimSpace(cfg.Statement)
75 + if statement == "" {
76 + statement = "Sign in to Portal"
77 + }
78 +
79 + return &WalletAuthenticator{
80 + allowed: allowed,
81 + allowAny: cfg.AllowAnyAddress,
82 + statement: statement,
83 + challenges: make(map[string]walletAuthChallenge),
84 + sessions: make(map[string]walletAuthSession),
85 + }, nil
86 +}
87 +
88 +func (a *WalletAuthenticator) IssueChallenge(req types.WalletAuthChallengeRequest, domain, uri string, now time.Time) (types.WalletAuthChallengeResponse, error) {
89 + if a == nil {
90 + return types.WalletAuthChallengeResponse{}, ErrWalletAuthUnauthorized
91 + }
92 + address, err := identity.NormalizeEVMAddress(req.Address)
93 + if err != nil {
94 + return types.WalletAuthChallengeResponse{}, err
95 + }
96 + if !a.addressAllowed(address) {
97 + return types.WalletAuthChallengeResponse{}, ErrWalletAuthUnauthorized
98 + }
99 +
100 + challengeID := utils.RandomID("wac_")
101 + nonce := siwe.GenerateNonce()
102 + expiresAt := now.UTC().Add(defaultWalletAuthChallengeTTL)
103 + message, err := siwe.InitMessage(domain, address, uri, nonce, map[string]interface{}{
104 + "statement": a.statement,
105 + "chainId": 1,
106 + "issuedAt": now.UTC().Format(time.RFC3339),
107 + "expirationTime": expiresAt.UTC().Format(time.RFC3339),
108 + "requestId": challengeID,
109 + })
110 + if err != nil {
111 + return types.WalletAuthChallengeResponse{}, fmt.Errorf("build wallet auth message: %w", err)
112 + }
113 +
114 + challenge := walletAuthChallenge{
115 + Address: address,
116 + Domain: strings.TrimSpace(domain),
117 + ExpiresAt: expiresAt,
118 + Nonce: nonce,
119 + SIWEMessage: message.String(),
120 + }
121 +
122 + a.mu.Lock()
123 + a.cleanupExpiredLocked(now)
124 + a.challenges[challengeID] = challenge
125 + a.mu.Unlock()
126 +
127 + return types.WalletAuthChallengeResponse{
128 + ChallengeID: challengeID,
129 + ExpiresAt: expiresAt,
130 + SIWEMessage: challenge.SIWEMessage,
131 + }, nil
132 +}
133 +
134 +func (a *WalletAuthenticator) Login(req types.WalletAuthLoginRequest, now time.Time) (string, string, error) {
135 + if a == nil {
136 + return "", "", ErrWalletAuthUnauthorized
137 + }
138 + challengeID := strings.TrimSpace(req.ChallengeID)
139 + if challengeID == "" {
140 + return "", "", ErrWalletAuthChallengeNotFound
141 + }
142 +
143 + a.mu.Lock()
144 + a.cleanupExpiredLocked(now)
145 + challenge, ok := a.challenges[challengeID]
146 + a.mu.Unlock()
147 + if !ok {
148 + return "", "", ErrWalletAuthChallengeNotFound
149 + }
150 + if now.After(challenge.ExpiresAt) {
151 + a.mu.Lock()
152 + delete(a.challenges, challengeID)
153 + a.mu.Unlock()
154 + return "", "", ErrWalletAuthChallengeExpired
155 + }
156 + if strings.TrimSpace(req.SIWEMessage) != challenge.SIWEMessage {
157 + return "", "", ErrWalletAuthInvalidSignature
158 + }
159 +
160 + message, err := siwe.ParseMessage(strings.TrimSpace(req.SIWEMessage))
161 + if err != nil {
162 + return "", "", ErrWalletAuthInvalidSignature
163 + }
164 + domain := challenge.Domain
165 + nonce := challenge.Nonce
166 + verifiedAt := now.UTC()
167 + if _, err := message.Verify(strings.TrimSpace(req.SIWESignature), &domain, &nonce, &verifiedAt); err != nil {
168 + return "", "", ErrWalletAuthInvalidSignature
169 + }
170 + address, err := identity.NormalizeEVMAddress(message.GetAddress().Hex())
171 + if err != nil {
172 + return "", "", ErrWalletAuthInvalidSignature
173 + }
174 + if !strings.EqualFold(address, challenge.Address) || !a.addressAllowed(address) {
175 + return "", "", ErrWalletAuthUnauthorized
176 + }
177 +
178 + token := utils.RandomID("was_")
179 + a.mu.Lock()
180 + delete(a.challenges, challengeID)
181 + a.sessions[token] = walletAuthSession{
182 + Address: address,
183 + ExpiresAt: now.UTC().Add(defaultWalletAuthSessionTTL),
184 + }
185 + a.cleanupExpiredLocked(now)
186 + a.mu.Unlock()
187 +
188 + return token, address, nil
189 +}
190 +
191 +func (a *WalletAuthenticator) ValidateSession(token string) (string, bool) {
192 + if a == nil {
193 + return "", false
194 + }
195 + token = strings.TrimSpace(token)
196 + if token == "" {
197 + return "", false
198 + }
199 +
200 + a.mu.Lock()
201 + defer a.mu.Unlock()
202 + session, ok := a.sessions[token]
203 + if !ok {
204 + return "", false
205 + }
206 + if time.Now().UTC().After(session.ExpiresAt) {
207 + delete(a.sessions, token)
208 + return "", false
209 + }
210 + return session.Address, true
211 +}
212 +
213 +func (a *WalletAuthenticator) DeleteSession(token string) {
214 + if a == nil {
215 + return
216 + }
217 + a.mu.Lock()
218 + defer a.mu.Unlock()
219 + delete(a.sessions, strings.TrimSpace(token))
220 +}
221 +
222 +func (a *WalletAuthenticator) addressAllowed(address string) bool {
223 + if a == nil {
224 + return false
225 + }
226 + if a.allowAny {
227 + return true
228 + }
229 + _, ok := a.allowed[strings.ToLower(strings.TrimSpace(address))]
230 + return ok
231 +}
232 +
233 +func (a *WalletAuthenticator) cleanupExpiredLocked(now time.Time) {
234 + now = now.UTC()
235 + for id, challenge := range a.challenges {
236 + if now.After(challenge.ExpiresAt) {
237 + delete(a.challenges, id)
238 + }
239 + }
240 + for token, session := range a.sessions {
241 + if now.After(session.ExpiresAt) {
242 + delete(a.sessions, token)
243 + }
244 + }
245 +}
portal/identity/store.go
-12
@@ -191,7 +191,6 @@ func normalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIden
191 return types.RelayIdentity{}, err
192 }
193 normalized.Identity = baseIdentity
194 - normalized.AdminSecretKey = strings.TrimSpace(normalized.AdminSecretKey)
194 normalized.WireGuardPublicKey = strings.TrimSpace(normalized.WireGuardPublicKey)
195 normalized.WireGuardPrivateKey = strings.TrimSpace(normalized.WireGuardPrivateKey)
196 normalized.EncryptedClientHelloSeed = strings.TrimSpace(normalized.EncryptedClientHelloSeed)
@@ -235,7 +234,6 @@ type storedIdentity struct {
234
235 type storedRelayIdentity struct {
236 storedIdentity
238 - AdminSecretKey string `json:"admin_secret_key,omitempty"`
237 WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
238 WireGuardPrivateKey string `json:"wireguard_private_key,omitempty"`
239 EncryptedClientHelloSeed string `json:"encrypted_client_hello_seed,omitempty"`
@@ -288,7 +286,6 @@ func saveRelayIdentity(path string, identity types.RelayIdentity) error {
286 PrivateKey: normalized.PrivateKey,
287 TokenSecret: normalized.TokenSecret,
288 },
291 - AdminSecretKey: normalized.AdminSecretKey,
289 WireGuardPublicKey: normalized.WireGuardPublicKey,
290 WireGuardPrivateKey: normalized.WireGuardPrivateKey,
291 EncryptedClientHelloSeed: normalized.EncryptedClientHelloSeed,
@@ -333,7 +330,6 @@ func loadRelayIdentity(path string) (types.RelayIdentity, error) {
330 PrivateKey: payload.PrivateKey,
331 TokenSecret: payload.TokenSecret,
332 },
336 - AdminSecretKey: payload.AdminSecretKey,
333 WireGuardPublicKey: payload.WireGuardPublicKey,
334 WireGuardPrivateKey: payload.WireGuardPrivateKey,
335 EncryptedClientHelloSeed: payload.EncryptedClientHelloSeed,
@@ -544,14 +540,6 @@ func populateRelayIdentity(identity *types.RelayIdentity, discoveryEnabled bool)
540 }
541 identity.Identity = baseIdentity
542
547 - if strings.TrimSpace(identity.AdminSecretKey) == "" {
548 - adminSecretKey, err := DeriveToken(identity.Identity, "admin-secret")
549 - if err != nil {
550 - return fmt.Errorf("derive relay admin secret key: %w", err)
551 - }
552 - identity.AdminSecretKey = adminSecretKey
553 - }
554 -
543 if discoveryEnabled && strings.TrimSpace(identity.WireGuardPrivateKey) == "" {
544 var err error
545 wireGuardPrivateKey, err := GenerateWireGuardPrivateKey()
sdk/expose.go
+1
@@ -404,6 +404,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
404 })
405
406 return types.AgentTunnelStatus{
407 + Address: e.identity.Address,
408 TargetAddr: e.TargetAddr,
409 MultiHop: multiHop,
410 Relays: relays,
types/agent.go
+5 -3
@@ -1,14 +1,16 @@
1 package types
2
3 type AgentStatusResponse struct {
4 - ConfigPath string `json:"config_path,omitempty"`
5 - ControlAddr string `json:"control_addr"`
6 - Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
4 + ConfigPath string `json:"config_path,omitempty"`
5 + ControlAddr string `json:"control_addr"`
6 + WalletAddress string `json:"wallet_address,omitempty"`
7 + Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
8 }
9
10 type AgentTunnelStatus struct {
11 ID string `json:"id"`
12 Name string `json:"name,omitempty"`
13 + Address string `json:"address,omitempty"`
14 State string `json:"state"`
15 TargetAddr string `json:"target_addr,omitempty"`
16 LastError string `json:"last_error,omitempty"`
types/api.go
+19 -7
@@ -194,17 +194,29 @@ type TunnelStatusResponse struct {
194 ServiceAlive bool `json:"service_alive"`
195 }
196
197 -type AdminLoginRequest struct {
198 - Key string `json:"key"`
197 +type WalletAuthChallengeRequest struct {
198 + Address string `json:"address"`
199 }
200
201 -type AdminLoginResponse struct {
202 - Success bool `json:"success,omitempty"`
201 +type WalletAuthChallengeResponse struct {
202 + ChallengeID string `json:"challenge_id"`
203 + ExpiresAt time.Time `json:"expires_at"`
204 + SIWEMessage string `json:"siwe_message"`
205 +}
206 +
207 +type WalletAuthLoginRequest struct {
208 + ChallengeID string `json:"challenge_id"`
209 + SIWEMessage string `json:"siwe_message"`
210 + SIWESignature string `json:"siwe_signature"`
211 +}
212 +
213 +type WalletAuthLoginResponse struct {
214 + WalletAddress string `json:"wallet_address,omitempty"`
215 }
216
205 -type AdminAuthStatusResponse struct {
206 - Authenticated bool `json:"authenticated"`
207 - AuthEnabled bool `json:"auth_enabled"`
217 +type WalletAuthStatusResponse struct {
218 + Authenticated bool `json:"authenticated"`
219 + WalletAddress string `json:"wallet_address,omitempty"`
220 }
221
222 type AdminSnapshotResponse struct {
types/error.go
-3
@@ -1,7 +1,6 @@
1 package types
2
3 const (
4 - APIErrorCodeAuthDisabled = "auth_disabled"
4 APIErrorCodeFeatureUnavailable = "feature_unavailable"
5 APIErrorCodeHijackFailed = "hijack_failed"
6 APIErrorCodeHijackUnsupported = "hijack_unsupported"
@@ -10,7 +9,6 @@ const (
9 APIErrorCodeInvalidAddress = "invalid_address"
10 APIErrorCodeInvalidIP = "invalid_ip"
11 APIErrorCodeInvalidJSON = "invalid_json"
13 - APIErrorCodeInvalidKey = "invalid_key"
12 APIErrorCodeInvalidMode = "invalid_mode"
13 APIErrorCodeInvalidRequest = "invalid_request"
14 APIErrorCodeInternal = "internal"
@@ -20,7 +18,6 @@ const (
18 APIErrorCodeMethodNotAllowed = "method_not_allowed"
19 APIErrorCodeNotFound = "not_found"
20 APIErrorCodeRateLimited = "rate_limited"
23 - APIErrorCodeSessionCreateFailed = "session_create_failed"
21 APIErrorCodeUnauthorized = "unauthorized"
22 APIErrorCodeUDPPortExhausted = "udp_port_exhausted"
23 APIErrorCodeUDPDisabled = "udp_disabled"
types/identity.go
-2
@@ -32,7 +32,6 @@ func (i Identity) Copy() Identity {
32
33 type RelayIdentity struct {
34 Identity
35 - AdminSecretKey string `json:"-"`
35 WireGuardPublicKey string `json:"-"`
36 WireGuardPrivateKey string `json:"-"`
37 EncryptedClientHelloSeed string `json:"-"`
@@ -41,7 +40,6 @@ type RelayIdentity struct {
40 func (i RelayIdentity) Copy() RelayIdentity {
41 return RelayIdentity{
42 Identity: i.Identity.Copy(),
44 - AdminSecretKey: i.AdminSecretKey,
43 WireGuardPublicKey: i.WireGuardPublicKey,
44 WireGuardPrivateKey: i.WireGuardPrivateKey,
45 EncryptedClientHelloSeed: i.EncryptedClientHelloSeed,
types/paths.go
+27 -22
@@ -1,34 +1,39 @@
1 package types
2
3 const (
4 - PathV1Sign = "/v1/sign"
5 - PathHealthz = "/healthz"
6 - PathRoot = "/"
7 - PathAssetsPrefix = "/assets/"
8 - PathApp = "/app"
9 - PathAppPrefix = "/app/"
10 - PathAdmin = "/admin"
11 - PathAdminPrefix = "/admin/"
12 - PathAdminSnapshot = "/admin/snapshot"
13 - PathAdminLeases = "/admin/leases"
14 - PathAdminLeasesPrefix = "/admin/leases/"
15 - PathAdminLogin = "/admin/login"
16 - PathAdminLogout = "/admin/logout"
17 - PathAdminAuthStatus = "/admin/auth/status"
18 - PathAdminApproval = "/admin/settings/approval-mode"
19 - PathAdminLandingPage = "/admin/settings/landing-page"
20 - PathAdminUDP = "/admin/settings/udp"
21 - PathAdminTCPPort = "/admin/settings/tcp-port"
22 - PathAdminIPsPrefix = "/admin/ips/"
23 - PathInstallShell = "/install.sh"
24 - PathInstallPowerShell = "/install.ps1"
25 - PathInstallBinPrefix = "/install/bin/"
4 + PathV1Sign = "/v1/sign"
5 + PathHealthz = "/healthz"
6 + PathRoot = "/"
7 + PathAssetsPrefix = "/assets/"
8 + PathApp = "/app"
9 + PathAppPrefix = "/app/"
10 + PathAdmin = "/admin"
11 + PathAdminPrefix = "/admin/"
12 + PathAdminSnapshot = "/admin/snapshot"
13 + PathAdminLeases = "/admin/leases"
14 + PathAdminLeasesPrefix = "/admin/leases/"
15 + PathAdminAuthChallenge = "/admin/auth/challenge"
16 + PathAdminAuthLogin = "/admin/auth/login"
17 + PathAdminLogout = "/admin/logout"
18 + PathAdminAuthStatus = "/admin/auth/status"
19 + PathAdminApproval = "/admin/settings/approval-mode"
20 + PathAdminLandingPage = "/admin/settings/landing-page"
21 + PathAdminUDP = "/admin/settings/udp"
22 + PathAdminTCPPort = "/admin/settings/tcp-port"
23 + PathAdminIPsPrefix = "/admin/ips/"
24 + PathInstallShell = "/install.sh"
25 + PathInstallPowerShell = "/install.ps1"
26 + PathInstallBinPrefix = "/install/bin/"
27
28 PathAgentPrefix = "/v1/agent"
29 PathAgentStatus = PathAgentPrefix + "/status"
30 PathAgentShutdown = PathAgentPrefix + "/shutdown"
31 PathAgentTunnels = PathAgentPrefix + "/tunnels"
32 PathAgentTunnelsPrefix = PathAgentPrefix + "/tunnels/"
33 + PathAgentAuthChallenge = PathAgentPrefix + "/auth/challenge"
34 + PathAgentAuthLogin = PathAgentPrefix + "/auth/login"
35 + PathAgentAuthLogout = PathAgentPrefix + "/auth/logout"
36 + PathAgentAuthStatus = PathAgentPrefix + "/auth/status"
37
38 PathTunnelStatus = "/tunnel/status"
39 PathThumbnailPrefix = "/thumbnail/"