feat(wechat): deploy WeChat Work bidirectional gateway at https://wx.961504.xyz and lock qyapi to SG Azure
This commit is contained in:
1 parent
e9c44439e6
commit
a9f03f1a08
6 files changed
+280
-3
No files matched your search
+11
-1
@@ -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"`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 安全视频)。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# ==============================================================================
|
||||
# 企业微信自建应用 (接入个人微信) 配置文件
|
||||
# 修改后无需重启,服务会自动热加载或通过 launchctl kickstart 重启
|
||||
# ==============================================================================
|
||||
WECOM_CORP_ID=""
|
||||
WECOM_AGENT_ID=""
|
||||
WECOM_SECRET=""
|
||||
WECOM_TOKEN=""
|
||||
WECOM_AES_KEY=""
|
||||
WECOM_TO_USER="@all"
|
||||
Executable
+247
@@ -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()
|
||||
Reference in new issue
Block a user