From 31f78e4d1f954ad533b23188b1ae970aaf7aab71 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 13:57:33 +0800 Subject: [PATCH] feat: add client IP diagnostics and blocking --- .env.example | 7 + back/api/clientIp.ts | 55 +++++++ back/api/index.ts | 2 + back/api/user.ts | 53 ++++++ back/data/system.ts | 1 + back/loaders/app.ts | 2 + back/loaders/express.ts | 37 +++-- back/services/user.ts | 67 ++++++-- back/shared/clientIp.ts | 29 ++++ back/shared/i18n.ts | 3 + back/shared/trustProxy.ts | 142 ++++++++++++++++ src/locales/en-US.json | 31 ++++ src/locales/zh-CN.json | 31 ++++ src/pages/setting/clientIp.tsx | 292 +++++++++++++++++++++++++++++++++ src/pages/setting/index.tsx | 8 +- src/pages/setting/loginLog.tsx | 147 +++++++++++------ 16 files changed, 832 insertions(+), 75 deletions(-) create mode 100644 back/api/clientIp.ts create mode 100644 back/shared/clientIp.ts create mode 100644 back/shared/trustProxy.ts create mode 100644 src/pages/setting/clientIp.tsx diff --git a/.env.example b/.env.example index f86264af..c0b29702 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/back/api/clientIp.ts b/back/api/clientIp.ts new file mode 100644 index 00000000..172c8e9b --- /dev/null +++ b/back/api/clientIp.ts @@ -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); + } + }, + ); +}; diff --git a/back/api/index.ts b/back/api/index.ts index 443c8e5c..cc196429 100644 --- a/back/api/index.ts +++ b/back/api/index.ts @@ -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; }; diff --git a/back/api/user.ts b/back/api/user.ts index 9e284dba..c1a18146 100644 --- a/back/api/user.ts +++ b/back/api/user.ts @@ -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) => { diff --git a/back/data/system.ts b/back/data/system.ts index 3f5cfce6..8ae1ab60 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -78,6 +78,7 @@ export interface AuthInfo { twoFactorActivated: boolean; twoFactorSecret: string; avatar: string; + blockedIps?: string[]; } export type SystemModelInfo = SystemConfigInfo & diff --git a/back/loaders/app.ts b/back/loaders/app.ts index 1c814504..094aac9b 100644 --- a/back/loaders/app.ts +++ b/back/loaders/app.ts @@ -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'); }; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 81e4b014..676e59ed 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -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); diff --git a/back/services/user.ts b/back/services/user.ts index 92a86f7b..73ed8dd7 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -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 { + const authInfo = await this.getAuthInfo(); + return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter( + Boolean, + ); + } + + public async blockIp(ip: string): Promise { + 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 { + 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 { 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 = ''; diff --git a/back/shared/clientIp.ts b/back/shared/clientIp.ts new file mode 100644 index 00000000..328ab0f6 --- /dev/null +++ b/back/shared/clientIp.ts @@ -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); +} diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 827fab87..561bc1d9 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -98,6 +98,9 @@ const messages: Record> = { '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 ', diff --git a/back/shared/trustProxy.ts b/back/shared/trustProxy.ts new file mode 100644 index 00000000..4afc3a8c --- /dev/null +++ b/back/shared/trustProxy.ts @@ -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 { + const doc = await SystemModel.findOne({ + where: { type: AuthDataType.systemConfig }, + }); + const info = (doc?.get('info') || {}) as Record; + 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, + }; +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 3d97a587..58c115a8 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -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", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 12ef0f0b..bdc5d522 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -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 个数", ",保存后不可恢复": ",保存后不可恢复", ",删除后不可恢复": ",删除后不可恢复", diff --git a/src/pages/setting/clientIp.tsx b/src/pages/setting/clientIp.tsx new file mode 100644 index 00000000..7db1da3f --- /dev/null +++ b/src/pages/setting/clientIp.tsx @@ -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(); + const [diagnostic, setDiagnostic] = useState(); + const [mode, setMode] = useState('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 = { + 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 ( +
+ + + + {intl.get( + '系统从离青龙最近的一跳开始,由右向左检查代理链,并把第一个不可信地址作为客户端 IP。', + )} + + + {intl.get( + '代理层数只适合所有访问路径长度完全一致的部署;生产环境更推荐填写代理的固定 IP 或专用网络 CIDR。不要直接使用 true。', + )} + +
+ } + /> + + {configInfo?.source === 'environment' && ( + + )} + +
+ {intl.get('Trust Proxy 自定义设置')} + setMode(event.target.value)} + disabled={!configInfo?.editable} + optionType="button" + buttonStyle="solid" + > + {intl.get('直接访问')} + {intl.get('一层代理')} + {intl.get('固定多层')} + + {intl.get('指定地址或网段')} + + + +
+ {mode === 'hops' && ( + setHops(value || 2)} + addonAfter={intl.get('层')} + disabled={!configInfo?.editable} + /> + )} + {mode === 'custom' && ( + setCustom(event.target.value)} + disabled={!configInfo?.editable} + placeholder="loopback,172.18.0.0/16,10.20.0.8/32" + /> + )} +
+ + + + + {intl.get('当前生效值')}: + {configInfo?.trustProxy || '-'} + {configInfo && `(${sourceMap[configInfo.source]})`} + + +
+ +
+ + + {intl.get('客户端 IP 诊断')} + + + + + {diagnostic && ( + <> + + + {diagnostic.remoteAddress || '-'} + + + + {diagnostic.forwardedFor.join(', ') || '-'} + + + + + {diagnostic.clientIp || '-'} + + + + + `${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) => ( + + {statusMap[value].text} + + ), + }, + ]} + /> + + )} + + + + ); +}; + +export default ClientIp; diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index dc0f17f9..e1d7490c 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -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(); - console.log('user',user) + console.log('user', user); const columns = [ { title: intl.get('名称'), @@ -345,6 +346,11 @@ const Setting = () => { label: intl.get('登录日志'), children: , }, + { + key: 'client-ip', + label: intl.get('客户端 IP'), + children: , + }, { key: 'dependence', label: intl.get('依赖设置'), diff --git a/src/pages/setting/loginLog.tsx b/src/pages/setting/loginLog.tsx index e025351d..b6534938 100644 --- a/src/pages/setting/loginLog.tsx +++ b/src/pages/setting/loginLog.tsx @@ -1,12 +1,10 @@ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; -import { Typography, Table, Tag, Button, Spin, message } from 'antd'; +import { Table, Tag, Button, Popconfirm, message } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import dayjs from 'dayjs'; -const { Text, Link } = Typography; - enum LoginStatus { '成功', '失败', @@ -17,70 +15,111 @@ enum LoginStatusColor { 'error', } -const columns = [ - { - title: intl.get('序号'), - width: 50, - render: (text: string, record: any, index: number) => { - return index + 1; +const LoginLog = ({ data, height }: { data: Array; height: number }) => { + const [blockedIps, setBlockedIps] = useState([]); + + const getIpBlacklist = () => { + request + .get(`${config.apiPrefix}user/ip-blacklist`) + .then(({ code, data }) => { + if (code === 200) { + setBlockedIps(data || []); + } + }); + }; + + useEffect(() => { + getIpBlacklist(); + }, []); + + const updateIpBlacklist = async (ip: string, blocked: boolean) => { + const response = blocked + ? await request.delete(`${config.apiPrefix}user/ip-blacklist`, { + data: { ip }, + }) + : await request.put(`${config.apiPrefix}user/ip-blacklist`, { ip }); + if (response.code === 200) { + setBlockedIps(response.data || []); + message.success(response.message); + } + }; + + const columns = [ + { + title: intl.get('序号'), + width: 50, + render: (text: string, record: any, index: number) => index + 1, }, - }, - { - title: intl.get('登录时间'), - dataIndex: 'timestamp', - key: 'timestamp', - width: 120, - render: (text: string, record: any) => { - return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'); + { + title: intl.get('登录时间'), + dataIndex: 'timestamp', + key: 'timestamp', + width: 120, + render: (text: string, record: any) => + dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'), }, - }, - { - title: intl.get('登录地址'), - dataIndex: 'address', - width: 120, - key: 'address', - }, - { - title: intl.get('登录IP'), - dataIndex: 'ip', - width: 100, - key: 'ip', - }, - { - title: intl.get('登录设备'), - dataIndex: 'platform', - key: 'platform', - width: 80, - }, - { - title: intl.get('登录状态'), - dataIndex: 'status', - key: 'status', - width: 80, - render: (text: string, record: any) => { - return ( + { + title: intl.get('登录地址'), + dataIndex: 'address', + width: 120, + key: 'address', + }, + { + title: intl.get('登录IP'), + dataIndex: 'ip', + width: 100, + key: 'ip', + }, + { + title: intl.get('登录设备'), + dataIndex: 'platform', + key: 'platform', + width: 80, + }, + { + title: intl.get('登录状态'), + dataIndex: 'status', + key: 'status', + width: 80, + render: (text: string, record: any) => ( {intl.get(LoginStatus[record.status])} - ); + ), }, - }, -]; + { + 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 ( + updateIpBlacklist(record.ip, blocked)} + > + + + ); + }, + }, + ]; -const LoginLog = ({ - data, - height, -}: { - data: Array; - height: number; -}) => { return ( <>
`${record.ip}-${record.timestamp}`} size="middle" scroll={{ x: 1000, y: height }} />