mirror of
https://github.com/239573049/FastGateway.git
synced 2026-10-16 21:10:58 +08:00
feat: improve gateway service cards and client IP sources
This commit is contained in:
@@ -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>
|
||||
/// 是否复制请求域名
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user