mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-13 00:10:27 +00:00
Add flexible IP access control to WebUI config
Replaces the 'disableNonLANAccess' option with a more flexible access control system supporting 'none', 'whitelist', and 'blacklist' modes, along with IP list and X-Forwarded-For support. Updates backend API, config schema, middleware, and frontend UI to allow configuration of access control mode, IP whitelist/blacklist, and X-Forwarded-For handling. Removes legacy LAN-only access logic and updates types accordingly.
This commit is contained in:
@@ -12,7 +12,10 @@ export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
|
||||
port: config.port,
|
||||
loginRate: config.loginRate,
|
||||
disableWebUI: config.disableWebUI,
|
||||
disableNonLANAccess: config.disableNonLANAccess,
|
||||
accessControlMode: config.accessControlMode || 'none',
|
||||
ipWhitelist: config.ipWhitelist || [],
|
||||
ipBlacklist: config.ipBlacklist || [],
|
||||
enableXForwardedFor: config.enableXForwardedFor || false,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
@@ -48,38 +51,47 @@ export const UpdateDisableWebUIHandler: RequestHandler = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
export const GetDisableNonLANAccessHandler: RequestHandler = async (_, res) => {
|
||||
// 获取当前客户端IP
|
||||
export const GetClientIPHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const disable = await WebUiConfig.GetDisableNonLANAccess();
|
||||
return sendSuccess(res, disable);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取非局域网访问禁用状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
export const UpdateDisableNonLANAccessHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { disable } = req.body;
|
||||
|
||||
if (typeof disable !== 'boolean') {
|
||||
return sendError(res, 'disable参数必须是布尔值');
|
||||
// 根据配置决定如何获取客户端IP(与 CORS 中间件逻辑一致)
|
||||
let clientIP: string;
|
||||
if (config.enableXForwardedFor) {
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
clientIP = forwardedFor.split(',')[0]?.trim() || '';
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
clientIP = forwardedFor[0] || '';
|
||||
} else {
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
} else {
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateDisableNonLANAccess(disable);
|
||||
return sendSuccess(res, null);
|
||||
// 标准化 IP(移除 IPv4-mapped IPv6 前缀,但保留纯 IPv6)
|
||||
let normalizedIP = clientIP;
|
||||
if (clientIP.startsWith('::ffff:')) {
|
||||
const ipv4 = clientIP.substring(7);
|
||||
// 检查是否是有效的 IPv4
|
||||
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipv4)) {
|
||||
normalizedIP = ipv4;
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { ip: normalizedIP });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `更新非局域网访问禁用状态失败: ${msg}`);
|
||||
return sendError(res, `获取客户端IP失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新WebUI基础配置
|
||||
export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { host, port, loginRate, disableWebUI, disableNonLANAccess } = req.body;
|
||||
const { host, port, loginRate, disableWebUI, accessControlMode, ipWhitelist, ipBlacklist, enableXForwardedFor } = req.body;
|
||||
|
||||
const updateConfig: any = {};
|
||||
|
||||
@@ -111,11 +123,32 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
|
||||
updateConfig.disableWebUI = disableWebUI;
|
||||
}
|
||||
|
||||
if (disableNonLANAccess !== undefined) {
|
||||
if (typeof disableNonLANAccess !== 'boolean') {
|
||||
return sendError(res, 'disableNonLANAccess必须是布尔值');
|
||||
if (accessControlMode !== undefined) {
|
||||
if (!['none', 'whitelist', 'blacklist'].includes(accessControlMode)) {
|
||||
return sendError(res, 'accessControlMode必须是none、whitelist或blacklist');
|
||||
}
|
||||
updateConfig.disableNonLANAccess = disableNonLANAccess;
|
||||
updateConfig.accessControlMode = accessControlMode;
|
||||
}
|
||||
|
||||
if (ipWhitelist !== undefined) {
|
||||
if (!Array.isArray(ipWhitelist)) {
|
||||
return sendError(res, 'ipWhitelist必须是数组');
|
||||
}
|
||||
updateConfig.ipWhitelist = ipWhitelist;
|
||||
}
|
||||
|
||||
if (ipBlacklist !== undefined) {
|
||||
if (!Array.isArray(ipBlacklist)) {
|
||||
return sendError(res, 'ipBlacklist必须是数组');
|
||||
}
|
||||
updateConfig.ipBlacklist = ipBlacklist;
|
||||
}
|
||||
|
||||
if (enableXForwardedFor !== undefined) {
|
||||
if (typeof enableXForwardedFor !== 'boolean') {
|
||||
return sendError(res, 'enableXForwardedFor必须是布尔值');
|
||||
}
|
||||
updateConfig.enableXForwardedFor = enableXForwardedFor;
|
||||
}
|
||||
|
||||
await WebUiConfig.UpdateWebUIConfig(updateConfig);
|
||||
|
||||
@@ -20,8 +20,18 @@ const WebUiConfigSchema = Type.Object({
|
||||
theme: themeType,
|
||||
// 是否关闭WebUI
|
||||
disableWebUI: Type.Boolean({ default: false }),
|
||||
// 是否关闭非局域网访问
|
||||
disableNonLANAccess: Type.Boolean({ default: false }),
|
||||
// 网络访问控制模式: 'none' | 'whitelist' | 'blacklist'
|
||||
accessControlMode: Type.Union([
|
||||
Type.Literal('none'),
|
||||
Type.Literal('whitelist'),
|
||||
Type.Literal('blacklist'),
|
||||
], { default: 'none' }),
|
||||
// IP白名单列表
|
||||
ipWhitelist: Type.Array(Type.String(), { default: [] }),
|
||||
// IP黑名单列表
|
||||
ipBlacklist: Type.Array(Type.String(), { default: [] }),
|
||||
// 是否启用 X-Forwarded-For 获取真实IP
|
||||
enableXForwardedFor: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
|
||||
@@ -245,14 +255,47 @@ export class WebUiConfigWrapper {
|
||||
await this.UpdateWebUIConfig({ disableWebUI: disable });
|
||||
}
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
async GetDisableNonLANAccess (): Promise<boolean> {
|
||||
// 获取访问控制模式
|
||||
async GetAccessControlMode (): Promise<'none' | 'whitelist' | 'blacklist'> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.disableNonLANAccess;
|
||||
return config.accessControlMode;
|
||||
}
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
async UpdateDisableNonLANAccess (disable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ disableNonLANAccess: disable });
|
||||
// 更新访问控制模式
|
||||
async UpdateAccessControlMode (mode: 'none' | 'whitelist' | 'blacklist'): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ accessControlMode: mode });
|
||||
}
|
||||
|
||||
// 获取IP白名单
|
||||
async GetIpWhitelist (): Promise<string[]> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.ipWhitelist;
|
||||
}
|
||||
|
||||
// 更新IP白名单
|
||||
async UpdateIpWhitelist (whitelist: string[]): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ ipWhitelist: whitelist });
|
||||
}
|
||||
|
||||
// 获取IP黑名单
|
||||
async GetIpBlacklist (): Promise<string[]> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.ipBlacklist;
|
||||
}
|
||||
|
||||
// 更新IP黑名单
|
||||
async UpdateIpBlacklist (blacklist: string[]): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ ipBlacklist: blacklist });
|
||||
}
|
||||
|
||||
// 获取是否启用 X-Forwarded-For
|
||||
async GetEnableXForwardedFor (): Promise<boolean> {
|
||||
const config = await this.GetWebUIConfig();
|
||||
return config.enableXForwardedFor || false;
|
||||
}
|
||||
|
||||
// 更新是否启用 X-Forwarded-For
|
||||
async UpdateEnableXForwardedFor (enable: boolean): Promise<void> {
|
||||
await this.UpdateWebUIConfig({ enableXForwardedFor: enable });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,223 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
|
||||
// 检查是否为局域网IP地址
|
||||
function isLANIP (ip: string): boolean {
|
||||
if (!ip) return false;
|
||||
// 标准化 IP 地址(移除 IPv6 前缀,但保留完整的 IPv6 地址)
|
||||
function normalizeIP (ip: string): string {
|
||||
// 移除 IPv4-mapped IPv6 前缀
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
const ipv4 = ip.substring(7);
|
||||
// 检查是否是有效的 IPv4
|
||||
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ipv4)) {
|
||||
return ipv4;
|
||||
}
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
// 移除IPv6的前缀,如果存在
|
||||
const cleanIP = ip.replace(/^::ffff:/, '');
|
||||
// 检查是否是 IPv6 地址
|
||||
function isIPv6 (ip: string): boolean {
|
||||
return ip.includes(':');
|
||||
}
|
||||
|
||||
// 本地回环地址
|
||||
if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') {
|
||||
return true;
|
||||
// 检查 IP 是否匹配规则(支持 IPv4 和 IPv6)
|
||||
function matchIPRule (ip: string, rule: string): boolean {
|
||||
const cleanIP = normalizeIP(ip);
|
||||
const cleanRule = normalizeIP(rule);
|
||||
|
||||
// 精确匹配
|
||||
if (cleanIP === cleanRule) return true;
|
||||
|
||||
// IPv6 地址不支持通配符,只支持 CIDR
|
||||
if (isIPv6(cleanIP) || isIPv6(cleanRule)) {
|
||||
if (cleanRule.includes('/')) {
|
||||
return matchIPv6CIDR(cleanIP, cleanRule);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查IPv4私有网络地址
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const match = cleanIP.match(ipv4Regex);
|
||||
// IPv4 通配符匹配 (例如: 192.168.*.* 或 192.168.1.*)
|
||||
if (cleanRule.includes('*')) {
|
||||
const ruleRegex = new RegExp('^' + cleanRule.replace(/\./g, '\\.').replace(/\*/g, '\\d+') + '$');
|
||||
return ruleRegex.test(cleanIP);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const [, a, b] = match.map(Number);
|
||||
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;
|
||||
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
|
||||
// 169.254.0.0/16 (链路本地地址)
|
||||
if (a === 169 && b === 254) return true;
|
||||
// IPv4 CIDR 匹配 (例如: 192.168.1.0/24)
|
||||
if (cleanRule.includes('/')) {
|
||||
return matchIPv4CIDR(cleanIP, cleanRule);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4 CIDR 匹配
|
||||
function matchIPv4CIDR (ip: string, cidr: string): boolean {
|
||||
const parts = cidr.split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const range = parts[0];
|
||||
const bits = parts[1];
|
||||
if (!range || !bits) return false;
|
||||
|
||||
const prefixLength = parseInt(bits);
|
||||
if (isNaN(prefixLength) || prefixLength < 0 || prefixLength > 32) return false;
|
||||
|
||||
const mask = prefixLength === 0 ? 0 : ~(2 ** (32 - prefixLength) - 1);
|
||||
|
||||
const ipNum = ipv4ToNumber(ip);
|
||||
const rangeNum = ipv4ToNumber(range);
|
||||
|
||||
if (ipNum === null || rangeNum === null) return false;
|
||||
|
||||
return (ipNum & mask) === (rangeNum & mask);
|
||||
}
|
||||
|
||||
// IPv4 转数字
|
||||
function ipv4ToNumber (ip: string): number | null {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const nums = parts.map(p => parseInt(p));
|
||||
if (nums.some(n => isNaN(n) || n < 0 || n > 255)) return null;
|
||||
|
||||
return ((nums[0] || 0) << 24) + ((nums[1] || 0) << 16) + ((nums[2] || 0) << 8) + (nums[3] || 0);
|
||||
}
|
||||
|
||||
// IPv6 CIDR 匹配
|
||||
function matchIPv6CIDR (ip: string, cidr: string): boolean {
|
||||
const parts = cidr.split('/');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const range = parts[0];
|
||||
const bits = parts[1];
|
||||
if (!range || !bits) return false;
|
||||
|
||||
const prefixLength = parseInt(bits);
|
||||
if (isNaN(prefixLength) || prefixLength < 0 || prefixLength > 128) return false;
|
||||
|
||||
try {
|
||||
const ipSegments = expandIPv6(ip);
|
||||
const rangeSegments = expandIPv6(range);
|
||||
|
||||
if (!ipSegments || !rangeSegments) return false;
|
||||
|
||||
// 按位比较
|
||||
let bitsToCompare = prefixLength;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (bitsToCompare <= 0) break;
|
||||
|
||||
const ipSeg = ipSegments[i] ?? 0;
|
||||
const rangeSeg = rangeSegments[i] ?? 0;
|
||||
|
||||
if (bitsToCompare >= 16) {
|
||||
// 完整比较这个段
|
||||
if (ipSeg !== rangeSeg) return false;
|
||||
bitsToCompare -= 16;
|
||||
} else {
|
||||
// 部分比较这个段
|
||||
const mask = (0xFFFF << (16 - bitsToCompare)) & 0xFFFF;
|
||||
if ((ipSeg & mask) !== (rangeSeg & mask)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 展开 IPv6 地址为 8 个 16 位段
|
||||
function expandIPv6 (ip: string): number[] | null {
|
||||
try {
|
||||
// 移除可能的 IPv4 映射部分
|
||||
let addr = ip;
|
||||
|
||||
// 处理 IPv4-mapped IPv6 (如 ::ffff:192.168.1.1)
|
||||
const ipv4Match = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
if (ipv4Match && ipv4Match[1]) {
|
||||
const ipv4 = ipv4Match[1];
|
||||
const ipv4Parts = ipv4.split('.').map(p => parseInt(p));
|
||||
if (ipv4Parts.length === 4 && ipv4Parts.every(n => n >= 0 && n <= 255)) {
|
||||
// 转换为 IPv6 格式
|
||||
const part1 = (ipv4Parts[0] ?? 0) << 8 | (ipv4Parts[1] ?? 0);
|
||||
const part2 = (ipv4Parts[2] ?? 0) << 8 | (ipv4Parts[3] ?? 0);
|
||||
addr = `::ffff:${part1.toString(16)}:${part2.toString(16)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 :: 缩写
|
||||
if (addr.includes('::')) {
|
||||
const sides = addr.split('::');
|
||||
if (sides.length > 2) return null;
|
||||
|
||||
const left = sides[0] ? sides[0].split(':') : [];
|
||||
const right = sides[1] ? sides[1].split(':') : [];
|
||||
const missing = 8 - left.length - right.length;
|
||||
|
||||
if (missing < 0) return null;
|
||||
|
||||
const segments = [
|
||||
...left,
|
||||
...Array(missing).fill('0'),
|
||||
...right,
|
||||
];
|
||||
|
||||
return segments.map(s => parseInt(s || '0', 16));
|
||||
}
|
||||
|
||||
// 标准格式
|
||||
const segments = addr.split(':');
|
||||
if (segments.length !== 8) return null;
|
||||
|
||||
return segments.map(s => parseInt(s || '0', 16));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否在 IP 列表中
|
||||
function isIPInList (ip: string, ipList: string[]): boolean {
|
||||
return ipList.some(rule => matchIPRule(ip, rule));
|
||||
}
|
||||
|
||||
// CORS 中间件,跨域用
|
||||
export const cors: RequestHandler = async (req, res, next) => {
|
||||
// 检查是否禁用非局域网访问
|
||||
const config = await WebUiConfig.GetWebUIConfig();
|
||||
if (config.disableNonLANAccess) {
|
||||
const clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
if (!isLANIP(clientIP)) {
|
||||
res.status(403).json({ error: '非局域网访问被禁止' });
|
||||
|
||||
// 根据配置决定如何获取客户端IP
|
||||
let clientIP: string;
|
||||
if (config.enableXForwardedFor) {
|
||||
// 启用 X-Forwarded-For 时,优先从该头部获取真实IP
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (typeof forwardedFor === 'string') {
|
||||
// X-Forwarded-For 可能包含多个IP,取第一个
|
||||
clientIP = forwardedFor.split(',')[0]?.trim() || '';
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
clientIP = forwardedFor[0] || '';
|
||||
} else {
|
||||
// 如果没有 X-Forwarded-For,回退到 socket IP
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
} else {
|
||||
// 默认使用 socket IP
|
||||
clientIP = req.ip || req.socket.remoteAddress || '';
|
||||
}
|
||||
|
||||
// 检查访问控制模式
|
||||
if (config.accessControlMode === 'whitelist') {
|
||||
// 白名单模式:只允许白名单中的IP访问
|
||||
if (!isIPInList(clientIP, config.ipWhitelist || [])) {
|
||||
res.status(403).json({ error: '访问被拒绝:IP不在白名单中' });
|
||||
return;
|
||||
}
|
||||
} else if (config.accessControlMode === 'blacklist') {
|
||||
// 黑名单模式:拒绝黑名单中的IP访问
|
||||
if (isIPInList(clientIP, config.ipBlacklist || [])) {
|
||||
res.status(403).json({ error: '访问被拒绝:IP在黑名单中' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 'none' 模式:不进行任何限制
|
||||
|
||||
const origin = req.headers.origin || '*';
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
|
||||
@@ -3,9 +3,8 @@ import {
|
||||
GetWebUIConfigHandler,
|
||||
GetDisableWebUIHandler,
|
||||
UpdateDisableWebUIHandler,
|
||||
GetDisableNonLANAccessHandler,
|
||||
UpdateDisableNonLANAccessHandler,
|
||||
UpdateWebUIConfigHandler,
|
||||
GetClientIPHandler,
|
||||
} from '@/napcat-webui-backend/src/api/WebUIConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
@@ -22,10 +21,7 @@ router.get('/GetDisableWebUI', GetDisableWebUIHandler);
|
||||
// 更新是否禁用WebUI
|
||||
router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
router.get('/GetDisableNonLANAccess', GetDisableNonLANAccessHandler);
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
router.post('/UpdateDisableNonLANAccess', UpdateDisableNonLANAccessHandler);
|
||||
// 获取当前客户端IP
|
||||
router.get('/GetClientIP', GetClientIPHandler);
|
||||
|
||||
export { router as WebUIConfigRouter };
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface WebUiConfigType {
|
||||
port: number;
|
||||
token: string;
|
||||
loginRate: number;
|
||||
accessControlMode?: 'none' | 'whitelist' | 'blacklist';
|
||||
ipWhitelist?: string[];
|
||||
ipBlacklist?: string[];
|
||||
enableXForwardedFor?: boolean;
|
||||
}
|
||||
export interface WebUiCredentialInnerJson {
|
||||
CreatedTime: number;
|
||||
|
||||
Reference in New Issue
Block a user