diff --git a/frontend/src/components/ServerCard.tsx b/frontend/src/components/ServerCard.tsx
index 0e813867..114da280 100644
--- a/frontend/src/components/ServerCard.tsx
+++ b/frontend/src/components/ServerCard.tsx
@@ -32,6 +32,7 @@ interface ServerCardProps {
isDenied?: boolean;
bps?: number;
ip?: string;
+ displayIP?: string;
isIPBanned?: boolean;
onBanStatusChange?: (
leaseId: string,
@@ -70,6 +71,7 @@ export function ServerCard({
isDenied = false,
bps = 0,
ip = "",
+ displayIP,
isIPBanned = false,
onBanStatusChange,
onBPSChange,
@@ -376,7 +378,7 @@ export function ServerCard({
{isApproved && ip && (
- IP: {ip}
+ IP: {displayIP || ip}
{isIPBanned && (
(Banned)
diff --git a/frontend/src/components/ServerListView.tsx b/frontend/src/components/ServerListView.tsx
index b17445fc..b5626ebf 100644
--- a/frontend/src/components/ServerListView.tsx
+++ b/frontend/src/components/ServerListView.tsx
@@ -491,6 +491,7 @@ export function ServerListView({
isDenied={adminServer?.isDenied}
bps={adminServer?.bps}
ip={adminServer?.ip}
+ displayIP={adminServer?.displayIP}
isIPBanned={adminServer?.isIPBanned}
transport={adminServer?.transport}
onBanStatusChange={onBanStatusChange}
diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts
index 9141c666..e4973974 100644
--- a/frontend/src/hooks/useAdmin.ts
+++ b/frontend/src/hooks/useAdmin.ts
@@ -39,6 +39,7 @@ export interface AdminServer extends BaseServer {
isApproved: boolean;
isDenied: boolean;
ip: string;
+ displayIP: string;
isIPBanned: boolean;
transport: string;
udpPort: number;
@@ -109,6 +110,7 @@ function toAdminServer(
isApproved: row.IsApproved || false,
isDenied: row.IsDenied || false,
ip: row.ClientIP || "",
+ displayIP: row.ReportedIP || row.ClientIP || "",
isIPBanned: row.IsIPBanned || false,
transport: row.Transport || "tcp",
udpPort: row.UDPPort || 0,
diff --git a/frontend/src/hooks/useSSRData.ts b/frontend/src/hooks/useSSRData.ts
index 9b514f97..d0ca931f 100644
--- a/frontend/src/hooks/useSSRData.ts
+++ b/frontend/src/hooks/useSSRData.ts
@@ -16,6 +16,7 @@ export interface ServerData {
Name: string;
BPS?: number;
ClientIP: string;
+ ReportedIP?: string;
Hostname: string;
Metadata: unknown;
Ready: number;
diff --git a/portal/api_server.go b/portal/api_server.go
index 1ae702c8..54de46d6 100644
--- a/portal/api_server.go
+++ b/portal/api_server.go
@@ -492,6 +492,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
FirstSeenAt: now,
LastSeenAt: now,
ClientIP: clientIP,
+ ReportedIP: utils.SanitizeReportedIP(req.ReportedIP),
UDPEnabled: req.UDPEnabled,
},
ReverseToken: req.ReverseToken,
@@ -568,7 +569,7 @@ func (s *Server) renewLease(req types.RenewRequest, clientIP string) (types.Rene
if req.TTL > 0 {
ttl = time.Duration(req.TTL) * time.Second
}
- record, err := s.registry.Renew(req.LeaseID, req.ReverseToken, ttl, clientIP)
+ record, err := s.registry.Renew(req.LeaseID, req.ReverseToken, ttl, clientIP, utils.SanitizeReportedIP(req.ReportedIP))
if err != nil {
return types.RenewResponse{}, err
}
diff --git a/portal/lease.go b/portal/lease.go
index e2aa6f12..7aa0130a 100644
--- a/portal/lease.go
+++ b/portal/lease.go
@@ -121,7 +121,7 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
return nil
}
-func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, clientIP string) (*leaseRecord, error) {
+func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -140,6 +140,9 @@ func (r *leaseRegistry) Renew(leaseID, reverseToken string, ttl time.Duration, c
record.ClientIP = clientIP
r.policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
}
+ if strings.TrimSpace(reportedIP) != "" {
+ record.ReportedIP = reportedIP
+ }
return record, nil
}
diff --git a/portal/lease_test.go b/portal/lease_test.go
index 1235da9e..105a1b90 100644
--- a/portal/lease_test.go
+++ b/portal/lease_test.go
@@ -36,7 +36,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
t.Fatalf("Lookup() = %v, %v, want registered lease", lookedUp, ok)
}
- renewed, err := registry.Renew(record.ID, record.ReverseToken, time.Minute, "203.0.113.10")
+ renewed, err := registry.Renew(record.ID, record.ReverseToken, time.Minute, "203.0.113.10", "")
if err != nil {
t.Fatalf("Renew() error = %v", err)
}
diff --git a/sdk/api_client.go b/sdk/api_client.go
index 4d93a632..2260e9cf 100644
--- a/sdk/api_client.go
+++ b/sdk/api_client.go
@@ -36,16 +36,17 @@ const (
var errRelayIncompatible = errors.New("relay is incompatible")
type apiClient struct {
- baseURL *url.URL
- httpClient *http.Client
- rawTLSConfig *tls.Config
- dialTimeout time.Duration
- requestTimeout time.Duration
- rootCAPEM []byte
- name string
- reverseToken string
- metadata types.LeaseMetadata
- ownerAddress string
+ baseURL *url.URL
+ httpClient *http.Client
+ rawTLSConfig *tls.Config
+ dialTimeout time.Duration
+ requestTimeout time.Duration
+ rootCAPEM []byte
+ name string
+ reverseToken string
+ metadata types.LeaseMetadata
+ ownerAddress string
+ resolvedPublicIP string
}
func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
@@ -103,6 +104,7 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
TTL: int(ttl / time.Second),
Bootstraps: bootstraps,
UDPEnabled: udpEnabled,
+ ReportedIP: a.resolvedPublicIP,
}, &resp); err != nil {
return types.RegisterResponse{}, err
}
@@ -143,6 +145,11 @@ func (a *apiClient) ensureReady(ctx context.Context) error {
a.close()
a.httpClient = httpClient
a.rawTLSConfig = rawTLSConfig
+
+ if a.resolvedPublicIP == "" {
+ a.resolvedPublicIP = utils.ResolvePublicIP(ctx)
+ }
+
return nil
}
@@ -171,6 +178,7 @@ func (a *apiClient) renewLease(ctx context.Context, leaseID string, ttl time.Dur
LeaseID: leaseID,
ReverseToken: a.reverseToken,
TTL: int(ttl / time.Second),
+ ReportedIP: a.resolvedPublicIP,
}, &types.RenewResponse{})
}
diff --git a/types/api.go b/types/api.go
index 7b2dee20..c28354e0 100644
--- a/types/api.go
+++ b/types/api.go
@@ -61,6 +61,7 @@ type RegisterRequest struct {
TTL int `json:"ttl,omitempty"`
Bootstraps []string `json:"bootstraps,omitempty"`
UDPEnabled bool `json:"udp_enabled,omitempty"`
+ ReportedIP string `json:"reported_ip,omitempty"`
}
type RegisterResponse struct {
@@ -102,6 +103,7 @@ type RenewRequest struct {
LeaseID string `json:"lease_id"`
ReverseToken string `json:"reverse_token"`
TTL int `json:"ttl,omitempty"`
+ ReportedIP string `json:"reported_ip,omitempty"`
}
type RenewResponse struct {
diff --git a/types/lease.go b/types/lease.go
index 21d7e9b0..aba22ab5 100644
--- a/types/lease.go
+++ b/types/lease.go
@@ -28,6 +28,7 @@ type Lease struct {
Name string
BPS int64
ClientIP string
+ ReportedIP string
Hostname string
Bootstraps []string
UDPEnabled bool
diff --git a/utils/network.go b/utils/network.go
new file mode 100644
index 00000000..ccdc96a2
--- /dev/null
+++ b/utils/network.go
@@ -0,0 +1,105 @@
+package utils
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gosuda/portal/v2/types"
+)
+
+// ResolvePublicIP attempts to determine the caller's public IP address
+// using well-known external services. Returns empty string on failure.
+// Best-effort with a short timeout to avoid blocking registration.
+func ResolvePublicIP(ctx context.Context) string {
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+
+ endpoints := []string{
+ "https://api.ipify.org",
+ "https://ifconfig.me/ip",
+ }
+ client := &http.Client{Timeout: 3 * time.Second}
+
+ for _, endpoint := range endpoints {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ continue
+ }
+ req.Header.Set("User-Agent", "portal-tunnel")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ continue
+ }
+
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
+ _ = resp.Body.Close()
+ if resp.StatusCode != http.StatusOK || readErr != nil {
+ continue
+ }
+
+ if candidate := SanitizeReportedIP(string(body)); candidate != "" {
+ return candidate
+ }
+ }
+
+ return ""
+}
+
+func SanitizeReportedIP(raw string) string {
+ candidate := strings.TrimSpace(raw)
+ if candidate == "" {
+ return ""
+ }
+ if net.ParseIP(candidate) == nil {
+ return ""
+ }
+ return candidate
+}
+
+func ResolvePortalRelayURLs(ctx context.Context, explicit []string, includeDefaults bool) ([]string, error) {
+ explicit, err := NormalizeRelayURLs(explicit...)
+ if err != nil {
+ return nil, err
+ }
+ if !includeDefaults {
+ return explicit, nil
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, types.PortalRelayRegistryURL, nil)
+ if err != nil {
+ return explicit, nil
+ }
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return explicit, nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return explicit, nil
+ }
+
+ var registry struct {
+ Relays []string `json:"relays"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(®istry); err != nil {
+ return explicit, nil
+ }
+
+ defaults, err := NormalizeRelayURLs(registry.Relays...)
+ if err != nil {
+ return explicit, nil
+ }
+ if len(defaults) == 0 {
+ return explicit, nil
+ }
+ return MergeRelayURLs(defaults, nil, explicit)
+}
diff --git a/utils/network_test.go b/utils/network_test.go
new file mode 100644
index 00000000..3d6ec36f
--- /dev/null
+++ b/utils/network_test.go
@@ -0,0 +1,30 @@
+package utils
+
+import "testing"
+
+func TestSanitizeReportedIP(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ raw string
+ want string
+ }{
+ {name: "empty", raw: "", want: ""},
+ {name: "whitespace", raw: " ", want: ""},
+ {name: "ipv4", raw: " 203.0.113.10 ", want: "203.0.113.10"},
+ {name: "ipv6", raw: " 2001:db8::1 ", want: "2001:db8::1"},
+ {name: "invalid", raw: "not-an-ip", want: ""},
+ {name: "host port", raw: "203.0.113.10:443", want: ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got := SanitizeReportedIP(tc.raw); got != tc.want {
+ t.Fatalf("SanitizeReportedIP(%q) = %q, want %q", tc.raw, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/utils/registry.go b/utils/registry.go
deleted file mode 100644
index 3a00d827..00000000
--- a/utils/registry.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package utils
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "time"
-
- "github.com/gosuda/portal/v2/types"
-)
-
-func ResolvePortalRelayURLs(ctx context.Context, explicit []string, includeDefaults bool) ([]string, error) {
- explicit, err := NormalizeRelayURLs(explicit...)
- if err != nil {
- return nil, err
- }
- if !includeDefaults {
- return explicit, nil
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, types.PortalRelayRegistryURL, nil)
- if err != nil {
- return explicit, nil
- }
-
- client := &http.Client{Timeout: 5 * time.Second}
- resp, err := client.Do(req)
- if err != nil {
- return explicit, nil
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return explicit, nil
- }
-
- var registry struct {
- Relays []string `json:"relays"`
- }
- if err := json.NewDecoder(resp.Body).Decode(®istry); err != nil {
- return explicit, nil
- }
-
- defaults, err := NormalizeRelayURLs(registry.Relays...)
- if err != nil {
- return explicit, nil
- }
- if len(defaults) == 0 {
- return explicit, nil
- }
- return MergeRelayURLs(defaults, nil, explicit)
-}