feat: 管理员白名单与个性化配置权限控制

系统配置仅允许 admin_list.txt 白名单 IP 编辑;localhost 访问时解析为 LAN IP;个性化配置支持按 IP 权限隔离,新增时自动填充当前 IP。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dongzp 2026-08-31 11:02:16 +08:00
parent 2b8cb3ca9a
commit 9bec3208f8
6 changed files with 281 additions and 42 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API Proxy Mock 配置管理</title> <title>企业级数据Mock管理系统 - by tony</title>
<link rel="stylesheet" href="/assets/element-ui/index.css" /> <link rel="stylesheet" href="/assets/element-ui/index.css" />
<style> <style>
body { body {
@ -56,6 +56,10 @@
overflow-y: auto; overflow-y: auto;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
} }
.basic-config-readonly {
pointer-events: none;
opacity: 0.65;
}
</style> </style>
</head> </head>
<body> <body>
@ -227,11 +231,21 @@
</el-card> </el-card>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="基础配置" name="basic"> <el-tab-pane label="系统配置" name="basic">
<el-card class="section-card"> <el-card class="section-card">
<div style="display:flex;justify-content:flex-end;align-items:center;margin-bottom:12px;"> <div style="display:flex;justify-content:flex-end;align-items:center;margin-bottom:12px;">
<el-button size="mini" icon="el-icon-refresh" :loading="configRefreshing" @click="refreshConfig">刷新</el-button> <el-button size="mini" icon="el-icon-refresh" :loading="configRefreshing" @click="refreshConfig">刷新</el-button>
</div> </div>
<el-alert
v-if="!canEditBasicConfig"
type="warning"
:closable="false"
show-icon
style="margin-bottom:16px;"
:title="'当前 IP(' + (clientIp || '未知') + ')不在管理员白名单内,无法修改系统配置'"
description="系统配置仅允许 admin_list.txt 中的 IP 访问与编辑。"
></el-alert>
<div :class="{ 'basic-config-readonly': !canEditBasicConfig }">
<el-form :model="form.config" label-width="180px"> <el-form :model="form.config" label-width="180px">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
@ -294,24 +308,30 @@
</el-col> </el-col>
</el-row> </el-row>
</el-form> </el-form>
</div>
</el-card> </el-card>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="IP管理" name="ip"> <el-tab-pane label="个性化配置" name="ip">
<el-card class="section-card"> <el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div> <div>
<strong>IP 访问策略</strong> <strong>个性化配置</strong>
<div class="small-text" style="margin-top:4px;"> <div class="small-text" style="margin-top:4px;">
按客户端 IP 拉黑或配置独立目标环境(不含本地代理端口),便于开发/测试分流。 为当前 IP 配置独立目标环境(不含本地代理端口),覆盖全局系统配置。
通过 localhost 访问时会自动匹配本机唯一 LAN IP 规则。 普通用户仅可管理 IP 为 <strong>{{ clientIp || "未知" }}</strong> 的配置;管理员可管理全部。
<a href="/__client-ip" target="_blank" style="color:#409EFF;">查看当前请求识别到的 IP</a> <a href="/__client-ip" target="_blank" style="color:#409EFF;">查看当前识别 IP</a>
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;align-items:center;"> <div style="display:flex;gap:8px;align-items:center;">
<el-input size="small" v-model="ipRuleSearch" placeholder="搜索 IP 或备注" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input> <el-input size="small" v-model="ipRuleSearch" placeholder="搜索 IP 或备注" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="ipRulesRefreshing" @click="refreshIpRules">刷新</el-button> <el-button size="mini" icon="el-icon-refresh" :loading="ipRulesRefreshing" @click="refreshIpRules">刷新</el-button>
<el-button size="mini" type="primary" @click="openIpRuleDialogForCreate">新增 IP 规则</el-button> <el-button
v-if="canCreateIpRule"
size="mini"
type="primary"
@click="openIpRuleDialogForCreate"
>{{ isAdmin ? "新增配置" : "配置我的环境" }}</el-button>
</div> </div>
</div> </div>
<el-table :data="pagedIpRules" border style="width: 100%;"> <el-table :data="pagedIpRules" border style="width: 100%;">
@ -346,9 +366,12 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="160"> <el-table-column label="操作" width="160">
<template slot-scope="scope"> <template slot-scope="scope">
<template v-if="canManageIpRule(scope.row)">
<el-button size="mini" type="primary" plain @click="openIpRuleDialogForEdit(scope.row)">修改</el-button> <el-button size="mini" type="primary" plain @click="openIpRuleDialogForEdit(scope.row)">修改</el-button>
<el-button size="mini" type="danger" @click="removeIpRule(scope.row)">删除</el-button> <el-button size="mini" type="danger" @click="removeIpRule(scope.row)">删除</el-button>
</template> </template>
<span v-else class="small-text">无权限</span>
</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-pagination <el-pagination
@ -619,7 +642,7 @@
</el-dialog> </el-dialog>
<el-dialog <el-dialog
:title="ipRuleDialog.mode === 'edit' ? '修改 IP 规则' : '新增 IP 规则'" :title="ipRuleDialog.mode === 'edit' ? '修改个性化配置' : (isAdmin ? '新增个性化配置' : '配置我的环境')"
:visible.sync="ipRuleDialog.visible" :visible.sync="ipRuleDialog.visible"
width="760px" width="760px"
> >
@ -627,21 +650,24 @@
<el-form-item label="IP 地址"> <el-form-item label="IP 地址">
<el-input <el-input
v-model="ipRuleDialog.form.ip" v-model="ipRuleDialog.form.ip"
placeholder="如 192.168.1.100" placeholder="自动识别当前 IP"
:disabled="ipRuleDialog.mode === 'edit'" disabled
></el-input> ></el-input>
<div class="small-text" style="margin-top:4px;">
{{ ipRuleDialog.mode === 'create' ? '自动填充当前 IP,不可修改' : 'IP 地址不可修改' }}
</div>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="ipRuleDialog.form.remark" placeholder="如:测试组、开发组"></el-input> <el-input v-model="ipRuleDialog.form.remark" placeholder="如:测试组、开发组"></el-input>
</el-form-item> </el-form-item>
<el-form-item label="拉黑访问"> <el-form-item v-if="isAdmin" label="拉黑访问">
<el-switch v-model="ipRuleDialog.form.blocked" @change="onIpRuleBlockedChange"></el-switch> <el-switch v-model="ipRuleDialog.form.blocked" @change="onIpRuleBlockedChange"></el-switch>
<div class="small-text" style="margin-top:4px;">开启后该 IP 的所有请求将被拒绝(403)</div> <div class="small-text" style="margin-top:4px;">开启后该 IP 的所有请求将被拒绝(403)</div>
</el-form-item> </el-form-item>
<template v-if="!ipRuleDialog.form.blocked"> <template v-if="!ipRuleDialog.form.blocked">
<el-form-item label="个性化配置"> <el-form-item label="个性化配置">
<el-switch v-model="ipRuleDialog.form.useCustomConfig"></el-switch> <el-switch v-model="ipRuleDialog.form.useCustomConfig"></el-switch>
<div class="small-text" style="margin-top:4px;">开启后可为此 IP 单独配置目标环境,覆盖全局基础配置</div> <div class="small-text" style="margin-top:4px;">开启后可为此 IP 单独配置目标环境,覆盖全局系统配置</div>
</el-form-item> </el-form-item>
<template v-if="ipRuleDialog.form.useCustomConfig"> <template v-if="ipRuleDialog.form.useCustomConfig">
<el-divider content-position="left">独立环境配置</el-divider> <el-divider content-position="left">独立环境配置</el-divider>
@ -714,6 +740,9 @@
mocksRefreshing: false, mocksRefreshing: false,
apiRefreshing: false, apiRefreshing: false,
configRefreshing: false, configRefreshing: false,
canEditBasicConfig: false,
clientIp: "",
isAdmin: false,
ipRulesRefreshing: false, ipRulesRefreshing: false,
ipRules: [], ipRules: [],
ipRuleSearch: "", ipRuleSearch: "",
@ -907,7 +936,7 @@
this.configRefreshing = true; this.configRefreshing = true;
try { try {
await this.loadConfig(); await this.loadConfig();
this.$message.success("基础配置已刷新"); this.$message.success("系统配置已刷新");
} finally { } finally {
this.configRefreshing = false; this.configRefreshing = false;
} }
@ -916,7 +945,7 @@
this.ipRulesRefreshing = true; this.ipRulesRefreshing = true;
try { try {
await this.loadIpRules(); await this.loadIpRules();
this.$message.success("IP 规则已刷新"); this.$message.success("个性化配置已刷新");
} finally { } finally {
this.ipRulesRefreshing = false; this.ipRulesRefreshing = false;
} }
@ -929,16 +958,31 @@
throw new Error(data.error || "加载 IP 规则失败"); throw new Error(data.error || "加载 IP 规则失败");
} }
this.ipRules = Array.isArray(data.list) ? data.list : []; this.ipRules = Array.isArray(data.list) ? data.list : [];
if (data.clientIp) {
this.clientIp = data.clientIp;
}
if (data.isAdmin === true) {
this.isAdmin = true;
} else if (data.isAdmin === false) {
this.isAdmin = false;
}
this.ipRulePagination.currentPage = 1; this.ipRulePagination.currentPage = 1;
} catch (err) { } catch (err) {
this.ipRules = []; this.ipRules = [];
this.$message.error("加载 IP 规则失败: " + err.message); this.$message.error("加载个性化配置失败: " + err.message);
} }
}, },
canManageIpRule: function (rule) {
if (!rule) return false;
if (this.isAdmin) return true;
var ruleIp = (rule.ip || "").trim();
var mine = (this.clientIp || "").trim();
return ruleIp && mine && ruleIp === mine;
},
getDefaultIpRuleForm: function () { getDefaultIpRuleForm: function () {
var global = this.form.config || {}; var global = this.form.config || {};
return { return {
ip: "", ip: this.clientIp || "",
remark: "", remark: "",
blocked: false, blocked: false,
useCustomConfig: true, useCustomConfig: true,
@ -967,12 +1011,23 @@
this.ipRuleDialog.form.useCustomConfig = false; this.ipRuleDialog.form.useCustomConfig = false;
} }
}, },
openIpRuleDialogForCreate: function () { openIpRuleDialogForCreate: async function () {
if (!this.canCreateIpRule) {
this.$message.warning("您已有个性化配置,请直接修改");
return;
}
if (!this.clientIp) {
await this.loadIpRules();
}
this.ipRuleDialog.mode = "create"; this.ipRuleDialog.mode = "create";
this.ipRuleDialog.form = this.getDefaultIpRuleForm(); this.ipRuleDialog.form = this.getDefaultIpRuleForm();
this.ipRuleDialog.visible = true; this.ipRuleDialog.visible = true;
}, },
openIpRuleDialogForEdit: function (rule) { openIpRuleDialogForEdit: function (rule) {
if (!this.canManageIpRule(rule)) {
this.$message.error("只能修改与当前 IP 匹配的个性化配置");
return;
}
var cfg = rule.config || {}; var cfg = rule.config || {};
this.ipRuleDialog.mode = "edit"; this.ipRuleDialog.mode = "edit";
this.ipRuleDialog.form = { this.ipRuleDialog.form = {
@ -993,19 +1048,28 @@
submitIpRuleDialog: async function () { submitIpRuleDialog: async function () {
var form = this.ipRuleDialog.form; var form = this.ipRuleDialog.form;
var ip = (form.ip || "").trim(); var ip = (form.ip || "").trim();
if (!this.isAdmin || this.ipRuleDialog.mode === "create") {
ip = (this.clientIp || "").trim();
form.blocked = false;
}
if (!ip) { if (!ip) {
this.$message.error("IP 地址不能为空"); this.$message.error("IP 地址不能为空");
return; return;
} }
if (!form.blocked && !form.useCustomConfig) { if (this.isAdmin && !form.blocked && !form.useCustomConfig) {
this.$message.error("请至少启用「拉黑访问」或「个性化配置」"); this.$message.error("请至少启用「拉黑访问」或「个性化配置」");
return; return;
} }
if (!this.isAdmin && !form.useCustomConfig) {
this.$message.error("请启用个性化配置");
return;
}
try { try {
var resp = await fetch("/__ip-rules", { var resp = await fetch("/__ip-rules", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
mode: this.ipRuleDialog.mode,
ip: ip, ip: ip,
remark: form.remark, remark: form.remark,
blocked: !!form.blocked, blocked: !!form.blocked,
@ -1025,6 +1089,10 @@
} }
}, },
removeIpRule: async function (rule) { removeIpRule: async function (rule) {
if (!this.canManageIpRule(rule)) {
this.$message.error("只能删除与当前 IP 匹配的个性化配置");
return;
}
try { try {
await this.$confirm("确定删除 IP「" + rule.ip + "」的规则吗?", "提示", { await this.$confirm("确定删除 IP「" + rule.ip + "」的规则吗?", "提示", {
type: "warning", type: "warning",
@ -1353,6 +1421,11 @@
var resp = await fetch("/__config"); var resp = await fetch("/__config");
var data = await resp.json(); var data = await resp.json();
this.form.config = Object.assign({}, this.form.config, data.config || {}); this.form.config = Object.assign({}, this.form.config, data.config || {});
this.canEditBasicConfig = data.canEditBasicConfig === true;
this.clientIp = data.clientIp || "";
if (data.canEditBasicConfig === true) {
this.isAdmin = true;
}
} catch (err) { } catch (err) {
this.$message.error("加载配置失败: " + err.message); this.$message.error("加载配置失败: " + err.message);
} }
@ -1379,6 +1452,9 @@
} }
}, },
saveServerConfig: async function () { saveServerConfig: async function () {
if (!this.canEditBasicConfig) {
return;
}
try { try {
var resp = await fetch("/__config", { var resp = await fetch("/__config", {
method: "POST", method: "POST",
@ -1770,6 +1846,12 @@
pagedIpRules: function () { pagedIpRules: function () {
return this.getPagedData(this.filteredIpRules, this.ipRulePagination); return this.getPagedData(this.filteredIpRules, this.ipRulePagination);
}, },
canCreateIpRule: function () {
if (this.isAdmin) return true;
return !(this.ipRules || []).some(function (rule) {
return rule && rule.ip;
});
},
routeDialogMockFiles: function () { routeDialogMockFiles: function () {
var groupFilter = this.routeDialog.form.groupFilter; var groupFilter = this.routeDialog.form.groupFilter;
if (groupFilter === null || groupFilter === undefined) { if (groupFilter === null || groupFilter === undefined) {

2
admin_list.txt Normal file
View File

@ -0,0 +1,2 @@
192.168.3.9
192.168.3.12

View File

@ -20,7 +20,8 @@ import {
upsertIpRuleToDb, upsertIpRuleToDb,
deleteIpRuleFromDb, deleteIpRuleFromDb,
} from "./db"; } from "./db";
import { isValidIpAddress, getClientIp, resolveEffectiveConfig, getMachineLanIpv4Addresses, formatTargetSummary } from "./ip-rules"; import { isAdminClient, resolveAdminClientIp, canManageIpRule } from "./admin-list";
import { isValidIpAddress, normalizeIp, resolveEffectiveConfig, getMachineLanIpv4Addresses, formatTargetSummary } from "./ip-rules";
import { import {
loadMockFiles, loadMockFiles,
normalizeMockFilePath, normalizeMockFilePath,
@ -100,8 +101,18 @@ async function handleConfig(
readBody(clientReq) readBody(clientReq)
.then(async (bodyText) => { .then(async (bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {}; const body = bodyText ? JSON.parse(bodyText) : {};
// 保存服务器配置 // 保存服务器配置(仅 admin_list.txt 白名单 IP 可修改)
if (body.config) { if (body.config) {
if (!isAdminClient(clientReq)) {
clientRes.writeHead(403, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: "当前 IP 不在管理员白名单内,无法修改基础配置",
}),
);
return;
}
await saveConfig(body.config); await saveConfig(body.config);
} }
// 保存路由配置 // 保存路由配置
@ -141,6 +152,8 @@ async function handleConfig(
JSON.stringify( JSON.stringify(
{ {
config: state.config, config: state.config,
canEditBasicConfig: isAdminClient(clientReq),
clientIp: resolveAdminClientIp(clientReq),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
null, null,
@ -628,11 +641,20 @@ async function handleIpRules(
} }
if (clientReq.method === "GET") { if (clientReq.method === "GET") {
const clientIp = resolveAdminClientIp(clientReq);
const isAdmin = isAdminClient(clientReq);
const list = isAdmin
? state.ipRules
: state.ipRules.filter(
(rule) => normalizeIp(rule.ip) === normalizeIp(clientIp),
);
clientRes.writeHead(200, { "Content-Type": "application/json" }); clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end( clientRes.end(
JSON.stringify({ JSON.stringify({
success: true, success: true,
list: state.ipRules, list,
clientIp,
isAdmin,
}), }),
); );
return; return;
@ -647,6 +669,16 @@ async function handleIpRules(
if (!ip) { if (!ip) {
throw new Error("ip 不能为空"); throw new Error("ip 不能为空");
} }
if (!canManageIpRule(clientReq, ip)) {
clientRes.writeHead(403, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: "只能删除与当前 IP 匹配的个性化配置",
}),
);
return;
}
await deleteIpRuleFromDb(ip); await deleteIpRuleFromDb(ip);
state.ipRules = await loadIpRulesFromDb(); state.ipRules = await loadIpRulesFromDb();
clientRes.writeHead(200, { "Content-Type": "application/json" }); clientRes.writeHead(200, { "Content-Type": "application/json" });
@ -655,6 +687,20 @@ async function handleIpRules(
} }
const rule = parseIpRuleBody(body); const rule = parseIpRuleBody(body);
const isCreate = body.mode === "create";
if (!isAdminClient(clientReq) || isCreate) {
rule.ip = resolveAdminClientIp(clientReq);
}
if (!canManageIpRule(clientReq, rule.ip)) {
clientRes.writeHead(403, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: "只能修改与当前 IP 匹配的个性化配置",
}),
);
return;
}
await upsertIpRuleToDb(rule); await upsertIpRuleToDb(rule);
state.ipRules = await loadIpRulesFromDb(); state.ipRules = await loadIpRulesFromDb();
clientRes.writeHead(200, { "Content-Type": "application/json" }); clientRes.writeHead(200, { "Content-Type": "application/json" });

45
src/admin-list.ts Normal file
View File

@ -0,0 +1,45 @@
import * as fs from "fs";
import * as http from "http";
import { ADMIN_LIST_FILE } from "./constants";
import { resolveClientIp, normalizeIp } from "./ip-rules";
import { state } from "./state";
export function loadAdminList(): string[] {
if (!fs.existsSync(ADMIN_LIST_FILE)) {
return [];
}
const content = fs.readFileSync(ADMIN_LIST_FILE, "utf-8");
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map(normalizeIp);
}
/** 客户端 IP 是否在 admin_list.txt 白名单内(localhost 会映射为本机 LAN IP) */
export function isAdminClient(req: http.IncomingMessage): boolean {
const adminList = loadAdminList();
if (adminList.length === 0) {
return false;
}
const whitelist = new Set(adminList.map(normalizeIp));
const clientIp = resolveClientIp(req, adminList);
return whitelist.has(clientIp);
}
export function resolveAdminClientIp(req: http.IncomingMessage): string {
const adminList = loadAdminList();
const hintIps = [...adminList, ...state.ipRules.map((rule) => rule.ip)];
return resolveClientIp(req, hintIps);
}
/** 是否可管理指定 IP 的规则(管理员可操作全部,普通用户仅可操作自身 IP) */
export function canManageIpRule(
req: http.IncomingMessage,
ruleIp: string,
): boolean {
if (isAdminClient(req)) {
return true;
}
return normalizeIp(ruleIp) === normalizeIp(resolveAdminClientIp(req));
}

View File

@ -5,3 +5,4 @@ export const MOCK_DIR = path.join(__dirname, "..", "mock");
export const DATA_DIR = path.join(__dirname, "..", "data"); export const DATA_DIR = path.join(__dirname, "..", "data");
export const DB_FILE = path.join(DATA_DIR, "mock-mappings.sqlite3"); export const DB_FILE = path.join(DATA_DIR, "mock-mappings.sqlite3");
export const LEGACY_DB_FILE = path.join(MOCK_DIR, "mock-mappings.sqlite3"); export const LEGACY_DB_FILE = path.join(MOCK_DIR, "mock-mappings.sqlite3");
export const ADMIN_LIST_FILE = path.join(__dirname, "..", "admin_list.txt");

View File

@ -3,7 +3,7 @@ import * as os from "os";
import { state } from "./state"; import { state } from "./state";
import type { AppConfig, IpRule, IpRuleConfig } from "./types"; import type { AppConfig, IpRule, IpRuleConfig } from "./types";
function normalizeIp(raw: string): string { export function normalizeIp(raw: string): string {
let ip = raw.trim(); let ip = raw.trim();
if (ip.startsWith("::ffff:")) { if (ip.startsWith("::ffff:")) {
ip = ip.slice(7); ip = ip.slice(7);
@ -37,6 +37,14 @@ export function getClientIp(req: http.IncomingMessage): string {
return normalizeIp(remote); return normalizeIp(remote);
} }
function getIpv4Subnet24(ip: string): string | undefined {
const parts = ip.split(".");
if (parts.length !== 4) {
return undefined;
}
return `${parts[0]}.${parts[1]}.${parts[2]}`;
}
/** 本机非回环 IPv4 地址,用于 localhost 访问时的规则回退 */ /** 本机非回环 IPv4 地址,用于 localhost 访问时的规则回退 */
export function getMachineLanIpv4Addresses(): string[] { export function getMachineLanIpv4Addresses(): string[] {
const ips = new Set<string>(); const ips = new Set<string>();
@ -53,27 +61,80 @@ export function getMachineLanIpv4Addresses(): string[] {
return Array.from(ips).sort(); return Array.from(ips).sort();
} }
/**
* localhost 访问时将 127.0.0.1 映射为本机 LAN IPv4。
* hints 用于在多个网卡时优先选择(如白名单、IP 规则中已配置的地址)。
*/
export function resolveLocalhostToLanIp(hints: string[] = []): string | undefined {
const lanIps = getMachineLanIpv4Addresses();
if (lanIps.length === 0) {
return undefined;
}
if (lanIps.length === 1) {
return lanIps[0];
}
const hintSet = new Set(hints.map(normalizeIp));
if (hintSet.size > 0) {
const matched = lanIps.filter((ip) => hintSet.has(normalizeIp(ip)));
if (matched.length === 1) {
return matched[0];
}
if (matched.length > 1) {
return matched.sort()[0];
}
const hintSubnets = new Set(
[...hintSet]
.map(getIpv4Subnet24)
.filter((subnet): subnet is string => !!subnet),
);
if (hintSubnets.size > 0) {
const sameSubnet = lanIps.filter((ip) => {
const subnet = getIpv4Subnet24(ip);
return subnet ? hintSubnets.has(subnet) : false;
});
if (sameSubnet.length === 1) {
return sameSubnet[0];
}
if (sameSubnet.length > 1) {
return sameSubnet.sort()[0];
}
}
}
const ruleLanIps = state.ipRules
.map((rule) => normalizeIp(rule.ip))
.filter((ip) => lanIps.includes(ip));
if (ruleLanIps.length === 1) {
return ruleLanIps[0];
}
const private192 = lanIps.filter((ip) => ip.startsWith("192.168."));
if (private192.length >= 1) {
return private192.sort()[0];
}
return lanIps[0];
}
/** 解析用于展示与策略匹配的客户端 IP(localhost → LAN IPv4) */
export function resolveClientIp(
req: http.IncomingMessage,
hints: string[] = [],
): string {
const raw = normalizeIp(getClientIp(req));
if (raw !== "127.0.0.1") {
return raw;
}
return resolveLocalhostToLanIp(hints) ?? raw;
}
export function findIpRule(clientIp: string): IpRule | undefined { export function findIpRule(clientIp: string): IpRule | undefined {
const normalized = normalizeIp(clientIp); const normalized = normalizeIp(clientIp);
const direct = state.ipRules.find( return state.ipRules.find(
(rule) => normalizeIp(rule.ip) === normalized, (rule) => normalizeIp(rule.ip) === normalized,
); );
if (direct) {
return direct;
}
// 本机通过 localhost 访问时,remoteAddress 为 127.0.0.1,与 LAN IP 规则不匹配
if (normalized === "127.0.0.1") {
const lanIps = new Set(getMachineLanIpv4Addresses());
const localRules = state.ipRules.filter((rule) =>
lanIps.has(normalizeIp(rule.ip)),
);
if (localRules.length === 1) {
return localRules[0];
}
}
return undefined;
} }
export function mergeConfigWithIpRule( export function mergeConfigWithIpRule(
@ -113,10 +174,12 @@ export type EffectiveConfigResult =
export function resolveEffectiveConfig( export function resolveEffectiveConfig(
req: http.IncomingMessage, req: http.IncomingMessage,
): EffectiveConfigResult { ): EffectiveConfigResult {
const ip = getClientIp(req); const rawIp = normalizeIp(getClientIp(req));
const hintIps = state.ipRules.map((rule) => rule.ip);
const ip = resolveClientIp(req, hintIps);
const rule = findIpRule(ip); const rule = findIpRule(ip);
const ruleMatchedViaLocalhost = const ruleMatchedViaLocalhost =
ip === "127.0.0.1" && !!rule && normalizeIp(rule.ip) !== ip; rawIp === "127.0.0.1" && !!rule && normalizeIp(rule.ip) !== rawIp;
if (rule?.blocked) { if (rule?.blocked) {
return { blocked: true, ip, matchedRule: rule }; return { blocked: true, ip, matchedRule: rule };