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`。
|
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`)。
|
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 生效。
|
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"`
|
- **回滚方式**:`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 联动(按需扩展)
|
## 阶段四:家庭核心应用与极空间 Z4 联动(按需扩展)
|
||||||
|
|
||||||
- [x] **4.1 家庭统一导航总入口 (`https://home.961504.xyz:10443`) 与反代安全锁**:
|
- [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 工具箱** 的内外网双模式卡片。
|
- 已部署 **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 密码锁保护。
|
- 已在 [`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.2 极空间 SMB 自动挂载与影视自动化**:挂载 `192.168.31.185` 共享目录,部署 **MoviePilot + qBittorrent**。
|
||||||
- [ ] **4.3 智能家居中枢**:部署 **Home Assistant + Scrypted**(摄像头接入 Apple HomeKit 安全视频)。
|
- [ ] **4.3 智能家居中枢**:部署 **Home Assistant + Scrypted**(摄像头接入 Apple HomeKit 安全视频)。
|
||||||
@@ -186,6 +186,7 @@ rules:
|
|||||||
- DOMAIN-SUFFIX,gemini.google.com,Ai平台
|
- DOMAIN-SUFFIX,gemini.google.com,Ai平台
|
||||||
- DOMAIN-SUFFIX,googleapis.com,Ai平台
|
- DOMAIN-SUFFIX,googleapis.com,Ai平台
|
||||||
- DOMAIN-SUFFIX,deepmind.com,Ai平台
|
- DOMAIN-SUFFIX,deepmind.com,Ai平台
|
||||||
|
- DOMAIN-SUFFIX,qyapi.weixin.qq.com,Ai平台
|
||||||
- DOMAIN,mtalk.google.com,谷歌FCM
|
- DOMAIN,mtalk.google.com,谷歌FCM
|
||||||
- DOMAIN-SUFFIX,alt1-mtalk.google.com,谷歌FCM
|
- DOMAIN-SUFFIX,alt1-mtalk.google.com,谷歌FCM
|
||||||
- DOMAIN-SUFFIX,alt2-mtalk.google.com,谷歌FCM
|
- DOMAIN-SUFFIX,alt2-mtalk.google.com,谷歌FCM
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ nav.961504.xyz:443 {
|
|||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 100.108.205.40:3002
|
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