Compare commits

..

No commits in common. "main" and "1.0.0" have entirely different histories.
main ... 1.0.0

18 changed files with 84 additions and 13394 deletions

View File

@ -3,8 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企业级数据Mock管理系统 - by tony</title>
<link rel="stylesheet" href="/assets/element-ui/index.css" />
<title>API Proxy Mock 配置管理</title>
<link
rel="stylesheet"
href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"
/>
<style>
body {
margin: 0;
@ -48,18 +51,6 @@
.route-table .el-table__cell .cell {
white-space: nowrap;
}
.mock-content-textarea textarea {
font-family: Consolas, Monaco, "Courier New", monospace;
line-height: 1.5;
}
.el-dialog.is-fullscreen .el-dialog__body {
overflow-y: auto;
max-height: calc(100vh - 120px);
}
.basic-config-readonly {
pointer-events: none;
opacity: 0.65;
}
</style>
</head>
<body>
@ -77,7 +68,6 @@
<strong>路由配置(routes)</strong>
<div style="display:flex;gap:8px;align-items:center;">
<el-input size="small" v-model="routeSearch" placeholder="搜索接口名称或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="routesRefreshing" @click="refreshRoutes">刷新</el-button>
<el-button size="mini" type="primary" @click="openRouteDialogForCreate">新增路由</el-button>
</div>
</div>
@ -92,9 +82,9 @@
<span>{{ scope.row.route }}</span>
</template>
</el-table-column>
<el-table-column label="Mock 文件" min-width="240" show-overflow-tooltip>
<el-table-column label="Mock 文件路径" min-width="240" show-overflow-tooltip>
<template slot-scope="scope">
<a href="javascript:void(0)" style="color:#409EFF;cursor:pointer;" @click="openMockFileFromRoute(scope.row)">{{ getMockFileAlias(scope.row.filePath) }}</a>
<a href="javascript:void(0)" style="color:#409EFF;cursor:pointer;" @click="openMockFileFromRoute(scope.row)">{{ scope.row.filePath }}</a>
</template>
</el-table-column>
<el-table-column label="返回状态码" width="120" align="center">
@ -144,7 +134,6 @@
<el-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select>
<el-input size="small" v-model="mockFileSearch" placeholder="搜索别名或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="mocksRefreshing" @click="refreshMocks">刷新</el-button>
<el-button size="mini" type="primary" @click="openMockFileDialogForCreate">新增 Mock 文件</el-button>
<el-button size="mini" @click="openMockGroupDialog">分组管理</el-button>
</div>
@ -193,10 +182,9 @@
<el-tab-pane label="接口管理" name="api">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<strong>接口管理(api)</strong>
<strong>接口列表</strong>
<div style="display:flex;gap:8px;align-items:center;">
<el-input size="small" v-model="apiSearch" placeholder="搜索接口名称或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="apiRefreshing" @click="refreshApiList">刷新</el-button>
<el-button size="mini" type="primary" @click="openApiDialogForCreate">新增接口</el-button>
<el-button size="mini" type="success" @click="openBatchImportDialog">批量导入</el-button>
</div>
@ -231,21 +219,8 @@
</el-card>
</el-tab-pane>
<el-tab-pane label="系统配置" name="basic">
<el-tab-pane label="基础配置" name="basic">
<el-card class="section-card">
<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>
</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-row :gutter="16">
<el-col :span="12">
@ -308,81 +283,6 @@
</el-col>
</el-row>
</el-form>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="个性化配置" name="ip">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div>
<strong>个性化配置</strong>
<div class="small-text" style="margin-top:4px;">
为当前 IP 配置独立目标环境(不含本地代理端口),覆盖全局系统配置。
普通用户仅可管理 IP 为 <strong>{{ clientIp || "未知" }}</strong> 的配置;管理员可管理全部。
<a href="/__client-ip" target="_blank" style="color:#409EFF;">查看当前识别 IP</a>
</div>
</div>
<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-button size="mini" icon="el-icon-refresh" :loading="ipRulesRefreshing" @click="refreshIpRules">刷新</el-button>
<el-button
v-if="canCreateIpRule"
size="mini"
type="primary"
@click="openIpRuleDialogForCreate"
>{{ isAdmin ? "新增配置" : "配置我的环境" }}</el-button>
</div>
</div>
<el-table :data="pagedIpRules" border style="width: 100%;">
<el-table-column label="IP 地址" min-width="160">
<template slot-scope="scope">
<span>{{ scope.row.ip }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.remark || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="策略" width="120" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.blocked" type="danger" size="mini">拉黑</el-tag>
<el-tag v-else-if="scope.row.useCustomConfig" type="warning" size="mini">个性化</el-tag>
<el-tag v-else type="info" size="mini">默认</el-tag>
</template>
</el-table-column>
<el-table-column label="目标环境" min-width="260" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ getIpRuleTargetSummary(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="Mock" width="90" align="center">
<template slot-scope="scope">
<span v-if="scope.row.blocked">-</span>
<span v-else-if="scope.row.useCustomConfig">{{ scope.row.config.mockEnabled === false ? "关闭" : "开启" }}</span>
<span v-else>全局</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<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="danger" @click="removeIpRule(scope.row)">删除</el-button>
</template>
<span v-else class="small-text">无权限</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="table-pagination"
background
layout="total, prev, pager, next"
:current-page="ipRulePagination.currentPage"
:page-size="ipRulePagination.pageSize"
:total="filteredIpRules.length"
@current-change="handleIpRulePageChange"
></el-pagination>
</el-card>
</el-tab-pane>
</el-tabs>
@ -392,12 +292,7 @@
:visible.sync="routeDialog.visible"
width="760px"
>
<el-form
ref="routeForm"
:model="routeDialog.form"
:rules="routeFormRules"
label-width="180px"
>
<el-form :model="routeDialog.form" label-width="180px">
<el-form-item label="预置接口">
<el-select
v-model="routeDialog.form.selectedApiRoute"
@ -415,21 +310,21 @@
></el-option>
</el-select>
</el-form-item>
<el-form-item label="接口名称" prop="apiName">
<el-form-item label="接口名称">
<el-input
v-model="routeDialog.form.apiName"
placeholder="例如:获取用户信息"
:disabled="isApiListLocked()"
></el-input>
</el-form-item>
<el-form-item label="请求路径" prop="route">
<el-form-item label="请求路径">
<el-input
v-model="routeDialog.form.route"
placeholder="/api/new/mock"
:disabled="isApiListLocked()"
></el-input>
</el-form-item>
<el-form-item label="Mock 文件" prop="filePath">
<el-form-item label="Mock 文件">
<div style="display:flex;gap:8px;">
<el-select
v-model="routeDialog.form.groupFilter"
@ -456,13 +351,13 @@
<el-option
v-for="item in routeDialogMockFiles"
:key="item.filePath"
:label="item.alias || item.filePath"
:label="item.alias ? item.alias + ' (' + item.filePath + ')' : item.filePath"
:value="item.filePath"
></el-option>
</el-select>
</div>
</el-form-item>
<el-form-item label="返回状态码" prop="statusCode">
<el-form-item label="返回状态码">
<el-select
v-model="routeDialog.form.statusCode"
filterable
@ -480,7 +375,7 @@
></el-option>
</el-select>
</el-form-item>
<el-form-item label="是否启用 Mock" prop="enabled" required>
<el-form-item label="是否启用 Mock">
<el-switch v-model="routeDialog.form.enabled"></el-switch>
</el-form-item>
</el-form>
@ -495,48 +390,34 @@
<el-dialog
:title="mockFileDialog.mode === 'edit' ? '修改 Mock 文件' : mockFileDialog.mode === 'copy' ? '复制 Mock 文件' : '新增 Mock 文件'"
:visible.sync="mockFileDialog.visible"
:width="mockFileDialog.fullscreen ? '100%' : '760px'"
:fullscreen="mockFileDialog.fullscreen"
:top="mockFileDialog.fullscreen ? '0' : '15vh'"
width="760px"
>
<el-form
ref="mockFileForm"
:model="mockFileDialog.form"
:rules="mockFileFormRules"
label-width="180px"
>
<el-form-item label="别名" prop="alias">
<el-form :model="mockFileDialog.form" label-width="180px">
<el-form-item label="别名">
<el-input
v-model="mockFileDialog.form.alias"
placeholder="必填:用于展示,支持任意文本"
placeholder="可选:用于展示,支持任意文本"
></el-input>
</el-form-item>
<el-form-item label="分组" prop="groupId" required>
<el-select v-model="mockFileDialog.form.groupId" placeholder="请选择分组" style="width: 100%;">
<el-form-item label="分组">
<el-select v-model="mockFileDialog.form.groupId" clearable placeholder="选择分组" style="width: 100%;">
<el-option label="无分组" :value="null"></el-option>
<el-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="Mock 文件路径" prop="fileName">
<el-form-item label="Mock 文件路径">
<el-input
v-model="mockFileDialog.form.fileName"
:placeholder="mockFileDialog.mode === 'edit' ? '' : '根据别名自动生成:别名_随机串.json'"
disabled
placeholder="请输入文件名,如 test123.json"
:disabled="mockFileDialog.mode === 'edit'"
>
<template slot="prepend">mock/</template>
</el-input>
</el-form-item>
<el-form-item label="文件内容" prop="content">
<div style="display:flex;justify-content:flex-end;margin-bottom:6px;">
<el-button size="mini" type="text" @click="mockFileDialog.fullscreen = !mockFileDialog.fullscreen">
<i :class="mockFileDialog.fullscreen ? 'el-icon-copy-document' : 'el-icon-full-screen'"></i>
{{ mockFileDialog.fullscreen ? '退出全屏' : '全屏编辑' }}
</el-button>
</div>
<el-form-item label="文件内容">
<el-input
type="textarea"
class="mock-content-textarea"
:autosize="mockFileDialog.fullscreen ? { minRows: 28, maxRows: 28 } : { minRows: 12, maxRows: 12 }"
:rows="12"
v-model="mockFileDialog.form.content"
placeholder='{"code":0,"message":"ok"}'
></el-input>
@ -651,132 +532,17 @@
<el-button type="danger" @click="submitDeleteGroup">确认删除</el-button>
</span>
</el-dialog>
<el-dialog
:title="ipRuleDialog.mode === 'edit' ? '修改个性化配置' : (isAdmin ? '新增个性化配置' : '配置我的环境')"
:visible.sync="ipRuleDialog.visible"
width="760px"
>
<el-form :model="ipRuleDialog.form" label-width="180px">
<el-form-item label="IP 地址">
<el-input
v-model="ipRuleDialog.form.ip"
:placeholder="isAdmin && ipRuleDialog.mode === 'create' ? '请输入 IP 地址' : '自动识别当前 IP'"
:disabled="!(isAdmin && ipRuleDialog.mode === 'create')"
></el-input>
<div class="small-text" style="margin-top:4px;">
{{ isAdmin && ipRuleDialog.mode === 'create'
? '管理员可填写任意有效 IP'
: (ipRuleDialog.mode === 'create' ? '自动填充当前 IP,不可修改' : 'IP 地址不可修改') }}
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ipRuleDialog.form.remark" placeholder="如:测试组、开发组"></el-input>
</el-form-item>
<el-form-item v-if="isAdmin" label="拉黑访问">
<el-switch v-model="ipRuleDialog.form.blocked" @change="onIpRuleBlockedChange"></el-switch>
<div class="small-text" style="margin-top:4px;">开启后该 IP 的所有请求将被拒绝(403)</div>
</el-form-item>
<template v-if="!ipRuleDialog.form.blocked">
<el-form-item label="个性化配置">
<el-switch v-model="ipRuleDialog.form.useCustomConfig"></el-switch>
<div class="small-text" style="margin-top:4px;">开启后可为此 IP 单独配置目标环境,覆盖全局系统配置</div>
</el-form-item>
<template v-if="ipRuleDialog.form.useCustomConfig">
<el-divider content-position="left">独立环境配置</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Mock 开关">
<el-switch v-model="ipRuleDialog.form.config.mockEnabled"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认 Content-Type">
<el-select
v-model="ipRuleDialog.form.config.defaultContentType"
filterable
allow-create
default-first-option
placeholder="请选择"
style="width: 100%;"
>
<el-option
v-for="item in contentTypeOptions"
:key="item"
:label="item"
:value="item"
></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标主机">
<el-input v-model="ipRuleDialog.form.config.targetHost" placeholder="如 dev.example.com"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标端口">
<el-input-number
v-model="ipRuleDialog.form.config.targetPort"
:min="1"
:max="65535"
style="width: 100%;"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标 HTTPS">
<el-switch v-model="ipRuleDialog.form.config.targetHttps"></el-switch>
</el-form-item>
</el-col>
</el-row>
</template>
</template>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="ipRuleDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitIpRuleDialog">保存</el-button>
</span>
</el-dialog>
</div>
</div>
<script src="/assets/vue.js"></script>
<script src="/assets/element-ui/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script>
new Vue({
el: "#app",
data: function () {
return {
activeMainTab: "routes",
routesRefreshing: false,
mocksRefreshing: false,
apiRefreshing: false,
configRefreshing: false,
canEditBasicConfig: false,
clientIp: "",
isAdmin: false,
ipRulesRefreshing: false,
ipRules: [],
ipRuleSearch: "",
ipRulePagination: { currentPage: 1, pageSize: 10 },
ipRuleDialog: {
visible: false,
mode: "create",
form: {
ip: "",
remark: "",
blocked: false,
useCustomConfig: true,
config: {
mockEnabled: true,
defaultContentType: "application/json",
targetHost: "",
targetPort: 443,
targetHttps: true,
},
},
},
apiList: [],
apiPagination: { currentPage: 1, pageSize: 10 },
apiDialog: {
@ -867,7 +633,6 @@
},
mockFileDialog: {
visible: false,
fullscreen: false,
mode: "create",
form: {
fileName: "",
@ -875,341 +640,21 @@
content: "",
},
},
routeFormRules: {
apiName: [
{ required: true, message: "请输入接口名称", trigger: "blur" },
],
route: [
{ required: true, message: "请输入请求路径", trigger: "blur" },
{
validator: function (rule, value, callback) {
if (value && String(value).charAt(0) !== "/") {
callback(new Error("请求路径必须以 / 开头"));
} else {
callback();
}
},
trigger: "blur",
},
],
filePath: [
{ required: true, message: "请选择 Mock 文件", trigger: "change" },
],
statusCode: [
{
required: true,
validator: function (rule, value, callback) {
if (value === null || value === undefined || value === "") {
callback(new Error("请选择或输入返回状态码"));
} else {
callback();
}
},
trigger: "change",
},
],
enabled: [
{
required: true,
validator: function (rule, value, callback) {
if (typeof value !== "boolean") {
callback(new Error("请设置是否启用 Mock"));
} else {
callback();
}
},
trigger: "change",
},
],
},
mockFileFormRules: {
alias: [
{ required: true, message: "请输入别名", trigger: "blur" },
],
groupId: [
{
validator: function (rule, value, callback) {
// null 表示「无分组」,视为已选择;仅未赋值时失败
if (value === undefined || value === "") {
callback(new Error("请选择分组"));
} else {
callback();
}
},
trigger: "change",
},
],
fileName: [
{ required: true, message: "文件路径不能为空", trigger: "change" },
],
content: [
{ required: true, message: "请输入文件内容", trigger: "blur" },
],
},
};
},
watch: {
activeMainTab: function (tab) {
if (tab === "routes") { this.loadRoutes(); }
else if (tab === "mocks") { this.loadMockFiles(); this.loadMockGroups(); }
else if (tab === "api") { this.loadApiList(); }
else if (tab === "basic") { this.loadConfig(); }
else if (tab === "ip") { this.loadIpRules(); }
},
routeSearch: function () { this.routePagination.currentPage = 1; },
mockFileSearch: function () { this.mockFilePagination.currentPage = 1; },
mockGroupFilter: function () { this.mockFilePagination.currentPage = 1; },
"mockFileDialog.form.alias": function () {
if (this.mockFileDialog.mode === "create" || this.mockFileDialog.mode === "copy") {
this.mockFileDialog.form.fileName = this.generateMockFileName(
this.mockFileDialog.form.alias,
);
}
},
apiSearch: function () { this.apiPagination.currentPage = 1; },
ipRuleSearch: function () { this.ipRulePagination.currentPage = 1; },
},
created: async function () {
await this.loadApiList();
await this.loadMockFiles();
await this.loadMockGroups();
await this.loadConfig();
await this.loadRoutes();
},
methods: {
loadRoutes: async function () {
try {
var resp = await fetch("/__routes");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载路由失败");
}
this.form.routes = this.toRouteArray(
data.routes || {},
data.routeStatuses || {},
data.routeEnabledMap || {},
data.routeApiNameMap || {},
);
this.routePagination.currentPage = 1;
} catch (err) {
this.$message.error("加载路由失败: " + err.message);
}
},
refreshRoutes: async function () {
this.routesRefreshing = true;
try {
await this.loadRoutes();
this.$message.success("路由配置已刷新");
} finally {
this.routesRefreshing = false;
}
},
refreshMocks: async function () {
this.mocksRefreshing = true;
try {
await this.loadMockFiles();
await this.loadMockGroups();
this.$message.success("Mock 数据已刷新");
} finally {
this.mocksRefreshing = false;
}
},
refreshApiList: async function () {
this.apiRefreshing = true;
try {
await this.loadApiList();
this.$message.success("接口列表已刷新");
} finally {
this.apiRefreshing = false;
}
},
refreshConfig: async function () {
this.configRefreshing = true;
try {
await this.loadConfig();
this.$message.success("系统配置已刷新");
} finally {
this.configRefreshing = false;
}
},
refreshIpRules: async function () {
this.ipRulesRefreshing = true;
try {
await this.loadIpRules();
this.$message.success("个性化配置已刷新");
} finally {
this.ipRulesRefreshing = false;
}
},
loadIpRules: async function () {
try {
var resp = await fetch("/__ip-rules");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载 IP 规则失败");
}
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;
} catch (err) {
this.ipRules = [];
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 () {
var global = this.form.config || {};
return {
ip: this.clientIp || "",
remark: "",
blocked: false,
useCustomConfig: true,
config: {
mockEnabled: global.mockEnabled !== false,
defaultContentType: global.defaultContentType || "application/json",
targetHost: global.targetHost || "",
targetPort: global.targetPort || 443,
targetHttps: global.targetHttps !== false,
},
};
},
getIpRuleTargetSummary: function (rule) {
if (!rule) return "-";
if (rule.blocked) return "已拉黑,禁止访问";
if (!rule.useCustomConfig) return "使用全局配置";
var cfg = rule.config || {};
var host = cfg.targetHost || this.form.config.targetHost || "-";
var port = cfg.targetPort != null ? cfg.targetPort : this.form.config.targetPort;
var https = cfg.targetHttps !== undefined ? cfg.targetHttps !== false : this.form.config.targetHttps !== false;
var proto = https ? "https" : "http";
return proto + "://" + host + (port ? ":" + port : "");
},
onIpRuleBlockedChange: function (blocked) {
if (blocked) {
this.ipRuleDialog.form.useCustomConfig = false;
}
},
openIpRuleDialogForCreate: async function () {
if (!this.canCreateIpRule) {
this.$message.warning("您已有个性化配置,请直接修改");
return;
}
if (!this.clientIp) {
await this.loadIpRules();
}
this.ipRuleDialog.mode = "create";
this.ipRuleDialog.form = this.getDefaultIpRuleForm();
this.ipRuleDialog.visible = true;
},
openIpRuleDialogForEdit: function (rule) {
if (!this.canManageIpRule(rule)) {
this.$message.error("只能修改与当前 IP 匹配的个性化配置");
return;
}
var cfg = rule.config || {};
this.ipRuleDialog.mode = "edit";
this.ipRuleDialog.form = {
ip: rule.ip || "",
remark: rule.remark || "",
blocked: !!rule.blocked,
useCustomConfig: !!rule.useCustomConfig,
config: {
mockEnabled: cfg.mockEnabled !== false,
defaultContentType: cfg.defaultContentType || this.form.config.defaultContentType || "application/json",
targetHost: cfg.targetHost || "",
targetPort: cfg.targetPort != null ? cfg.targetPort : this.form.config.targetPort,
targetHttps: cfg.targetHttps !== undefined ? cfg.targetHttps !== false : this.form.config.targetHttps !== false,
},
};
this.ipRuleDialog.visible = true;
},
submitIpRuleDialog: async function () {
var form = this.ipRuleDialog.form;
var ip = (form.ip || "").trim();
if (!this.isAdmin) {
ip = (this.clientIp || "").trim();
form.blocked = false;
}
if (!ip) {
this.$message.error("IP 地址不能为空");
return;
}
if (this.isAdmin && !form.blocked && !form.useCustomConfig) {
this.$message.error("请至少启用「拉黑访问」或「个性化配置」");
return;
}
if (!this.isAdmin && !form.useCustomConfig) {
this.$message.error("请启用个性化配置");
return;
}
try {
var resp = await fetch("/__ip-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: this.ipRuleDialog.mode,
ip: ip,
remark: form.remark,
blocked: !!form.blocked,
useCustomConfig: !!form.useCustomConfig,
config: form.useCustomConfig ? form.config : {},
}),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "保存 IP 规则失败");
}
this.$message.success("IP 规则已保存");
this.ipRuleDialog.visible = false;
await this.loadIpRules();
} catch (err) {
this.$message.error("保存 IP 规则失败: " + err.message);
}
},
removeIpRule: async function (rule) {
if (!this.canManageIpRule(rule)) {
this.$message.error("只能删除与当前 IP 匹配的个性化配置");
return;
}
try {
await this.$confirm("确定删除 IP「" + rule.ip + "」的规则吗?", "提示", {
type: "warning",
});
} catch (e) {
return;
}
try {
var resp = await fetch("/__ip-rules", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip: rule.ip }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "删除 IP 规则失败");
}
this.$message.success("IP 规则已删除");
await this.loadIpRules();
} catch (err) {
this.$message.error("删除 IP 规则失败: " + err.message);
}
},
handleIpRulePageChange: function (page) {
this.ipRulePagination.currentPage = page;
},
getPreviewText: function (content) {
var text = String(content || "").replace(/\s+/g, " ").trim();
if (!text) return "(空文件)";
@ -1348,9 +793,6 @@
this.routeDialog.editingIndex = -1;
this.routeDialog.form = this.getDefaultRouteForm();
this.routeDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.routeForm) this.$refs.routeForm.clearValidate();
});
},
openRouteDialogForEdit: function (index) {
var actualIndex =
@ -1374,24 +816,10 @@
groupFilter: groupFilter,
};
this.routeDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.routeForm) this.$refs.routeForm.clearValidate();
});
},
sanitizeMockFileAlias: function (alias) {
var text = String(alias || "").trim();
text = text.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_");
text = text.replace(/_+/g, "_").replace(/^_+|_+$/g, "");
return text || "mock";
},
generateMockFileName: function (alias) {
var prefix = this.sanitizeMockFileAlias(alias);
var random = Math.random().toString(36).slice(2, 10);
return prefix + "_" + random + ".json";
},
getDefaultMockFileForm: function () {
return {
fileName: this.generateMockFileName(""),
fileName: "",
alias: "",
content: "",
groupId: null,
@ -1399,18 +827,13 @@
},
openMockFileDialogForCreate: function () {
this.mockFileDialog.mode = "create";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = this.getDefaultMockFileForm();
this.mockFileDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.mockFileForm) this.$refs.mockFileForm.clearValidate();
});
},
openMockFileDialogForEdit: function (item) {
var full = item.filePath || "";
var name = full.indexOf("mock/") === 0 ? full.slice(5) : full;
this.mockFileDialog.mode = "edit";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = {
fileName: name,
alias: String(item.alias || ""),
@ -1418,9 +841,6 @@
groupId: item.groupId || null,
};
this.mockFileDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.mockFileForm) this.$refs.mockFileForm.clearValidate();
});
},
openMockFileFromRoute: function (routeRow) {
var filePath = routeRow.filePath || "";
@ -1431,28 +851,15 @@
}
this.openMockFileDialogForEdit(mockFile);
},
getMockFileAlias: function (filePath) {
var path = filePath || "";
var mockFile = this.mockFiles.find(function (f) { return f.filePath === path; });
if (mockFile && mockFile.alias) {
return mockFile.alias;
}
return path || "-";
},
openMockFileDialogForCopy: function (item) {
var alias = String(item.alias || "") + "-副本";
this.mockFileDialog.mode = "copy";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = {
fileName: this.generateMockFileName(alias),
alias: alias,
fileName: "",
alias: String(item.alias || "") + "-副本",
content: String(item.content || ""),
groupId: item.groupId || null,
};
this.mockFileDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.mockFileForm) this.$refs.mockFileForm.clearValidate();
});
},
loadMockFiles: async function () {
try {
@ -1468,19 +875,7 @@
this.$message.error("加载 mock 文件失败: " + err.message);
}
},
submitMockFileDialog: function () {
var self = this;
if (this.mockFileDialog.mode === "create" || this.mockFileDialog.mode === "copy") {
this.mockFileDialog.form.fileName = this.generateMockFileName(
this.mockFileDialog.form.alias,
);
}
this.$refs.mockFileForm.validate(function (valid) {
if (!valid) return;
self.doSubmitMockFileDialog();
});
},
doSubmitMockFileDialog: async function () {
submitMockFileDialog: async function () {
var fileName = (this.mockFileDialog.form.fileName || "").trim();
if (!fileName) {
this.$message.error("文件名不能为空");
@ -1559,11 +954,13 @@
var resp = await fetch("/__config");
var data = await resp.json();
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;
}
this.form.routes = this.toRouteArray(
data.routes || {},
data.routeStatuses || {},
data.routeEnabledMap || {},
data.routeApiNameMap || {},
);
this.routePagination.currentPage = 1;
} catch (err) {
this.$message.error("加载配置失败: " + err.message);
}
@ -1590,9 +987,6 @@
}
},
saveServerConfig: async function () {
if (!this.canEditBasicConfig) {
return;
}
try {
var resp = await fetch("/__config", {
method: "POST",
@ -1607,14 +1001,15 @@
this.$message.error("保存配置失败: " + err.message);
}
},
submitRouteDialog: function () {
var self = this;
this.$refs.routeForm.validate(function (valid) {
if (!valid) return;
self.doSubmitRouteDialog();
});
},
doSubmitRouteDialog: async function () {
submitRouteDialog: async function () {
if (!this.routeDialog.form.route) {
this.$message.error("请求路径不能为空");
return;
}
if (!this.routeDialog.form.filePath) {
this.$message.error("请选择 Mock 文件");
return;
}
var payload = Object.assign({}, this.routeDialog.form, {
useExistingFile: true,
statusCode: this.normalizeStatusCode(this.routeDialog.form.statusCode),
@ -1636,7 +1031,7 @@
);
this.routeDialog.visible = false;
await this.loadMockFiles();
await this.loadRoutes();
await this.loadConfig();
} catch (err) {
this.$message.error("创建失败: " + err.message);
}
@ -1653,8 +1048,7 @@
openApiDialogForEdit: function (index) {
var actualIndex =
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
var item = this.filteredApiList[actualIndex];
if (!item) return;
var item = this.apiList[actualIndex];
this.apiDialog.mode = "edit";
this.apiDialog.editingIndex = actualIndex;
this.apiDialog.form = {
@ -1693,8 +1087,7 @@
removeApiItem: async function (index) {
var actualIndex =
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
var item = this.filteredApiList[actualIndex];
if (!item) return;
var item = this.apiList[actualIndex];
try {
await this.$confirm("确定删除接口「" + item.name + "」?", "提示", {
confirmButtonText: "确定",
@ -1962,17 +1355,6 @@
);
});
},
filteredIpRules: function () {
var q = (this.ipRuleSearch || "").toLowerCase().trim();
var list = this.ipRules || [];
if (!q) return list;
return list.filter(function (item) {
return (
(item.ip || "").toLowerCase().indexOf(q) !== -1 ||
(item.remark || "").toLowerCase().indexOf(q) !== -1
);
});
},
pagedRoutes: function () {
return this.getPagedData(this.filteredRoutes, this.routePagination);
},
@ -1982,15 +1364,6 @@
pagedApiList: function () {
return this.getPagedData(this.filteredApiList, this.apiPagination);
},
pagedIpRules: function () {
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 () {
var groupFilter = this.routeDialog.form.groupFilter;
if (groupFilter === null || groupFilter === undefined) {

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
{}

View File

@ -16,12 +16,7 @@ import {
countMockFilesByGroup,
updateMockFilesGroupId,
deleteMockFilesByGroup,
loadIpRulesFromDb,
upsertIpRuleToDb,
deleteIpRuleFromDb,
} from "./db";
import { isAdminClient, resolveAdminClientIp, canManageIpRule } from "./admin-list";
import { isValidIpAddress, normalizeIp, resolveEffectiveConfig, getMachineLanIpv4Addresses, formatTargetSummary } from "./ip-rules";
import {
loadMockFiles,
normalizeMockFilePath,
@ -29,7 +24,6 @@ import {
} from "./mock-files";
import { normalizeStatusCode, readBody } from "./utils";
import { state } from "./state";
import type { IpRule, IpRuleConfig } from "./types";
async function handleReloadConfig(
clientReq: http.IncomingMessage,
@ -101,18 +95,8 @@ async function handleConfig(
readBody(clientReq)
.then(async (bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {};
// 保存服务器配置(仅 admin_list.txt 白名单 IP 可修改)
// 保存服务器配置
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);
}
// 保存路由配置
@ -151,10 +135,13 @@ async function handleConfig(
clientRes.end(
JSON.stringify(
{
routes: state.rawRoutes,
routeStatuses: state.rawRouteStatuses,
routeEnabledMap: state.routeEnabledMap,
routeApiNameMap: state.routeApiNameMap,
config: state.config,
canEditBasicConfig: isAdminClient(clientReq),
clientIp: resolveAdminClientIp(clientReq),
timestamp: new Date().toISOString(),
totalRoutes: Object.keys(state.rawRoutes).length,
},
null,
2,
@ -175,31 +162,16 @@ async function handleRoutes(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
): Promise<void> {
if (clientReq.method !== "GET" && clientReq.method !== "POST") {
if (clientReq.method !== "POST") {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "GET, POST",
Allow: "POST",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["GET, POST"],
}),
);
return;
}
if (clientReq.method === "GET") {
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
routes: state.rawRoutes,
routeStatuses: state.rawRouteStatuses,
routeEnabledMap: state.routeEnabledMap,
routeApiNameMap: state.routeApiNameMap,
totalRoutes: Object.keys(state.rawRoutes).length,
allow: ["POST"],
}),
);
return;
@ -575,253 +547,6 @@ async function handleMockGroups(
});
}
function parseIpRuleBody(body: Record<string, unknown>): IpRule {
const ip = String(body.ip || "").trim();
if (!isValidIpAddress(ip)) {
throw new Error("IP 地址格式无效");
}
const blocked = body.blocked === true;
const useCustomConfig = body.useCustomConfig === true && !blocked;
const configInput = (body.config || {}) as IpRuleConfig;
const config: IpRuleConfig = {};
if (useCustomConfig) {
if (configInput.mockEnabled !== undefined) {
config.mockEnabled = configInput.mockEnabled !== false;
}
if (configInput.defaultContentType) {
config.defaultContentType = String(configInput.defaultContentType).trim();
}
if (configInput.targetHost) {
config.targetHost = String(configInput.targetHost).trim();
}
if (configInput.targetPort !== undefined && configInput.targetPort !== null) {
const port = Number(configInput.targetPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("目标端口无效");
}
config.targetPort = port;
}
if (configInput.targetHttps !== undefined) {
config.targetHttps = configInput.targetHttps !== false;
}
}
if (!blocked && !useCustomConfig) {
throw new Error("请至少启用「拉黑」或「个性化配置」");
}
return {
ip,
blocked,
remark: String(body.remark || "").trim(),
useCustomConfig,
config,
};
}
async function handleIpRules(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
): Promise<void> {
if (
clientReq.method !== "GET" &&
clientReq.method !== "POST" &&
clientReq.method !== "DELETE"
) {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "GET, POST, DELETE",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["GET, POST, DELETE"],
}),
);
return;
}
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.end(
JSON.stringify({
success: true,
list,
clientIp,
isAdmin,
}),
);
return;
}
readBody(clientReq)
.then(async (bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {};
if (clientReq.method === "DELETE") {
const ip = String(body.ip || "").trim();
if (!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);
state.ipRules = await loadIpRulesFromDb();
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(JSON.stringify({ success: true, ip }));
return;
}
const rule = parseIpRuleBody(body);
if (!isAdminClient(clientReq)) {
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);
state.ipRules = await loadIpRulesFromDb();
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(JSON.stringify({ success: true, rule }));
})
.catch((error) => {
clientRes.writeHead(400, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: (error as Error).message,
}),
);
});
}
async function handleClientIp(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
): Promise<void> {
if (clientReq.method !== "GET") {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "GET",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["GET"],
}),
);
return;
}
const effective = resolveEffectiveConfig(clientReq);
const matchedRule = effective.matchedRule;
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
clientIp: effective.ip,
localIpv4Addresses: getMachineLanIpv4Addresses(),
matchedRule: matchedRule
? {
ip: matchedRule.ip,
remark: matchedRule.remark,
blocked: matchedRule.blocked,
useCustomConfig: matchedRule.useCustomConfig,
}
: null,
ruleMatchedViaLocalhost:
!effective.blocked && effective.ruleMatchedViaLocalhost === true,
effectiveTarget: effective.blocked
? null
: formatTargetSummary(effective.config),
globalTarget: formatTargetSummary(state.config),
}),
);
}
const ASSETS_ROOT = path.join(__dirname, "..", "assets");
const ASSET_CONTENT_TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".woff": "font/woff",
".ttf": "font/ttf",
".map": "application/json",
};
async function handleAdminAssets(
requestPath: string,
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
): Promise<void> {
if (clientReq.method !== "GET" && clientReq.method !== "HEAD") {
clientRes.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
clientRes.end("Method Not Allowed");
return;
}
const relativePath = decodeURIComponent(requestPath.slice("/assets/".length));
if (!relativePath || relativePath.includes("\0")) {
clientRes.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
clientRes.end("Bad Request");
return;
}
const resolved = path.resolve(ASSETS_ROOT, relativePath);
const assetsRootResolved = path.resolve(ASSETS_ROOT);
if (
resolved !== assetsRootResolved &&
!resolved.startsWith(assetsRootResolved + path.sep)
) {
clientRes.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
clientRes.end("Forbidden");
return;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
clientRes.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
clientRes.end("Not Found");
return;
}
const ext = path.extname(resolved).toLowerCase();
const contentType =
ASSET_CONTENT_TYPES[ext] || "application/octet-stream";
const body = fs.readFileSync(resolved);
clientRes.writeHead(200, {
"Content-Type": contentType,
"Content-Length": body.length,
"Cache-Control": "public, max-age=86400",
});
if (clientReq.method === "HEAD") {
clientRes.end();
return;
}
clientRes.end(body);
}
async function handleAdminPage(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
@ -850,11 +575,6 @@ export async function dispatchAdmin(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
): Promise<boolean> {
if (requestPath === "/assets" || requestPath.startsWith("/assets/")) {
await handleAdminAssets(requestPath, clientReq, clientRes);
return true;
}
switch (requestPath) {
case "/__reload-config":
await handleReloadConfig(clientReq, clientRes);
@ -874,12 +594,6 @@ export async function dispatchAdmin(
case "/__mock-groups":
await handleMockGroups(clientReq, clientRes);
return true;
case "/__ip-rules":
await handleIpRules(clientReq, clientRes);
return true;
case "/__client-ip":
await handleClientIp(clientReq, clientRes);
return true;
case "/__admin":
await handleAdminPage(clientReq, clientRes);
return true;

View File

@ -1,45 +0,0 @@
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,7 +5,6 @@ import {
loadRoutesFromDb,
loadServerConfig,
saveServerConfig,
loadIpRulesFromDb,
} from "./db";
import { getActiveRoutes } from "./route-matching";
import { state } from "./state";
@ -51,8 +50,6 @@ export async function loadConfig(): Promise<void> {
state.routeEnabledMap = dbMappings.routeEnabledMap;
state.routeApiNameMap = dbMappings.routeApiNameMap;
state.ipRules = await loadIpRulesFromDb();
const activeCount = Object.values(state.routeEnabledMap).filter(
(v) => v !== false,
).length;
@ -67,7 +64,6 @@ export async function loadConfig(): Promise<void> {
state.rawRouteStatuses = {};
state.routeEnabledMap = {};
state.routeApiNameMap = {};
state.ipRules = [];
state.config = {
cacheConfig: true,
reloadOnChange: true,

View File

@ -5,4 +5,3 @@ export const MOCK_DIR = path.join(__dirname, "..", "mock");
export const DATA_DIR = path.join(__dirname, "..", "data");
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 ADMIN_LIST_FILE = path.join(__dirname, "..", "admin_list.txt");

116
src/db.ts
View File

@ -13,9 +13,6 @@ import type {
RouteEnabledMap,
RouteRow,
RouteStatusConfig,
IpRule,
IpRuleRow,
IpRuleConfig,
} from "./types";
export function dbRun(sql: string, params: unknown[] = []): Promise<void> {
@ -57,22 +54,11 @@ export function openDatabase(): Promise<void> {
});
}
async function tableExists(name: string): Promise<boolean> {
const rows = await dbAll<{ cnt: number }>(
"SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type='table' AND name=?",
[name],
);
return (rows[0]?.cnt ?? 0) > 0;
}
export async function initDatabase(): Promise<void> {
fs.mkdirSync(MOCK_DIR, { recursive: true });
fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(DB_FILE) && fs.existsSync(LEGACY_DB_FILE)) {
fs.copyFileSync(LEGACY_DB_FILE, DB_FILE);
console.log(
`[DB] 已从旧路径迁移数据库: ${LEGACY_DB_FILE} -> ${DB_FILE}`,
);
}
await openDatabase();
await dbRun(`
@ -143,23 +129,6 @@ export async function initDatabase(): Promise<void> {
value TEXT NOT NULL
)
`);
const hadIpRules = await tableExists("ip_rules");
await dbRun(`
CREATE TABLE IF NOT EXISTS ip_rules (
ip TEXT PRIMARY KEY,
blocked INTEGER NOT NULL DEFAULT 0,
remark TEXT NOT NULL DEFAULT '',
use_custom_config INTEGER NOT NULL DEFAULT 0,
mock_enabled INTEGER,
default_content_type TEXT,
target_host TEXT,
target_port INTEGER,
target_https INTEGER
)
`);
if (!hadIpRules) {
console.log("[DB] 数据库已自动升级: 新增 IP 管理(ip_rules)表");
}
// 清理不该出现在 mock 列表中的系统文件
await dbRun(
"DELETE FROM mock_files WHERE file_path = ? OR file_path LIKE ?",
@ -403,88 +372,3 @@ export async function saveServerConfig(config: AppConfig): Promise<void> {
]);
}
}
function rowToIpRule(row: IpRuleRow): IpRule {
const config: IpRuleConfig = {};
if (row.mock_enabled != null) {
config.mockEnabled = row.mock_enabled !== 0;
}
if (row.default_content_type) {
config.defaultContentType = row.default_content_type;
}
if (row.target_host) {
config.targetHost = row.target_host;
}
if (row.target_port != null) {
config.targetPort = row.target_port;
}
if (row.target_https != null) {
config.targetHttps = row.target_https !== 0;
}
return {
ip: row.ip,
blocked: row.blocked !== 0,
remark: row.remark || "",
useCustomConfig: row.use_custom_config !== 0,
config,
};
}
export async function loadIpRulesFromDb(): Promise<IpRule[]> {
const rows = await dbAll<IpRuleRow>(
"SELECT ip, blocked, remark, use_custom_config, mock_enabled, default_content_type, target_host, target_port, target_https FROM ip_rules ORDER BY ip ASC",
);
return rows.map(rowToIpRule);
}
export async function upsertIpRuleToDb(rule: IpRule): Promise<void> {
const mockEnabled =
rule.config.mockEnabled !== undefined
? rule.config.mockEnabled
? 1
: 0
: null;
const targetHttps =
rule.config.targetHttps !== undefined
? rule.config.targetHttps
? 1
: 0
: null;
await dbRun(
`INSERT INTO ip_rules(
ip, blocked, remark, use_custom_config,
mock_enabled, default_content_type, target_host, target_port, target_https
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ip) DO UPDATE SET
blocked = excluded.blocked,
remark = excluded.remark,
use_custom_config = excluded.use_custom_config,
mock_enabled = excluded.mock_enabled,
default_content_type = excluded.default_content_type,
target_host = excluded.target_host,
target_port = excluded.target_port,
target_https = excluded.target_https`,
[
rule.ip,
rule.blocked ? 1 : 0,
rule.remark || "",
rule.useCustomConfig ? 1 : 0,
mockEnabled,
rule.config.defaultContentType || null,
rule.config.targetHost || null,
rule.config.targetPort ?? null,
targetHttps,
],
);
}
export async function deleteIpRuleFromDb(ip: string): Promise<void> {
await dbRun("DELETE FROM ip_rules WHERE ip = ?", [ip]);
}
export async function saveAllIpRulesToDb(rules: IpRule[]): Promise<void> {
await dbRun("DELETE FROM ip_rules");
for (const rule of rules) {
await upsertIpRuleToDb(rule);
}
}

View File

@ -1,222 +0,0 @@
import * as http from "http";
import * as os from "os";
import { state } from "./state";
import type { AppConfig, IpRule, IpRuleConfig } from "./types";
export function normalizeIp(raw: string): string {
let ip = raw.trim();
if (ip.startsWith("::ffff:")) {
ip = ip.slice(7);
}
if (ip === "::1") {
return "127.0.0.1";
}
return ip;
}
export function getClientIp(req: http.IncomingMessage): string {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
const first = String(
Array.isArray(forwarded) ? forwarded[0] : forwarded,
)
.split(",")[0]
.trim();
if (first) {
return normalizeIp(first);
}
}
const realIp = req.headers["x-real-ip"];
if (realIp) {
const value = String(Array.isArray(realIp) ? realIp[0] : realIp).trim();
if (value) {
return normalizeIp(value);
}
}
const remote = req.socket.remoteAddress || "";
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 访问时的规则回退 */
export function getMachineLanIpv4Addresses(): string[] {
const ips = new Set<string>();
const interfaces = os.networkInterfaces();
for (const addrs of Object.values(interfaces)) {
if (!addrs) continue;
for (const addr of addrs) {
const family = String(addr.family);
if (family === "IPv4" && !addr.internal) {
ips.add(normalizeIp(addr.address));
}
}
}
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 {
const normalized = normalizeIp(clientIp);
return state.ipRules.find(
(rule) => normalizeIp(rule.ip) === normalized,
);
}
export function mergeConfigWithIpRule(
baseConfig: AppConfig,
ruleConfig: IpRuleConfig,
): AppConfig {
return {
...baseConfig,
mockEnabled:
ruleConfig.mockEnabled !== undefined
? ruleConfig.mockEnabled
: baseConfig.mockEnabled,
defaultContentType:
ruleConfig.defaultContentType || baseConfig.defaultContentType,
targetHost: ruleConfig.targetHost || baseConfig.targetHost,
targetPort:
ruleConfig.targetPort !== undefined
? ruleConfig.targetPort
: baseConfig.targetPort,
targetHttps:
ruleConfig.targetHttps !== undefined
? ruleConfig.targetHttps
: baseConfig.targetHttps,
};
}
export type EffectiveConfigResult =
| { blocked: true; ip: string; matchedRule?: IpRule }
| {
blocked: false;
config: AppConfig;
ip: string;
matchedRule?: IpRule;
ruleMatchedViaLocalhost?: boolean;
};
export function resolveEffectiveConfig(
req: http.IncomingMessage,
): EffectiveConfigResult {
const rawIp = normalizeIp(getClientIp(req));
const hintIps = state.ipRules.map((rule) => rule.ip);
const ip = resolveClientIp(req, hintIps);
const rule = findIpRule(ip);
const ruleMatchedViaLocalhost =
rawIp === "127.0.0.1" && !!rule && normalizeIp(rule.ip) !== rawIp;
if (rule?.blocked) {
return { blocked: true, ip, matchedRule: rule };
}
if (rule?.useCustomConfig) {
return {
blocked: false,
config: mergeConfigWithIpRule(state.config, rule.config),
ip,
matchedRule: rule,
ruleMatchedViaLocalhost,
};
}
return { blocked: false, config: state.config, ip };
}
export function formatTargetSummary(config: AppConfig): string {
const https = config.targetHttps !== false;
const port =
config.targetPort != null
? config.targetPort
: https
? 443
: 80;
const proto = https ? "https" : "http";
const defaultPort = https ? 443 : 80;
const host = config.targetHost || "localhost";
return port === defaultPort
? `${proto}://${host}`
: `${proto}://${host}:${port}`;
}
export function isValidIpAddress(value: string): boolean {
const ip = value.trim();
if (!ip) return false;
const ipv4 =
/^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(ip);
if (ipv4) return true;
return /^[\da-fA-F:]+$/.test(ip) && ip.includes(":");
}

View File

@ -3,50 +3,32 @@ import * as http from "http";
import * as https from "https";
import * as path from "path";
import { dispatchAdmin } from "./admin-handlers";
import { resolveEffectiveConfig, formatTargetSummary } from "./ip-rules";
import {
getMockFilePath,
getMockStatusCode,
isMockRoute,
} from "./route-matching";
import { state } from "./state";
import type { AppConfig } from "./types";
import { decodeBodyByEncoding } from "./utils";
function isTargetHttps(config: AppConfig): boolean {
return config.targetHttps !== false;
function isTargetHttps(): boolean {
return state.config.targetHttps !== false;
}
function getTargetPort(config: AppConfig): number {
if (config.targetPort != null) return config.targetPort;
return isTargetHttps(config) ? 443 : 80;
function getTargetPort(): number {
if (state.config.targetPort != null) return state.config.targetPort;
return isTargetHttps() ? 443 : 80;
}
function upstreamRequest(
options: http.RequestOptions,
useHttps: boolean,
callback: (proxyRes: http.IncomingMessage) => void,
): http.ClientRequest {
return useHttps
return isTargetHttps()
? https.request(options, callback)
: http.request(options, callback);
}
function handleBlockedIp(
clientRes: http.ServerResponse,
ip: string,
): void {
clientRes.writeHead(403, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
error: "Access denied",
message: "Your IP address is blocked",
ip,
timestamp: new Date().toISOString(),
}),
);
}
export function createProxyServer(): http.Server {
return http.createServer(async (clientReq, clientRes) => {
const parsedUrl = new URL(`http://localhost${clientReq.url!}`);
@ -56,22 +38,11 @@ export function createProxyServer(): http.Server {
const handled = await dispatchAdmin(requestPath, clientReq, clientRes);
if (handled) return;
const effective = resolveEffectiveConfig(clientReq);
if (effective.blocked) {
console.log(`[IP] 拒绝访问: ${effective.ip}`);
handleBlockedIp(clientRes, effective.ip);
return;
}
const effectiveConfig = effective.config;
// 检查是否为需要mock的路由
if (isMockRoute(requestPath, effectiveConfig)) {
if (isMockRoute(requestPath)) {
const mockFile = getMockFilePath(requestPath);
const mockStatusCode = getMockStatusCode(requestPath);
console.log(
`[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile} | 客户端IP: ${effective.ip}${effective.matchedRule ? ` | 规则: ${effective.matchedRule.ip}` : ""}${effective.ruleMatchedViaLocalhost ? " (localhost回退)" : ""}`,
);
console.log(`[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile}`);
try {
const mockFilePath = path.join(__dirname, "..", mockFile);
@ -82,20 +53,19 @@ export function createProxyServer(): http.Server {
`[MOCK] Mock文件不存在,回源并自动生成: ${mockFilePath}`,
);
const targetPort = getTargetPort(effectiveConfig);
const useHttps = isTargetHttps(effectiveConfig);
const targetPort = getTargetPort();
const options: http.RequestOptions = {
hostname: effectiveConfig.targetHost,
hostname: state.config.targetHost,
port: targetPort,
method: clientReq.method,
path: parsedUrl.pathname + parsedUrl.search,
headers: {
...clientReq.headers,
host: effectiveConfig.targetHost,
host: state.config.targetHost,
},
};
const proxyReq = upstreamRequest(options, useHttps, (proxyRes) => {
const proxyReq = upstreamRequest(options, (proxyRes) => {
const chunks: Buffer[] = [];
proxyRes.on("data", (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@ -164,7 +134,7 @@ export function createProxyServer(): http.Server {
// 设置响应头
const contentType =
effectiveConfig.defaultContentType || "application/json";
state.config.defaultContentType || "application/json";
clientRes.writeHead(mockStatusCode, {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
@ -195,26 +165,22 @@ export function createProxyServer(): http.Server {
}
// 如果不是mock路由,正常代理转发
const targetSummary = formatTargetSummary(effectiveConfig);
console.log(
`[PROXY] 转发请求: ${requestPath} | 客户端IP: ${effective.ip} -> ${targetSummary}${effective.matchedRule ? ` | 规则: ${effective.matchedRule.ip}${effective.matchedRule.remark ? "/" + effective.matchedRule.remark : ""}` : " | 全局配置"}${effective.ruleMatchedViaLocalhost ? " (localhost回退)" : ""}`,
);
console.log(`[PROXY] 转发请求: ${requestPath}`);
const targetPort = getTargetPort(effectiveConfig);
const useHttps = isTargetHttps(effectiveConfig);
const targetPort = getTargetPort();
const options: http.RequestOptions = {
hostname: effectiveConfig.targetHost,
hostname: state.config.targetHost,
port: targetPort,
method: clientReq.method,
path: parsedUrl.pathname + parsedUrl.search,
headers: {
...clientReq.headers,
host: effectiveConfig.targetHost,
host: state.config.targetHost,
},
};
const proxyReq = upstreamRequest(options, useHttps, (proxyRes) => {
const proxyReq = upstreamRequest(options, (proxyRes) => {
clientRes.writeHead(proxyRes.statusCode!, proxyRes.headers);
proxyRes.pipe(clientRes);
});
@ -237,9 +203,9 @@ export function createProxyServer(): http.Server {
export function getEffectiveTargetPort(): number {
if (state.config.targetPort != null) return state.config.targetPort;
return state.config.targetHttps !== false ? 443 : 80;
return isTargetHttps() ? 443 : 80;
}
export function getEffectiveTargetHttps(): boolean {
return state.config.targetHttps !== false;
return isTargetHttps();
}

View File

@ -1,17 +1,9 @@
import { state } from "./state";
import { normalizeStatusCode } from "./utils";
import type { AppConfig } from "./types";
function isMockEnabled(config: AppConfig): boolean {
return config.mockEnabled !== false;
}
// 检查是否为mock路由的函数
export function isMockRoute(
requestPath: string,
config: AppConfig = state.config,
): boolean {
if (!isMockEnabled(config)) return false;
export function isMockRoute(requestPath: string): boolean {
if (state.config.mockEnabled === false) return false;
if (!state.rawRoutes.hasOwnProperty(requestPath)) return false;
return state.routeEnabledMap[requestPath] !== false;
}

View File

@ -1,5 +1,5 @@
import type sqlite3 from "sqlite3";
import type { AppConfig, IpRule, RouteApiNameMap, RouteConfig, RouteEnabledMap, RouteStatusConfig } from "./types";
import type { AppConfig, RouteApiNameMap, RouteConfig, RouteEnabledMap, RouteStatusConfig } from "./types";
export const state = {
rawRoutes: {} as RouteConfig,
@ -15,5 +15,4 @@ export const state = {
targetPort: 443,
} as AppConfig,
db: null as unknown as sqlite3.Database,
ipRules: [] as IpRule[],
};

View File

@ -64,32 +64,3 @@ export interface MockFileRow {
alias: string;
group_id: number | null;
}
/** 单 IP 可覆盖的服务端配置(不含 proxyPort) */
export interface IpRuleConfig {
mockEnabled?: boolean;
defaultContentType?: string;
targetHost?: string;
targetPort?: number;
targetHttps?: boolean;
}
export interface IpRule {
ip: string;
blocked: boolean;
remark: string;
useCustomConfig: boolean;
config: IpRuleConfig;
}
export interface IpRuleRow {
ip: string;
blocked: number;
remark: string;
use_custom_config: number;
mock_enabled: number | null;
default_content_type: string | null;
target_host: string | null;
target_port: number | null;
target_https: number | null;
}