Add flexible IP access control to WebUI config
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run

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:
手瓜一十雪 2026-01-26 19:46:15 +08:00
parent 59d4b08982
commit 63a9d571f3
9 changed files with 489 additions and 107 deletions

View File

@ -12,7 +12,10 @@ export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
port: config.port, port: config.port,
loginRate: config.loginRate, loginRate: config.loginRate,
disableWebUI: config.disableWebUI, disableWebUI: config.disableWebUI,
disableNonLANAccess: config.disableNonLANAccess, accessControlMode: config.accessControlMode || 'none',
ipWhitelist: config.ipWhitelist || [],
ipBlacklist: config.ipBlacklist || [],
enableXForwardedFor: config.enableXForwardedFor || false,
}); });
} catch (error) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message;
@ -48,38 +51,47 @@ export const UpdateDisableWebUIHandler: RequestHandler = async (req, res) => {
} }
}; };
// 获取是否禁用非局域网访问 // 获取当前客户端IP
export const GetDisableNonLANAccessHandler: RequestHandler = async (_, res) => { export const GetClientIPHandler: RequestHandler = async (req, res) => {
try { try {
const disable = await WebUiConfig.GetDisableNonLANAccess(); const config = await WebUiConfig.GetWebUIConfig();
return sendSuccess(res, disable);
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `获取非局域网访问禁用状态失败: ${msg}`);
}
};
// 更新是否禁用非局域网访问 // 根据配置决定如何获取客户端IP与 CORS 中间件逻辑一致)
export const UpdateDisableNonLANAccessHandler: RequestHandler = async (req, res) => { let clientIP: string;
try { if (config.enableXForwardedFor) {
const { disable } = req.body; const forwardedFor = req.headers['x-forwarded-for'];
if (typeof forwardedFor === 'string') {
if (typeof disable !== 'boolean') { clientIP = forwardedFor.split(',')[0]?.trim() || '';
return sendError(res, 'disable参数必须是布尔值'); } 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); // 标准化 IP移除 IPv4-mapped IPv6 前缀,但保留纯 IPv6
return sendSuccess(res, null); 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) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message;
return sendError(res, `更新非局域网访问禁用状态失败: ${msg}`); return sendError(res, `获取客户端IP失败: ${msg}`);
} }
}; };
// 更新WebUI基础配置 // 更新WebUI基础配置
export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => { export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
try { try {
const { host, port, loginRate, disableWebUI, disableNonLANAccess } = req.body; const { host, port, loginRate, disableWebUI, accessControlMode, ipWhitelist, ipBlacklist, enableXForwardedFor } = req.body;
const updateConfig: any = {}; const updateConfig: any = {};
@ -111,11 +123,32 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
updateConfig.disableWebUI = disableWebUI; updateConfig.disableWebUI = disableWebUI;
} }
if (disableNonLANAccess !== undefined) { if (accessControlMode !== undefined) {
if (typeof disableNonLANAccess !== 'boolean') { if (!['none', 'whitelist', 'blacklist'].includes(accessControlMode)) {
return sendError(res, 'disableNonLANAccess必须是布尔值'); 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); await WebUiConfig.UpdateWebUIConfig(updateConfig);

View File

@ -20,8 +20,18 @@ const WebUiConfigSchema = Type.Object({
theme: themeType, theme: themeType,
// 是否关闭WebUI // 是否关闭WebUI
disableWebUI: Type.Boolean({ default: false }), disableWebUI: Type.Boolean({ default: false }),
// 是否关闭非局域网访问 // 网络访问控制模式: 'none' | 'whitelist' | 'blacklist'
disableNonLANAccess: Type.Boolean({ default: false }), 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>; export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
@ -245,14 +255,47 @@ export class WebUiConfigWrapper {
await this.UpdateWebUIConfig({ disableWebUI: disable }); await this.UpdateWebUIConfig({ disableWebUI: disable });
} }
// 获取是否禁用非局域网访问 // 获取访问控制模式
async GetDisableNonLANAccess (): Promise<boolean> { async GetAccessControlMode (): Promise<'none' | 'whitelist' | 'blacklist'> {
const config = await this.GetWebUIConfig(); const config = await this.GetWebUIConfig();
return config.disableNonLANAccess; return config.accessControlMode;
} }
// 更新是否禁用非局域网访问 // 更新访问控制模式
async UpdateDisableNonLANAccess (disable: boolean): Promise<void> { async UpdateAccessControlMode (mode: 'none' | 'whitelist' | 'blacklist'): Promise<void> {
await this.UpdateWebUIConfig({ disableNonLANAccess: disable }); 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 });
} }
} }

View File

@ -1,52 +1,223 @@
import type { RequestHandler } from 'express'; import type { RequestHandler } from 'express';
import { WebUiConfig } from '@/napcat-webui-backend/index'; import { WebUiConfig } from '@/napcat-webui-backend/index';
// 检查是否为局域网IP地址 // 标准化 IP 地址(移除 IPv6 前缀,但保留完整的 IPv6 地址)
function isLANIP (ip: string): boolean { function normalizeIP (ip: string): string {
if (!ip) return false; // 移除 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的前缀如果存在 // 检查是否是 IPv6 地址
const cleanIP = ip.replace(/^::ffff:/, ''); function isIPv6 (ip: string): boolean {
return ip.includes(':');
}
// 本地回环地址 // 检查 IP 是否匹配规则(支持 IPv4 和 IPv6
if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') { function matchIPRule (ip: string, rule: string): boolean {
return true; 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私有网络地址 // IPv4 通配符匹配 (例如: 192.168.*.* 或 192.168.1.*)
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; if (cleanRule.includes('*')) {
const match = cleanIP.match(ipv4Regex); const ruleRegex = new RegExp('^' + cleanRule.replace(/\./g, '\\.').replace(/\*/g, '\\d+') + '$');
return ruleRegex.test(cleanIP);
}
if (match) { // IPv4 CIDR 匹配 (例如: 192.168.1.0/24)
const [, a, b] = match.map(Number); if (cleanRule.includes('/')) {
return matchIPv4CIDR(cleanIP, cleanRule);
// 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;
} }
return false; 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 中间件,跨域用 // CORS 中间件,跨域用
export const cors: RequestHandler = async (req, res, next) => { export const cors: RequestHandler = async (req, res, next) => {
// 检查是否禁用非局域网访问
const config = await WebUiConfig.GetWebUIConfig(); const config = await WebUiConfig.GetWebUIConfig();
if (config.disableNonLANAccess) {
const clientIP = req.ip || req.socket.remoteAddress || ''; // 根据配置决定如何获取客户端IP
if (!isLANIP(clientIP)) { let clientIP: string;
res.status(403).json({ error: '非局域网访问被禁止' }); 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; return;
} }
} }
// 'none' 模式:不进行任何限制
const origin = req.headers.origin || '*'; const origin = req.headers.origin || '*';
res.header('Access-Control-Allow-Origin', origin); res.header('Access-Control-Allow-Origin', origin);

View File

@ -3,9 +3,8 @@ import {
GetWebUIConfigHandler, GetWebUIConfigHandler,
GetDisableWebUIHandler, GetDisableWebUIHandler,
UpdateDisableWebUIHandler, UpdateDisableWebUIHandler,
GetDisableNonLANAccessHandler,
UpdateDisableNonLANAccessHandler,
UpdateWebUIConfigHandler, UpdateWebUIConfigHandler,
GetClientIPHandler,
} from '@/napcat-webui-backend/src/api/WebUIConfig'; } from '@/napcat-webui-backend/src/api/WebUIConfig';
const router: Router = Router(); const router: Router = Router();
@ -22,10 +21,7 @@ router.get('/GetDisableWebUI', GetDisableWebUIHandler);
// 更新是否禁用WebUI // 更新是否禁用WebUI
router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler); router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
// 获取是否禁用非局域网访问 // 获取当前客户端IP
router.get('/GetDisableNonLANAccess', GetDisableNonLANAccessHandler); router.get('/GetClientIP', GetClientIPHandler);
// 更新是否禁用非局域网访问
router.post('/UpdateDisableNonLANAccess', UpdateDisableNonLANAccessHandler);
export { router as WebUIConfigRouter }; export { router as WebUIConfigRouter };

View File

@ -20,6 +20,10 @@ export interface WebUiConfigType {
port: number; port: number;
token: string; token: string;
loginRate: number; loginRate: number;
accessControlMode?: 'none' | 'whitelist' | 'blacklist';
ipWhitelist?: string[];
ipBlacklist?: string[];
enableXForwardedFor?: boolean;
} }
export interface WebUiCredentialInnerJson { export interface WebUiCredentialInnerJson {
CreatedTime: number; CreatedTime: number;

View File

@ -1,7 +1,11 @@
{ {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 6099, "port": 6099,
"prefix": "", "prefix": "",
"token": "random", "token": "random",
"loginRate": 3 "loginRate": 3,
"accessControlMode": "none",
"ipWhitelist": [],
"ipBlacklist": [],
"enableXForwardedFor": false
} }

View File

@ -273,19 +273,10 @@ export default class WebUIManager {
return data.data; return data.data;
} }
// 获取是否禁用非局域网访问 // 获取当前客户端IP
public static async getDisableNonLANAccess () { public static async getClientIP () {
const { data } = await serverRequest.get<ServerResponse<boolean>>( const { data } = await serverRequest.get<ServerResponse<{ ip: string; }>>(
'/WebUIConfig/GetDisableNonLANAccess' '/WebUIConfig/GetClientIP'
);
return data.data;
}
// 更新是否禁用非局域网访问
public static async updateDisableNonLANAccess (disable: boolean) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/WebUIConfig/UpdateDisableNonLANAccess',
{ disable }
); );
return data.data; return data.data;
} }

