From a9f03f1a0845cc196f3d0d2fb0fa72e2a9780493 Mon Sep 17 00:00:00 2001 From: Yin Kou Date: Fri, 6 Nov 2026 22:32:08 +0800 Subject: [PATCH] feat(wechat): deploy WeChat Work bidirectional gateway at https://wx.961504.xyz and lock qyapi to SG Azure --- CHANGELOG.md | 12 +- PLAN.md | 7 +- configs/router-shellcrash-config.yaml | 1 + stacks/azure-edge/Caddyfile | 6 + stacks/wechat-bot/config.env | 10 ++ stacks/wechat-bot/server.py | 247 ++++++++++++++++++++++++++ 6 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 stacks/wechat-bot/config.env create mode 100755 stacks/wechat-bot/server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be33a7e..7b572f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,9 +124,19 @@ 1. **Cloudflare DNS**:新增 `status.961504.xyz` 与 `nav.961504.xyz` 解析指向 `104.43.105.243`。 2. **Uptime Kuma 容器**:在 Azure Rootless Docker 中启动 `louislam/uptime-kuma:1`(监听 `127.0.0.1:3001`,数据目录 `~/homelab/uptime-kuma-data`)。 3. **Caddy 443 免端口反代**:将 [`stacks/azure-edge/Caddyfile`](file:///Users/ixvii0iv/homelab/stacks/azure-edge/Caddyfile) 写入 Azure 的 `/etc/caddy/sites/homelab.conf` 并重启 Caddy,使 `https://status.961504.xyz` 与 `https://nav.961504.xyz` 免端口 HTTPS 生效。 - 4. **Tailscale 组网**:在 Azure 安装 Tailscale(开启 `--accept-dns=false --accept-routes=false` 确保不影响原有 `233boy` 代理节点)。 + 4. **Tailscale 组网**:在 Azure 安装 Tailscale(开启 `--accept-dns=false --accept-routes=false` 确保不影响原有 `233boy` 代理节点),获得虚拟 IP `100.115.204.51` 并与家里 Mac mini (`100.108.205.40`) 建立 P2P 直连。 - **回滚方式**:`ssh azure "sudo rm -f /etc/caddy/sites/homelab.conf && sudo systemctl restart caddy"` +### 13. 部署微信(企业微信自建应用 $\leftrightarrow$ 个人微信)双向控制与通知网关 (`https://wx.961504.xyz`) +- **操作时间**:2026-11-06 22:30 ~ 22:31 +- **目标设备**:小米路由器 (`192.168.31.1`) & Mac mini (`192.168.31.164`) & SG Azure (`104.43.105.243`) +- **变更内容**: + 1. **固定企微白名单出口 IP**:在路由器 [`configs/router-shellcrash-config.yaml`](file:///Users/ixvii0iv/homelab/configs/router-shellcrash-config.yaml) 新增 `DOMAIN-SUFFIX,qyapi.weixin.qq.com,Ai平台`,确保主动推送消息出口永远为 `104.43.105.243`。 + 2. **微信双向网关服务**:编写 [`stacks/wechat-bot/server.py`](file:///Users/ixvii0iv/homelab/stacks/wechat-bot/server.py) 与 [`stacks/wechat-bot/config.env`](file:///Users/ixvii0iv/homelab/stacks/wechat-bot/config.env),在 Mac mini 注册 `~/Library/LaunchAgents/com.homelab.wechat-bot.plist`(通过 `uv run` 常驻运行于 `3005` 端口)。 + 3. **免端口 HTTPS 回调入口**:在 Cloudflare 添加 `wx.961504.xyz -> 104.43.105.243`,并在 Azure Caddy 配置反代至 `100.108.205.40:3005`。 +- **回滚方式**:`ssh mini "launchctl unload ~/Library/LaunchAgents/com.homelab.wechat-bot.plist"` + + diff --git a/PLAN.md b/PLAN.md index 5cc7caa..be10c32 100644 --- a/PLAN.md +++ b/PLAN.md @@ -52,8 +52,11 @@ ## 阶段四:家庭核心应用与极空间 Z4 联动(按需扩展) -- [x] **4.1 家庭统一导航总入口 (`https://home.961504.xyz:10443`) 与反代安全锁**: - - 已部署 **Sun-Panel** ([`stacks/sun-panel/docker-compose.yml`](file:///Users/ixvii0iv/homelab/stacks/sun-panel/docker-compose.yml)) 并预置 **极空间 Z4、Clash 路由面板、小米路由器后台、Lucky 工具箱** 的内外网双模式卡片。 +- [x] **4.1 家庭统一导航总入口 (`https://nav.961504.xyz` / `https://home.961504.xyz:10443`) 与反代安全锁**: + - 已部署 **Sun-Panel** ([`stacks/sun-panel/docker-compose.yml`](file:///Users/ixvii0iv/homelab/stacks/sun-panel/docker-compose.yml)) 并预置 **极空间 Z4、Clash 路由面板、小米路由器后台、Lucky 工具箱、Uptime 外部监控** 卡片。 - 已在 [`stacks/gateway/Caddyfile`](file:///Users/ixvii0iv/homelab/stacks/gateway/Caddyfile) 为 `clash.home.961504.xyz:10443` 与 `router.home.961504.xyz:10443` 开启 HTTP Basic Auth 密码锁保护。 +- [x] **4.2 个人微信 / 企业微信双向控制与通知网关 (`https://wx.961504.xyz`)**: + - 已在小米路由器将企业微信接口 `qyapi.weixin.qq.com` 固定路由至 SG Azure (`104.43.105.243`),满足企业微信固定可信 IP 白名单要求。 + - 已在 Mac mini 部署微信双向网关守护进程 ([`stacks/wechat-bot/server.py`](file:///Users/ixvii0iv/homelab/stacks/wechat-bot/server.py),监听 `3005`),并通过 Azure 标准 `443` 端口反代暴露为 `https://wx.961504.xyz/wechat/callback`。 - [ ] **4.2 极空间 SMB 自动挂载与影视自动化**:挂载 `192.168.31.185` 共享目录,部署 **MoviePilot + qBittorrent**。 - [ ] **4.3 智能家居中枢**:部署 **Home Assistant + Scrypted**(摄像头接入 Apple HomeKit 安全视频)。 diff --git a/configs/router-shellcrash-config.yaml b/configs/router-shellcrash-config.yaml index 6b83111..d0c02b4 100644 --- a/configs/router-shellcrash-config.yaml +++ b/configs/router-shellcrash-config.yaml @@ -186,6 +186,7 @@ rules: - DOMAIN-SUFFIX,gemini.google.com,Ai平台 - DOMAIN-SUFFIX,googleapis.com,Ai平台 - DOMAIN-SUFFIX,deepmind.com,Ai平台 + - DOMAIN-SUFFIX,qyapi.weixin.qq.com,Ai平台 - DOMAIN,mtalk.google.com,谷歌FCM - DOMAIN-SUFFIX,alt1-mtalk.google.com,谷歌FCM - DOMAIN-SUFFIX,alt2-mtalk.google.com,谷歌FCM diff --git a/stacks/azure-edge/Caddyfile b/stacks/azure-edge/Caddyfile index f51a3e1..b9c336f 100644 --- a/stacks/azure-edge/Caddyfile +++ b/stacks/azure-edge/Caddyfile @@ -15,3 +15,9 @@ nav.961504.xyz:443 { encode zstd gzip reverse_proxy 100.108.205.40:3002 } + +# 3. 个人微信 / 企业微信自建应用 443 免端口回调与转发网关 -> https://wx.961504.xyz +wx.961504.xyz:443 { + encode zstd gzip + reverse_proxy 100.108.205.40:3005 +} diff --git a/stacks/wechat-bot/config.env b/stacks/wechat-bot/config.env new file mode 100644 index 0000000..24db54c --- /dev/null +++ b/stacks/wechat-bot/config.env @@ -0,0 +1,10 @@ +# ============================================================================== +# 企业微信自建应用 (接入个人微信) 配置文件 +# 修改后无需重启,服务会自动热加载或通过 launchctl kickstart 重启 +# ============================================================================== +WECOM_CORP_ID="" +WECOM_AGENT_ID="" +WECOM_SECRET="" +WECOM_TOKEN="" +WECOM_AES_KEY="" +WECOM_TO_USER="@all" diff --git a/stacks/wechat-bot/server.py b/stacks/wechat-bot/server.py new file mode 100755 index 0000000..59a14aa --- /dev/null +++ b/stacks/wechat-bot/server.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "cryptography", +# "requests", +# ] +# /// +""" +Mac mini 家庭 All-in-One 微信双向控制与通知网关 (WeCom <-> Personal WeChat) +监听端口: 3005 (由 Azure https://wx.961504.xyz:443 通过 Tailscale 专线转发) +""" +import base64 +import hashlib +import json +import os +import socket +import struct +import subprocess +import threading +import time +import urllib.parse +import xml.etree.ElementTree as ET +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import requests +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +BASE_DIR = Path(__file__).resolve().parent +CONFIG_PATH = BASE_DIR / "config.env" + + +def load_config() -> dict: + cfg = { + "WECOM_CORP_ID": "", + "WECOM_AGENT_ID": "", + "WECOM_SECRET": "", + "WECOM_TOKEN": "", + "WECOM_AES_KEY": "", + "WECOM_TO_USER": "@all", + } + if CONFIG_PATH.exists(): + for line in CONFIG_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + cfg[k.strip()] = v.strip().strip('"').strip("'") + return cfg + + +class WXBizMsgCrypt: + """企业微信官方消息加解密算法实现 (AES-256-CBC + PKCS#7 32字节填充)""" + + def __init__(self, token: str, encoding_aes_key: str, receive_id: str): + self.token = token + self.receive_id = receive_id + self.key = base64.b64decode(encoding_aes_key + "=") + + def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt_msg: str) -> bool: + items = sorted([self.token, timestamp, nonce, encrypt_msg]) + sha1 = hashlib.sha1("".join(items).encode("utf-8")).hexdigest() + return sha1 == msg_signature + + def decrypt(self, encrypt_msg: str) -> str: + cipher_bytes = base64.b64decode(encrypt_msg) + cipher = Cipher(algorithms.AES(self.key), modes.CBC(self.key[:16]), backend=default_backend()) + decryptor = cipher.decryptor() + plain = decryptor.update(cipher_bytes) + decryptor.finalize() + pad_len = plain[-1] + content = plain[:-pad_len] + msg_len = struct.unpack("!I", content[16:20])[0] + raw_msg = content[20 : 20 + msg_len].decode("utf-8") + return raw_msg + + +TOKEN_CACHE = {"token": "", "expires_at": 0.0} + + +def send_wecom_text(content: str, to_user: str = None) -> dict: + cfg = load_config() + corp_id = cfg["WECOM_CORP_ID"] + secret = cfg["WECOM_SECRET"] + agent_id = cfg["WECOM_AGENT_ID"] + if not (corp_id and secret and agent_id): + return {"errcode": -1, "errmsg": "WeCom credentials not configured yet"} + + now = time.time() + if not TOKEN_CACHE["token"] or now >= TOKEN_CACHE["expires_at"]: + resp = requests.get( + "https://qyapi.weixin.qq.com/cgi-bin/gettoken", + params={"corpid": corp_id, "corpsecret": secret}, + timeout=10, + ).json() + if resp.get("errcode") != 0: + return resp + TOKEN_CACHE["token"] = resp["access_token"] + TOKEN_CACHE["expires_at"] = now + resp.get("expires_in", 7200) - 200 + + # 企业微信单条文本上限 2048 字节,超长自动截断 + if len(content.encode("utf-8")) > 1900: + content = content[:600] + "\n...(内容已截断)" + + payload = { + "touser": to_user or cfg.get("WECOM_TO_USER") or "@all", + "msgtype": "text", + "agentid": int(agent_id), + "text": {"content": content}, + } + return requests.post( + f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={TOKEN_CACHE['token']}", + json=payload, + timeout=10, + ).json() + + +def handle_user_command(user_id: str, text: str): + text = text.strip() + try: + if text in ("状态", "/status", "status", "体检"): + cmd = ( + "export PATH=/opt/homebrew/bin:$PATH; " + "echo '🖥️ Mac mini (M6) 状态报告'; " + "echo \"⏱️ 运行时间: $(uptime | sed 's/.*up //; s/, [0-9]* users.*//')\"; " + "echo \"💾 磁盘剩余: $(df -h / | awk 'NR==2{print $4 \" (已用 \" $5 \")\"}')\"; " + "echo \"🌐 家里公网: $(cat ~/homelab/backups/last_wan_ip.txt 2>/dev/null || echo 未知)\"; " + "echo \"🐳 运行容器: $(docker ps --format '{{.Names}}' | tr '\\n' ' ')\"" + ) + out = subprocess.check_output(cmd, shell=True, text=True, timeout=15) + send_wecom_text(out.strip(), to_user=user_id) + elif text.startswith("/sh "): + shell_cmd = text[4:].strip() + out = subprocess.check_output( + f"export PATH=/opt/homebrew/bin:$HOME/.local/bin:$PATH; {shell_cmd}", + shell=True, + stderr=subprocess.STDOUT, + text=True, + timeout=60, + ) + send_wecom_text(f"✅ 执行完成:\n{out.strip() or '(无输出)'}", to_user=user_id) + else: + send_wecom_text( + f"🤖 Mac mini 已收到指令:\n「{text}」\n\n" + "可用快捷指令:\n" + "• 发送「状态」:查看 Mac mini 实时健康度与公网 IP\n" + "• 发送「/sh <命令>」:远程在 Mac mini 上执行终端命令", + to_user=user_id, + ) + except Exception as e: + send_wecom_text(f"⚠️ 执行异常: {e}", to_user=user_id) + + +class GatewayHandler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + if parsed.path == "/wechat/callback": + qs = urllib.parse.parse_qs(parsed.query) + msg_sig = qs.get("msg_signature", [""])[0] + timestamp = qs.get("timestamp", [""])[0] + nonce = qs.get("nonce", [""])[0] + echostr = qs.get("echostr", [""])[0] + cfg = load_config() + if not (cfg["WECOM_TOKEN"] and cfg["WECOM_AES_KEY"] and cfg["WECOM_CORP_ID"]): + self.send_response(400) + self.end_headers() + self.wfile.write(b"WeCom callback parameters not configured yet") + return + crypt = WXBizMsgCrypt(cfg["WECOM_TOKEN"], cfg["WECOM_AES_KEY"], cfg["WECOM_CORP_ID"]) + if not crypt.verify_signature(msg_sig, timestamp, nonce, echostr): + self.send_response(403) + self.end_headers() + self.wfile.write(b"Signature verification failed") + return + plain_echo = crypt.decrypt(echostr) + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(plain_echo.encode("utf-8")) + return + + cfg = load_config() + configured = bool(cfg["WECOM_CORP_ID"] and cfg["WECOM_SECRET"]) + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write( + json.dumps( + { + "service": "macmini-wechat-gateway", + "status": "ok", + "wecom_configured": configured, + "callback_url": "https://wx.961504.xyz/wechat/callback", + "notify_webhook": "https://wx.961504.xyz/notify", + }, + ensure_ascii=False, + ).encode("utf-8") + ) + + def do_POST(self): + parsed = urllib.parse.urlparse(self.path) + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode("utf-8") + + if parsed.path == "/wechat/callback": + qs = urllib.parse.parse_qs(parsed.query) + msg_sig = qs.get("msg_signature", [""])[0] + timestamp = qs.get("timestamp", [""])[0] + nonce = qs.get("nonce", [""])[0] + cfg = load_config() + crypt = WXBizMsgCrypt(cfg["WECOM_TOKEN"], cfg["WECOM_AES_KEY"], cfg["WECOM_CORP_ID"]) + root = ET.fromstring(body) + encrypt_node = root.find("Encrypt") + if encrypt_node is not None and crypt.verify_signature(msg_sig, timestamp, nonce, encrypt_node.text): + xml_plain = crypt.decrypt(encrypt_node.text) + msg_root = ET.fromstring(xml_plain) + msg_type = msg_root.findtext("MsgType", "") + from_user = msg_root.findtext("FromUserName", "@all") + if msg_type == "text": + content = msg_root.findtext("Content", "") + threading.Thread(target=handle_user_command, args=(from_user, content), daemon=True).start() + self.send_response(200) + self.end_headers() + self.wfile.write(b"success") + return + + if parsed.path == "/notify": + try: + data = json.loads(body) if body else {} + msg = data.get("msg") or data.get("text") or body + except Exception: + msg = body + res = send_wecom_text(f"🔔 【家庭服务器通知】\n{msg}") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps(res, ensure_ascii=False).encode("utf-8")) + return + + self.send_response(404) + self.end_headers() + + +if __name__ == "__main__": + server = ThreadingHTTPServer(("0.0.0.0", 3005), GatewayHandler) + print("WeChat Gateway listening on 0.0.0.0:3005...") + server.serve_forever()