Compare commits

..

10 Commits
1.0.0 ... main

Author SHA1 Message Date
3790ada0f1 修复接口管理修改错位问题 2026-09-23 10:04:02 +08:00
b8aee98468 feat:路由配置、数据配置加表单校验 2026-09-08 11:27:53 +08:00
07a6af6fd9 feat: Mock 文件名按别名自动生成,路由展示与选择改为别名
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 11:20:56 +08:00
574757f903 feat: 管理员新增个性化配置支持任意 IP 输入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:07:51 +08:00
9bec3208f8 feat: 管理员白名单与个性化配置权限控制
系统配置仅允许 admin_list.txt 白名单 IP 编辑;localhost 访问时解析为 LAN IP;个性化配置支持按 IP 权限隔离,新增时自动填充当前 IP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:02:16 +08:00
2b8cb3ca9a admin.html改为内部资源调用 2026-08-05 14:08:39 +08:00
0fb285b045 自动升级 2026-07-10 12:01:20 +08:00
0223db88b7 feat:新增IP个性化配置 2026-07-10 11:57:28 +08:00
46bdff3734 加刷新按钮,切换tab自动刷新 2026-06-10 17:55:05 +08:00
a23a9846b3 全屏编辑mock数据 2026-06-09 14:33:03 +08:00
18 changed files with 13394 additions and 84 deletions

View File

