Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3790ada0f1 | |||
| b8aee98468 | |||
| 07a6af6fd9 | |||
| 574757f903 | |||
| 9bec3208f8 | |||
| 2b8cb3ca9a | |||
| 0fb285b045 | |||
| 0223db88b7 | |||
| 46bdff3734 | |||
| a23a9846b3 |
731
admin.html
731
admin.html
@ -3,11 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>API Proxy Mock 配置管理</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"
|
||||
/>
|
||||
<title>企业级数据Mock管理系统 - by tony</title>
|
||||
<link rel="stylesheet" href="/assets/element-ui/index.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@ -51,6 +48,18 @@
|
||||
.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>
|
||||
@ -68,6 +77,7 @@
|
||||
<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>
|
||||
@ -82,9 +92,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)">{{ scope.row.filePath }}</a>
|
||||
<a href="javascript:void(0)" style="color:#409EFF;cursor:pointer;" @click="openMockFileFromRoute(scope.row)">{{ getMockFileAlias(scope.row.filePath) }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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-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>
|
||||
@ -182,9 +193,10 @@
|
||||
<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>接口列表</strong>
|
||||
<strong>接口管理(api)</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>
|
||||
@ -219,8 +231,21 @@
|
||||
</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">
|
||||
@ -283,6 +308,81 @@
|
||||
</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>
|
||||
@ -292,7 +392,12 @@
|
||||
:visible.sync="routeDialog.visible"
|
||||
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-select
|
||||
v-model="routeDialog.form.selectedApiRoute"
|
||||
@ -310,21 +415,21 @@
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="接口名称">
|
||||
<el-form-item label="接口名称" prop="apiName">
|
||||
<el-input
|
||||
v-model="routeDialog.form.apiName"
|
||||
placeholder="例如:获取用户信息"
|
||||
:disabled="isApiListLocked()"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="请求路径">
|
||||
<el-form-item label="请求路径" prop="route">
|
||||
<el-input
|
||||
v-model="routeDialog.form.route"
|
||||
placeholder="/api/new/mock"
|
||||
:disabled="isApiListLocked()"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="Mock 文件">
|
||||
<el-form-item label="Mock 文件" prop="filePath">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<el-select
|
||||
v-model="routeDialog.form.groupFilter"
|
||||
@ -351,13 +456,13 @@
|
||||
<el-option
|
||||
v-for="item in routeDialogMockFiles"
|
||||
:key="item.filePath"
|
||||
:label="item.alias ? item.alias + ' (' + item.filePath + ')' : item.filePath"
|
||||
:label="item.alias || item.filePath"
|
||||
:value="item.filePath"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="返回状态码">
|
||||
<el-form-item label="返回状态码" prop="statusCode">
|
||||
<el-select
|
||||
v-model="routeDialog.form.statusCode"
|
||||
filterable
|
||||
@ -375,7 +480,7 @@
|
||||
></el-option>
|
||||
</el-select>
|
||||
</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-form-item>
|
||||
</el-form>
|
||||
@ -390,34 +495,48 @@
|
||||
<el-dialog
|
||||
:title="mockFileDialog.mode === 'edit' ? '修改 Mock 文件' : mockFileDialog.mode === 'copy' ? '复制 Mock 文件' : '新增 Mock 文件'"
|
||||
: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-item label="别名">
|
||||
<el-form
|
||||
ref="mockFileForm"
|
||||
:model="mockFileDialog.form"
|
||||
:rules="mockFileFormRules"
|
||||
label-width="180px"
|
||||
>
|
||||
<el-form-item label="别名" prop="alias">
|
||||
<el-input
|
||||
v-model="mockFileDialog.form.alias"
|
||||
placeholder="可选:用于展示,支持任意文本"
|
||||
placeholder="必填:用于展示,支持任意文本"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="mockFileDialog.form.groupId" clearable placeholder="选择分组" style="width: 100%;">
|
||||
<el-form-item label="分组" prop="groupId" required>
|
||||
<el-select v-model="mockFileDialog.form.groupId" 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 文件路径">
|
||||
<el-form-item label="Mock 文件路径" prop="fileName">
|
||||
<el-input
|
||||
v-model="mockFileDialog.form.fileName"
|
||||
placeholder="请输入文件名,如 test123.json"
|
||||
:disabled="mockFileDialog.mode === 'edit'"
|
||||
:placeholder="mockFileDialog.mode === 'edit' ? '' : '根据别名自动生成:别名_随机串.json'"
|
||||
disabled
|
||||
>
|
||||
<template slot="prepend">mock/</template>
|
||||
</el-input>
|
||||
</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
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
class="mock-content-textarea"
|
||||
:autosize="mockFileDialog.fullscreen ? { minRows: 28, maxRows: 28 } : { minRows: 12, maxRows: 12 }"
|
||||
v-model="mockFileDialog.form.content"
|
||||
placeholder='{"code":0,"message":"ok"}'
|
||||
></el-input>
|
||||
@ -532,17 +651,132 @@
|
||||
<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="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
|
||||
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
|
||||
<script src="/assets/vue.js"></script>
|
||||
<script src="/assets/element-ui/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: {
|
||||
@ -633,6 +867,7 @@
|
||||
},
|
||||
mockFileDialog: {
|
||||
visible: false,
|
||||
fullscreen: false,
|
||||
mode: "create",
|
||||
form: {
|
||||
fileName: "",
|
||||
@ -640,21 +875,341 @@
|
||||
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 "(空文件)";
|
||||
@ -793,6 +1348,9 @@
|
||||
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 =
|
||||
@ -816,10 +1374,24 @@
|
||||
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: "",
|
||||
fileName: this.generateMockFileName(""),
|
||||
alias: "",
|
||||
content: "",
|
||||
groupId: null,
|
||||
@ -827,13 +1399,18 @@
|
||||
},
|
||||
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 || ""),
|
||||
@ -841,6 +1418,9 @@
|
||||
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 || "";
|
||||
@ -851,15 +1431,28 @@
|
||||
}
|
||||
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: "",
|
||||
alias: String(item.alias || "") + "-副本",
|
||||
fileName: this.generateMockFileName(alias),
|
||||
alias: 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 {
|
||||
@ -875,7 +1468,19 @@
|
||||
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();
|
||||
if (!fileName) {
|
||||
this.$message.error("文件名不能为空");
|
||||
@ -954,13 +1559,11 @@
|
||||
var resp = await fetch("/__config");
|
||||
var data = await resp.json();
|
||||
this.form.config = Object.assign({}, this.form.config, data.config || {});
|
||||
this.form.routes = this.toRouteArray(
|
||||
data.routes || {},
|
||||
data.routeStatuses || {},
|
||||
data.routeEnabledMap || {},
|
||||
data.routeApiNameMap || {},
|
||||
);
|
||||
this.routePagination.currentPage = 1;
|
||||
this.canEditBasicConfig = data.canEditBasicConfig === true;
|
||||
this.clientIp = data.clientIp || "";
|
||||
if (data.canEditBasicConfig === true) {
|
||||
this.isAdmin = true;
|
||||
}
|
||||
} catch (err) {
|
||||
this.$message.error("加载配置失败: " + err.message);
|
||||
}
|
||||
@ -987,6 +1590,9 @@
|
||||
}
|
||||
},
|
||||
saveServerConfig: async function () {
|
||||
if (!this.canEditBasicConfig) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var resp = await fetch("/__config", {
|
||||
method: "POST",
|
||||
@ -1001,15 +1607,14 @@
|
||||
this.$message.error("保存配置失败: " + err.message);
|
||||
}
|
||||
},
|
||||
submitRouteDialog: async function () {
|
||||
if (!this.routeDialog.form.route) {
|
||||
this.$message.error("请求路径不能为空");
|
||||
return;
|
||||
}
|
||||
if (!this.routeDialog.form.filePath) {
|
||||
this.$message.error("请选择 Mock 文件");
|
||||
return;
|
||||
}
|
||||
submitRouteDialog: function () {
|
||||
var self = this;
|
||||
this.$refs.routeForm.validate(function (valid) {
|
||||
if (!valid) return;
|
||||
self.doSubmitRouteDialog();
|
||||
});
|
||||
},
|
||||
doSubmitRouteDialog: async function () {
|
||||
var payload = Object.assign({}, this.routeDialog.form, {
|
||||
useExistingFile: true,
|
||||
statusCode: this.normalizeStatusCode(this.routeDialog.form.statusCode),
|
||||
@ -1031,7 +1636,7 @@
|
||||
);
|
||||
this.routeDialog.visible = false;
|
||||
await this.loadMockFiles();
|
||||
await this.loadConfig();
|
||||
await this.loadRoutes();
|
||||
} catch (err) {
|
||||
this.$message.error("创建失败: " + err.message);
|
||||
}
|
||||
@ -1048,7 +1653,8 @@
|
||||
openApiDialogForEdit: function (index) {
|
||||
var actualIndex =
|
||||
(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.editingIndex = actualIndex;
|
||||
this.apiDialog.form = {
|
||||
@ -1087,7 +1693,8 @@
|
||||
removeApiItem: async function (index) {
|
||||
var actualIndex =
|
||||
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
|
||||
var item = this.apiList[actualIndex];
|
||||
var item = this.filteredApiList[actualIndex];
|
||||
if (!item) return;
|
||||
try {
|
||||
await this.$confirm("确定删除接口「" + item.name + "」?", "提示", {
|
||||
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 () {
|
||||
return this.getPagedData(this.filteredRoutes, this.routePagination);
|
||||
},
|
||||
@ -1364,6 +1982,15 @@
|
||||
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) {
|
||||
|
||||
2
admin_list.txt
Normal file
2
admin_list.txt
Normal file
@ -0,0 +1,2 @@
|
||||
192.168.3.9
|
||||
192.168.3.12
|
||||
BIN
assets/element-ui/fonts/element-icons.ttf
Normal file
BIN
assets/element-ui/fonts/element-icons.ttf
Normal file
Binary file not shown.
BIN
assets/element-ui/fonts/element-icons.woff
Normal file
BIN
assets/element-ui/fonts/element-icons.woff
Normal file
Binary file not shown.
1
assets/element-ui/index.css
Normal file
1
assets/element-ui/index.css
Normal file
File diff suppressed because one or more lines are too long
1
assets/element-ui/index.js
Normal file
1
assets/element-ui/index.js
Normal file
File diff suppressed because one or more lines are too long
11932
assets/vue.js
Normal file
11932
assets/vue.js
Normal file
File diff suppressed because it is too large
Load Diff
1
mock/你好33333333333_wr0mdksb.json
Normal file
1
mock/你好33333333333_wr0mdksb.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
@ -16,7 +16,12 @@ 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,
|
||||
@ -24,6 +29,7 @@ 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,
|
||||
@ -95,8 +101,18 @@ 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);
|
||||
}
|
||||
// 保存路由配置
|
||||
@ -135,13 +151,10 @@ 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,
|
||||
@ -162,16 +175,31 @@ async function handleRoutes(
|
||||
clientReq: http.IncomingMessage,
|
||||
clientRes: http.ServerResponse,
|
||||
): Promise<void> {
|
||||
if (clientReq.method !== "POST") {
|
||||
if (clientReq.method !== "GET" && clientReq.method !== "POST") {
|
||||
clientRes.writeHead(405, {
|
||||
"Content-Type": "application/json",
|
||||
Allow: "POST",
|
||||
Allow: "GET, POST",
|
||||
});
|
||||
clientRes.end(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
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;
|
||||
@ -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(
|
||||
clientReq: http.IncomingMessage,
|
||||
clientRes: http.ServerResponse,
|
||||
@ -575,6 +850,11 @@ 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);
|
||||
@ -594,6 +874,12 @@ 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;
|
||||
|
||||
45
src/admin-list.ts
Normal file
45
src/admin-list.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import * as fs from "fs";
|
||||
import * as http from "http";
|
||||
import { ADMIN_LIST_FILE } from "./constants";
|
||||
import { resolveClientIp, normalizeIp } from "./ip-rules";
|
||||
import { state } from "./state";
|
||||
|
||||
export function loadAdminList(): string[] {
|
||||
if (!fs.existsSync(ADMIN_LIST_FILE)) {
|
||||
return [];
|
||||
}
|
||||
const content = fs.readFileSync(ADMIN_LIST_FILE, "utf-8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
.map(normalizeIp);
|
||||
}
|
||||
|
||||
/** 客户端 IP 是否在 admin_list.txt 白名单内(localhost 会映射为本机 LAN IP) */
|
||||
export function isAdminClient(req: http.IncomingMessage): boolean {
|
||||
const adminList = loadAdminList();
|
||||
if (adminList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const whitelist = new Set(adminList.map(normalizeIp));
|
||||
const clientIp = resolveClientIp(req, adminList);
|
||||
return whitelist.has(clientIp);
|
||||
}
|
||||
|
||||
export function resolveAdminClientIp(req: http.IncomingMessage): string {
|
||||
const adminList = loadAdminList();
|
||||
const hintIps = [...adminList, ...state.ipRules.map((rule) => rule.ip)];
|
||||
return resolveClientIp(req, hintIps);
|
||||
}
|
||||
|
||||
/** 是否可管理指定 IP 的规则(管理员可操作全部,普通用户仅可操作自身 IP) */
|
||||
export function canManageIpRule(
|
||||
req: http.IncomingMessage,
|
||||
ruleIp: string,
|
||||
): boolean {
|
||||
if (isAdminClient(req)) {
|
||||
return true;
|
||||
}
|
||||
return normalizeIp(ruleIp) === normalizeIp(resolveAdminClientIp(req));
|
||||
}
|
||||
@ -5,6 +5,7 @@ import {
|
||||
loadRoutesFromDb,
|
||||
loadServerConfig,
|
||||
saveServerConfig,
|
||||
loadIpRulesFromDb,
|
||||
} from "./db";
|
||||
import { getActiveRoutes } from "./route-matching";
|
||||
import { state } from "./state";
|
||||
@ -50,6 +51,8 @@ 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;
|
||||
@ -64,6 +67,7 @@ export async function loadConfig(): Promise<void> {
|
||||
state.rawRouteStatuses = {};
|
||||
state.routeEnabledMap = {};
|
||||
state.routeApiNameMap = {};
|
||||
state.ipRules = [];
|
||||
state.config = {
|
||||
cacheConfig: true,
|
||||
reloadOnChange: true,
|
||||
|
||||
@ -5,3 +5,4 @@ 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
116
src/db.ts
@ -13,6 +13,9 @@ import type {
|
||||
RouteEnabledMap,
|
||||
RouteRow,
|
||||
RouteStatusConfig,
|
||||
IpRule,
|
||||
IpRuleRow,
|
||||
IpRuleConfig,
|
||||
} from "./types";
|
||||
|
||||
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> {
|
||||
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(`
|
||||
@ -129,6 +143,23 @@ 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 ?",
|
||||
@ -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
222
src/ip-rules.ts
Normal 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(":");
|
||||
}
|
||||
74
src/proxy.ts
74
src/proxy.ts
@ -3,32 +3,50 @@ 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(): boolean {
|
||||
return state.config.targetHttps !== false;
|
||||
function isTargetHttps(config: AppConfig): boolean {
|
||||
return config.targetHttps !== false;
|
||||
}
|
||||
|
||||
function getTargetPort(): number {
|
||||
if (state.config.targetPort != null) return state.config.targetPort;
|
||||
return isTargetHttps() ? 443 : 80;
|
||||
function getTargetPort(config: AppConfig): number {
|
||||
if (config.targetPort != null) return config.targetPort;
|
||||
return isTargetHttps(config) ? 443 : 80;
|
||||
}
|
||||
|
||||
function upstreamRequest(
|
||||
options: http.RequestOptions,
|
||||
useHttps: boolean,
|
||||
callback: (proxyRes: http.IncomingMessage) => void,
|
||||
): http.ClientRequest {
|
||||
return isTargetHttps()
|
||||
return useHttps
|
||||
? 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!}`);
|
||||
@ -38,11 +56,22 @@ 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)) {
|
||||
if (isMockRoute(requestPath, effectiveConfig)) {
|
||||
const mockFile = getMockFilePath(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 {
|
||||
const mockFilePath = path.join(__dirname, "..", mockFile);
|
||||
@ -53,19 +82,20 @@ export function createProxyServer(): http.Server {
|
||||
`[MOCK] Mock文件不存在,回源并自动生成: ${mockFilePath}`,
|
||||
);
|
||||
|
||||
const targetPort = getTargetPort();
|
||||
const targetPort = getTargetPort(effectiveConfig);
|
||||
const useHttps = isTargetHttps(effectiveConfig);
|
||||
const options: http.RequestOptions = {
|
||||
hostname: state.config.targetHost,
|
||||
hostname: effectiveConfig.targetHost,
|
||||
port: targetPort,
|
||||
method: clientReq.method,
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
headers: {
|
||||
...clientReq.headers,
|
||||
host: state.config.targetHost,
|
||||
host: effectiveConfig.targetHost,
|
||||
},
|
||||
};
|
||||
|
||||
const proxyReq = upstreamRequest(options, (proxyRes) => {
|
||||
const proxyReq = upstreamRequest(options, useHttps, (proxyRes) => {
|
||||
const chunks: Buffer[] = [];
|
||||
proxyRes.on("data", (chunk: Buffer) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
@ -134,7 +164,7 @@ export function createProxyServer(): http.Server {
|
||||
|
||||
// 设置响应头
|
||||
const contentType =
|
||||
state.config.defaultContentType || "application/json";
|
||||
effectiveConfig.defaultContentType || "application/json";
|
||||
clientRes.writeHead(mockStatusCode, {
|
||||
"Content-Type": contentType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@ -165,22 +195,26 @@ export function createProxyServer(): http.Server {
|
||||
}
|
||||
|
||||
// 如果不是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 = {
|
||||
hostname: state.config.targetHost,
|
||||
hostname: effectiveConfig.targetHost,
|
||||
port: targetPort,
|
||||
method: clientReq.method,
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
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);
|
||||
proxyRes.pipe(clientRes);
|
||||
});
|
||||
@ -203,9 +237,9 @@ export function createProxyServer(): http.Server {
|
||||
|
||||
export function getEffectiveTargetPort(): number {
|
||||
if (state.config.targetPort != null) return state.config.targetPort;
|
||||
return isTargetHttps() ? 443 : 80;
|
||||
return state.config.targetHttps !== false ? 443 : 80;
|
||||
}
|
||||
|
||||
export function getEffectiveTargetHttps(): boolean {
|
||||
return isTargetHttps();
|
||||
return state.config.targetHttps !== false;
|
||||
}
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
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): boolean {
|
||||
if (state.config.mockEnabled === false) return false;
|
||||
export function isMockRoute(
|
||||
requestPath: string,
|
||||
config: AppConfig = state.config,
|
||||
): boolean {
|
||||
if (!isMockEnabled(config)) return false;
|
||||
if (!state.rawRoutes.hasOwnProperty(requestPath)) return false;
|
||||
return state.routeEnabledMap[requestPath] !== false;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
rawRoutes: {} as RouteConfig,
|
||||
@ -15,4 +15,5 @@ export const state = {
|
||||
targetPort: 443,
|
||||
} as AppConfig,
|
||||
db: null as unknown as sqlite3.Database,
|
||||
ipRules: [] as IpRule[],
|
||||
};
|
||||
|
||||
29
src/types.ts
29
src/types.ts
@ -64,3 +64,32 @@ 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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user