View File

@ -1,8 +1,9 @@
import { Input } from '@heroui/input'; import { Input } from '@heroui/input';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Select, SelectItem } from '@heroui/select';
import SaveButtons from '@/components/button/save_buttons'; import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading'; import PageLoading from '@/components/page_loading';
@ -18,40 +19,73 @@ const ServerConfigCard = () => {
refreshAsync: refreshConfig, refreshAsync: refreshConfig,
} = useRequest(WebUIManager.getWebUIConfig); } = useRequest(WebUIManager.getWebUIConfig);
const [ipListText, setIpListText] = useState('');
const { const {
control, control,
handleSubmit: handleConfigSubmit, handleSubmit: handleConfigSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
setValue: setConfigValue, setValue: setConfigValue,
watch,
} = useForm<{ } = useForm<{
host: string; host: string;
port: number; port: number;
loginRate: number; loginRate: number;
disableWebUI: boolean; disableWebUI: boolean;
disableNonLANAccess: boolean; accessControlMode: 'none' | 'whitelist' | 'blacklist';
ipWhitelist: string[];
ipBlacklist: string[];
enableXForwardedFor: boolean;
}>({ }>({
defaultValues: { defaultValues: {
host: '0.0.0.0', host: '0.0.0.0',
port: 6099, port: 6099,
loginRate: 10, loginRate: 10,
disableWebUI: false, disableWebUI: false,
disableNonLANAccess: false, accessControlMode: 'none',
ipWhitelist: [],
ipBlacklist: [],
enableXForwardedFor: false,
}, },
}); });
const accessControlMode = watch('accessControlMode');
const reset = () => { const reset = () => {
if (configData) { if (configData) {
setConfigValue('host', configData.host); setConfigValue('host', configData.host);
setConfigValue('port', configData.port); setConfigValue('port', configData.port);
setConfigValue('loginRate', configData.loginRate); setConfigValue('loginRate', configData.loginRate);
setConfigValue('disableWebUI', configData.disableWebUI); setConfigValue('disableWebUI', configData.disableWebUI);
setConfigValue('disableNonLANAccess', configData.disableNonLANAccess); setConfigValue('accessControlMode', configData.accessControlMode || 'none');
setConfigValue('ipWhitelist', configData.ipWhitelist || []);
setConfigValue('ipBlacklist', configData.ipBlacklist || []);
setConfigValue('enableXForwardedFor', configData.enableXForwardedFor || false);
// 更新IP列表文本
if (configData.accessControlMode === 'whitelist') {
setIpListText((configData.ipWhitelist || []).join('\n'));
} else if (configData.accessControlMode === 'blacklist') {
setIpListText((configData.ipBlacklist || []).join('\n'));
}
} }
}; };
const onSubmit = handleConfigSubmit(async (data) => { const onSubmit = handleConfigSubmit(async (data) => {
try { try {
await WebUIManager.updateWebUIConfig(data); // 解析IP列表
const ipList = ipListText
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip.length > 0);
const submitData = {
...data,
ipWhitelist: data.accessControlMode === 'whitelist' ? ipList : [],
ipBlacklist: data.accessControlMode === 'blacklist' ? ipList : [],
};
await WebUIManager.updateWebUIConfig(submitData);
toast.success('保存成功'); toast.success('保存成功');
} catch (error) { } catch (error) {
const msg = (error as Error).message; const msg = (error as Error).message;
@ -73,6 +107,39 @@ const ServerConfigCard = () => {
reset(); reset();
}, [configData]); }, [configData]);
useEffect(() => {
// 当模式切换时更新IP列表文本
const handleModeChange = async () => {
if (configData) {
if (accessControlMode === 'whitelist') {
const currentList = configData.ipWhitelist || [];
// 如果白名单为空自动获取当前IP并填入
if (currentList.length === 0) {
try {
const clientIPData = await WebUIManager.getClientIP();
if (clientIPData?.ip) {
setIpListText(clientIPData.ip);
} else {
setIpListText('');
}
} catch (error) {
console.error('获取客户端IP失败:', error);
setIpListText('');
}
} else {
setIpListText(currentList.join('\n'));
}
} else if (accessControlMode === 'blacklist') {
setIpListText((configData.ipBlacklist || []).join('\n'));
} else {
setIpListText('');
}
}
};
handleModeChange();
}, [accessControlMode, configData]);
if (configLoading) return <PageLoading loading />; if (configLoading) return <PageLoading loading />;
return ( return (
@ -161,19 +228,89 @@ const ServerConfigCard = () => {
/> />
)} )}
/> />
<Controller
control={control} <div className='flex flex-col gap-3 mt-2'>
name='disableNonLANAccess' <div className='text-sm font-medium text-default-700 dark:text-default-300 px-1'>访</div>
render={({ field }) => ( <Controller
<SwitchCard control={control}
value={field.value} name='accessControlMode'
onValueChange={(value: boolean) => field.onChange(value)} render={({ field }) => (
disabled={!!configError} <Select
label='禁用非局域网访问' {...field}
description='启用后只允许局域网内的设备访问WebUI提高安全性' label='访问控制模式'
/> placeholder='选择访问控制模式'
description='选择如何控制网络访问'
selectedKeys={[field.value]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as 'none' | 'whitelist' | 'blacklist';
field.onChange(value);
}}
isDisabled={!!configError}
classNames={{
trigger:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
}}
>
<SelectItem key='none' value='none'>
</SelectItem>
<SelectItem key='whitelist' value='whitelist'>
</SelectItem>
<SelectItem key='blacklist' value='blacklist'>
</SelectItem>
</Select>
)}
/>
{accessControlMode !== 'none' && (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<label className='text-sm font-medium text-default-700 dark:text-default-300'>
{accessControlMode === 'whitelist' ? 'IP白名单' : 'IP黑名单'}
</label>
<textarea
value={ipListText}
onChange={(e) => setIpListText(e.target.value)}
placeholder={`每行一个IP地址或规则\n支持格式\nIPv4:\n- 精确IP: 192.168.1.100\n- 通配符: 192.168.1.*\n- CIDR: 192.168.1.0/24\nIPv6:\n- 精确IP: 2001:db8::1\n- CIDR: 2001:db8::/32`}
disabled={!!configError}
rows={10}
className='w-full px-3 py-2 bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm hover:border-default-300 rounded-lg text-default-700 dark:text-default-300 placeholder:text-default-400 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
<p className='text-xs text-default-500'>
{accessControlMode === 'whitelist'
? '只有列表中的IP可以访问'
: '列表中的IP将被拒绝访问'}
</p>
</div>
<div className='text-xs text-default-500 px-1'>
<div className='font-medium mb-1'></div>
<div className='space-y-0.5 font-mono'>
<div> 127.0.0.1 (IPv4 )</div>
<div> 192.168.1.* (IPv4 )</div>
<div> 10.0.0.0/8 (IPv4 CIDR)</div>
<div> ::1 (IPv6 )</div>
<div> 2001:db8::/32 (IPv6 CIDR)</div>
</div>
</div>
</div>
)} )}
/>
<Controller
control={control}
name='enableXForwardedFor'
render={({ field }) => (
<SwitchCard
value={field.value}
onValueChange={(value: boolean) => field.onChange(value)}
disabled={!!configError}
label='启用 X-Forwarded-For'
description='启用后将从 X-Forwarded-For 头部获取真实IP地址适用于反向代理场景'
/>
)}
/>
</div>
</div> </div>
</div> </div>

View File

@ -188,5 +188,8 @@ interface WebUIConfig {
port: number; port: number;
loginRate: number; loginRate: number;
disableWebUI: boolean; disableWebUI: boolean;
disableNonLANAccess: boolean; accessControlMode: 'none' | 'whitelist' | 'blacklist';
ipWhitelist: string[];
ipBlacklist: string[];
enableXForwardedFor: boolean;
} }