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
+7
View File
@@ -6,6 +6,13 @@ BACK_PORT=5700
# BIND_HOST=0.0.0.0 # BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0 # BIND_HOST_GRPC=0.0.0.0
# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。
# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表:
# QL_TRUST_PROXY=1
# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32
# 仅在面板端口绝对无法绕过代理直接访问时使用 true。
# QL_TRUST_PROXY=loopback
LOG_LEVEL='info' LOG_LEVEL='info'
JWT_SECRET= JWT_SECRET=
+55
View File
@@ -0,0 +1,55 @@
import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate';
import {
diagnoseClientIp,
getTrustProxyConfig,
updateTrustProxy,
} from '../shared/trustProxy';
const route = Router();
export default (app: Router) => {
app.use('/system/client-ip', route);
route.get(
'/config',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await getTrustProxyConfig() });
} catch (error) {
next(error);
}
},
);
route.put(
'/config',
celebrate({
body: Joi.object({
trustProxy: Joi.string().max(500).required(),
}),
}),
async (req: Request, res: Response) => {
try {
const data = await updateTrustProxy(req.body.trustProxy);
res.send({ code: 200, data });
} catch (error) {
res.send({
code: 400,
message: error instanceof Error ? error.message : '配置更新失败',
});
}
},
);
route.get(
'/diagnose',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({ code: 200, data: await diagnoseClientIp(req) });
} catch (error) {
next(error);
}
},
);
};
+2
View File
@@ -12,6 +12,7 @@ import subscription from './subscription';
import update from './update'; import update from './update';
import dashboard from './dashboard'; import dashboard from './dashboard';
import health from './health'; import health from './health';
import clientIp from './clientIp';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -28,6 +29,7 @@ export default () => {
update(app); update(app);
dashboard(app); dashboard(app);
health(app); health(app);
clientIp(app);
return app; return app;
}; };
+53
View File
@@ -189,6 +189,59 @@ export default (app: Router) => {
}, },
); );
route.get(
'/ip-blacklist',
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.getIpBlacklist();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/ip-blacklist',
celebrate({
body: Joi.object({
ip: Joi.string()
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
.required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.blockIp(req.body.ip);
res.send({ code: 200, data, message: t('已加入 IP 黑名单') });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/ip-blacklist',
celebrate({
body: Joi.object({
ip: Joi.string()
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
.required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const data = await userService.unblockIp(req.body.ip);
res.send({ code: 200, data, message: t('已移出 IP 黑名单') });
} catch (e) {
return next(e);
}
},
);
route.get( route.get(
'/notification', '/notification',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
+1
View File
@@ -78,6 +78,7 @@ export interface AuthInfo {
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: string; avatar: string;
blockedIps?: string[];
} }
export type SystemModelInfo = SystemConfigInfo & export type SystemModelInfo = SystemConfigInfo &
+2
View File
@@ -6,6 +6,7 @@ import { Application } from 'express';
import linkDeps from './deps'; import linkDeps from './deps';
import initTask from './initTask'; import initTask from './initTask';
import initFile from './initFile'; import initFile from './initFile';
import { initializeTrustProxy } from '../shared/trustProxy';
export default async ({ app }: { app: Application }) => { export default async ({ app }: { app: Application }) => {
depInjectorLoader(); depInjectorLoader();
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
Logger.info('[boot] Init task loaded'); Logger.info('[boot] Init task loaded');
expressLoader({ app }); expressLoader({ app });
await initializeTrustProxy(app);
Logger.info('[boot] Express loaded'); Logger.info('[boot] Express loaded');
}; };
+27 -10
View File
@@ -15,11 +15,25 @@ import path from 'path';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import { AppScope } from '../data/open'; import { AppScope } from '../data/open';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim();
if (!setting) {
return 'loopback';
}
if (setting === 'true' || setting === 'false') {
return setting === 'true';
}
if (/^\d+$/.test(setting)) {
return Number(setting);
}
return setting;
}
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass // Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true); app.set('case sensitive routing', true);
app.set('strict routing', true); app.set('strict routing', true);
app.set('trust proxy', 'loopback'); app.set('trust proxy', resolveTrustProxy());
app.use(cors()); app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks // Security: Path normalization middleware to prevent case variation attacks
@@ -28,11 +42,14 @@ export default ({ app }: { app: Application }) => {
const normalizedPath = originalPath.toLowerCase(); const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths // Block requests with case variations on protected paths
if (originalPath !== normalizedPath && if (
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') ||
normalizedPath.startsWith('/open/'))
) {
return res.status(400).json({ return res.status(400).json({
code: 400, code: 400,
message: 'Invalid path format' message: 'Invalid path format',
}); });
} }
@@ -97,7 +114,10 @@ export default ({ app }: { app: Application }) => {
return next(err); return next(err);
} }
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) { if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', { const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'), message: t('Token 已失效'),
}); });
@@ -123,9 +143,7 @@ export default ({ app }: { app: Application }) => {
} }
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
? t('Token 已失效')
: t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage }); const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err); next(err);
}); });
@@ -142,8 +160,7 @@ export default ({ app }: { app: Application }) => {
) { ) {
return next(); return next();
} }
const authInfo = const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo); let isInitialized = !isDefaultAuthInfo(authInfo);
+57 -10
View File
@@ -20,12 +20,12 @@ import ScheduleService from './schedule';
import SockService from './sock'; import SockService from './sock';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import IP2Region from 'ip2region'; import IP2Region from 'ip2region';
import requestIp from 'request-ip';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { t, tf } from '../shared/i18n'; import { t, tf } from '../shared/i18n';
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
@Service() @Service()
export default class UserService { export default class UserService {
@@ -49,6 +49,14 @@ export default class UserService {
let { username, password } = payloads; let { username, password } = payloads;
const content = await this.getAuthInfo(); const content = await this.getAuthInfo();
const timestamp = Date.now(); const timestamp = Date.now();
const ip = getClientIp(req);
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
let { let {
username: cUsername, username: cUsername,
password: cPassword, password: cPassword,
@@ -59,7 +67,27 @@ export default class UserService {
twoFactorActivated, twoFactorActivated,
tokens = {}, tokens = {},
platform, platform,
blockedIps = [],
} = content; } = content;
if (
ip &&
blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip)
) {
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.fail,
},
});
this.getLoginLog();
return { code: 403, message: t('该 IP 已被列入黑名单') };
}
const retriesTime = Math.pow(3, retries) * 1000; const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) { if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil( const waitTime = Math.ceil(
@@ -87,14 +115,6 @@ export default class UserService {
}; };
} }
const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) { if (username === cUsername && password === cPassword) {
const data = createRandomString(50, 100); const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d'; const expiration = twoFactorActivated ? '60d' : '20d';
@@ -264,6 +284,33 @@ export default class UserService {
return []; return [];
} }
public async getIpBlacklist(): Promise<string[]> {
const authInfo = await this.getAuthInfo();
return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter(
Boolean,
);
}
public async blockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
const blockedIps = uniq([
...(authInfo.blockedIps || []).map(normalizeClientIp),
normalizeClientIp(ip),
]).filter(Boolean);
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
public async unblockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo();
const normalizedIp = normalizeClientIp(ip);
const blockedIps = (authInfo.blockedIps || [])
.map(normalizeClientIp)
.filter((blockedIp) => blockedIp && blockedIp !== normalizedIp);
await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps;
}
private async insertDb(payload: SystemInfo): Promise<SystemInfo> { private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true }); const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc; return doc;
@@ -330,7 +377,7 @@ export default class UserService {
if (isValid) { if (isValid) {
return this.login({ username, password }, req, false); return this.login({ username, password }, req, false);
} else { } else {
const ip = requestIp.getClientIp(req) || ''; const ip = getClientIp(req);
const query = new IP2Region(); const query = new IP2Region();
const ipAddress = query.search(ip); const ipAddress = query.search(ip);
let address = ''; let address = '';
+29
View File
@@ -0,0 +1,29 @@
import { Request } from 'express';
const IPV4_MAPPED_PREFIX = '::ffff:';
export function normalizeClientIp(value?: string): string {
let ip = (value || '').trim().toLowerCase();
if (!ip) {
return '';
}
if (ip.startsWith('[') && ip.endsWith(']')) {
ip = ip.slice(1, -1);
}
const zoneIndex = ip.indexOf('%');
if (zoneIndex !== -1) {
ip = ip.slice(0, zoneIndex);
}
if (ip.startsWith(IPV4_MAPPED_PREFIX)) {
return ip.slice(IPV4_MAPPED_PREFIX.length);
}
return ip;
}
export function getClientIp(req: Request): string {
return normalizeClientIp(req.ip || req.socket.remoteAddress);
}
+3
View File
@@ -98,6 +98,9 @@ const messages: Record<string, Record<string, string>> = {
'Log name can only contain letters, numbers, underscores, and hyphens', 'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters', '日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again', '错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked',
'已加入 IP 黑名单': 'IP address added to the blacklist',
'已移出 IP 黑名单': 'IP address removed from the blacklist',
'青龙快讯': 'QingLong', '青龙快讯': 'QingLong',
'登录通知': 'Login Notification', '登录通知': 'Login Notification',
'你于': 'You at ', '你于': 'You at ',
+142
View File
@@ -0,0 +1,142 @@
import express, { Application, Request } from 'express';
import { AuthDataType, SystemModel } from '../data/system';
import { normalizeClientIp } from './clientIp';
const DEFAULT_TRUST_PROXY = 'loopback';
type TrustProxyValue = boolean | number | string;
let activeApp: Application | undefined;
function getEnvironmentSetting(): string {
return process.env.QL_TRUST_PROXY?.trim() || '';
}
function normalizeSetting(value?: string): string {
return value?.trim() || DEFAULT_TRUST_PROXY;
}
export function resolveTrustProxy(value?: string): TrustProxyValue {
const setting = normalizeSetting(value);
if (setting === 'true' || setting === 'false') {
return setting === 'true';
}
if (/^\d+$/.test(setting)) {
return Number(setting);
}
return setting;
}
function validateTrustProxy(value: string): string {
const setting = normalizeSetting(value);
if (setting.length > 500 || /[\r\n]/.test(setting)) {
throw new Error('trust proxy 配置格式无效');
}
if (/^\d+$/.test(setting) && Number(setting) > 20) {
throw new Error('代理层数不能超过 20');
}
const probe = express();
probe.set('trust proxy', resolveTrustProxy(setting));
return setting;
}
async function getStoredSetting(): Promise<string> {
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
const info = (doc?.get('info') || {}) as Record<string, unknown>;
return typeof info.trustProxy === 'string' ? info.trustProxy : '';
}
export async function getTrustProxyConfig() {
const environmentSetting = getEnvironmentSetting();
const storedSetting = await getStoredSetting();
const trustProxy = normalizeSetting(environmentSetting || storedSetting);
return {
trustProxy,
source: environmentSetting
? 'environment'
: storedSetting
? 'system'
: 'default',
editable: !environmentSetting,
};
}
export async function initializeTrustProxy(app: Application) {
activeApp = app;
const { trustProxy } = await getTrustProxyConfig();
app.set('trust proxy', resolveTrustProxy(trustProxy));
}
export async function updateTrustProxy(value: string) {
if (getEnvironmentSetting()) {
throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖');
}
const trustProxy = validateTrustProxy(value);
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (!doc) {
throw new Error('系统配置不存在');
}
const plain = doc.get({ plain: true });
await SystemModel.update(
{ info: { ...(plain.info || {}), trustProxy } as any },
{ where: { id: plain.id } },
);
activeApp?.set('trust proxy', resolveTrustProxy(trustProxy));
return getTrustProxyConfig();
}
function parseForwardedFor(value: string | string[] | undefined): string[] {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values
.flatMap((item) => item.split(','))
.map((item) => normalizeClientIp(item))
.filter(Boolean);
}
export async function diagnoseClientIp(req: Request) {
const remoteAddress = normalizeClientIp(req.socket.remoteAddress);
const forwardedFor = parseForwardedFor(req.headers['x-forwarded-for']);
const hopsFromApp = [remoteAddress, ...forwardedFor.slice().reverse()].filter(
Boolean,
);
const trust = req.app.get('trust proxy fn') as
| ((ip: string, hop: number) => boolean)
| undefined;
let selectedIndex = Math.max(hopsFromApp.length - 1, 0);
for (let index = 0; index < hopsFromApp.length - 1; index += 1) {
if (!trust?.(hopsFromApp[index], index)) {
selectedIndex = index;
break;
}
}
const hops = hopsFromApp.map((ip, index) => ({
ip,
hop: index,
status:
index < selectedIndex
? 'trusted'
: index === selectedIndex
? 'client'
: 'not_checked',
}));
return {
...(await getTrustProxyConfig()),
remoteAddress,
forwardedFor,
expressIps: req.ips.map(normalizeClientIp),
clientIp: normalizeClientIp(req.ip || req.socket.remoteAddress),
hops,
};
}
+31
View File
@@ -596,6 +596,37 @@
"验证码": "Verification Code", "验证码": "Verification Code",
"验证码为6位数字": "Verification code is a 6-digit number", "验证码为6位数字": "Verification code is a 6-digit number",
"黑名单": "Blacklist", "黑名单": "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", "默认为 CPU 个数": "Default is the number of CPUs",
",保存后不可恢复": ", it can't be recovered after saving.", ",保存后不可恢复": ", it can't be recovered after saving.",
",删除后不可恢复": ", it can't be recovered after deletion", ",删除后不可恢复": ", it can't be recovered after deletion",
+31
View File
@@ -596,6 +596,37 @@
"验证码": "验证码", "验证码": "验证码",
"验证码为6位数字": "验证码为6位数字", "验证码为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 个数", "默认为 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 useResizeObserver from '@react-hook/resize-observer';
import SystemLog from './systemLog'; import SystemLog from './systemLog';
import Dependence from './dependence'; import Dependence from './dependence';
import ClientIp from './clientIp';
const { Text } = Typography; const { Text } = Typography;
const isDemoEnv = window.__ENV__DeployEnv === 'demo'; const isDemoEnv = window.__ENV__DeployEnv === 'demo';
@@ -50,7 +51,7 @@ const Setting = () => {
reloadTheme, reloadTheme,
systemInfo, systemInfo,
} = useOutletContext<SharedContext>(); } = useOutletContext<SharedContext>();
console.log('user',user) console.log('user', user);
const columns = [ const columns = [
{ {
title: intl.get('名称'), title: intl.get('名称'),
@@ -345,6 +346,11 @@ const Setting = () => {
label: intl.get('登录日志'), label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />, children: <LoginLog height={height} data={loginLogData} />,
}, },
{
key: 'client-ip',
label: intl.get('客户端 IP'),
children: <ClientIp />,
},
{ {
key: 'dependence', key: 'dependence',
label: intl.get('依赖设置'), label: intl.get('依赖设置'),
+60 -21
View File
@@ -1,12 +1,10 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react'; 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 { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const { Text, Link } = Typography;
enum LoginStatus { enum LoginStatus {
'成功', '成功',
'失败', '失败',
@@ -17,22 +15,48 @@ enum LoginStatusColor {
'error', 'error',
} }
const columns = [ 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('序号'), title: intl.get('序号'),
width: 50, width: 50,
render: (text: string, record: any, index: number) => { render: (text: string, record: any, index: number) => index + 1,
return index + 1;
},
}, },
{ {
title: intl.get('登录时间'), title: intl.get('登录时间'),
dataIndex: 'timestamp', dataIndex: 'timestamp',
key: 'timestamp', key: 'timestamp',
width: 120, width: 120,
render: (text: string, record: any) => { render: (text: string, record: any) =>
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'); dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'),
},
}, },
{ {
title: intl.get('登录地址'), title: intl.get('登录地址'),
@@ -57,30 +81,45 @@ const columns = [
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 80, width: 80,
render: (text: string, record: any) => { render: (text: string, record: any) => (
return (
<Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}> <Tag color={LoginStatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(LoginStatus[record.status])} {intl.get(LoginStatus[record.status])}
</Tag> </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 ( return (
<> <>
<Table <Table
columns={columns} columns={columns}
pagination={false} pagination={false}
dataSource={data} dataSource={data}
rowKey="id" rowKey={(record) => `${record.ip}-${record.timestamp}`}
size="middle" size="middle"
scroll={{ x: 1000, y: height }} scroll={{ x: 1000, y: height }}
/> />