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/"