feat:新增IP个性化配置
This commit is contained in:
parent
46bdff3734
commit
0223db88b7
324
admin.html
324
admin.html
@ -299,6 +299,72 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="IP管理" name="ip">
|
||||||
|
<el-card class="section-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
|
<div>
|
||||||
|
<strong>IP 访问策略</strong>
|
||||||
|
<div class="small-text" style="margin-top:4px;">
|
||||||
|
按客户端 IP 拉黑或配置独立目标环境(不含本地代理端口),便于开发/测试分流。
|
||||||
|
通过 localhost 访问时会自动匹配本机唯一 LAN IP 规则。
|
||||||
|
<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 size="mini" type="primary" @click="openIpRuleDialogForCreate">新增 IP 规则</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">
|
||||||
|
<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>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
class="table-pagination"
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:current-page="ipRulePagination.currentPage"
|
||||||
|
:page-size="ipRulePagination.pageSize"
|
||||||
|
:total="filteredIpRules.length"
|
||||||
|
@current-change="handleIpRulePageChange"
|
||||||
|
></el-pagination>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@ -554,6 +620,88 @@
|
|||||||
<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' ? '修改 IP 规则' : '新增 IP 规则'"
|
||||||
|
: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="如 192.168.1.100"
|
||||||
|
:disabled="ipRuleDialog.mode === 'edit'"
|
||||||
|
></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="ipRuleDialog.form.remark" placeholder="如:测试组、开发组"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item 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>
|
||||||
|
|
||||||
@ -569,6 +717,27 @@
|
|||||||
mocksRefreshing: false,
|
mocksRefreshing: false,
|
||||||
apiRefreshing: false,
|
apiRefreshing: false,
|
||||||
configRefreshing: false,
|
configRefreshing: 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: {
|
||||||
@ -675,11 +844,13 @@
|
|||||||
else if (tab === "mocks") { this.loadMockFiles(); this.loadMockGroups(); }
|
else if (tab === "mocks") { this.loadMockFiles(); this.loadMockGroups(); }
|
||||||
else if (tab === "api") { this.loadApiList(); }
|
else if (tab === "api") { this.loadApiList(); }
|
||||||
else if (tab === "basic") { this.loadConfig(); }
|
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; },
|
||||||
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();
|
||||||
@ -744,6 +915,145 @@
|
|||||||
this.configRefreshing = false;
|
this.configRefreshing = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
refreshIpRules: async function () {
|
||||||
|
this.ipRulesRefreshing = true;
|
||||||
|
try {
|
||||||
|
await this.loadIpRules();
|
||||||
|
this.$message.success("IP 规则已刷新");
|
||||||
|
} 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 : [];
|
||||||
|
this.ipRulePagination.currentPage = 1;
|
||||||
|
} catch (err) {
|
||||||
|
this.ipRules = [];
|
||||||
|
this.$message.error("加载 IP 规则失败: " + err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getDefaultIpRuleForm: function () {
|
||||||
|
var global = this.form.config || {};
|
||||||
|
return {
|
||||||
|
ip: "",
|
||||||
|
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: function () {
|
||||||
|
this.ipRuleDialog.mode = "create";
|
||||||
|
this.ipRuleDialog.form = this.getDefaultIpRuleForm();
|
||||||
|
this.ipRuleDialog.visible = true;
|
||||||
|
},
|
||||||
|
openIpRuleDialogForEdit: function (rule) {
|
||||||
|
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 (!ip) {
|
||||||
|
this.$message.error("IP 地址不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.blocked && !form.useCustomConfig) {
|
||||||
|
this.$message.error("请至少启用「拉黑访问」或「个性化配置」");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var resp = await fetch("/__ip-rules", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
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) {
|
||||||
|
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 "(空文件)";
|
||||||
@ -1440,6 +1750,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);
|
||||||
},
|
},
|
||||||
@ -1449,6 +1770,9 @@
|
|||||||
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);
|
||||||
|
},
|
||||||
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) {
|
||||||
|
|||||||
@ -16,7 +16,11 @@ import {
|
|||||||
countMockFilesByGroup,
|
countMockFilesByGroup,
|
||||||
updateMockFilesGroupId,
|
updateMockFilesGroupId,
|
||||||
deleteMockFilesByGroup,
|
deleteMockFilesByGroup,
|
||||||
|
loadIpRulesFromDb,
|
||||||
|
upsertIpRuleToDb,
|
||||||
|
deleteIpRuleFromDb,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
|
import { isValidIpAddress, getClientIp, resolveEffectiveConfig, getMachineLanIpv4Addresses, formatTargetSummary } from "./ip-rules";
|
||||||
import {
|
import {
|
||||||
loadMockFiles,
|
loadMockFiles,
|
||||||
normalizeMockFilePath,
|
normalizeMockFilePath,
|
||||||
@ -24,6 +28,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,
|
||||||
@ -557,6 +562,160 @@ 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") {
|
||||||
|
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
clientRes.end(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
list: state.ipRules,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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 不能为空");
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAdminPage(
|
async function handleAdminPage(
|
||||||
clientReq: http.IncomingMessage,
|
clientReq: http.IncomingMessage,
|
||||||
clientRes: http.ServerResponse,
|
clientRes: http.ServerResponse,
|
||||||
@ -604,6 +763,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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
101
src/db.ts
101
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> {
|
||||||
@ -129,6 +132,19 @@ export async function initDatabase(): Promise<void> {
|
|||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`);
|
||||||
// 清理不该出现在 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 +388,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
159
src/ip-rules.ts
Normal file
159
src/ip-rules.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import * as http from "http";
|
||||||
|
import * as os from "os";
|
||||||
|
import { state } from "./state";
|
||||||
|
import type { AppConfig, IpRule, IpRuleConfig } from "./types";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机非回环 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findIpRule(clientIp: string): IpRule | undefined {
|
||||||
|
const normalized = normalizeIp(clientIp);
|
||||||
|
const direct = state.ipRules.find(
|
||||||
|
(rule) => normalizeIp(rule.ip) === normalized,
|
||||||
|
);
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本机通过 localhost 访问时,remoteAddress 为 127.0.0.1,与 LAN IP 规则不匹配
|
||||||
|
if (normalized === "127.0.0.1") {
|
||||||
|
const lanIps = new Set(getMachineLanIpv4Addresses());
|
||||||
|
const localRules = state.ipRules.filter((rule) =>
|
||||||
|
lanIps.has(normalizeIp(rule.ip)),
|
||||||
|
);
|
||||||
|
if (localRules.length === 1) {
|
||||||
|
return localRules[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ip = getClientIp(req);
|
||||||
|
const rule = findIpRule(ip);
|
||||||
|
const ruleMatchedViaLocalhost =
|
||||||
|
ip === "127.0.0.1" && !!rule && normalizeIp(rule.ip) !== ip;
|
||||||
|
|
||||||
|
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