feat: 管理员白名单与个性化配置权限控制
系统配置仅允许 admin_list.txt 白名单 IP 编辑;localhost 访问时解析为 LAN IP;个性化配置支持按 IP 权限隔离,新增时自动填充当前 IP。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2b8cb3ca9a
commit
9bec3208f8
124
admin.html
124
admin.html
@ -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,8 +366,11 @@
|
|||||||
</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">
|
||||||
<el-button size="mini" type="primary" plain @click="openIpRuleDialogForEdit(scope.row)">修改</el-button>
|
<template v-if="canManageIpRule(scope.row)">
|
||||||
<el-button size="mini" type="danger" @click="removeIpRule(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>
|
||||||
|
</template>
|
||||||
|
<span v-else class="small-text">无权限</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -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
2
admin_list.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
192.168.3.9
|
||||||
|
192.168.3.12
|
||||||
@ -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
45
src/admin-list.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findIpRule(clientIp: string): IpRule | undefined {
|
/**
|
||||||
const normalized = normalizeIp(clientIp);
|
* localhost 访问时将 127.0.0.1 映射为本机 LAN IPv4。
|
||||||
const direct = state.ipRules.find(
|
* hints 用于在多个网卡时优先选择(如白名单、IP 规则中已配置的地址)。
|
||||||
(rule) => normalizeIp(rule.ip) === normalized,
|
*/
|
||||||
);
|
export function resolveLocalhostToLanIp(hints: string[] = []): string | undefined {
|
||||||
if (direct) {
|
const lanIps = getMachineLanIpv4Addresses();
|
||||||
return direct;
|
if (lanIps.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (lanIps.length === 1) {
|
||||||
|
return lanIps[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本机通过 localhost 访问时,remoteAddress 为 127.0.0.1,与 LAN IP 规则不匹配
|
const hintSet = new Set(hints.map(normalizeIp));
|
||||||
if (normalized === "127.0.0.1") {
|
if (hintSet.size > 0) {
|
||||||
const lanIps = new Set(getMachineLanIpv4Addresses());
|
const matched = lanIps.filter((ip) => hintSet.has(normalizeIp(ip)));
|
||||||
const localRules = state.ipRules.filter((rule) =>
|
if (matched.length === 1) {
|
||||||
lanIps.has(normalizeIp(rule.ip)),
|
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 (localRules.length === 1) {
|
if (hintSubnets.size > 0) {
|
||||||
return localRules[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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
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 {
|
||||||
|
const normalized = normalizeIp(clientIp);
|
||||||
|
return state.ipRules.find(
|
||||||
|
(rule) => normalizeIp(rule.ip) === normalized,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user