feat: add client IP diagnostics and blocking

This commit is contained in:
whyour
2026-08-16 13:57:33 +08:00
parent 83e4490b57
commit 31f78e4d1f
16 changed files with 832 additions and 75 deletions
+31
View File
@@ -596,6 +596,37 @@
"验证码": "Verification Code",
"验证码为6位数字": "Verification code is a 6-digit number",
"黑名单": "Blacklist",
"客户端 IP": "Client IP",
"可信代理配置说明": "Trusted proxy configuration",
"系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。": "QingLong checks the proxy chain from the nearest hop to the farthest and uses the first untrusted address as the client IP.",
"代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。": "Hop count is safe only when every request path has the same length. In production, prefer fixed proxy IPs or dedicated network CIDRs. Do not use true directly.",
"当前由环境变量 QL_TRUST_PROXY 管理": "Managed by QL_TRUST_PROXY",
"请修改容器环境变量并重启,系统设置不能覆盖环境变量。": "Change the container environment variable and restart. System settings cannot override it.",
"Trust Proxy 自定义设置": "Custom trust proxy setting",
"直接访问": "Direct access",
"一层代理": "One proxy",
"固定多层": "Fixed hops",
"指定地址或网段": "IP addresses or CIDRs",
"层": "hops",
"保存配置": "Save",
"当前生效值": "Effective value",
"默认配置": "Default",
"环境变量": "Environment variable",
"客户端 IP 诊断": "Client IP diagnostics",
"重新诊断": "Run again",
"Socket 地址": "Socket address",
"最终客户端 IP": "Final client IP",
"距离青龙": "Distance from QingLong",
"跳": "hop(s)",
"判定": "Decision",
"可信代理": "Trusted proxy",
"最终客户端": "Final client",
"未检查": "Not checked",
"请输入可信代理地址或网段": "Enter trusted proxy addresses or CIDRs",
"加入黑名单": "Block IP",
"移出黑名单": "Unblock IP",
"确认加入 IP 黑名单": "Block this IP address?",
"确认移出 IP 黑名单": "Unblock this IP address?",
"默认为 CPU 个数": "Default is the number of CPUs",
",保存后不可恢复": ", it can't be recovered after saving.",
",删除后不可恢复": ", it can't be recovered after deletion",
+31
View File
@@ -596,6 +596,37 @@
"验证码": "验证码",
"验证码为6位数字": "验证码为6位数字",
"黑名单": "黑名单",
"客户端 IP": "客户端 IP",
"可信代理配置说明": "可信代理配置说明",
"系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。": "系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。",
"代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。": "代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。",
"当前由环境变量 QL_TRUST_PROXY 管理": "当前由环境变量 QL_TRUST_PROXY 管理",
"请修改容器环境变量并重启,系统设置不能覆盖环境变量。": "请修改容器环境变量并重启,系统设置不能覆盖环境变量。",
"Trust Proxy 自定义设置": "Trust Proxy 自定义设置",
"直接访问": "直接访问",
"一层代理": "一层代理",
"固定多层": "固定多层",
"指定地址或网段": "指定地址或网段",
"层": "层",
"保存配置": "保存配置",
"当前生效值": "当前生效值",
"默认配置": "默认配置",
"环境变量": "环境变量",
"客户端 IP 诊断": "客户端 IP 诊断",
"重新诊断": "重新诊断",
"Socket 地址": "Socket 地址",
"最终客户端 IP": "最终客户端 IP",
"距离青龙": "距离青龙",
"跳": "跳",
"判定": "判定",
"可信代理": "可信代理",
"最终客户端": "最终客户端",
"未检查": "未检查",
"请输入可信代理地址或网段": "请输入可信代理地址或网段",
"加入黑名单": "加入黑名单",
"移出黑名单": "移出黑名单",
"确认加入 IP 黑名单": "确认将该 IP 加入黑名单?",
"确认移出 IP 黑名单": "确认将该 IP 移出黑名单?",
"默认为 CPU 个数": "默认为 CPU 个数",
",保存后不可恢复": ",保存后不可恢复",
",删除后不可恢复": ",删除后不可恢复",
+292
View File
@@ -0,0 +1,292 @@
import React, { useEffect, useState } from 'react';
import intl from 'react-intl-universal';
import {
Alert,
Button,
Descriptions,
Input,
InputNumber,
Radio,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import config from '@/utils/config';
import { request } from '@/utils/http';
const { Paragraph, Text } = Typography;
type TrustProxySource = 'default' | 'system' | 'environment';
type TrustProxyMode = 'direct' | 'single' | 'hops' | 'custom';
interface TrustProxyConfig {
trustProxy: string;
source: TrustProxySource;
editable: boolean;
}
interface ClientIpDiagnostic extends TrustProxyConfig {
remoteAddress: string;
forwardedFor: string[];
expressIps: string[];
clientIp: string;
hops: Array<{
ip: string;
hop: number;
status: 'trusted' | 'client' | 'not_checked';
}>;
}
function parseSetting(setting: string) {
if (setting === 'false' || setting === '0') {
return { mode: 'direct' as const, hops: 2, custom: '' };
}
if (setting === '1') {
return { mode: 'single' as const, hops: 2, custom: '' };
}
if (/^\d+$/.test(setting)) {
return { mode: 'hops' as const, hops: Number(setting), custom: '' };
}
return { mode: 'custom' as const, hops: 2, custom: setting };
}
const ClientIp = () => {
const [configInfo, setConfigInfo] = useState<TrustProxyConfig>();
const [diagnostic, setDiagnostic] = useState<ClientIpDiagnostic>();
const [mode, setMode] = useState<TrustProxyMode>('direct');
const [hops, setHops] = useState(2);
const [custom, setCustom] = useState('');
const [saving, setSaving] = useState(false);
const [diagnosing, setDiagnosing] = useState(false);
const applyConfig = (data: TrustProxyConfig) => {
setConfigInfo(data);
const parsed = parseSetting(data.trustProxy);
setMode(parsed.mode);
setHops(parsed.hops);
setCustom(parsed.custom);
};
const getConfig = async () => {
const response = await request.get(
`${config.apiPrefix}system/client-ip/config`,
);
if (response.code === 200) {
applyConfig(response.data);
}
};
const diagnose = async () => {
setDiagnosing(true);
try {
const response = await request.get(
`${config.apiPrefix}system/client-ip/diagnose`,
);
if (response.code === 200) {
setDiagnostic(response.data);
}
} finally {
setDiagnosing(false);
}
};
const getSetting = () => {
if (mode === 'direct') return 'false';
if (mode === 'single') return '1';
if (mode === 'hops') return String(hops);
return custom.trim();
};
const save = async () => {
const trustProxy = getSetting();
if (!trustProxy) {
message.error(intl.get('请输入可信代理地址或网段'));
return;
}
setSaving(true);
try {
const response = await request.put(
`${config.apiPrefix}system/client-ip/config`,
{ trustProxy },
);
if (response.code === 200) {
applyConfig(response.data);
message.success(intl.get('更新成功'));
await diagnose();
}
} finally {
setSaving(false);
}
};
useEffect(() => {
getConfig();
diagnose();
}, []);
const sourceMap: Record<TrustProxySource, string> = {
default: intl.get('默认配置'),
system: intl.get('系统设置'),
environment: intl.get('环境变量'),
};
const statusMap = {
trusted: { color: 'green', text: intl.get('可信代理') },
client: { color: 'blue', text: intl.get('最终客户端') },
not_checked: { color: 'default', text: intl.get('未检查') },
};
return (
<div style={{ maxWidth: 960, padding: '12px 0 32px' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={intl.get('可信代理配置说明')}
description={
<div>
<Paragraph>
{intl.get(
'系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。',
)}
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
{intl.get(
'代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。',
)}
</Paragraph>
</div>
}
/>
{configInfo?.source === 'environment' && (
<Alert
type="warning"
showIcon
message={intl.get('当前由环境变量 QL_TRUST_PROXY 管理')}
description={intl.get(
'请修改容器环境变量并重启,系统设置不能覆盖环境变量。',
)}
/>
)}
<div>
<Paragraph strong>{intl.get('Trust Proxy 自定义设置')}</Paragraph>
<Radio.Group
value={mode}
onChange={(event) => setMode(event.target.value)}
disabled={!configInfo?.editable}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="direct">{intl.get('直接访问')}</Radio.Button>
<Radio.Button value="single">{intl.get('一层代理')}</Radio.Button>
<Radio.Button value="hops">{intl.get('固定多层')}</Radio.Button>
<Radio.Button value="custom">
{intl.get('指定地址或网段')}
</Radio.Button>
</Radio.Group>
<div style={{ marginTop: 12 }}>
{mode === 'hops' && (
<InputNumber
min={2}
max={20}
value={hops}
onChange={(value) => setHops(value || 2)}
addonAfter={intl.get('层')}
disabled={!configInfo?.editable}
/>
)}
{mode === 'custom' && (
<Input
style={{ maxWidth: 620 }}
value={custom}
onChange={(event) => setCustom(event.target.value)}
disabled={!configInfo?.editable}
placeholder="loopback,172.18.0.0/16,10.20.0.8/32"
/>
)}
</div>
<Space style={{ marginTop: 12 }} wrap>
<Button
type="primary"
onClick={save}
loading={saving}
disabled={!configInfo?.editable}
>
{intl.get('保存配置')}
</Button>
<Text type="secondary">
{intl.get('当前生效值')}:
<Text copyable>{configInfo?.trustProxy || '-'}</Text>
{configInfo && `(${sourceMap[configInfo.source]})`}
</Text>
</Space>
</div>
<div>
<Space style={{ marginBottom: 12 }}>
<Paragraph strong style={{ marginBottom: 0 }}>
{intl.get('客户端 IP 诊断')}
</Paragraph>
<Button onClick={diagnose} loading={diagnosing}>
{intl.get('重新诊断')}
</Button>
</Space>
{diagnostic && (
<>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label={intl.get('Socket 地址')}>
<Text copyable>{diagnostic.remoteAddress || '-'}</Text>
</Descriptions.Item>
<Descriptions.Item label="X-Forwarded-For">
<Text copyable>
{diagnostic.forwardedFor.join(', ') || '-'}
</Text>
</Descriptions.Item>
<Descriptions.Item label={intl.get('最终客户端 IP')}>
<Text strong copyable>
{diagnostic.clientIp || '-'}
</Text>
</Descriptions.Item>
</Descriptions>
<Table
style={{ marginTop: 12 }}
size="small"
pagination={false}
rowKey={(record) => `${record.hop}-${record.ip}`}
dataSource={diagnostic.hops}
columns={[
{
title: intl.get('距离青龙'),
dataIndex: 'hop',
width: 120,
render: (value) => `${value} ${intl.get('跳')}`,
},
{ title: 'IP', dataIndex: 'ip' },
{
title: intl.get('判定'),
dataIndex: 'status',
width: 140,
render: (value: keyof typeof statusMap) => (
<Tag color={statusMap[value].color}>
{statusMap[value].text}
</Tag>
),
},
]}
/>
</>
)}
</div>
</Space>
</div>
);
};
export default ClientIp;
+7 -1
View File
@@ -35,6 +35,7 @@ import './index.less';
import useResizeObserver from '@react-hook/resize-observer';
import SystemLog from './systemLog';
import Dependence from './dependence';
import ClientIp from './clientIp';
const { Text } = Typography;
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
@@ -50,7 +51,7 @@ const Setting = () => {
reloadTheme,
systemInfo,
} = useOutletContext<SharedContext>();
console.log('user',user)
console.log('user', user);
const columns = [
{
title: intl.get('名称'),
@@ -345,6 +346,11 @@ const Setting = () => {
label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />,
},
{
key: 'client-ip',
label: intl.get('客户端 IP'),
children: <ClientIp />,
},
{
key: 'dependence',
label: intl.get('依赖设置'),
+93 -54
View File
@@ -1,12 +1,10 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Typography, Table, Tag, Button, Spin, message } from 'antd';
import { Table, Tag, Button, Popconfirm, message } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import dayjs from 'dayjs';
const { Text, Link } = Typography;
enum LoginStatus {
'成功',
'失败',
@@ -17,70 +15,111 @@ enum LoginStatusColor {
'error',
}
const columns = [
{
title: intl.get('序号'),
width: 50,
render: (text: string, record: any, index: number) => {
return index + 1;
const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
const [blockedIps, setBlockedIps] = useState<string[]>([]);
const getIpBlacklist = () => {
request
.get(`${config.apiPrefix}user/ip-blacklist`)
.then(({ code, data }) => {
if (code === 200) {
setBlockedIps(data || []);
}
});
};
useEffect(() => {
getIpBlacklist();
}, []);
const updateIpBlacklist = async (ip: string, blocked: boolean) => {
const response = blocked
? await request.delete(`${config.apiPrefix}user/ip-blacklist`, {
data: { ip },
})
: await request.put(`${config.apiPrefix}user/ip-blacklist`, { ip });
if (response.code === 200) {
setBlockedIps(response.data || []);
message.success(response.message);
}
};
const columns = [
{
title: intl.get('序号'),
width: 50,
render: (text: string, record: any, index: number) => index + 1,
},
},
{
title: intl.get('登录时间'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 120,
render: (text: string, record: any) => {
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
{
title: intl.get('登录时间'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 120,
render: (text: string, record: any) =>
dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'),
},
},
{
title: intl.get('登录地址'),
dataIndex: 'address',
width: 120,
key: 'address',
},
{
title: intl.get('登录IP'),
dataIndex: 'ip',
width: 100,
key: 'ip',
},
{
title: intl.get('登录设备'),
dataIndex: 'platform',
key: 'platform',
width: 80,
},
{
title: intl.get('登录状态'),
dataIndex: 'status',
key: 'status',
width: 80,
render: (text: string, record: any) => {
return (
{
title: intl.get('登录地址'),
dataIndex: 'address',
width: 120,
key: 'address',
},
{
title: intl.get('登录IP'),
dataIndex: 'ip',
width: 100,
key: 'ip',
},
{
title: intl.get('登录设备'),
dataIndex: 'platform',
key: 'platform',
width: 80,
},
{
title: intl.get('登录状态'),
dataIndex: 'status',
key: 'status',
width: 80,
render: (text: string, record: any) => (
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(LoginStatus[record.status])}
</Tag>
);
),
},
},
];
{
title: intl.get('操作'),
key: 'action',
width: 100,
render: (text: string, record: any) => {
if (!record.ip || record.status !== 1) {
return null;
}
const blocked = blockedIps.includes(record.ip);
const label = blocked ? '移出黑名单' : '加入黑名单';
return (
<Popconfirm
title={intl.get(
blocked ? '确认移出 IP 黑名单' : '确认加入 IP 黑名单',
)}
onConfirm={() => updateIpBlacklist(record.ip, blocked)}
>
<Button type="link" danger={!blocked} size="small">
{intl.get(label)}
</Button>
</Popconfirm>
);
},
},
];
const LoginLog = ({
data,
height,
}: {
data: Array<object>;
height: number;
}) => {
return (
<>
<Table
columns={columns}
pagination={false}
dataSource={data}
rowKey="id"
rowKey={(record) => `${record.ip}-${record.timestamp}`}
size="middle"
scroll={{ x: 1000, y: height }}
/>