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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user