mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-01 05:13:50 +08:00
feat: add client IP diagnostics and blocking
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 个数",
|
||||
",保存后不可恢复": ",保存后不可恢复",
|
||||
",删除后不可恢复": ",删除后不可恢复",
|
||||
|
||||
@@ -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;
|
||||
@@ -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('依赖设置'),
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user