From e5cbe80644b8441d840e70a0d7e2179e6a8556a1 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 23 Sep 2026 10:07:32 +0800 Subject: [PATCH] feat(wechat): widen 105 egress proxy to /cgi-bin/* and add egress fetch helper The proxy now forwards any api.weixin.qq.com/cgi-bin/* path (drafts, material upload, etc.), binds to the 10.10.0.1 tunnel address, and uses a 120s timeout. wechat-egress-fetch.mjs rewrites WeChat API URLs to the egress base when MEMIND_WECHAT_EGRESS_BASE_URL is set. Co-authored-by: Cursor --- ops/wechat-egress-proxy-105.service | 2 +- scripts/wechat-egress-proxy-105.py | 32 +++++++++---------- wechat-egress-fetch.mjs | 49 +++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 wechat-egress-fetch.mjs diff --git a/ops/wechat-egress-proxy-105.service b/ops/wechat-egress-proxy-105.service index b2f751a..75df033 100644 --- a/ops/wechat-egress-proxy-105.service +++ b/ops/wechat-egress-proxy-105.service @@ -5,7 +5,7 @@ Wants=network-online.target [Service] Type=simple -Environment=WECHAT_EGRESS_HOST=127.0.0.1 +Environment=WECHAT_EGRESS_HOST=10.10.0.1 Environment=WECHAT_EGRESS_PORT=19090 ExecStart=/usr/bin/python3 /root/wechat_egress_proxy.py Restart=always diff --git a/scripts/wechat-egress-proxy-105.py b/scripts/wechat-egress-proxy-105.py index b5068c3..67c39ec 100755 --- a/scripts/wechat-egress-proxy-105.py +++ b/scripts/wechat-egress-proxy-105.py @@ -1,23 +1,18 @@ #!/usr/bin/env python3 +"""105 固定 IP 出站:仅转发 api.weixin.qq.com/cgi-bin/* 到微信官方 API。""" from http.server import BaseHTTPRequestHandler, HTTPServer import os import urllib.error import urllib.parse import urllib.request - -ALLOWED_PATHS = { - "/cgi-bin/stable_token", - "/cgi-bin/message/custom/send", - "/cgi-bin/menu/create", - "/cgi-bin/menu/get", - "/cgi-bin/ticket/getticket", -} +ALLOWED_PREFIX = "/cgi-bin/" +TIMEOUT_SEC = 120 class Handler(BaseHTTPRequestHandler): def do_GET(self): - if self.path == "/healthz": + if self.path == "/healthz" or self.path.startswith("/healthz?"): self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() @@ -30,29 +25,32 @@ class Handler(BaseHTTPRequestHandler): def _proxy(self, method): parsed = urllib.parse.urlsplit(self.path) - if parsed.path not in ALLOWED_PATHS: + if not parsed.path.startswith(ALLOWED_PREFIX): self.send_error(403) return length = int(self.headers.get("Content-Length", "0")) if method == "POST" else 0 body = self.rfile.read(length) if length else None url = "https://api.weixin.qq.com" + self.path - headers = { - "Content-Type": self.headers.get("Content-Type", "application/json"), - } + headers = {} + content_type = self.headers.get("Content-Type") + if content_type: + headers["Content-Type"] = content_type req = urllib.request.Request(url, data=body, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=20) as resp: + with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp: payload = resp.read() self.send_response(resp.status) - self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json")) + out_type = resp.headers.get("Content-Type", "application/json") + self.send_header("Content-Type", out_type) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) except urllib.error.HTTPError as err: payload = err.read() self.send_response(err.code) - self.send_header("Content-Type", err.headers.get("Content-Type", "application/json")) + out_type = err.headers.get("Content-Type", "application/json") + self.send_header("Content-Type", out_type) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) @@ -62,6 +60,6 @@ class Handler(BaseHTTPRequestHandler): if __name__ == "__main__": - host = os.environ.get("WECHAT_EGRESS_HOST", "127.0.0.1") + host = os.environ.get("WECHAT_EGRESS_HOST", "10.10.0.1") port = int(os.environ.get("WECHAT_EGRESS_PORT", "19090")) HTTPServer((host, port), Handler).serve_forever() diff --git a/wechat-egress-fetch.mjs b/wechat-egress-fetch.mjs new file mode 100644 index 0000000..591a1d6 --- /dev/null +++ b/wechat-egress-fetch.mjs @@ -0,0 +1,49 @@ +import { fetch as undiciFetch } from 'undici'; + +const WECHAT_API_HOST = 'api.weixin.qq.com'; + +/** + * 105 固定 IP 出站代理基址,例如 http://10.10.0.1:19090 + * 未配置时直连 api.weixin.qq.com(开发环境)。 + */ +export function resolveWechatEgressBaseUrl(env = process.env) { + return String( + env.MEMIND_WECHAT_EGRESS_BASE_URL + ?? env.WECHAT_EGRESS_BASE_URL + ?? '', + ).trim().replace(/\/$/, ''); +} + +/** 把微信 API URL 改写到 105 egress proxy。 */ +export function rewriteWechatApiUrl(url, egressBase) { + const raw = String(url ?? '').trim(); + if (!raw || !egressBase) return raw; + let parsed; + try { + parsed = new URL(raw); + } catch { + return raw; + } + if (parsed.hostname !== WECHAT_API_HOST) return raw; + return `${egressBase}${parsed.pathname}${parsed.search}`; +} + +/** + * 返回 wechatFetch:请求 api.weixin.qq.com 时改走 egress proxy,其余 URL 不变。 + */ +export function createWechatEgressFetch(egressBase, { fetchImpl = undiciFetch } = {}) { + const base = String(egressBase ?? '').trim().replace(/\/$/, ''); + if (!base) return fetchImpl; + return async (input, init) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + return fetchImpl(rewriteWechatApiUrl(url, base), init); + }; +} + +export function resolveWechatFetch(env = process.env) { + return createWechatEgressFetch(resolveWechatEgressBaseUrl(env)); +}