feat:新增IP个性化配置

This commit is contained in:
dongzp 2026-07-10 11:57:28 +08:00
parent 46bdff3734
commit 0223db88b7
9 changed files with 848 additions and 23 deletions

View File

@ -299,6 +299,72 @@
</el-form>
</el-card>
</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-dialog
@ -554,6 +620,88 @@
<el-button type="danger" @click="submitDeleteGroup">确认删除</el-button>
</span>
</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>
@ -569,6 +717,27 @@
mocksRefreshing: false,
apiRefreshing: 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: [],
apiPagination: { currentPage: 1, pageSize: 10 },
apiDialog: {
@ -675,11 +844,13 @@
else if (tab === "mocks") { this.loadMockFiles(); this.loadMockGroups(); }
else if (tab === "api") { this.loadApiList(); }
else if (tab === "basic") { this.loadConfig(); }
else if (tab === "ip") { this.loadIpRules(); }
},
routeSearch: function () { this.routePagination.currentPage = 1; },
mockFileSearch: function () { this.mockFilePagination.currentPage = 1; },
mockGroupFilter: function () { this.mockFilePagination.currentPage = 1; },
apiSearch: function () { this.apiPagination.currentPage = 1; },
ipRuleSearch: function () { this.ipRulePagination.currentPage = 1; },
},
created: async function () {
await this.loadApiList();
@ -744,6 +915,145 @@
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) {
var text = String(content || "").replace(/\s+/g, " ").trim();
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 () {
return this.getPagedData(this.filteredRoutes, this.routePagination);
},
@ -1449,6 +1770,9 @@
pagedApiList: function () {
return this.getPagedData(this.filteredApiList, this.apiPagination);
},
pagedIpRules: function () {
return this.getPagedData(this.filteredIpRules, this.ipRulePagination);
},
routeDialogMockFiles: function () {
var groupFilter = this.routeDialog.form.groupFilter;
if (groupFilter === null || groupFilter === undefined) {

View File

@ -16,7 +16,11 @@ import {
countMockFilesByGroup,
updateMockFilesGroupId,
deleteMockFilesByGroup,
loadIpRulesFromDb,
upsertIpRuleToDb,
deleteIpRuleFromDb,
} from "./db";
import { isValidIpAddress, getClientIp, resolveEffectiveConfig, getMachineLanIpv4Addresses, formatTargetSummary } from "./ip-rules";
import {
loadMockFiles,
normalizeMockFilePath,
@ -24,6 +28,7 @@ import {
} from "./mock-files";
import { normalizeStatusCode, readBody } from "./utils";
import { state } from "./state";
import type { IpRule, IpRuleConfig } from "./types";
async function handleReloadConfig(
clientReq: http.IncomingMessage,
@ -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(
clientReq: http.IncomingMessage,
clientRes: http.ServerResponse,
@ -604,6 +763,12 @@ export async function dispatchAdmin(
case "/__mock-groups":
await handleMockGroups(clientReq, clientRes);
return true;
case "/__ip-rules":
await handleIpRules(clientReq, clientRes);
return true;
case "/__client-ip":
await handleClientIp(clientReq, clientRes);
return true;
case "/__admin":
await handleAdminPage(clientReq, clientRes);
return true;

View File

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

101
src/db.ts
View File

@ -13,6 +13,9 @@ import type {
RouteEnabledMap,
RouteRow,
RouteStatusConfig,
IpRule,
IpRuleRow,
IpRuleConfig,
} from "./types";
export function dbRun(sql: string, params: unknown[] = []): Promise<void> {
@ -129,6 +132,19 @@ export async function initDatabase(): Promise<void> {
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 列表中的系统文件
await dbRun(
"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
View 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(":");
}

View File

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

View File

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

View File

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

View File

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