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) -}