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=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=
|
||||||
|
|||||||
@@ -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 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 &
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 = '';
|
||||||
|
|||||||
@@ -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',
|
'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 ',
|
||||||
|
|||||||
@@ -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",
|
"验证码": "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",
|
||||||
|
|||||||
@@ -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 个数",
|
||||||
",保存后不可恢复": ",保存后不可恢复",
|
",保存后不可恢复": ",保存后不可恢复",
|
||||||
",删除后不可恢复": ",删除后不可恢复",
|
",删除后不可恢复": ",删除后不可恢复",
|
||||||
|
|||||||
@@ -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 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('依赖设置'),
|
||||||
|
|||||||
@@ -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,70 +15,111 @@ enum LoginStatusColor {
|
|||||||
'error',
|
'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const LoginLog = ({ data, height }: { data: Array<any>; height: number }) => {
|
||||||
{
|
const [blockedIps, setBlockedIps] = useState<string[]>([]);
|
||||||
title: intl.get('序号'),
|
|
||||||
width: 50,
|
const getIpBlacklist = () => {
|
||||||
render: (text: string, record: any, index: number) => {
|
request
|
||||||
return index + 1;
|
.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('登录时间'),
|
||||||
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) => {
|
dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: intl.get('登录地址'),
|
||||||
title: intl.get('登录地址'),
|
dataIndex: 'address',
|
||||||
dataIndex: 'address',
|
width: 120,
|
||||||
width: 120,
|
key: 'address',
|
||||||
key: 'address',
|
},
|
||||||
},
|
{
|
||||||
{
|
title: intl.get('登录IP'),
|
||||||
title: intl.get('登录IP'),
|
dataIndex: 'ip',
|
||||||
dataIndex: 'ip',
|
width: 100,
|
||||||
width: 100,
|
key: 'ip',
|
||||||
key: 'ip',
|
},
|
||||||
},
|
{
|
||||||
{
|
title: intl.get('登录设备'),
|
||||||
title: intl.get('登录设备'),
|
dataIndex: 'platform',
|
||||||
dataIndex: 'platform',
|
key: 'platform',
|
||||||
key: 'platform',
|
width: 80,
|
||||||
width: 80,
|
},
|
||||||
},
|
{
|
||||||
{
|
title: intl.get('登录状态'),
|
||||||
title: intl.get('登录状态'),
|
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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user