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