@ -3,11 +3,8 @@
<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 <link rel="stylesheet" href="/assets/element-ui/index.css" />
rel="stylesheet"
href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"
/>
<style> <style>
body { body {
margin: 0; margin: 0;
@ -51,6 +48,18 @@
.route-table .el-table__cell .cell { .route-table .el-table__cell .cell {
white-space: nowrap; 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> </style>
</head> </head>
<body> <body>
@ -68,6 +77,7 @@
<strong>路由配置(routes)</strong> <strong>路由配置(routes)</strong>
<div style="display:flex;gap:8px;align-items:center;"> <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-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> <el-button size="mini" type="primary" @click="openRouteDialogForCreate">新增路由</el-button>
</div> </div>
</div> </div>
@ -82,9 +92,9 @@
<span>{{ scope.row.route }}</span> <span>{{ scope.row.route }}</span>
</template> </template>
</el-table-column> </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"> <template slot-scope="scope">
<a href="javascript:void(0)" style="color:#409EFF;cursor:pointer;" @click="openMockFileFromRoute(scope.row)">{{ scope.row.filePath }}</a> <a href="javascript:void(0)" style="color:#409EFF;cursor:pointer;" @click="openMockFileFromRoute(scope.row)">{{ getMockFileAlias(scope.row.filePath) }}</a>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="返回状态码" width="120" align="center"> <el-table-column label="返回状态码" width="120" align="center">
@ -134,6 +144,7 @@
<el-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option> <el-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select> </el-select>
<el-input size="small" v-model="mockFileSearch" placeholder="搜索别名或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input> <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" type="primary" @click="openMockFileDialogForCreate">新增 Mock 文件</el-button>
<el-button size="mini" @click="openMockGroupDialog">分组管理</el-button> <el-button size="mini" @click="openMockGroupDialog">分组管理</el-button>
</div> </div>
@ -182,9 +193,10 @@
<el-tab-pane label="接口管理" name="api"> <el-tab-pane label="接口管理" name="api">
<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;">
<strong>接口列表</strong> <strong>接口管理(api)</strong>
<div style="display:flex;gap:8px;align-items:center;"> <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-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="primary" @click="openApiDialogForCreate">新增接口</el-button>
<el-button size="mini" type="success" @click="openBatchImportDialog">批量导入</el-button> <el-button size="mini" type="success" @click="openBatchImportDialog">批量导入</el-button>
</div> </div>
@ -219,8 +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;">
<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-form :model="form.config" label-width="180px">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
@ -283,6 +308,81 @@
</el-col> </el-col>
</el-row> </el-row>
</el-form> </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-card>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@ -292,7 +392,12 @@
:visible.sync="routeDialog.visible" :visible.sync="routeDialog.visible"
width="760px" width="760px"
> >
<el-form :model="routeDialog.form" label-width="180px"> <el-form
ref="routeForm"
:model="routeDialog.form"
:rules="routeFormRules"
label-width="180px"
>
<el-form-item label="预置接口"> <el-form-item label="预置接口">
<el-select <el-select
v-model="routeDialog.form.selectedApiRoute" v-model="routeDialog.form.selectedApiRoute"
@ -310,21 +415,21 @@
></el-option> ></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="接口名称"> <el-form-item label="接口名称" prop="apiName">
<el-input <el-input
v-model="routeDialog.form.apiName" v-model="routeDialog.form.apiName"
placeholder="例如:获取用户信息" placeholder="例如:获取用户信息"
:disabled="isApiListLocked()" :disabled="isApiListLocked()"
></el-input> ></el-input>
</el-form-item> </el-form-item>
<el-form-item label="请求路径"> <el-form-item label="请求路径" prop="route">
<el-input <el-input
v-model="routeDialog.form.route" v-model="routeDialog.form.route"
placeholder="/api/new/mock" placeholder="/api/new/mock"
:disabled="isApiListLocked()" :disabled="isApiListLocked()"
></el-input> ></el-input>
</el-form-item> </el-form-item>
<el-form-item label="Mock 文件"> <el-form-item label="Mock 文件" prop="filePath">
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<el-select <el-select
v-model="routeDialog.form.groupFilter" v-model="routeDialog.form.groupFilter"
@ -351,13 +456,13 @@
<el-option <el-option
v-for="item in routeDialogMockFiles" v-for="item in routeDialogMockFiles"
:key="item.filePath" :key="item.filePath"
:label="item.alias ? item.alias + ' (' + item.filePath + ')' : item.filePath" :label="item.alias || item.filePath"
:value="item.filePath" :value="item.filePath"
></el-option> ></el-option>
</el-select> </el-select>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="返回状态码"> <el-form-item label="返回状态码" prop="statusCode">
<el-select <el-select
v-model="routeDialog.form.statusCode" v-model="routeDialog.form.statusCode"
filterable filterable
@ -375,7 +480,7 @@
></el-option> ></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="是否启用 Mock"> <el-form-item label="是否启用 Mock" prop="enabled" required>
<el-switch v-model="routeDialog.form.enabled"></el-switch> <el-switch v-model="routeDialog.form.enabled"></el-switch>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -390,34 +495,48 @@
<el-dialog <el-dialog
:title="mockFileDialog.mode === 'edit' ? '修改 Mock 文件' : mockFileDialog.mode === 'copy' ? '复制 Mock 文件' : '新增 Mock 文件'" :title="mockFileDialog.mode === 'edit' ? '修改 Mock 文件' : mockFileDialog.mode === 'copy' ? '复制 Mock 文件' : '新增 Mock 文件'"
:visible.sync="mockFileDialog.visible" :visible.sync="mockFileDialog.visible"
width="760px" :width="mockFileDialog.fullscreen ? '100%' : '760px'"
:fullscreen="mockFileDialog.fullscreen"
:top="mockFileDialog.fullscreen ? '0' : '15vh'"
> >
<el-form :model="mockFileDialog.form" label-width="180px"> <el-form
<el-form-item label="别名"> ref="mockFileForm"
:model="mockFileDialog.form"
:rules="mockFileFormRules"
label-width="180px"
>
<el-form-item label="别名" prop="alias">
<el-input <el-input
v-model="mockFileDialog.form.alias" v-model="mockFileDialog.form.alias"
placeholder="可选:用于展示,支持任意文本" placeholder="必填:用于展示,支持任意文本"
></el-input> ></el-input>
</el-form-item> </el-form-item>
<el-form-item label="分组"> <el-form-item label="分组" prop="groupId" required>
<el-select v-model="mockFileDialog.form.groupId" clearable placeholder="选择分组" style="width: 100%;"> <el-select v-model="mockFileDialog.form.groupId" placeholder="请选择分组" style="width: 100%;">
<el-option label="无分组" :value="null"></el-option> <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-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="Mock 文件路径"> <el-form-item label="Mock 文件路径" prop="fileName">
<el-input <el-input
v-model="mockFileDialog.form.fileName" v-model="mockFileDialog.form.fileName"
placeholder="请输入文件名,如 test123.json" :placeholder="mockFileDialog.mode === 'edit' ? '' : '根据别名自动生成:别名_随机串.json'"
:disabled="mockFileDialog.mode === 'edit'" disabled
> >
<template slot="prepend">mock/</template> <template slot="prepend">mock/</template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="文件内容"> <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-input <el-input
type="textarea" type="textarea"
:rows="12" class="mock-content-textarea"
:autosize="mockFileDialog.fullscreen ? { minRows: 28, maxRows: 28 } : { minRows: 12, maxRows: 12 }"
v-model="mockFileDialog.form.content" v-model="mockFileDialog.form.content"
placeholder='{"code":0,"message":"ok"}' placeholder='{"code":0,"message":"ok"}'
></el-input> ></el-input>
@ -532,17 +651,132 @@
<el-button type="danger" @click="submitDeleteGroup">确认删除</el-button> <el-button type="danger" @click="submitDeleteGroup">确认删除</el-button>
</span> </span>
</el-dialog> </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>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> <script src="/assets/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script> <script src="/assets/element-ui/index.js"></script>
<script> <script>
new Vue({ new Vue({
el: "#app", el: "#app",
data: function () { data: function () {
return { return {
activeMainTab: "routes", 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: [], apiList: [],
apiPagination: { currentPage: 1, pageSize: 10 }, apiPagination: { currentPage: 1, pageSize: 10 },
apiDialog: { apiDialog: {
@ -633,6 +867,7 @@
}, },
mockFileDialog: { mockFileDialog: {
visible: false, visible: false,
fullscreen: false,
mode: "create", mode: "create",
form: { form: {
fileName: "", fileName: "",
@ -640,21 +875,341 @@
content: "", 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: { 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; }, routeSearch: function () { this.routePagination.currentPage = 1; },
mockFileSearch: function () { this.mockFilePagination.currentPage = 1; }, mockFileSearch: function () { this.mockFilePagination.currentPage = 1; },
mockGroupFilter: 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; }, apiSearch: function () { this.apiPagination.currentPage = 1; },
ipRuleSearch: function () { this.ipRulePagination.currentPage = 1; },
}, },
created: async function () { created: async function () {
await this.loadApiList(); await this.loadApiList();
await this.loadMockFiles(); await this.loadMockFiles();
await this.loadMockGroups(); await this.loadMockGroups();
await this.loadConfig(); await this.loadConfig();
await this.loadRoutes();
}, },
methods: { 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) { getPreviewText: function (content) {
var text = String(content || "").replace(/\s+/g, " ").trim(); var text = String(content || "").replace(/\s+/g, " ").trim();
if (!text) return "(空文件)"; if (!text) return "(空文件)";
@ -793,6 +1348,9 @@
this.routeDialog.editingIndex = -1; this.routeDialog.editingIndex = -1;
this.routeDialog.form = this.getDefaultRouteForm(); this.routeDialog.form = this.getDefaultRouteForm();
this.routeDialog.visible = true; this.routeDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.routeForm) this.$refs.routeForm.clearValidate();
});
}, },
openRouteDialogForEdit: function (index) { openRouteDialogForEdit: function (index) {
var actualIndex = var actualIndex =
@ -816,10 +1374,24 @@
groupFilter: groupFilter, groupFilter: groupFilter,
}; };
this.routeDialog.visible = true; 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 () { getDefaultMockFileForm: function () {
return { return {
fileName: "", fileName: this.generateMockFileName(""),
alias: "", alias: "",
content: "", content: "",
groupId: null, groupId: null,
@ -827,13 +1399,18 @@
}, },
openMockFileDialogForCreate: function () { openMockFileDialogForCreate: function () {
this.mockFileDialog.mode = "create"; this.mockFileDialog.mode = "create";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = this.getDefaultMockFileForm(); this.mockFileDialog.form = this.getDefaultMockFileForm();
this.mockFileDialog.visible = true; this.mockFileDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.mockFileForm) this.$refs.mockFileForm.clearValidate();
});
}, },
openMockFileDialogForEdit: function (item) { openMockFileDialogForEdit: function (item) {
var full = item.filePath || ""; var full = item.filePath || "";
var name = full.indexOf("mock/") === 0 ? full.slice(5) : full; var name = full.indexOf("mock/") === 0 ? full.slice(5) : full;
this.mockFileDialog.mode = "edit"; this.mockFileDialog.mode = "edit";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = { this.mockFileDialog.form = {
fileName: name, fileName: name,
alias: String(item.alias || ""), alias: String(item.alias || ""),
@ -841,6 +1418,9 @@
groupId: item.groupId || null, groupId: item.groupId || null,
}; };
this.mockFileDialog.visible = true; this.mockFileDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.mockFileForm) this.$refs.mockFileForm.clearValidate();
});
}, },
openMockFileFromRoute: function (routeRow) { openMockFileFromRoute: function (routeRow) {
var filePath = routeRow.filePath || ""; var filePath = routeRow.filePath || "";
@ -851,15 +1431,28 @@
} }
this.openMockFileDialogForEdit(mockFile); 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) { openMockFileDialogForCopy: function (item) {
var alias = String(item.alias || "") + "-副本";
this.mockFileDialog.mode = "copy"; this.mockFileDialog.mode = "copy";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = { this.mockFileDialog.form = {
fileName: "", fileName: this.generateMockFileName(alias),
alias: String(item.alias || "") + "-副本", alias: alias,
content: String(item.content || ""), content: String(item.content || ""),
groupId: item.groupId || null, groupId: item.groupId || null,
}; };
this.mockFileDialog.visible = true; this.mockFileDialog.visible = true;
this.$nextTick(function () {
if (this.$refs.mockFileForm) this.$refs.mockFileForm.clearValidate();
});
}, },
loadMockFiles: async function () { loadMockFiles: async function () {
try { try {
@ -875,7 +1468,19 @@
this.$message.error("加载 mock 文件失败: " + err.message); this.$message.error("加载 mock 文件失败: " + err.message);
} }
}, },
submitMockFileDialog: async function () { 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 () {
var fileName = (this.mockFileDialog.form.fileName || "").trim(); var fileName = (this.mockFileDialog.form.fileName || "").trim();
if (!fileName) { if (!fileName) {
this.$message.error("文件名不能为空"); this.$message.error("文件名不能为空");
@ -954,13 +1559,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.form.routes = this.toRouteArray( this.canEditBasicConfig = data.canEditBasicConfig === true;
data.routes || {}, this.clientIp = data.clientIp || "";
data.routeStatuses || {}, if (data.canEditBasicConfig === true) {
data.routeEnabledMap || {}, this.isAdmin = true;
data.routeApiNameMap || {}, }
);
this.routePagination.currentPage = 1;
} catch (err) { } catch (err) {
this.$message.error("加载配置失败: " + err.message); this.$message.error("加载配置失败: " + err.message);
} }
@ -987,6 +1590,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",
@ -1001,15 +1607,14 @@
this.$message.error("保存配置失败: " + err.message); this.$message.error("保存配置失败: " + err.message);
} }
}, },
submitRouteDialog: async function () { submitRouteDialog: function () {
if (!this.routeDialog.form.route) { var self = this;
this.$message.error("请求路径不能为空"); this.$refs.routeForm.validate(function (valid) {
return; if (!valid) return;
} self.doSubmitRouteDialog();
if (!this.routeDialog.form.filePath) { });
this.$message.error("请选择 Mock 文件"); },
return; doSubmitRouteDialog: async function () {
}
var payload = Object.assign({}, this.routeDialog.form, { var payload = Object.assign({}, this.routeDialog.form, {
useExistingFile: true, useExistingFile: true,
statusCode: this.normalizeStatusCode(this.routeDialog.form.statusCode), statusCode: this.normalizeStatusCode(this.routeDialog.form.statusCode),
@ -1031,7 +1636,7 @@
); );
this.routeDialog.visible = false; this.routeDialog.visible = false;
await this.loadMockFiles(); await this.loadMockFiles();
await this.loadConfig(); await this.loadRoutes();
} catch (err) { } catch (err) {
this.$message.error("创建失败: " + err.message); this.$message.error("创建失败: " + err.message);
} }
@ -1048,7 +1653,8 @@
openApiDialogForEdit: function (index) { openApiDialogForEdit: function (index) {
var actualIndex = var actualIndex =
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index; (this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
var item = this.apiList[actualIndex]; var item = this.filteredApiList[actualIndex];
if (!item) return;
this.apiDialog.mode = "edit"; this.apiDialog.mode = "edit";
this.apiDialog.editingIndex = actualIndex; this.apiDialog.editingIndex = actualIndex;
this.apiDialog.form = { this.apiDialog.form = {
@ -1087,7 +1693,8 @@
removeApiItem: async function (index) { removeApiItem: async function (index) {
var actualIndex = var actualIndex =
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index; (this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
var item = this.apiList[actualIndex]; var item = this.filteredApiList[actualIndex];
if (!item) return;
try { try {
await this.$confirm("确定删除接口「" + item.name + "」?", "提示", { await this.$confirm("确定删除接口「" + item.name + "」?", "提示", {
confirmButtonText: "确定", confirmButtonText: "确定",
@ -1355,6 +1962,17 @@
); );
}); });
}, },
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 () { pagedRoutes: function () {
return this.getPagedData(this.filteredRoutes, this.routePagination); return this.getPagedData(this.filteredRoutes, this.routePagination);
}, },
@ -1364,6 +1982,15 @@
pagedApiList: function () { pagedApiList: function () {
return this.getPagedData(this.filteredApiList, this.apiPagination); 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 () { 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

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11932
assets/vue.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{}

View File

@ -16,7 +16,12 @@ import {
countMockFilesByGroup, countMockFilesByGroup,
updateMockFilesGroupId, updateMockFilesGroupId,
deleteMockFilesByGroup, deleteMockFilesByGroup,
loadIpRulesFromDb,
upsertIpRuleToDb,
deleteIpRuleFromDb,
} from "./db"; } from "./db";
import { isAdminClient, resolveAdminClientIp, canManageIpRule } from "./admin-list";
import { isValidIpAddress, normalizeIp, resolveEffectiveConfig, getMachineLanIpv4Addresses, formatTargetSummary } from "./ip-rules";
import { import {
loadMockFiles, loadMockFiles,
normalizeMockFilePath, normalizeMockFilePath,
@ -24,6 +29,7 @@ import {
} from "./mock-files"; } from "./mock-files";
import { normalizeStatusCode, readBody } from "./utils"; import { normalizeStatusCode, readBody } from "./utils";
import { state } from "./state"; import { state } from "./state";
import type { IpRule, IpRuleConfig } from "./types";
async function handleReloadConfig( async function handleReloadConfig(
clientReq: http.IncomingMessage, clientReq: http.IncomingMessage,
@ -95,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);
} }
// 保存路由配置 // 保存路由配置
@ -135,13 +151,10 @@ async function handleConfig(
clientRes.end( clientRes.end(
JSON.stringify( JSON.stringify(
{ {
routes: state.rawRoutes,
routeStatuses: state.rawRouteStatuses,
routeEnabledMap: state.routeEnabledMap,
routeApiNameMap: state.routeApiNameMap,
config: state.config, config: state.config,
canEditBasicConfig: isAdminClient(clientReq),
clientIp: resolveAdminClientIp(clientReq),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
totalRoutes: Object.keys(state.rawRoutes).length,
}, },
null, null,
2, 2,
@ -162,16 +175,31 @@ async function handleRoutes(
clientReq: http.IncomingMessage, clientReq: http.IncomingMessage,
clientRes: http.ServerResponse, clientRes: http.ServerResponse,
): Promise<void> { ): Promise<void> {
if (clientReq.method !== "POST") { if (clientReq.method !== "GET" && clientReq.method !== "POST") {
clientRes.writeHead(405, { clientRes.writeHead(405, {
"Content-Type": "application/json", "Content-Type": "application/json",
Allow: "POST", Allow: "GET, POST",
}); });
clientRes.end( clientRes.end(
JSON.stringify({ JSON.stringify({
success: false, success: false,
error: "Method Not Allowed", error: "Method Not Allowed",
allow: ["POST"], 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,
}), }),
); );
return; return;
@ -547,6 +575,253 @@ 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( async function handleAdminPage(
clientReq: http.IncomingMessage, clientReq: http.IncomingMessage,
clientRes: http.ServerResponse, clientRes: http.ServerResponse,
@ -575,6 +850,11 @@ export async function dispatchAdmin(
clientReq: http.IncomingMessage, clientReq: http.IncomingMessage,
clientRes: http.ServerResponse, clientRes: http.ServerResponse,
): Promise<boolean> { ): Promise<boolean> {
if (requestPath === "/assets" || requestPath.startsWith("/assets/")) {
await handleAdminAssets(requestPath, clientReq, clientRes);
return true;
}
switch (requestPath) { switch (requestPath) {
case "/__reload-config": case "/__reload-config":
await handleReloadConfig(clientReq, clientRes); await handleReloadConfig(clientReq, clientRes);
@ -594,6 +874,12 @@ export async function dispatchAdmin(
case "/__mock-groups": case "/__mock-groups":
await handleMockGroups(clientReq, clientRes); await handleMockGroups(clientReq, clientRes);
return true; return true;
case "/__ip-rules":
await handleIpRules(clientReq, clientRes);
return true;
case "/__client-ip":
await handleClientIp(clientReq, clientRes);
return true;
case "/__admin": case "/__admin":
await handleAdminPage(clientReq, clientRes); await handleAdminPage(clientReq, clientRes);
return true; return true;

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

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");

116
src/db.ts
View File

@ -13,6 +13,9 @@ import type {
RouteEnabledMap, RouteEnabledMap,
RouteRow, RouteRow,
RouteStatusConfig, RouteStatusConfig,
IpRule,
IpRuleRow,
IpRuleConfig,
} from "./types"; } from "./types";
export function dbRun(sql: string, params: unknown[] = []): Promise<void> { export function dbRun(sql: string, params: unknown[] = []): Promise<void> {
@ -54,11 +57,22 @@ 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> { export async function initDatabase(): Promise<void> {
fs.mkdirSync(MOCK_DIR, { recursive: true }); fs.mkdirSync(MOCK_DIR, { recursive: true });
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(DB_FILE) && fs.existsSync(LEGACY_DB_FILE)) { if (!fs.existsSync(DB_FILE) && fs.existsSync(LEGACY_DB_FILE)) {
fs.copyFileSync(LEGACY_DB_FILE, DB_FILE); fs.copyFileSync(LEGACY_DB_FILE, DB_FILE);
console.log(
`[DB] 已从旧路径迁移数据库: ${LEGACY_DB_FILE} -> ${DB_FILE}`,
);
} }
await openDatabase(); await openDatabase();
await dbRun(` await dbRun(`
@ -129,6 +143,23 @@ export async function initDatabase(): Promise<void> {
value TEXT NOT NULL 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 列表中的系统文件 // 清理不该出现在 mock 列表中的系统文件
await dbRun( await dbRun(
"DELETE FROM mock_files WHERE file_path = ? OR file_path LIKE ?", "DELETE FROM mock_files WHERE file_path = ? OR file_path LIKE ?",
@ -372,3 +403,88 @@ 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);
}
}

222
src/ip-rules.ts Normal file
View File

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

View File

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

View File

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

View File

@ -64,3 +64,32 @@ export interface MockFileRow {
alias: string; alias: string;
group_id: number | null; 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;
}