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>
|
<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
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,
|
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
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,
|
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,
|
||||||
|
|||||||
@ -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
116
src/db.ts
@ -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
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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[],
|
||||||
};
|
};
|
||||||
|
|||||||
29
src/types.ts
29
src/types.ts
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user