mirror of
https://github.com/whyour/qinglong.git
synced 2026-08-16 14:20:51 +08:00
feat: add client IP diagnostics and blocking
This commit is contained in:
@@ -6,6 +6,13 @@ BACK_PORT=5700
|
||||
# BIND_HOST=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'
|
||||
|
||||
JWT_SECRET=
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import clientIp from './clientIp';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
@@ -28,6 +29,7 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
clientIp(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
'/notification',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface AuthInfo {
|
||||
twoFactorActivated: boolean;
|
||||
twoFactorSecret: string;
|
||||
avatar: string;
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
export type SystemModelInfo = SystemConfigInfo &
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Application } from 'express';
|
||||
import linkDeps from './deps';
|
||||
import initTask from './initTask';
|
||||
import initFile from './initFile';
|
||||
import { initializeTrustProxy } from '../shared/trustProxy';
|
||||
|
||||
export default async ({ app }: { app: Application }) => {
|
||||
depInjectorLoader();
|
||||
@@ -24,5 +25,6 @@ export default async ({ app }: { app: Application }) => {
|
||||
Logger.info('[boot] Init task loaded');
|
||||
|
||||
expressLoader({ app });
|
||||
await initializeTrustProxy(app);
|
||||
Logger.info('[boot] Express loaded');
|
||||
};
|
||||
|
||||
+27
-10
@@ -15,11 +15,25 @@ import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
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 }) => {
|
||||
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||
app.set('case sensitive routing', true);
|
||||
app.set('strict routing', true);
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.set('trust proxy', resolveTrustProxy());
|
||||
app.use(cors());
|
||||
|
||||
// Security: Path normalization middleware to prevent case variation attacks
|
||||
@@ -28,11 +42,14 @@ export default ({ app }: { app: Application }) => {
|
||||
const normalizedPath = originalPath.toLowerCase();
|
||||
|
||||
// Block requests with case variations on protected paths
|
||||
if (originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
|
||||
if (
|
||||
originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') ||
|
||||
normalizedPath.startsWith('/open/'))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: 'Invalid path format'
|
||||
message: 'Invalid path format',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +114,10 @@ export default ({ app }: { app: Application }) => {
|
||||
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', {
|
||||
message: t('Token 已失效'),
|
||||
});
|
||||
@@ -123,9 +143,7 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||
const errorMessage = headerToken
|
||||
? t('Token 已失效')
|
||||
: t('请先登录');
|
||||
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
|
||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||
next(err);
|
||||
});
|
||||
@@ -142,8 +160,7 @@ export default ({ app }: { app: Application }) => {
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo =
|
||||
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
|
||||
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||
|
||||
|
||||
+57
-10
@@ -20,12 +20,12 @@ import ScheduleService from './schedule';
|
||||
import SockService from './sock';
|
||||
import dayjs from 'dayjs';
|
||||
import IP2Region from 'ip2region';
|
||||
import requestIp from 'request-ip';
|
||||
import uniq from 'lodash/uniq';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
@@ -49,6 +49,14 @@ export default class UserService {
|
||||
let { username, password } = payloads;
|
||||
const content = await this.getAuthInfo();
|
||||
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 {
|
||||
username: cUsername,
|
||||
password: cPassword,
|
||||
@@ -59,7 +67,27 @@ export default class UserService {
|
||||
twoFactorActivated,
|
||||
tokens = {},
|
||||
platform,
|
||||
blockedIps = [],
|
||||
} = 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;
|
||||
if (retries > 2 && timestamp - lastlogon < retriesTime) {
|
||||
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) {
|
||||
const data = createRandomString(50, 100);
|
||||
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||
@@ -264,6 +284,33 @@ export default class UserService {
|
||||
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> {
|
||||
const doc = await SystemModel.create({ ...payload }, { returning: true });
|
||||
return doc;
|
||||
@@ -330,7 +377,7 @@ export default class UserService {
|
||||
if (isValid) {
|
||||
return this.login({ username, password }, req, false);
|
||||
} else {
|
||||
const ip = requestIp.getClientIp(req) || '';
|
||||
const ip = getClientIp(req);
|
||||
const query = new IP2Region();
|
||||
const ipAddress = query.search(ip);
|
||||
let address = '';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -98,6 +98,9 @@ const messages: Record<string, Record<string, string>> = {
|
||||
'Log name can only contain letters, numbers, underscores, and hyphens',
|
||||
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
||||
'错误的用户名密码,请重试': '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',
|
||||
'登录通知': 'Login Notification',
|
||||
'你于': 'You at ',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,22 +15,48 @@ enum LoginStatusColor {
|
||||
'error',
|
||||
}
|
||||
|
||||
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) => {
|
||||
return index + 1;
|
||||
},
|
||||
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');
|
||||
},
|
||||
render: (text: string, record: any) =>
|
||||
dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: intl.get('登录地址'),
|
||||
@@ -57,30 +81,45 @@ const columns = [
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (text: string, record: any) => {
|
||||
return (
|
||||
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