diff --git a/admin.html b/admin.html index 0e90fb3..17b538e 100644 --- a/admin.html +++ b/admin.html @@ -299,6 +299,72 @@ + + + +
+
+ IP 访问策略 +
+ 按客户端 IP 拉黑或配置独立目标环境(不含本地代理端口),便于开发/测试分流。 + 通过 localhost 访问时会自动匹配本机唯一 LAN IP 规则。 + 查看当前请求识别到的 IP +
+
+
+ + 刷新 + 新增 IP 规则 +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
确认删除 + + + + + + + + + + + +
开启后该 IP 的所有请求将被拒绝(403)
+
+ +
+ + 取消 + 保存 + +
@@ -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) { diff --git a/src/admin-handlers.ts b/src/admin-handlers.ts index 01722c6..89e1737 100644 --- a/src/admin-handlers.ts +++ b/src/admin-handlers.ts @@ -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): 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 { + 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 { + 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; diff --git a/src/config.ts b/src/config.ts index b220ebc..0309e64 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { 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 { state.rawRouteStatuses = {}; state.routeEnabledMap = {}; state.routeApiNameMap = {}; + state.ipRules = []; state.config = { cacheConfig: true, reloadOnChange: true, diff --git a/src/db.ts b/src/db.ts index 74d44e2..7e8f633 100644 --- a/src/db.ts +++ b/src/db.ts @@ -13,6 +13,9 @@ import type { RouteEnabledMap, RouteRow, RouteStatusConfig, + IpRule, + IpRuleRow, + IpRuleConfig, } from "./types"; export function dbRun(sql: string, params: unknown[] = []): Promise { @@ -129,6 +132,19 @@ export async function initDatabase(): Promise { 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 { ]); } } + +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 { + const rows = await dbAll( + "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 { + 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 { + await dbRun("DELETE FROM ip_rules WHERE ip = ?", [ip]); +} + +export async function saveAllIpRulesToDb(rules: IpRule[]): Promise { + await dbRun("DELETE FROM ip_rules"); + for (const rule of rules) { + await upsertIpRuleToDb(rule); + } +} diff --git a/src/ip-rules.ts b/src/ip-rules.ts new file mode 100644 index 0000000..a2280af --- /dev/null +++ b/src/ip-rules.ts @@ -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(); + 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(":"); +} diff --git a/src/proxy.ts b/src/proxy.ts index 5eaa4fe..af88287 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,32 +3,50 @@ import * as http from "http"; import * as https from "https"; import * as path from "path"; import { dispatchAdmin } from "./admin-handlers"; +import { resolveEffectiveConfig, formatTargetSummary } from "./ip-rules"; import { getMockFilePath, getMockStatusCode, isMockRoute, } from "./route-matching"; import { state } from "./state"; +import type { AppConfig } from "./types"; import { decodeBodyByEncoding } from "./utils"; -function isTargetHttps(): boolean { - return state.config.targetHttps !== false; +function isTargetHttps(config: AppConfig): boolean { + return config.targetHttps !== false; } -function getTargetPort(): number { - if (state.config.targetPort != null) return state.config.targetPort; - return isTargetHttps() ? 443 : 80; +function getTargetPort(config: AppConfig): number { + if (config.targetPort != null) return config.targetPort; + return isTargetHttps(config) ? 443 : 80; } function upstreamRequest( options: http.RequestOptions, + useHttps: boolean, callback: (proxyRes: http.IncomingMessage) => void, ): http.ClientRequest { - return isTargetHttps() + return useHttps ? https.request(options, callback) : http.request(options, callback); } +function handleBlockedIp( + clientRes: http.ServerResponse, + ip: string, +): void { + clientRes.writeHead(403, { "Content-Type": "application/json" }); + clientRes.end( + JSON.stringify({ + error: "Access denied", + message: "Your IP address is blocked", + ip, + timestamp: new Date().toISOString(), + }), + ); +} + export function createProxyServer(): http.Server { return http.createServer(async (clientReq, clientRes) => { const parsedUrl = new URL(`http://localhost${clientReq.url!}`); @@ -38,11 +56,22 @@ export function createProxyServer(): http.Server { const handled = await dispatchAdmin(requestPath, clientReq, clientRes); if (handled) return; + const effective = resolveEffectiveConfig(clientReq); + if (effective.blocked) { + console.log(`[IP] 拒绝访问: ${effective.ip}`); + handleBlockedIp(clientRes, effective.ip); + return; + } + + const effectiveConfig = effective.config; + // 检查是否为需要mock的路由 - if (isMockRoute(requestPath)) { + if (isMockRoute(requestPath, effectiveConfig)) { const mockFile = getMockFilePath(requestPath); const mockStatusCode = getMockStatusCode(requestPath); - console.log(`[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile}`); + console.log( + `[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile} | 客户端IP: ${effective.ip}${effective.matchedRule ? ` | 规则: ${effective.matchedRule.ip}` : ""}${effective.ruleMatchedViaLocalhost ? " (localhost回退)" : ""}`, + ); try { const mockFilePath = path.join(__dirname, "..", mockFile); @@ -53,19 +82,20 @@ export function createProxyServer(): http.Server { `[MOCK] Mock文件不存在,回源并自动生成: ${mockFilePath}`, ); - const targetPort = getTargetPort(); + const targetPort = getTargetPort(effectiveConfig); + const useHttps = isTargetHttps(effectiveConfig); const options: http.RequestOptions = { - hostname: state.config.targetHost, + hostname: effectiveConfig.targetHost, port: targetPort, method: clientReq.method, path: parsedUrl.pathname + parsedUrl.search, headers: { ...clientReq.headers, - host: state.config.targetHost, + host: effectiveConfig.targetHost, }, }; - const proxyReq = upstreamRequest(options, (proxyRes) => { + const proxyReq = upstreamRequest(options, useHttps, (proxyRes) => { const chunks: Buffer[] = []; proxyRes.on("data", (chunk: Buffer) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); @@ -134,7 +164,7 @@ export function createProxyServer(): http.Server { // 设置响应头 const contentType = - state.config.defaultContentType || "application/json"; + effectiveConfig.defaultContentType || "application/json"; clientRes.writeHead(mockStatusCode, { "Content-Type": contentType, "Access-Control-Allow-Origin": "*", @@ -165,22 +195,26 @@ export function createProxyServer(): http.Server { } // 如果不是mock路由,正常代理转发 - console.log(`[PROXY] 转发请求: ${requestPath}`); + const targetSummary = formatTargetSummary(effectiveConfig); + console.log( + `[PROXY] 转发请求: ${requestPath} | 客户端IP: ${effective.ip} -> ${targetSummary}${effective.matchedRule ? ` | 规则: ${effective.matchedRule.ip}${effective.matchedRule.remark ? "/" + effective.matchedRule.remark : ""}` : " | 全局配置"}${effective.ruleMatchedViaLocalhost ? " (localhost回退)" : ""}`, + ); - const targetPort = getTargetPort(); + const targetPort = getTargetPort(effectiveConfig); + const useHttps = isTargetHttps(effectiveConfig); const options: http.RequestOptions = { - hostname: state.config.targetHost, + hostname: effectiveConfig.targetHost, port: targetPort, method: clientReq.method, path: parsedUrl.pathname + parsedUrl.search, headers: { ...clientReq.headers, - host: state.config.targetHost, + host: effectiveConfig.targetHost, }, }; - const proxyReq = upstreamRequest(options, (proxyRes) => { + const proxyReq = upstreamRequest(options, useHttps, (proxyRes) => { clientRes.writeHead(proxyRes.statusCode!, proxyRes.headers); proxyRes.pipe(clientRes); }); @@ -203,9 +237,9 @@ export function createProxyServer(): http.Server { export function getEffectiveTargetPort(): number { if (state.config.targetPort != null) return state.config.targetPort; - return isTargetHttps() ? 443 : 80; + return state.config.targetHttps !== false ? 443 : 80; } export function getEffectiveTargetHttps(): boolean { - return isTargetHttps(); + return state.config.targetHttps !== false; } diff --git a/src/route-matching.ts b/src/route-matching.ts index 4dc9182..0972db2 100644 --- a/src/route-matching.ts +++ b/src/route-matching.ts @@ -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; } diff --git a/src/state.ts b/src/state.ts index ca72799..e8ee1f1 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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[], }; diff --git a/src/types.ts b/src/types.ts index b136eb2..bae8597 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,3 +64,32 @@ export interface MockFileRow { alias: string; group_id: number | null; } + +/** 单 IP 可覆盖的服务端配置(不含 proxyPort) */ +export interface IpRuleConfig { + mockEnabled?: boolean; + defaultContentType?: string; + targetHost?: string; + targetPort?: number; + targetHttps?: boolean; +} + +export interface IpRule { + ip: string; + blocked: boolean; + remark: string; + useCustomConfig: boolean; + config: IpRuleConfig; +} + +export interface IpRuleRow { + ip: string; + blocked: number; + remark: string; + use_custom_config: number; + mock_enabled: number | null; + default_content_type: string | null; + target_host: string | null; + target_port: number | null; + target_https: number | null; +}