feat(admin): show tunnel client's public IP instead of Docker internal IP

Hee Sung Son committed Mar 26, 2026 at 09:13 UTC 977536db4653bddaba91f5f33aae84a7c4aa660f
10 files changed +105 -13
frontend/src/components/ServerCard.tsx
+3 -1
@@ -32,6 +32,7 @@ interface ServerCardProps {
32 isDenied?: boolean;
33 bps?: number;
34 ip?: string;
35 + displayIP?: string;
36 isIPBanned?: boolean;
37 onBanStatusChange?: (
38 leaseId: string,
@@ -70,6 +71,7 @@ export function ServerCard({
71 isDenied = false,
72 bps = 0,
73 ip = "",
74 + displayIP,
75 isIPBanned = false,
76 onBanStatusChange,
77 onBPSChange,
@@ -376,7 +378,7 @@ export function ServerCard({
378
379 {isApproved && ip && (
380 <div className="text-[11px] text-text-muted">
379 - IP: <span className="font-mono text-foreground">{ip}</span>
381 + IP: <span className="font-mono text-foreground">{displayIP || ip}</span>
382 {isIPBanned && (
383 <span className="ml-2 font-medium text-destructive">
384 (Banned)
frontend/src/components/ServerListView.tsx
+1
@@ -491,6 +491,7 @@ export function ServerListView({
491 isDenied={adminServer?.isDenied}
492 bps={adminServer?.bps}
493 ip={adminServer?.ip}
494 + displayIP={adminServer?.displayIP}
495 isIPBanned={adminServer?.isIPBanned}
496 transport={adminServer?.transport}
497 onBanStatusChange={onBanStatusChange}
frontend/src/hooks/useAdmin.ts
+2
@@ -39,6 +39,7 @@ export interface AdminServer extends BaseServer {
39 isApproved: boolean;
40 isDenied: boolean;
41 ip: string;
42 + displayIP: string;
43 isIPBanned: boolean;
44 transport: string;
45 udpPort: number;
@@ -109,6 +110,7 @@ function toAdminServer(
110 isApproved: row.IsApproved || false,
111 isDenied: row.IsDenied || false,
112 ip: row.ClientIP || "",
113 + displayIP: row.ReportedIP || row.ClientIP || "",
114 isIPBanned: row.IsIPBanned || false,
115 transport: row.Transport || "tcp",
116 udpPort: row.UDPPort || 0,
frontend/src/hooks/useSSRData.ts
+1
@@ -16,6 +16,7 @@ export interface ServerData {
16 Name: string;
17 BPS?: number;
18 ClientIP: string;
19 + ReportedIP?: string;
20 Hostname: string;
21 Metadata: unknown;
22 Ready: number;
portal/api_server.go
+13 -1
@@ -432,6 +432,17 @@ func (s *Server) handleQUICTunnelConn(conn *quic.Conn) {
432 Msg("quic tunnel connected")
433 }
434
435 +func sanitizeReportedIP(raw string) string {
436 + candidate := strings.TrimSpace(raw)
437 + if candidate == "" {
438 + return ""
439 + }
440 + if net.ParseIP(candidate) == nil {
441 + return ""
442 + }
443 + return candidate
444 +}
445 +
446 func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (types.RegisterResponse, error) {
447 name, err := utils.NormalizeDNSLabel(req.Name)
448 if err != nil {
@@ -492,6 +503,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
503 FirstSeenAt: now,
504 LastSeenAt: now,
505 ClientIP: clientIP,
506 + ReportedIP: sanitizeReportedIP(req.ReportedIP),
507 UDPEnabled: req.UDPEnabled,
508 },
509 ReverseToken: req.ReverseToken,
@@ -568,7 +580,7 @@ func (s *Server) renewLease(req types.RenewRequest, clientIP string) (types.Rene
580 if req.TTL > 0 {
581 ttl = time.Duration(req.TTL) * time.Second
582 }
571 - record, err := s.registry.Renew(req.LeaseID, req.ReverseToken, ttl, clientIP)
583 + record, err := s.registry.Renew(req.LeaseID, req.ReverseToken, ttl, clientIP, sanitizeReportedIP(req.ReportedIP))
584 if err != nil {
585 return types.RenewResponse{}, err
586 }
portal/lease.go
+4 -1
@@ -121,7 +121,7 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
121 return nil
122 }
123
124 -func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, clientIP string) (*leaseRecord, error) {
124 +func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
125 r.mu.Lock()
126 defer r.mu.Unlock()
127
@@ -140,6 +140,9 @@ func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, c
140 record.ClientIP = clientIP
141 r.policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
142 }
143 + if strings.TrimSpace(reportedIP) != "" {
144 + record.ReportedIP = reportedIP
145 + }
146 return record, nil
147 }
148
sdk/api_client.go
+18 -10
@@ -36,16 +36,17 @@ const (
36 var errRelayIncompatible = errors.New("relay is incompatible")
37
38 type apiClient struct {
39 - baseURL *url.URL
40 - httpClient *http.Client
41 - rawTLSConfig *tls.Config
42 - dialTimeout time.Duration
43 - requestTimeout time.Duration
44 - rootCAPEM []byte
45 - name string
46 - reverseToken string
47 - metadata types.LeaseMetadata
48 - ownerAddress string
39 + baseURL *url.URL
40 + httpClient *http.Client
41 + rawTLSConfig *tls.Config
42 + dialTimeout time.Duration
43 + requestTimeout time.Duration
44 + rootCAPEM []byte
45 + name string
46 + reverseToken string
47 + metadata types.LeaseMetadata
48 + ownerAddress string
49 + resolvedPublicIP string
50 }
51
52 func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
@@ -103,6 +104,7 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
104 TTL: int(ttl / time.Second),
105 Bootstraps: bootstraps,
106 UDPEnabled: udpEnabled,
107 + ReportedIP: a.resolvedPublicIP,
108 }, &resp); err != nil {
109 return types.RegisterResponse{}, err
110 }
@@ -143,6 +145,11 @@ func (a *apiClient) ensureReady(ctx context.Context) error {
145 a.close()
146 a.httpClient = httpClient
147 a.rawTLSConfig = rawTLSConfig
148 +
149 + if a.resolvedPublicIP == "" {
150 + a.resolvedPublicIP = resolvePublicIP(ctx)
151 + }
152 +
153 return nil
154 }
155
@@ -171,6 +178,7 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
178 LeaseID: leaseID,
179 ReverseToken: a.reverseToken,
180 TTL: int(ttl / time.Second),
181 + ReportedIP: a.resolvedPublicIP,
182 }, &types.RenewResponse{})
183 }
184
sdk/public_ip.go new
+60
@@ -0,0 +1,60 @@
1 +package sdk
2 +
3 +import (
4 + "context"
5 + "io"
6 + "net"
7 + "net/http"
8 + "strings"
9 + "time"
10 +)
11 +
12 +// resolvePublicIP attempts to determine the caller's public IP address
13 +// using well-known external services. Returns empty string on failure.
14 +// Best-effort with a short timeout to avoid blocking registration.
15 +func resolvePublicIP(ctx context.Context) string {
16 + ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
17 + defer cancel()
18 +
19 + endpoints := []string{
20 + "https://api.ipify.org",
21 + "https://ifconfig.me/ip",
22 + }
23 +
24 + for _, endpoint := range endpoints {
25 + if ip := queryIPEndpoint(ctx, endpoint); ip != "" {
26 + return ip
27 + }
28 + }
29 + return ""
30 +}
31 +
32 +func queryIPEndpoint(ctx context.Context, url string) string {
33 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
34 + if err != nil {
35 + return ""
36 + }
37 + req.Header.Set("User-Agent", "portal-tunnel")
38 +
39 + client := &http.Client{Timeout: 3 * time.Second}
40 + resp, err := client.Do(req)
41 + if err != nil {
42 + return ""
43 + }
44 + defer resp.Body.Close()
45 +
46 + if resp.StatusCode != http.StatusOK {
47 + return ""
48 + }
49 +
50 + body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
51 + if err != nil {
52 + return ""
53 + }
54 +
55 + candidate := strings.TrimSpace(string(body))
56 + if net.ParseIP(candidate) == nil {
57 + return ""
58 + }
59 + return candidate
60 +}
types/api.go
+2
@@ -61,6 +61,7 @@ type RegisterRequest struct {
61 TTL int `json:"ttl,omitempty"`
62 Bootstraps []string `json:"bootstraps,omitempty"`
63 UDPEnabled bool `json:"udp_enabled,omitempty"`
64 + ReportedIP string `json:"reported_ip,omitempty"`
65 }
66
67 type RegisterResponse struct {
@@ -102,6 +103,7 @@ type RenewRequest struct {
103 LeaseID string `json:"lease_id"`
104 ReverseToken string `json:"reverse_token"`
105 TTL int `json:"ttl,omitempty"`
106 + ReportedIP string `json:"reported_ip,omitempty"`
107 }
108
109 type RenewResponse struct {
types/lease.go
+1
@@ -28,6 +28,7 @@ type Lease struct {
28 Name string
29 BPS int64
30 ClientIP string
31 + ReportedIP string
32 Hostname string
33 Bootstraps []string
34 UDPEnabled bool