From 36b5dd192fc3b0fadd374441db274b07709d208a Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:16:42 +0800 Subject: [PATCH] feat: improve gateway service cards and client IP sources --- src/Core/Entities/Server.cs | 10 +++ src/FastGateway/Dto/ServerDto.cs | 6 +- src/FastGateway/Gateway/Gateway.cs | 3 + .../Infrastructure/AppJsonContext.cs | 3 +- .../Infrastructure/ClientIpHelper.cs | 83 ++++++++++++++++--- src/FastGateway/Services/RateLimitService.cs | 16 +++- src/FastGateway/Services/ServerService.cs | 2 + .../pages/server/features/CreateServer.tsx | 39 ++++++++- web/src/pages/server/features/ProxyList.tsx | 24 +++--- .../pages/server/features/UpdateServer.tsx | 39 ++++++++- web/src/types/index.ts | 42 ++++++++++ 11 files changed, 238 insertions(+), 29 deletions(-) diff --git a/src/Core/Entities/Server.cs b/src/Core/Entities/Server.cs index c9ff478..053478d 100644 --- a/src/Core/Entities/Server.cs +++ b/src/Core/Entities/Server.cs @@ -1,5 +1,13 @@ namespace Core.Entities; +public enum ClientIpSource +{ + Default = 0, + XForwardedFor = 1, + XRealIp = 2, + CfConnectingIp = 3 +} + /// /// 服务 /// @@ -56,6 +64,8 @@ public sealed class Server /// 启用白名单 (白名单优先级高,设置了白名单则忽略黑名单) /// public bool EnableWhitelist { get; set; } + + public ClientIpSource ClientIpSource { get; set; } = ClientIpSource.Default; /// /// 是否复制请求域名 diff --git a/src/FastGateway/Dto/ServerDto.cs b/src/FastGateway/Dto/ServerDto.cs index 5206b2e..2c96260 100644 --- a/src/FastGateway/Dto/ServerDto.cs +++ b/src/FastGateway/Dto/ServerDto.cs @@ -1,4 +1,6 @@ -namespace FastGateway.Dto; +using Core.Entities; + +namespace FastGateway.Dto; public class ServerDto { @@ -54,6 +56,8 @@ public class ServerDto /// public bool EnableWhitelist { get; set; } + public ClientIpSource ClientIpSource { get; set; } = ClientIpSource.Default; + public bool OnLine { get; set; } /// diff --git a/src/FastGateway/Gateway/Gateway.cs b/src/FastGateway/Gateway/Gateway.cs index 5708703..41e63d2 100644 --- a/src/FastGateway/Gateway/Gateway.cs +++ b/src/FastGateway/Gateway/Gateway.cs @@ -269,6 +269,8 @@ public static class Gateway /// public static async Task CloseGateway(string serverId) { + ClientIpHelper.RemoveClientIpSource(serverId); + if (GatewayWebApplications.TryRemove(serverId, out var webApplication)) { await webApplication.StopAsync(); @@ -536,6 +538,7 @@ public static class Gateway await next.Invoke(); }); + app.UseClientIpResolution(server.Id, server.ClientIpSource); app.UseInitGatewayMiddleware(); // 统计采集:在限流/黑名单之前挂载,next 之后记录,可观察到 403/429 短路后的最终状态 diff --git a/src/FastGateway/Infrastructure/AppJsonContext.cs b/src/FastGateway/Infrastructure/AppJsonContext.cs index f82aae6..83fb43f 100644 --- a/src/FastGateway/Infrastructure/AppJsonContext.cs +++ b/src/FastGateway/Infrastructure/AppJsonContext.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Core.Entities; using Core.Entities.Core; using FastGateway.Dto; @@ -48,6 +48,7 @@ namespace FastGateway.Infrastructure; [JsonSerializable(typeof(RenewStats))] [JsonSerializable(typeof(StreamProtocol))] [JsonSerializable(typeof(StreamLoadBalancing))] +[JsonSerializable(typeof(ClientIpSource))] // ===== 展示 DTO ===== [JsonSerializable(typeof(ServerDto))] [JsonSerializable(typeof(List))] diff --git a/src/FastGateway/Infrastructure/ClientIpHelper.cs b/src/FastGateway/Infrastructure/ClientIpHelper.cs index e720b11..8fafe07 100644 --- a/src/FastGateway/Infrastructure/ClientIpHelper.cs +++ b/src/FastGateway/Infrastructure/ClientIpHelper.cs @@ -1,25 +1,84 @@ +using System.Collections.Concurrent; using System.Net; +using Core.Entities; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; namespace FastGateway.Infrastructure; public static class ClientIpHelper { + private static readonly ConcurrentDictionary ServerSources = new(); + private static readonly object ResolvedClientIpKey = new(); + + public const string ResolvedClientIpHeader = "X-FastGateway-Client-IP"; + + public static void SetClientIpSource(string serverId, ClientIpSource source) + { + if (string.IsNullOrWhiteSpace(serverId)) return; + ServerSources[serverId] = source; + } + + public static void RemoveClientIpSource(string serverId) + { + if (string.IsNullOrWhiteSpace(serverId)) return; + ServerSources.TryRemove(serverId, out _); + } + + public static IApplicationBuilder UseClientIpResolution(this IApplicationBuilder app, string serverId, + ClientIpSource defaultSource) + { + SetClientIpSource(serverId, defaultSource); + + return app.Use(async (context, next) => + { + context.Request.Headers.Remove(ResolvedClientIpHeader); + + var source = ServerSources.TryGetValue(serverId, out var configuredSource) + ? configuredSource + : defaultSource; + context.Items[ResolvedClientIpKey] = GetClientIp(context, source); + await next(context); + }); + } + public static string GetClientIp(HttpContext context) { - if (context.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor)) - { - var raw = forwardedFor.ToString(); - if (!string.IsNullOrWhiteSpace(raw)) - { - var candidate = raw.Split(',')[0].Trim(); - if (TryParseIp(candidate, out var ip)) - { - return ip; - } - } - } + if (context.Items.TryGetValue(ResolvedClientIpKey, out var value) && value is string ip) + return ip; + return GetClientIp(context, ClientIpSource.Default); + } + + public static string GetClientIp(HttpContext context, ClientIpSource source) + { + return source switch + { + ClientIpSource.XForwardedFor => GetHeaderIpOrRemote(context, "X-Forwarded-For"), + ClientIpSource.XRealIp => GetHeaderIpOrRemote(context, "X-Real-IP"), + ClientIpSource.CfConnectingIp => GetHeaderIpOrRemote(context, "CF-Connecting-IP"), + _ => GetRemoteIp(context) + }; + } + + private static string GetHeaderIpOrRemote(HttpContext context, string headerName) + { + return TryGetHeaderIp(context, headerName, out var ip) ? ip : GetRemoteIp(context); + } + + private static bool TryGetHeaderIp(HttpContext context, string headerName, out string ip) + { + ip = string.Empty; + if (!context.Request.Headers.TryGetValue(headerName, out var headerValue)) return false; + + var raw = headerValue.ToString(); + if (string.IsNullOrWhiteSpace(raw)) return false; + + return TryParseIp(raw.Split(',')[0].Trim(), out ip); + } + + private static string GetRemoteIp(HttpContext context) + { var remote = context.Connection.RemoteIpAddress; return remote == null ? string.Empty : Normalize(remote); } diff --git a/src/FastGateway/Services/RateLimitService.cs b/src/FastGateway/Services/RateLimitService.cs index 4ec866e..3756eb0 100644 --- a/src/FastGateway/Services/RateLimitService.cs +++ b/src/FastGateway/Services/RateLimitService.cs @@ -34,7 +34,7 @@ public static class RateLimitService options.EnableRegexRuleMatching = false; options.EndpointWhitelist = endpointWhitelist; options.IpWhitelist = ipWhitelist; - options.RealIpHeader = "X-Forwarded-For"; + options.RealIpHeader = ClientIpHelper.ResolvedClientIpHeader; options.RequestBlockedBehaviorAsync = async (context, _, _, _) => { context.Items[StatisticsCollector.BlockReasonKey] = (byte)BlockReason.RateLimit; @@ -57,7 +57,21 @@ public static class RateLimitService { if (rateLimits.Count == 0) return app; + app.Use(async (context, next) => + { + var ip = ClientIpHelper.GetClientIp(context); + context.Request.Headers.Remove(ClientIpHelper.ResolvedClientIpHeader); + if (!string.IsNullOrWhiteSpace(ip)) + context.Request.Headers[ClientIpHelper.ResolvedClientIpHeader] = ip; + + await next(context); + }); app.UseIpRateLimiting(); + app.Use(async (context, next) => + { + context.Request.Headers.Remove(ClientIpHelper.ResolvedClientIpHeader); + await next(context); + }); return app; } diff --git a/src/FastGateway/Services/ServerService.cs b/src/FastGateway/Services/ServerService.cs index 7d123cf..aa7ae91 100644 --- a/src/FastGateway/Services/ServerService.cs +++ b/src/FastGateway/Services/ServerService.cs @@ -41,6 +41,7 @@ public static class ServerService EnableBlacklist = x.EnableBlacklist, EnableTunnel = x.EnableTunnel, EnableWhitelist = x.EnableWhitelist, + ClientIpSource = x.ClientIpSource, Description = x.Description, MaxRequestBodySize = x.MaxRequestBodySize, Timeout = x.Timeout, @@ -72,6 +73,7 @@ public static class ServerService server.Id = id; configService.UpdateServer(server); + ClientIpHelper.SetClientIpSource(server.Id, server.ClientIpSource); }).WithDescription("更新服务").WithDisplayName("更新服务").WithTags("服务"); server.MapPut("{id}/enable", (ConfigurationService configService, string id) => diff --git a/web/src/pages/server/features/CreateServer.tsx b/web/src/pages/server/features/CreateServer.tsx index 0c4ba20..f735804 100644 --- a/web/src/pages/server/features/CreateServer.tsx +++ b/web/src/pages/server/features/CreateServer.tsx @@ -1,8 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { createServer } from "@/services/ServerService"; -import { Server } from "@/types"; +import { ClientIpSource, clientIpSourceOptions, Server } from "@/types"; import { Button } from "@/components/ui/button"; import { @@ -15,6 +15,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; @@ -46,6 +47,7 @@ export default function CreateServer({ id: null, isHttps: false, onLine: false, + clientIpSource: ClientIpSource.Default, copyRequestHost: true, maxRequestBodySize: null, timeout: 900, @@ -354,6 +356,39 @@ export default function CreateServer({ + + + + setValue((prev) => ({ + ...prev, + clientIpSource: Number(next) as ClientIpSource, + })) + } + > + + + + + {clientIpSourceOptions.map((option) => ( + + {option.label} + + ))} + + + + {clientIpSourceOptions.find( + (option) => option.value === value.clientIpSource + )?.description} + + + { return ( @@ -250,7 +251,7 @@ const ProxyList = memo(() => { ) : null} { navigate(`/server/${server.id}`); }} disabled={!server.id || batchMode} - className="w-full text-left text-sm text-muted-foreground transition-colors hover:text-foreground line-clamp-2 disabled:cursor-not-allowed disabled:opacity-60" + className="w-full rounded-sm text-left text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 line-clamp-2 disabled:cursor-not-allowed disabled:opacity-60" > {server.description || "暂无描述"} @@ -295,6 +296,9 @@ const ProxyList = memo(() => { :{server.listen} + + IP · {getClientIpSourceShortLabel(server.clientIpSource)} + {hasFeatures ? ( {server.isHttps && ( @@ -334,7 +338,7 @@ const ProxyList = memo(() => { {batchMode ? null : ( - + { ) : null} - + {isLoading ? ( - + {Array.from({ length: 6 }).map((_, index) => ( @@ -674,7 +678,7 @@ const ProxyList = memo(() => { ) : ( - + {filteredServers.map((server) => ( {renderServerRow(server)} diff --git a/web/src/pages/server/features/UpdateServer.tsx b/web/src/pages/server/features/UpdateServer.tsx index 0bd11fb..d2a6463 100644 --- a/web/src/pages/server/features/UpdateServer.tsx +++ b/web/src/pages/server/features/UpdateServer.tsx @@ -1,8 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { updateServer } from "@/services/ServerService"; -import { Server } from "@/types"; +import { ClientIpSource, clientIpSourceOptions, Server } from "@/types"; import { Button } from "@/components/ui/button"; import { @@ -15,6 +15,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; @@ -48,6 +49,7 @@ export default function UpdateServer({ id: null, isHttps: false, onLine: false, + clientIpSource: ClientIpSource.Default, copyRequestHost: true, maxRequestBodySize: null, timeout: 900, @@ -361,6 +363,39 @@ export default function UpdateServer({ + + + + setValue((prev) => ({ + ...prev, + clientIpSource: Number(next) as ClientIpSource, + })) + } + > + + + + + {clientIpSourceOptions.map((option) => ( + + {option.label} + + ))} + + + + {clientIpSourceOptions.find( + (option) => option.value === value.clientIpSource + )?.description} + + + option.value === source)?.shortLabel ?? + clientIpSourceOptions[0].shortLabel + ); +} + export interface Server { id: string | null; listen: number; @@ -10,6 +51,7 @@ export interface Server { enableTunnel: boolean; enableBlacklist: boolean; enableWhitelist: boolean; + clientIpSource: ClientIpSource; onLine: boolean; copyRequestHost: boolean; maxRequestBodySize: number | null;
+ {clientIpSourceOptions.find( + (option) => option.value === value.clientIpSource + )?.description} +