feat: improve gateway service cards and client IP sources

This commit is contained in:
token
2026-07-24 16:16:42 +08:00
parent 4c96ca2ed1
commit 36b5dd192f
11 changed files with 238 additions and 29 deletions
+10
View File
@@ -1,5 +1,13 @@
namespace Core.Entities;
public enum ClientIpSource
{
Default = 0,
XForwardedFor = 1,
XRealIp = 2,
CfConnectingIp = 3
}
/// <summary>
/// 服务
/// </summary>
@@ -56,6 +64,8 @@ public sealed class Server
/// 启用白名单 (白名单优先级高,设置了白名单则忽略黑名单)
/// </summary>
public bool EnableWhitelist { get; set; }
public ClientIpSource ClientIpSource { get; set; } = ClientIpSource.Default;
/// <summary>
/// 是否复制请求域名
+5 -1
View File
@@ -1,4 +1,6 @@
namespace FastGateway.Dto;
using Core.Entities;
namespace FastGateway.Dto;
public class ServerDto
{
@@ -54,6 +56,8 @@ public class ServerDto
/// </summary>
public bool EnableWhitelist { get; set; }
public ClientIpSource ClientIpSource { get; set; } = ClientIpSource.Default;
public bool OnLine { get; set; }
/// <summary>
+3
View File
@@ -269,6 +269,8 @@ public static class Gateway
/// <returns></returns>
public static async Task<bool> 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 短路后的最终状态
@@ -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<ServerDto>))]
@@ -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<string, ClientIpSource> 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);
}
+15 -1
View File
@@ -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;
}
@@ -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) =>
+37 -2
View File
@@ -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({
</TabsContent>
<TabsContent value="features" className="space-y-4">
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<FieldLabel
htmlFor="create-server-client-ip-source"
label="客户端 IP 获取方式"
tooltip="默认保持当前解析逻辑;使用请求头方式前,请确保可信前置代理会覆盖对应请求头。"
/>
<Select
value={String(value.clientIpSource)}
onValueChange={(next) =>
setValue((prev) => ({
...prev,
clientIpSource: Number(next) as ClientIpSource,
}))
}
>
<SelectTrigger id="create-server-client-ip-source">
<SelectValue placeholder="选择客户端 IP 获取方式" />
</SelectTrigger>
<SelectContent>
{clientIpSourceOptions.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{clientIpSourceOptions.find(
(option) => option.value === value.clientIpSource
)?.description}
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<SettingSwitch
id="create-server-enable"
+14 -10
View File
@@ -1,5 +1,5 @@
import { deleteServer, enableServer, getServers, onlineServer, reloadServer } from "@/services/ServerService";
import { Server } from "@/types";
import { deleteServer, enableServer, getServers, onlineServer, reloadServer } from "@/services/ServerService";
import { getClientIpSourceShortLabel, Server } from "@/types";
import { Badge } from "@/components/ui/badge";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -230,7 +230,8 @@ const ProxyList = memo(() => {
return (
<div
className={cn(
"group flex flex-col gap-4 p-4 transition-colors hover:bg-muted/30 sm:flex-row sm:items-start sm:justify-between",
"group flex flex-col gap-4 rounded-xl border bg-card p-4 shadow-sm transition-[transform,box-shadow,border-color,background-color] duration-200 ease-out hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md focus-within:border-primary/50 sm:flex-row sm:items-start sm:justify-between sm:p-5",
selected && "border-primary/50 bg-primary/[0.03] ring-1 ring-primary/15",
!server.enable && "opacity-75"
)}
>
@@ -250,7 +251,7 @@ const ProxyList = memo(() => {
) : null}
<div
className={cn(
"mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border",
"mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border shadow-sm transition-transform duration-200 group-hover:scale-105",
server.onLine
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300"
: "bg-muted text-muted-foreground"
@@ -285,7 +286,7 @@ const ProxyList = memo(() => {
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 || "暂无描述"}
</button>
@@ -295,6 +296,9 @@ const ProxyList = memo(() => {
<Globe className="h-3.5 w-3.5" />
:{server.listen}
</Badge>
<Badge variant="outline" className="h-6 text-xs font-normal text-muted-foreground">
IP · {getClientIpSourceShortLabel(server.clientIpSource)}
</Badge>
{hasFeatures ? (
<div className="flex flex-wrap gap-1.5">
{server.isHttps && (
@@ -334,7 +338,7 @@ const ProxyList = memo(() => {
</div>
{batchMode ? null : (
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<div className="flex flex-wrap items-center gap-2 border-t pt-3 sm:shrink-0 sm:justify-end sm:border-t-0 sm:pt-0 sm:transition-transform sm:duration-200 sm:group-hover:translate-x-0.5">
<Button
size="sm"
variant="secondary"
@@ -618,13 +622,13 @@ const ProxyList = memo(() => {
) : null}
</CardHeader>
<CardContent className="p-0">
<CardContent className="p-3 sm:p-4">
{isLoading ? (
<div className="divide-y">
<div className="grid gap-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="flex flex-col gap-4 p-4 sm:flex-row"
className="flex flex-col gap-4 rounded-xl border bg-card p-4 shadow-sm sm:flex-row sm:p-5"
>
<Skeleton className="h-9 w-9 rounded-lg" />
<div className="flex-1 space-y-3">
@@ -674,7 +678,7 @@ const ProxyList = memo(() => {
</Button>
</div>
) : (
<div className="divide-y">
<div className="grid gap-3">
{filteredServers.map((server) => (
<div key={server.id ?? server.name}>
{renderServerRow(server)}
+37 -2
View File
@@ -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({
</TabsContent>
<TabsContent value="features" className="space-y-4">
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
<FieldLabel
htmlFor="update-server-client-ip-source"
label="客户端 IP 获取方式"
tooltip="默认保持当前解析逻辑;使用请求头方式前,请确保可信前置代理会覆盖对应请求头。"
/>
<Select
value={String(value.clientIpSource)}
onValueChange={(next) =>
setValue((prev) => ({
...prev,
clientIpSource: Number(next) as ClientIpSource,
}))
}
>
<SelectTrigger id="update-server-client-ip-source">
<SelectValue placeholder="选择客户端 IP 获取方式" />
</SelectTrigger>
<SelectContent>
{clientIpSourceOptions.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{clientIpSourceOptions.find(
(option) => option.value === value.clientIpSource
)?.description}
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<SettingSwitch
id="update-server-enable"
+42
View File
@@ -1,3 +1,44 @@
export enum ClientIpSource {
Default = 0,
XForwardedFor = 1,
XRealIp = 2,
CfConnectingIp = 3,
}
export const clientIpSourceOptions = [
{
value: ClientIpSource.Default,
label: "默认方式(直接获取 IP)",
shortLabel: "直连 IP",
description: "直接读取与网关建立连接的远端 IP,不读取转发请求头。",
},
{
value: ClientIpSource.XForwardedFor,
label: "X-Forwarded-For",
shortLabel: "XFF",
description: "读取 X-Forwarded-For 的首个 IP,缺失或无效时读取连接 IP。",
},
{
value: ClientIpSource.XRealIp,
label: "X-Real-IP",
shortLabel: "X-Real-IP",
description: "读取 X-Real-IP,缺失或无效时读取连接 IP。",
},
{
value: ClientIpSource.CfConnectingIp,
label: "CF-Connecting-IP",
shortLabel: "CF",
description: "读取 Cloudflare 写入的 CF-Connecting-IP,缺失或无效时读取连接 IP。",
},
] as const;
export function getClientIpSourceShortLabel(source: ClientIpSource | null | undefined) {
return (
clientIpSourceOptions.find((option) => 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;