diff --git a/README.md b/README.md index 938fdce..bd5fbd3 100644 --- a/README.md +++ b/README.md @@ -612,8 +612,9 @@ Post content to WeChat Official Account (微信公众号). Two modes available: | Method | Speed | Requirements | |--------|-------|--------------| -| API (Recommended) | Fast | API credentials | +| API (Recommended) | Fast | API credentials (local IP allowlisted in WeChat) | | Browser | Slow | Chrome, login session | +| Remote API | Fast | API credentials + SSH-reachable server whose IP is on WeChat's allowlist | **API Configuration** (for faster publishing): @@ -631,6 +632,17 @@ To obtain credentials: **Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved). +**Remote API Method** (for when WeChat's IP allowlist excludes your local machine): tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on the allowlist. No files are written to the remote host and `AppSecret` never leaves the local process. Add to your EXTEND.md: + +```yaml +# Optional: only set when WeChat's IP allowlist excludes your local machine +remote_publish_host: server.example.com +remote_publish_user: deploy +remote_publish_identity_file: ~/.ssh/id_ed25519 +``` + +Then publish with `--remote` (or set `default_publish_method: remote-api`). Authentication is SSH key only; only the typed `remote_publish_*` keys are honored. + **Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`: ```bash diff --git a/README.zh.md b/README.zh.md index 69ce785..e6ee038 100644 --- a/README.zh.md +++ b/README.zh.md @@ -612,8 +612,9 @@ clawhub install baoyu-markdown-to-html | 方式 | 速度 | 要求 | |------|------|------| -| API(推荐) | 快 | API 凭证 | +| API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) | | 浏览器 | 慢 | Chrome,登录会话 | +| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 | **API 配置**(更快的发布方式): @@ -631,6 +632,8 @@ WECHAT_APP_SECRET=你的AppSecret **浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存) +**远程 API 方式**(适用于本机 IP 不在公众号白名单内的情况):通过 SSH SOCKS5 动态端口转发,将对 `api.weixin.qq.com` 的 HTTPS 调用转发到 IP 在白名单内的服务器上。Markdown 渲染、图片处理、草稿组装仍在本地完成;远端不会落任何文件,`AppSecret` 不会离开本地进程。仅支持 SSH 密钥认证,且只接受类型化的 `remote_publish_*` 配置项,不透传任意 ssh 选项。在 EXTEND.md 中配置 `remote_publish_host` 等字段后,发布时加上 `--remote`(或将 `default_publish_method` 设为 `remote-api`)。 + **多账号支持**:通过 `EXTEND.md` 管理多个微信公众号: ```bash diff --git a/skills/baoyu-post-to-wechat/SKILL.md b/skills/baoyu-post-to-wechat/SKILL.md index f8d60c7..6b522fb 100644 --- a/skills/baoyu-post-to-wechat/SKILL.md +++ b/skills/baoyu-post-to-wechat/SKILL.md @@ -64,13 +64,26 @@ Found → read, parse, apply. Not found → run first-time setup (`references/co ```md default_theme: default default_color: blue -default_publish_method: api +default_publish_method: browser default_author: 宝玉 need_open_comment: 1 only_fans_can_comment: 0 chrome_profile_path: /path/to/chrome/profile + +# Remote API publishing (optional) — only set if WeChat's IP allowlist +# excludes your local machine. See "Remote API Method" below. +# remote_publish_host: server.example.com +# remote_publish_user: deploy +# remote_publish_port: 22 +# remote_publish_identity_file: ~/.ssh/id_ed25519 +# remote_publish_known_hosts_file: ~/.ssh/known_hosts +# remote_publish_strict_host_key_checking: accept-new +# remote_publish_connect_timeout: 10 +# remote_publish_proxy_jump: bastion.example.com ``` +Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only (no passwords). + **Theme options**: default, grace, simple, modern. **Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex). **Value priority**: CLI args → frontmatter → EXTEND.md (account-level → global) → skill defaults. @@ -148,11 +161,14 @@ Ask method unless specified in EXTEND.md or CLI: | Method | Speed | Requires | |--------|-------|----------| -| `api` (Recommended) | Fast | API credentials | +| `api` (Recommended) | Fast | API credentials (local IP allowlisted) | | `browser` | Slow | Chrome + logged-in session | +| `remote-api` | Fast | API credentials + an SSH-reachable server whose IP is on the WeChat allowlist | **API selected + missing credentials** → run guided setup per `references/api-setup.md` (writes to `.baoyu-skills/.env`). +**`remote-api` method**: WeChat's "公众号设置 → IP 白名单" often limits API access to one or two fixed IPs. If your local machine's IP is not on that list but a cloud server's is, use `remote-api`: all markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally, and only the outbound HTTPS calls to `api.weixin.qq.com` (token, uploadimg, add_material, draft/add) are tunneled through an SSH SOCKS5 dynamic port forward (`ssh -N -D`) so that WeChat sees the remote server as the source IP. No files are written to the remote host; `AppSecret` never leaves the local process. Requires only `sshd` and outbound network on the remote host — no Python, no agent process. See "Remote API Method" below. + ### Step 3: Resolve Theme/Color and Validate Metadata 1. **Theme**: CLI `--theme` → EXTEND.md `default_theme` → `default` (first match wins; do NOT ask if resolved). @@ -183,6 +199,14 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts --theme [--color Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md. +**Remote API method** (same script, adds `--remote`): + +```bash +${BUN_X} {baseDir}/scripts/wechat-api.ts --theme --remote [--remote-host ] [--remote-user ] [--remote-port ] [--remote-identity-file ] [--remote-known-hosts-file ] [--remote-strict-host-key-checking yes|no|accept-new] [--remote-connect-timeout ] [--remote-proxy-jump ] +``` + +Any `--remote-*` flag implies `--remote`. CLI values override account-level then global `remote_publish_*` keys from EXTEND.md. Setting `default_publish_method: remote-api` also enables remote mode without `--remote`. + **`draft/add` payload rules**: - Endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN` - `article_type`: `news` (default) or `newspic` @@ -225,19 +249,20 @@ Files created: ## Feature Comparison -| Feature | Image-Text | Article (API) | Article (Browser) | -|---------|:---:|:---:|:---:| -| Plain text input | ✗ | ✓ | ✓ | -| HTML input | ✗ | ✓ | ✓ | -| Markdown input | Title/content | ✓ | ✓ | -| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | -| Themes | ✗ | ✓ | ✓ | -| Auto-generate metadata | ✗ | ✓ | ✓ | -| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ | -| Comment control | ✗ | ✓ | ✗ | -| Requires Chrome | ✓ | ✗ | ✓ | -| Requires API credentials | ✗ | ✓ | ✗ | -| Speed | Medium | Fast | Slow | +| Feature | Image-Text | Article (API) | Article (Remote API) | Article (Browser) | +|---------|:---:|:---:|:---:|:---:| +| Plain text input | ✗ | ✓ | ✓ | ✓ | +| HTML input | ✗ | ✓ | ✓ | ✓ | +| Markdown input | Title/content | ✓ | ✓ | ✓ | +| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | ✓ (inline) | +| Themes | ✗ | ✓ | ✓ | ✓ | +| Auto-generate metadata | ✗ | ✓ | ✓ | ✓ | +| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✓ | ✗ | +| Comment control | ✗ | ✓ | ✓ | ✗ | +| Requires Chrome | ✓ | ✗ | ✗ | ✓ | +| Requires API credentials | ✗ | ✓ | ✓ | ✗ | +| Requires SSH-reachable server with allowlisted IP | ✗ | ✗ | ✓ | ✗ | +| Speed | Medium | Fast | Fast | Slow | ## Troubleshooting @@ -251,6 +276,10 @@ Files created: | No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory | | Wrong comment defaults | Check `need_open_comment` / `only_fans_can_comment` in EXTEND.md | | Paste fails | Check system clipboard permissions | +| `Remote publish host is required` | Set `--remote-host` or `remote_publish_host` in EXTEND.md | +| `SOCKS proxy on 127.0.0.1:… not ready` | SSH could not start the tunnel — check key, host, `StrictHostKeyChecking`, or use `--remote-connect-timeout` | +| `ssh exited early` during remote publish | Verify the user can `ssh` non-interactively to the server; raise `--remote-connect-timeout` if the link is slow | +| Remote API call returns `errcode 40164` (invalid IP) | The remote server's egress IP is not on WeChat's allowlist; add it in 公众号设置 → IP 白名单 | ## References diff --git a/skills/baoyu-post-to-wechat/references/config/first-time-setup.md b/skills/baoyu-post-to-wechat/references/config/first-time-setup.md index 5df16d9..e627df7 100644 --- a/skills/baoyu-post-to-wechat/references/config/first-time-setup.md +++ b/skills/baoyu-post-to-wechat/references/config/first-time-setup.md @@ -86,8 +86,12 @@ options: description: "Fast, requires API credentials (AppID + AppSecret)" - label: "browser" description: "Slow, requires Chrome and login session" + - label: "remote-api" + description: "Fast, tunnels WeChat API calls through SSH to a server whose IP is on the WeChat allowlist" ``` +If the user selects `remote-api`, prompt for `remote_publish_host` and (optionally) `remote_publish_user`, `remote_publish_identity_file`. These can also be filled in later by editing EXTEND.md. + ### Question 4: Default Author ```yaml @@ -157,13 +161,26 @@ options: ```md default_theme: [default/grace/simple/modern] default_color: [preset name, hex, or empty for theme default] -default_publish_method: [api/browser] +default_publish_method: [api/browser/remote-api] default_author: [author name or empty] need_open_comment: [1/0] only_fans_can_comment: [1/0] chrome_profile_path: + +# Remote API publishing — only fill in if default_publish_method is remote-api +# or you plan to pass --remote on the CLI. +remote_publish_host: +remote_publish_user: +remote_publish_port: +remote_publish_identity_file: +remote_publish_known_hosts_file: +remote_publish_strict_host_key_checking: +remote_publish_connect_timeout: +remote_publish_proxy_jump: ``` +Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only. + ### Multi-Account ```md @@ -174,15 +191,19 @@ accounts: - name: [display name] alias: [short key, e.g. "baoyu"] default: true - default_publish_method: [api/browser] + default_publish_method: [api/browser/remote-api] default_author: [author name] need_open_comment: [1/0] only_fans_can_comment: [1/0] app_id: [WeChat App ID, optional] app_secret: [WeChat App Secret, optional] + # Remote API publishing (optional, per-account override of globals) + remote_publish_host: + remote_publish_user: + remote_publish_identity_file: - name: [second account name] alias: [short key, e.g. "ai-tools"] - default_publish_method: [api/browser] + default_publish_method: [api/browser/remote-api] default_author: [author name] need_open_comment: [1/0] only_fans_can_comment: [1/0] diff --git a/skills/baoyu-post-to-wechat/references/multi-account.md b/skills/baoyu-post-to-wechat/references/multi-account.md index 9c7a9a8..d0b5746 100644 --- a/skills/baoyu-post-to-wechat/references/multi-account.md +++ b/skills/baoyu-post-to-wechat/references/multi-account.md @@ -37,7 +37,7 @@ accounts: ## Per-Account vs Global Keys -**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`. +**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`, `remote_publish_host`, `remote_publish_user`, `remote_publish_port`, `remote_publish_identity_file`, `remote_publish_known_hosts_file`, `remote_publish_strict_host_key_checking`, `remote_publish_connect_timeout`, `remote_publish_proxy_jump`. **Global-only** (always shared): `default_theme`, `default_color`. @@ -99,3 +99,54 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts --theme default --account ai-too ${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown --theme default --account baoyu ${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown --images ./photos/ --account baoyu ``` + +## Remote API Publishing + +`wechat-api.ts` supports a `remote-api` mode that tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on WeChat's allowlist. Markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally; only outbound HTTPS calls to `api.weixin.qq.com` traverse the tunnel. No files are written to the remote host and `AppSecret` never leaves the local process. The remote host needs only `sshd` and outbound network access. + +### Per-Account Configuration + +```md +default_theme: default +default_color: blue +default_publish_method: browser # browser remains the default + +accounts: + - name: 宝玉的技术分享 + alias: baoyu + default: true + default_publish_method: api + default_author: 宝玉 + app_id: your_wechat_app_id + app_secret: your_wechat_app_secret + - name: AI工具集 + alias: ai-tools + default_publish_method: remote-api + default_author: AI工具集 + app_id: your_ai_tools_app_id + app_secret: your_ai_tools_app_secret + remote_publish_host: ai-tools-server.example.com + remote_publish_user: deploy + remote_publish_port: 22 + remote_publish_identity_file: /home/me/.ssh/id_ed25519 + remote_publish_known_hosts_file: /home/me/.ssh/known_hosts + remote_publish_strict_host_key_checking: accept-new +``` + +Account-level `remote_publish_*` values override top-level globals. CLI `--remote-*` flags override both. + +### CLI Usage + +```bash +# Use the account's default_publish_method (remote-api here): +${BUN_X} {baseDir}/scripts/wechat-api.ts --theme default --account ai-tools + +# Force remote mode regardless of default_publish_method: +${BUN_X} {baseDir}/scripts/wechat-api.ts --theme default --account baoyu --remote --remote-host other-server.example.com +``` + +### Security Notes + +- Authentication is SSH key only. Passwords and `ssh-askpass` are not used. +- Only the typed `remote_publish_*` keys are read; raw `ssh` / `scp` options are intentionally not supported. +- The tunnel forwards raw TCP; TLS verification for `api.weixin.qq.com` is still performed end-to-end by the local process. diff --git a/skills/baoyu-post-to-wechat/scripts/bun.lock b/skills/baoyu-post-to-wechat/scripts/bun.lock index bcf3faf..0cf535f 100644 --- a/skills/baoyu-post-to-wechat/scripts/bun.lock +++ b/skills/baoyu-post-to-wechat/scripts/bun.lock @@ -9,6 +9,7 @@ "baoyu-chrome-cdp": "^0.1.0", "baoyu-md": "^0.1.0", "jimp": "^1.6.0", + "socks-proxy-agent": "^10.0.0", }, }, }, @@ -87,6 +88,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], @@ -165,6 +168,8 @@ "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="], @@ -277,6 +282,12 @@ "slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@10.0.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], diff --git a/skills/baoyu-post-to-wechat/scripts/package.json b/skills/baoyu-post-to-wechat/scripts/package.json index 94122d7..3669e69 100644 --- a/skills/baoyu-post-to-wechat/scripts/package.json +++ b/skills/baoyu-post-to-wechat/scripts/package.json @@ -6,6 +6,7 @@ "@jsquash/webp": "^1.5.0", "baoyu-chrome-cdp": "^0.1.0", "baoyu-md": "^0.1.0", - "jimp": "^1.6.0" + "jimp": "^1.6.0", + "socks-proxy-agent": "^10.0.0" } } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts index 8e40e8d..93a870c 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts @@ -2,13 +2,26 @@ import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts"; +import type http from "node:http"; +import { + loadWechatExtendConfig, + resolveAccount, + loadCredentials, + type ResolvedAccount, + type StrictHostKeyChecking, +} from "./wechat-extend-config.ts"; import { type WechatUploadAsset, prepareWechatBodyImageUpload, needsWechatBodyImageProcessing, - detectImageFormatFromBuffer, } from "./wechat-image-processor.ts"; +import { loadUploadAsset } from "./wechat-image-loader.ts"; +import { wechatHttp, buildMultipart } from "./wechat-http.ts"; +import { + type RemotePublishConfig, + normalizeRemoteConfig, + withSshTunnel, +} from "./wechat-remote-publish.ts"; interface AccessTokenResponse { access_token?: string; @@ -62,13 +75,13 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg"; const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"; const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"; -async function fetchAccessToken(appId: string, appSecret: string): Promise { +async function fetchAccessToken(appId: string, appSecret: string, agent?: http.Agent): Promise { const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`; - const res = await fetch(url); - if (!res.ok) { + const res = await wechatHttp(url, { agent }); + if (res.status < 200 || res.status >= 300) { throw new Error(`Failed to fetch access token: ${res.status}`); } - const data = await res.json() as AccessTokenResponse; + const data = await res.json(); if (data.errcode) { throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`); } @@ -83,86 +96,12 @@ function toHttpsUrl(url: string | undefined): string { return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url; } -async function loadUploadAsset( - imagePath: string, - baseDir?: string, -): Promise { - let fileBuffer: Buffer; - let filename: string; - let contentType: string; - let fileSize = 0; - let fileExt = ""; - - if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { - const response = await fetch(imagePath); - if (!response.ok) { - throw new Error(`Failed to download image: ${imagePath}`); - } - const buffer = await response.arrayBuffer(); - if (buffer.byteLength === 0) { - throw new Error(`Remote image is empty: ${imagePath}`); - } - fileBuffer = Buffer.from(buffer); - fileSize = buffer.byteLength; - const urlPath = imagePath.split("?")[0]; - filename = path.basename(urlPath) || "image.jpg"; - fileExt = path.extname(filename).toLowerCase(); - contentType = response.headers.get("content-type") || "image/jpeg"; - } else { - const resolvedPath = path.isAbsolute(imagePath) - ? imagePath - : path.resolve(baseDir || process.cwd(), imagePath); - - if (!fs.existsSync(resolvedPath)) { - throw new Error(`Image not found: ${resolvedPath}`); - } - const stats = fs.statSync(resolvedPath); - if (stats.size === 0) { - throw new Error(`Local image is empty: ${resolvedPath}`); - } - fileSize = stats.size; - fileBuffer = fs.readFileSync(resolvedPath); - filename = path.basename(resolvedPath); - fileExt = path.extname(filename).toLowerCase(); - const mimeTypes: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".tiff": "image/tiff", - ".tif": "image/tiff", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - }; - contentType = mimeTypes[fileExt] || "image/jpeg"; - } - - // Detect actual format from magic bytes to fix extension/content-type mismatches - // (e.g. CDNs serving WebP for URLs with .png extension) - const detected = detectImageFormatFromBuffer(fileBuffer); - if (detected && detected.contentType !== contentType) { - console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`); - contentType = detected.contentType; - fileExt = detected.fileExt; - filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`; - } - - return { - buffer: fileBuffer, - filename, - contentType, - fileExt, - fileSize, - }; -} - async function uploadImage( imagePath: string, accessToken: string, baseDir?: string, - uploadType: "body" | "material" = "body" + uploadType: "body" | "material" = "body", + agent?: http.Agent, ): Promise { const asset = await loadUploadAsset(imagePath, baseDir); let uploadAsset = asset; @@ -187,6 +126,7 @@ async function uploadImage( uploadAsset.contentType, accessToken, uploadType, + agent, ); // media/uploadimg 接口只返回 URL,material/add_material 返回 media_id @@ -201,39 +141,28 @@ async function uploadImage( } } -// 实际的微信上传函数 async function uploadToWechat( fileBuffer: Buffer, filename: string, contentType: string, accessToken: string, - uploadType: "body" | "material" + uploadType: "body" | "material", + agent?: http.Agent, ): Promise { - const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`; - const header = [ - `--${boundary}`, - `Content-Disposition: form-data; name="media"; filename="${filename}"`, - `Content-Type: ${contentType}`, - "", - "", - ].join("\r\n"); - const footer = `\r\n--${boundary}--\r\n`; - - const headerBuffer = Buffer.from(header, "utf-8"); - const footerBuffer = Buffer.from(footer, "utf-8"); - const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]); + const multipart = buildMultipart([ + { name: "media", filename, contentType, data: fileBuffer }, + ]); const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL; const url = `${uploadUrl}?type=image&access_token=${accessToken}`; - const res = await fetch(url, { + const res = await wechatHttp(url, { method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=${boundary}`, - }, - body, + headers: { "Content-Type": multipart.contentType }, + body: multipart.body, + agent, }); - const data = await res.json() as UploadResponse; + const data = await res.json(); if (data.errcode && data.errcode !== 0) { throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`); } @@ -248,6 +177,7 @@ async function uploadImagesInHtml( contentImages: ImageInfo[] = [], articleType: ArticleType = "news", collectNewsCoverFallback: boolean = false, + agent?: http.Agent, ): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> { const imgRegex = /]*\ssrc=["']([^"']+)["'][^>]*>/gi; const matches = [...html.matchAll(imgRegex)]; @@ -268,7 +198,7 @@ async function uploadImagesInHtml( if (src.startsWith("https://mmbiz.qpic.cn")) { if (collectNewsCoverFallback && !firstCoverMediaId) { try { - const coverResp = await uploadImage(src, accessToken, baseDir, "material"); + const coverResp = await uploadImage(src, accessToken, baseDir, "material", agent); firstCoverMediaId = coverResp.media_id; } catch (err) { console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err); @@ -284,8 +214,7 @@ async function uploadImagesInHtml( try { let resp = uploadedBySource.get(imagePath); if (!resp) { - // 正文图片使用 media/uploadimg 接口获取 URL - resp = await uploadImage(imagePath, accessToken, baseDir, "body"); + resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent); uploadedBySource.set(imagePath, resp); } const newTag = fullTag @@ -296,7 +225,7 @@ async function uploadImagesInHtml( if (shouldUploadMaterial) { let materialResp = uploadedBySource.get(`${imagePath}:material`); if (!materialResp) { - materialResp = await uploadImage(imagePath, accessToken, baseDir, "material"); + materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent); uploadedBySource.set(`${imagePath}:material`, materialResp); } if (articleType === "newspic" && materialResp.media_id) { @@ -320,8 +249,7 @@ async function uploadImagesInHtml( try { let resp = uploadedBySource.get(imagePath); if (!resp) { - // 正文图片使用 media/uploadimg 接口获取 URL - resp = await uploadImage(imagePath, accessToken, baseDir, "body"); + resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent); uploadedBySource.set(imagePath, resp); } @@ -331,7 +259,7 @@ async function uploadImagesInHtml( if (shouldUploadMaterial) { let materialResp = uploadedBySource.get(`${imagePath}:material`); if (!materialResp) { - materialResp = await uploadImage(imagePath, accessToken, baseDir, "material"); + materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent); uploadedBySource.set(`${imagePath}:material`, materialResp); } if (articleType === "newspic" && materialResp.media_id) { @@ -351,7 +279,8 @@ async function uploadImagesInHtml( async function publishToDraft( options: ArticleOptions, - accessToken: string + accessToken: string, + agent?: http.Agent, ): Promise { const url = `${DRAFT_URL}?access_token=${accessToken}`; @@ -388,15 +317,14 @@ async function publishToDraft( if (options.digest) article.digest = options.digest; } - const res = await fetch(url, { + const res = await wechatHttp(url, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ articles: [article] }), + agent, }); - const data = await res.json() as PublishResponse; + const data = await res.json(); if (data.errcode && data.errcode !== 0) { throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`); } @@ -494,6 +422,15 @@ Options: --account Select account by alias (for multi-account setups) --no-cite Disable bottom citations for ordinary external links in markdown mode --dry-run Parse and render only, don't publish + --remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server + --remote-host Remote server host (implies --remote) + --remote-user SSH user (default: root, implies --remote) + --remote-port SSH port (default: 22, implies --remote) + --remote-identity-file

SSH private key path (implies --remote) + --remote-known-hosts-file

SSH known_hosts file path (implies --remote) + --remote-strict-host-key-checking (implies --remote) + --remote-connect-timeout SSH ConnectTimeout (implies --remote) + --remote-proxy-jump SSH ProxyJump value (implies --remote) --help Show this help Frontmatter Fields (markdown): @@ -539,6 +476,15 @@ interface CliArgs { account?: string; citeStatus: boolean; dryRun: boolean; + remote: boolean; + remoteHost?: string; + remoteUser?: string; + remotePort?: number; + remoteIdentityFile?: string; + remoteKnownHostsFile?: string; + remoteStrictHostKeyChecking?: StrictHostKeyChecking; + remoteConnectTimeout?: number; + remoteProxyJump?: string; } function parseArgs(argv: string[]): CliArgs { @@ -553,6 +499,7 @@ function parseArgs(argv: string[]): CliArgs { theme: "default", citeStatus: true, dryRun: false, + remote: false, }; for (let i = 0; i < argv.length; i++) { @@ -582,6 +529,47 @@ function parseArgs(argv: string[]): CliArgs { args.citeStatus = false; } else if (arg === "--dry-run") { args.dryRun = true; + } else if (arg === "--remote") { + args.remote = true; + } else if (arg === "--remote-host" && argv[i + 1]) { + args.remoteHost = argv[++i]; + args.remote = true; + } else if (arg === "--remote-user" && argv[i + 1]) { + args.remoteUser = argv[++i]; + args.remote = true; + } else if (arg === "--remote-port" && argv[i + 1]) { + const n = Number.parseInt(argv[++i]!, 10); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`); + process.exit(1); + } + args.remotePort = n; + args.remote = true; + } else if (arg === "--remote-identity-file" && argv[i + 1]) { + args.remoteIdentityFile = argv[++i]; + args.remote = true; + } else if (arg === "--remote-known-hosts-file" && argv[i + 1]) { + args.remoteKnownHostsFile = argv[++i]; + args.remote = true; + } else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) { + const v = argv[++i]!.toLowerCase(); + if (v !== "yes" && v !== "no" && v !== "accept-new") { + console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`); + process.exit(1); + } + args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking; + args.remote = true; + } else if (arg === "--remote-connect-timeout" && argv[i + 1]) { + const n = Number.parseInt(argv[++i]!, 10); + if (!Number.isInteger(n) || n <= 0) { + console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`); + process.exit(1); + } + args.remoteConnectTimeout = n; + args.remote = true; + } else if (arg === "--remote-proxy-jump" && argv[i + 1]) { + args.remoteProxyJump = argv[++i]; + args.remote = true; } else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) { i++; } else if (!arg.startsWith("-")) { @@ -607,6 +595,27 @@ function extractHtmlTitle(html: string): string { return ""; } +function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig { + const host = args.remoteHost ?? resolved.remote_publish_host; + if (!host) { + throw new Error( + "Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " + + "or an account-level remote_publish_host.", + ); + } + return { + host, + user: args.remoteUser ?? resolved.remote_publish_user, + port: args.remotePort ?? resolved.remote_publish_port, + identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file, + knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file, + strictHostKeyChecking: + args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking, + connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout, + proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump, + }; +} + async function main(): Promise { const args = parseArgs(process.argv.slice(2)); @@ -709,8 +718,6 @@ async function main(): Promise { console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`); } console.error(`[wechat-api] Credentials source: ${creds.source}`); - console.error("[wechat-api] Fetching access token..."); - const accessToken = await fetchAccessToken(creds.appId, creds.appSecret); const rawCoverPath = args.cover || frontmatter.coverImage || @@ -722,62 +729,80 @@ async function main(): Promise { : rawCoverPath; const needNewsCoverFallback = args.articleType === "news" && !coverPath; - console.error("[wechat-api] Uploading body images..."); - const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml( - htmlContent, - accessToken, - baseDir, - contentImages, - args.articleType, - needNewsCoverFallback, - ); - htmlContent = processedHtml; + const useRemote = args.remote || resolved.default_publish_method === "remote-api"; + const method = useRemote ? "remote-api" : "api"; - let thumbMediaId = ""; + const publishWithAgent = async (agent?: http.Agent): Promise => { + console.error("[wechat-api] Fetching access token..."); + const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, agent); - if (coverPath) { - console.error(`[wechat-api] Uploading cover: ${coverPath}`); - // 封面图片使用 material/add_material 接口 - const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material"); - thumbMediaId = coverResp.media_id; - console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`); - } else if (firstCoverMediaId && args.articleType === "news") { - // news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑) - thumbMediaId = firstCoverMediaId; - console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`); + console.error("[wechat-api] Uploading body images..."); + const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml( + htmlContent, + accessToken, + baseDir, + contentImages, + args.articleType, + needNewsCoverFallback, + agent, + ); + htmlContent = processedHtml; + + let thumbMediaId = ""; + + if (coverPath) { + console.error(`[wechat-api] Uploading cover: ${coverPath}`); + const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", agent); + thumbMediaId = coverResp.media_id; + console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`); + } else if (firstCoverMediaId && args.articleType === "news") { + thumbMediaId = firstCoverMediaId; + console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`); + } + + if (args.articleType === "news" && !thumbMediaId) { + throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content."); + } + + if (args.articleType === "newspic" && imageMediaIds.length === 0) { + throw new Error("newspic requires at least one image in content."); + } + + console.error("[wechat-api] Publishing to draft..."); + const result = await publishToDraft({ + title, + author: author || undefined, + digest: digest || undefined, + content: htmlContent, + thumbMediaId, + articleType: args.articleType, + imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined, + needOpenComment: resolved.need_open_comment, + onlyFansCanComment: resolved.only_fans_can_comment, + }, accessToken, agent); + + console.log(JSON.stringify({ + success: true, + media_id: result.media_id, + title, + articleType: args.articleType, + method, + }, null, 2)); + + console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`); + }; + + if (useRemote) { + const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved)); + console.error( + `[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`, + ); + await withSshTunnel(remoteConfig, async (agent) => { + await publishWithAgent(agent); + }); + } else { + await publishWithAgent(); } - - if (args.articleType === "news" && !thumbMediaId) { - console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content."); - process.exit(1); - } - - if (args.articleType === "newspic" && imageMediaIds.length === 0) { - console.error("Error: newspic requires at least one image in content."); - process.exit(1); - } - - console.error("[wechat-api] Publishing to draft..."); - const result = await publishToDraft({ - title, - author: author || undefined, - digest: digest || undefined, - content: htmlContent, - thumbMediaId, - articleType: args.articleType, - imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined, - needOpenComment: resolved.need_open_comment, - onlyFansCanComment: resolved.only_fans_can_comment, - }, accessToken); - - console.log(JSON.stringify({ - success: true, - media_id: result.media_id, - title, - articleType: args.articleType, - }, null, 2)); - - console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`); } await main().catch((err) => { diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts index d3a1281..c54e00c 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import process from "node:process"; import test, { type TestContext } from "node:test"; -import { loadCredentials } from "./wechat-extend-config.ts"; +import { + loadCredentials, + loadWechatExtendConfig, + resolveAccount, +} from "./wechat-extend-config.ts"; function useCwd(t: TestContext, cwd: string): void { const previous = process.cwd(); @@ -73,6 +77,28 @@ async function writeEnvFile(root: string, content: string): Promise { await fs.writeFile(envPath, content); } +async function writeExtendFile(root: string, content: string): Promise { + const extendPath = path.join(root, ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"); + await fs.mkdir(path.dirname(extendPath), { recursive: true }); + await fs.writeFile(extendPath, content); +} + +function useXdgConfigHome(t: TestContext, value: string | undefined): void { + const previous = process.env.XDG_CONFIG_HOME; + if (value === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = value; + } + t.after(() => { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + return; + } + process.env.XDG_CONFIG_HOME = previous; + }); +} + test("loadCredentials selects the first complete source without mixing values across sources", async (t) => { const cwdRoot = await makeTempDir("wechat-creds-cwd-"); const homeRoot = await makeTempDir("wechat-creds-home-"); @@ -119,6 +145,115 @@ test("loadCredentials prefers a complete process.env pair over lower-priority fi assert.deepEqual(credentials.skippedSources, []); }); +test("resolveAccount returns global remote_publish_* values when no account is configured", async (t) => { + const cwdRoot = await makeTempDir("wechat-extend-cwd-"); + const homeRoot = await makeTempDir("wechat-extend-home-"); + + useCwd(t, cwdRoot); + useHome(t, homeRoot); + useXdgConfigHome(t, undefined); + + await writeExtendFile( + cwdRoot, + [ + "default_publish_method: remote-api", + "remote_publish_host: bastion.example.com", + "remote_publish_user: deploy", + "remote_publish_port: 2222", + "remote_publish_identity_file: /home/me/.ssh/id_ed25519", + "remote_publish_known_hosts_file: /home/me/.ssh/known_hosts", + "remote_publish_strict_host_key_checking: accept-new", + "remote_publish_connect_timeout: 12", + "remote_publish_proxy_jump: jump.example.com", + ].join("\n"), + ); + + const config = loadWechatExtendConfig(); + const resolved = resolveAccount(config); + + assert.equal(resolved.default_publish_method, "remote-api"); + assert.equal(resolved.remote_publish_host, "bastion.example.com"); + assert.equal(resolved.remote_publish_user, "deploy"); + assert.equal(resolved.remote_publish_port, 2222); + assert.equal(resolved.remote_publish_identity_file, "/home/me/.ssh/id_ed25519"); + assert.equal(resolved.remote_publish_known_hosts_file, "/home/me/.ssh/known_hosts"); + assert.equal(resolved.remote_publish_strict_host_key_checking, "accept-new"); + assert.equal(resolved.remote_publish_connect_timeout, 12); + assert.equal(resolved.remote_publish_proxy_jump, "jump.example.com"); +}); + +test("resolveAccount lets account-level remote_publish_* override globals", async (t) => { + const cwdRoot = await makeTempDir("wechat-extend-cwd-"); + const homeRoot = await makeTempDir("wechat-extend-home-"); + + useCwd(t, cwdRoot); + useHome(t, homeRoot); + useXdgConfigHome(t, undefined); + + await writeExtendFile( + cwdRoot, + [ + "default_publish_method: browser", + "remote_publish_host: global.example.com", + "remote_publish_user: deploy", + "remote_publish_port: 22", + "accounts:", + " - name: Primary", + " alias: primary", + " default: true", + " remote_publish_host: primary.example.com", + " remote_publish_user: primary-user", + " remote_publish_port: 2200", + " remote_publish_identity_file: /p/id_primary", + " - name: Secondary", + " alias: secondary", + " remote_publish_proxy_jump: jump.example.com", + ].join("\n"), + ); + + const config = loadWechatExtendConfig(); + const primary = resolveAccount(config, "primary"); + assert.equal(primary.alias, "primary"); + assert.equal(primary.remote_publish_host, "primary.example.com"); + assert.equal(primary.remote_publish_user, "primary-user"); + assert.equal(primary.remote_publish_port, 2200); + assert.equal(primary.remote_publish_identity_file, "/p/id_primary"); + assert.equal(primary.remote_publish_proxy_jump, undefined); + + const secondary = resolveAccount(config, "secondary"); + assert.equal(secondary.alias, "secondary"); + assert.equal(secondary.remote_publish_host, "global.example.com"); + assert.equal(secondary.remote_publish_user, "deploy"); + assert.equal(secondary.remote_publish_port, 22); + assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com"); +}); + +test("resolveAccount drops invalid remote_publish_port and strict_host_key_checking values", async (t) => { + const cwdRoot = await makeTempDir("wechat-extend-cwd-"); + const homeRoot = await makeTempDir("wechat-extend-home-"); + + useCwd(t, cwdRoot); + useHome(t, homeRoot); + useXdgConfigHome(t, undefined); + + await writeExtendFile( + cwdRoot, + [ + "remote_publish_host: example.com", + "remote_publish_port: 99999", + "remote_publish_connect_timeout: 0", + "remote_publish_strict_host_key_checking: maybe", + ].join("\n"), + ); + + const config = loadWechatExtendConfig(); + const resolved = resolveAccount(config); + assert.equal(resolved.remote_publish_host, "example.com"); + assert.equal(resolved.remote_publish_port, undefined); + assert.equal(resolved.remote_publish_connect_timeout, undefined); + assert.equal(resolved.remote_publish_strict_host_key_checking, undefined); +}); + test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => { const cwdRoot = await makeTempDir("wechat-creds-cwd-"); const homeRoot = await makeTempDir("wechat-creds-home-"); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts index 2196980..d9f8014 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; +export type StrictHostKeyChecking = "yes" | "no" | "accept-new"; + export interface WechatAccount { name: string; alias: string; @@ -13,6 +15,14 @@ export interface WechatAccount { app_id?: string; app_secret?: string; chrome_profile_path?: string; + remote_publish_host?: string; + remote_publish_user?: string; + remote_publish_port?: number; + remote_publish_identity_file?: string; + remote_publish_known_hosts_file?: string; + remote_publish_strict_host_key_checking?: StrictHostKeyChecking; + remote_publish_connect_timeout?: number; + remote_publish_proxy_jump?: string; } export interface WechatExtendConfig { @@ -23,6 +33,14 @@ export interface WechatExtendConfig { need_open_comment?: number; only_fans_can_comment?: number; chrome_profile_path?: string; + remote_publish_host?: string; + remote_publish_user?: string; + remote_publish_port?: number; + remote_publish_identity_file?: string; + remote_publish_known_hosts_file?: string; + remote_publish_strict_host_key_checking?: StrictHostKeyChecking; + remote_publish_connect_timeout?: number; + remote_publish_proxy_jump?: string; accounts?: WechatAccount[]; } @@ -36,6 +54,14 @@ export interface ResolvedAccount { app_id?: string; app_secret?: string; chrome_profile_path?: string; + remote_publish_host?: string; + remote_publish_user?: string; + remote_publish_port?: number; + remote_publish_identity_file?: string; + remote_publish_known_hosts_file?: string; + remote_publish_strict_host_key_checking?: StrictHostKeyChecking; + remote_publish_connect_timeout?: number; + remote_publish_proxy_jump?: string; } function stripQuotes(s: string): string { @@ -46,6 +72,26 @@ function toBool01(v: string): number { return v === "1" || v === "true" ? 1 : 0; } +function toOptionalPort(v: string): number | undefined { + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined; + return n; +} + +function toOptionalPositiveInt(v: string): number | undefined { + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n; +} + +function toStrictHostKeyChecking(v: string): StrictHostKeyChecking | undefined { + const lower = v.toLowerCase(); + if (lower === "yes" || lower === "no" || lower === "accept-new") { + return lower; + } + return undefined; +} + function parseWechatExtend(content: string): WechatExtendConfig { const config: WechatExtendConfig = {}; const lines = content.split("\n"); @@ -106,6 +152,14 @@ function parseWechatExtend(content: string): WechatExtendConfig { case "need_open_comment": config.need_open_comment = toBool01(val); break; case "only_fans_can_comment": config.only_fans_can_comment = toBool01(val); break; case "chrome_profile_path": config.chrome_profile_path = val; break; + case "remote_publish_host": config.remote_publish_host = val; break; + case "remote_publish_user": config.remote_publish_user = val; break; + case "remote_publish_port": config.remote_publish_port = toOptionalPort(val); break; + case "remote_publish_identity_file": config.remote_publish_identity_file = val; break; + case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break; + case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = toStrictHostKeyChecking(val); break; + case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = toOptionalPositiveInt(val); break; + case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break; } } @@ -123,6 +177,18 @@ function parseWechatExtend(content: string): WechatExtendConfig { app_id: a.app_id || undefined, app_secret: a.app_secret || undefined, chrome_profile_path: a.chrome_profile_path || undefined, + remote_publish_host: a.remote_publish_host || undefined, + remote_publish_user: a.remote_publish_user || undefined, + remote_publish_port: a.remote_publish_port ? toOptionalPort(a.remote_publish_port) : undefined, + remote_publish_identity_file: a.remote_publish_identity_file || undefined, + remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined, + remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking + ? toStrictHostKeyChecking(a.remote_publish_strict_host_key_checking) + : undefined, + remote_publish_connect_timeout: a.remote_publish_connect_timeout + ? toOptionalPositiveInt(a.remote_publish_connect_timeout) + : undefined, + remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined, })); } @@ -168,6 +234,15 @@ export function resolveAccount(config: WechatExtendConfig, alias?: string): Reso app_id: acct?.app_id, app_secret: acct?.app_secret, chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path, + remote_publish_host: acct?.remote_publish_host ?? config.remote_publish_host, + remote_publish_user: acct?.remote_publish_user ?? config.remote_publish_user, + remote_publish_port: acct?.remote_publish_port ?? config.remote_publish_port, + remote_publish_identity_file: acct?.remote_publish_identity_file ?? config.remote_publish_identity_file, + remote_publish_known_hosts_file: acct?.remote_publish_known_hosts_file ?? config.remote_publish_known_hosts_file, + remote_publish_strict_host_key_checking: + acct?.remote_publish_strict_host_key_checking ?? config.remote_publish_strict_host_key_checking, + remote_publish_connect_timeout: acct?.remote_publish_connect_timeout ?? config.remote_publish_connect_timeout, + remote_publish_proxy_jump: acct?.remote_publish_proxy_jump ?? config.remote_publish_proxy_jump, }; } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts new file mode 100644 index 0000000..eb52b2e --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import test, { type TestContext } from "node:test"; + +import { buildMultipart, wechatHttp } from "./wechat-http.ts"; + +interface ReceivedRequest { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: Buffer; +} + +async function startEchoServer(t: TestContext): Promise<{ baseUrl: string; received: ReceivedRequest[] }> { + const received: ReceivedRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + received.push({ + method: req.method ?? "", + url: req.url ?? "", + headers: req.headers, + body: Buffer.concat(chunks), + }); + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: true, echo: { url: req.url, method: req.method } })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to start echo server"); + } + const baseUrl = `http://127.0.0.1:${address.port}`; + + t.after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + return { baseUrl, received }; +} + +test("wechatHttp performs a GET and passes through query string", async (t) => { + const { baseUrl, received } = await startEchoServer(t); + const res = await wechatHttp(`${baseUrl}/cgi-bin/token?grant_type=client_credential&appid=AID`); + assert.equal(res.status, 200); + const data = await res.json<{ ok: boolean; echo: { url: string; method: string } }>(); + assert.equal(data.ok, true); + assert.equal(received.length, 1); + assert.equal(received[0]!.method, "GET"); + assert.equal(received[0]!.url, "/cgi-bin/token?grant_type=client_credential&appid=AID"); + assert.equal(received[0]!.body.length, 0); +}); + +test("wechatHttp POST sends JSON body with content-length header", async (t) => { + const { baseUrl, received } = await startEchoServer(t); + const body = JSON.stringify({ articles: [{ title: "hi" }] }); + const res = await wechatHttp(`${baseUrl}/cgi-bin/draft/add?access_token=T`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + assert.equal(res.status, 200); + assert.equal(received.length, 1); + assert.equal(received[0]!.method, "POST"); + assert.equal(received[0]!.headers["content-type"], "application/json"); + assert.equal(received[0]!.headers["content-length"], String(Buffer.byteLength(body))); + assert.equal(received[0]!.body.toString("utf-8"), body); +}); + +test("wechatHttp .text() returns the raw response body", async (t) => { + const { baseUrl } = await startEchoServer(t); + const res = await wechatHttp(`${baseUrl}/hello`); + const text = await res.text(); + assert.match(text, /"ok":true/); +}); + +test("wechatHttp .buffer() returns a Buffer of the response body", async (t) => { + const { baseUrl } = await startEchoServer(t); + const res = await wechatHttp(`${baseUrl}/`); + const buf = await res.buffer(); + assert.ok(Buffer.isBuffer(buf)); + assert.ok(buf.length > 0); +}); + +test("buildMultipart produces a parsable multipart payload", () => { + const fileData = Buffer.from("ZZZ"); + const { contentType, body } = buildMultipart([ + { + name: "media", + filename: "image.png", + contentType: "image/png", + data: fileData, + }, + ]); + + const boundaryMatch = contentType.match(/^multipart\/form-data; boundary=(.+)$/); + assert.ok(boundaryMatch, `expected boundary in Content-Type, got ${contentType}`); + const boundary = boundaryMatch![1]!; + const text = body.toString("binary"); + assert.ok(text.startsWith(`--${boundary}\r\n`), "body must start with opening boundary"); + assert.ok( + text.endsWith(`--${boundary}--\r\n`), + "body must end with closing boundary", + ); + assert.match( + text, + /Content-Disposition: form-data; name="media"; filename="image\.png"\r\n/, + ); + assert.match(text, /Content-Type: image\/png\r\n/); + // The raw file bytes must appear verbatim after a blank line. + assert.ok( + text.includes("\r\n\r\nZZZ\r\n"), + "body must contain the raw file bytes after the part headers", + ); +}); + +test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) => { + const { baseUrl, received } = await startEchoServer(t); + const fileData = Buffer.from("HELLO"); + const multipart = buildMultipart([ + { name: "media", filename: "x.png", contentType: "image/png", data: fileData }, + ]); + const res = await wechatHttp(`${baseUrl}/cgi-bin/media/uploadimg?access_token=T`, { + method: "POST", + headers: { "Content-Type": multipart.contentType }, + body: multipart.body, + }); + assert.equal(res.status, 200); + assert.equal(received.length, 1); + assert.equal(received[0]!.method, "POST"); + assert.match(received[0]!.headers["content-type"]!, /^multipart\/form-data; boundary=/); + assert.ok(received[0]!.body.includes(Buffer.from("HELLO"))); +}); + +test("wechatHttp forwards the agent option to http.request", async (t) => { + const { baseUrl } = await startEchoServer(t); + const baseAgent = new http.Agent({ keepAlive: false }); + let addRequestCalls = 0; + const originalAddRequest = baseAgent.addRequest.bind(baseAgent); + baseAgent.addRequest = function (...callArgs: Parameters) { + addRequestCalls++; + return originalAddRequest(...callArgs); + }; + + await wechatHttp(`${baseUrl}/`, { agent: baseAgent }); + assert.ok(addRequestCalls > 0, "expected the supplied agent to handle the request"); + baseAgent.destroy(); +}); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-http.ts b/skills/baoyu-post-to-wechat/scripts/wechat-http.ts new file mode 100644 index 0000000..44ae3cd --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-http.ts @@ -0,0 +1,106 @@ +import http from "node:http"; +import https from "node:https"; +import { URL } from "node:url"; + +export interface WechatHttpInit { + method?: string; + headers?: Record; + body?: string | Buffer; + agent?: http.Agent | https.Agent | false; +} + +export interface WechatHttpResponse { + status: number; + statusText: string; + headers: Record; + buffer(): Promise; + text(): Promise; + json(): Promise; +} + +export interface MultipartFilePart { + name: string; + filename: string; + contentType: string; + data: Buffer; +} + +export interface MultipartBody { + contentType: string; + body: Buffer; +} + +export function buildMultipart(parts: MultipartFilePart[]): MultipartBody { + const boundary = `----WebKitFormBoundary${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`; + const chunks: Buffer[] = []; + + for (const part of parts) { + const header = + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` + + `Content-Type: ${part.contentType}\r\n\r\n`; + chunks.push(Buffer.from(header, "utf-8")); + chunks.push(part.data); + chunks.push(Buffer.from("\r\n", "utf-8")); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf-8")); + + return { + contentType: `multipart/form-data; boundary=${boundary}`, + body: Buffer.concat(chunks), + }; +} + +export async function wechatHttp(url: string, init: WechatHttpInit = {}): Promise { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const transport = isHttps ? https : http; + + const body = init.body == null + ? undefined + : Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body, "utf-8"); + + const headers: Record = { ...(init.headers ?? {}) }; + if (body && headers["Content-Length"] === undefined && headers["content-length"] === undefined) { + headers["Content-Length"] = String(body.length); + } + + const requestOptions: https.RequestOptions = { + method: init.method ?? (body ? "POST" : "GET"), + hostname: parsed.hostname, + port: parsed.port ? Number(parsed.port) : undefined, + path: `${parsed.pathname}${parsed.search}`, + headers, + }; + if (init.agent !== undefined) { + requestOptions.agent = init.agent; + } + + return new Promise((resolve, reject) => { + const req = transport.request(requestOptions, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const bodyBuffer = Buffer.concat(chunks); + resolve({ + status: res.statusCode ?? 0, + statusText: res.statusMessage ?? "", + headers: res.headers as Record, + async buffer() { + return bodyBuffer; + }, + async text() { + return bodyBuffer.toString("utf-8"); + }, + async json() { + return JSON.parse(bodyBuffer.toString("utf-8")) as T; + }, + }); + }); + res.on("error", reject); + }); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-image-loader.ts b/skills/baoyu-post-to-wechat/scripts/wechat-image-loader.ts new file mode 100644 index 0000000..ba2dd69 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-image-loader.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + type WechatUploadAsset, + detectImageFormatFromBuffer, +} from "./wechat-image-processor.ts"; + +export type { WechatUploadAsset }; + +const MIME_TYPES_BY_EXT: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +export async function loadUploadAsset( + imagePath: string, + baseDir?: string, +): Promise { + let fileBuffer: Buffer; + let filename: string; + let contentType: string; + let fileSize = 0; + let fileExt = ""; + + if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { + const response = await fetch(imagePath); + if (!response.ok) { + throw new Error(`Failed to download image: ${imagePath}`); + } + const buffer = await response.arrayBuffer(); + if (buffer.byteLength === 0) { + throw new Error(`Remote image is empty: ${imagePath}`); + } + fileBuffer = Buffer.from(buffer); + fileSize = buffer.byteLength; + const urlPath = imagePath.split("?")[0]!; + filename = path.basename(urlPath) || "image.jpg"; + fileExt = path.extname(filename).toLowerCase(); + contentType = response.headers.get("content-type") || "image/jpeg"; + } else { + const resolvedPath = path.isAbsolute(imagePath) + ? imagePath + : path.resolve(baseDir || process.cwd(), imagePath); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Image not found: ${resolvedPath}`); + } + const stats = fs.statSync(resolvedPath); + if (stats.size === 0) { + throw new Error(`Local image is empty: ${resolvedPath}`); + } + fileSize = stats.size; + fileBuffer = fs.readFileSync(resolvedPath); + filename = path.basename(resolvedPath); + fileExt = path.extname(filename).toLowerCase(); + contentType = MIME_TYPES_BY_EXT[fileExt] || "image/jpeg"; + } + + const detected = detectImageFormatFromBuffer(fileBuffer); + if (detected && detected.contentType !== contentType) { + console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`); + contentType = detected.contentType; + fileExt = detected.fileExt; + filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`; + } + + return { + buffer: fileBuffer, + filename, + contentType, + fileExt, + fileSize, + }; +} diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.test.ts new file mode 100644 index 0000000..f27cd14 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import net from "node:net"; +import test from "node:test"; + +import { + buildSshArgs, + findFreePort, + normalizeRemoteConfig, +} from "./wechat-remote-publish.ts"; + +test("normalizeRemoteConfig requires a host", () => { + assert.throws( + () => normalizeRemoteConfig({ host: "" }), + /Remote publish host is required/, + ); + assert.throws( + () => normalizeRemoteConfig({ host: " " }), + /Remote publish host is required/, + ); +}); + +test("normalizeRemoteConfig applies user/port defaults and trims host", () => { + const result = normalizeRemoteConfig({ host: " example.com " }); + assert.equal(result.host, "example.com"); + assert.equal(result.user, "root"); + assert.equal(result.port, 22); + assert.equal(result.identityFile, undefined); + assert.equal(result.knownHostsFile, undefined); + assert.equal(result.strictHostKeyChecking, undefined); + assert.equal(result.connectTimeout, undefined); + assert.equal(result.proxyJump, undefined); +}); + +test("normalizeRemoteConfig preserves explicit user, port, and SSH options", () => { + const result = normalizeRemoteConfig({ + host: "example.com", + user: "deploy", + port: 2222, + identityFile: "/home/me/.ssh/id_ed25519", + knownHostsFile: "/home/me/.ssh/known_hosts", + strictHostKeyChecking: "accept-new", + connectTimeout: 15, + proxyJump: "bastion.example.com", + }); + assert.equal(result.user, "deploy"); + assert.equal(result.port, 2222); + assert.equal(result.identityFile, "/home/me/.ssh/id_ed25519"); + assert.equal(result.knownHostsFile, "/home/me/.ssh/known_hosts"); + assert.equal(result.strictHostKeyChecking, "accept-new"); + assert.equal(result.connectTimeout, 15); + assert.equal(result.proxyJump, "bastion.example.com"); +}); + +test("normalizeRemoteConfig rejects invalid port", () => { + assert.throws( + () => normalizeRemoteConfig({ host: "example.com", port: 0 }), + /Invalid remote publish port/, + ); + assert.throws( + () => normalizeRemoteConfig({ host: "example.com", port: 65536 }), + /Invalid remote publish port/, + ); + assert.throws( + () => normalizeRemoteConfig({ host: "example.com", port: 1.5 }), + /Invalid remote publish port/, + ); +}); + +test("normalizeRemoteConfig rejects invalid connect timeout", () => { + assert.throws( + () => normalizeRemoteConfig({ host: "example.com", connectTimeout: 0 }), + /Invalid remote_publish_connect_timeout/, + ); + assert.throws( + () => normalizeRemoteConfig({ host: "example.com", connectTimeout: -3 }), + /Invalid remote_publish_connect_timeout/, + ); +}); + +test("normalizeRemoteConfig rejects invalid strict host key checking", () => { + assert.throws( + () => + normalizeRemoteConfig({ + host: "example.com", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + strictHostKeyChecking: "maybe" as any, + }), + /Invalid remote_publish_strict_host_key_checking/, + ); +}); + +test("normalizeRemoteConfig falls back to default user when blank string provided", () => { + const result = normalizeRemoteConfig({ host: "example.com", user: " " }); + assert.equal(result.user, "root"); +}); + +test("buildSshArgs emits the whitelisted minimum set", () => { + const args = buildSshArgs( + { host: "example.com", user: "root", port: 22 }, + 1080, + ); + assert.deepEqual(args, [ + "-N", + "-T", + "-D", "127.0.0.1:1080", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-p", "22", + "root@example.com", + ]); +}); + +test("buildSshArgs threads optional ssh options in stable order", () => { + const args = buildSshArgs( + { + host: "example.com", + user: "deploy", + port: 2222, + identityFile: "/p/id_ed25519", + knownHostsFile: "/p/known_hosts", + strictHostKeyChecking: "accept-new", + connectTimeout: 12, + proxyJump: "bastion.example.com", + }, + 1080, + ); + assert.deepEqual(args, [ + "-N", + "-T", + "-D", "127.0.0.1:1080", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-p", "2222", + "-i", "/p/id_ed25519", + "-o", "UserKnownHostsFile=/p/known_hosts", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=12", + "-J", "bastion.example.com", + "deploy@example.com", + ]); +}); + +test("buildSshArgs does not emit raw ssh options for unknown fields", () => { + const args = buildSshArgs( + { + host: "example.com", + user: "root", + port: 22, + // No extra unknown keys are accepted — typed config is the whitelist. + }, + 1080, + ); + assert.equal( + args.filter((a) => a === "-o" || a.startsWith("--")).length, + 3, // ExitOnForwardFailure, ServerAliveInterval, ServerAliveCountMax (and only those) + "buildSshArgs must only emit the three baseline -o options when no extras given", + ); +}); + +test("buildSshArgs rejects invalid SOCKS port", () => { + assert.throws( + () => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 0), + /Invalid SOCKS port/, + ); + assert.throws( + () => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 70_000), + /Invalid SOCKS port/, + ); +}); + +test("findFreePort returns a usable loopback port", async () => { + const port = await findFreePort(); + assert.ok(port > 0 && port < 65536, `expected valid port, got ${port}`); + + await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }); +}); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts b/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts new file mode 100644 index 0000000..1dfda69 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts @@ -0,0 +1,273 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import net from "node:net"; +import { SocksProxyAgent } from "socks-proxy-agent"; +import type { Readable } from "node:stream"; + +import type { StrictHostKeyChecking } from "./wechat-extend-config.ts"; + +export interface RemotePublishConfig { + host: string; + user?: string; + port?: number; + identityFile?: string; + knownHostsFile?: string; + strictHostKeyChecking?: StrictHostKeyChecking; + connectTimeout?: number; + proxyJump?: string; +} + +export interface NormalizedRemotePublishConfig { + host: string; + user: string; + port: number; + identityFile?: string; + knownHostsFile?: string; + strictHostKeyChecking?: StrictHostKeyChecking; + connectTimeout?: number; + proxyJump?: string; +} + +export interface SshTunnel { + port: number; + agent: SocksProxyAgent; + close: () => Promise; +} + +export interface StartSshTunnelOptions { + readyTimeoutMs?: number; + killTimeoutMs?: number; +} + +const DEFAULT_USER = "root"; +const DEFAULT_PORT = 22; +const DEFAULT_READY_TIMEOUT_MS = 10_000; +const DEFAULT_KILL_TIMEOUT_MS = 3_000; +const SSH_LOOPBACK_HOST = "127.0.0.1"; + +export function normalizeRemoteConfig(config: RemotePublishConfig): NormalizedRemotePublishConfig { + if (!config.host || !config.host.trim()) { + throw new Error("Remote publish host is required (set remote_publish_host or --remote-host)."); + } + + const port = config.port ?? DEFAULT_PORT; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid remote publish port: ${config.port}`); + } + + if (config.connectTimeout !== undefined) { + if (!Number.isInteger(config.connectTimeout) || config.connectTimeout <= 0) { + throw new Error(`Invalid remote_publish_connect_timeout: ${config.connectTimeout}`); + } + } + + if ( + config.strictHostKeyChecking !== undefined && + config.strictHostKeyChecking !== "yes" && + config.strictHostKeyChecking !== "no" && + config.strictHostKeyChecking !== "accept-new" + ) { + throw new Error(`Invalid remote_publish_strict_host_key_checking: ${config.strictHostKeyChecking}`); + } + + return { + host: config.host.trim(), + user: (config.user ?? DEFAULT_USER).trim() || DEFAULT_USER, + port, + identityFile: config.identityFile, + knownHostsFile: config.knownHostsFile, + strictHostKeyChecking: config.strictHostKeyChecking, + connectTimeout: config.connectTimeout, + proxyJump: config.proxyJump, + }; +} + +export function buildSshArgs(config: NormalizedRemotePublishConfig, socksPort: number): string[] { + if (!Number.isInteger(socksPort) || socksPort < 1 || socksPort > 65535) { + throw new Error(`Invalid SOCKS port: ${socksPort}`); + } + + const args: string[] = [ + "-N", + "-T", + "-D", `${SSH_LOOPBACK_HOST}:${socksPort}`, + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-p", String(config.port), + ]; + + if (config.identityFile) { + args.push("-i", config.identityFile); + } + if (config.knownHostsFile) { + args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`); + } + if (config.strictHostKeyChecking) { + args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking}`); + } + if (config.connectTimeout !== undefined) { + args.push("-o", `ConnectTimeout=${config.connectTimeout}`); + } + if (config.proxyJump) { + args.push("-J", config.proxyJump); + } + + args.push(`${config.user}@${config.host}`); + return args; +} + +export async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, SSH_LOOPBACK_HOST, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire free port"))); + return; + } + const port = address.port; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +export async function waitForSocksReady(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = undefined; + + while (Date.now() < deadline) { + try { + await tryConnect(port); + return; + } catch (err) { + lastError = err; + await sleep(150); + } + } + throw new Error(`SOCKS proxy on ${SSH_LOOPBACK_HOST}:${port} not ready within ${timeoutMs}ms${lastError ? `: ${(lastError as Error).message}` : ""}`); +} + +function tryConnect(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: SSH_LOOPBACK_HOST, port }); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("error", (err) => { + socket.destroy(); + reject(err); + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function startSshTunnel( + config: NormalizedRemotePublishConfig, + options: StartSshTunnelOptions = {}, +): Promise { + const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; + const killTimeout = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS; + + const port = await findFreePort(); + const args = buildSshArgs(config, port); + + console.error(`[wechat-remote-publish] Starting SSH SOCKS5 tunnel: ssh ${args.join(" ")}`); + const child = spawn("ssh", args, { + stdio: ["ignore", "pipe", "pipe"], + }) as ChildProcessByStdio; + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk: Buffer) => { + stderrChunks.push(chunk.toString("utf-8")); + }); + + let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined; + child.once("exit", (code, signal) => { + earlyExit = { code, signal }; + }); + + try { + await waitForSocksReady(port, readyTimeout); + } catch (err) { + await killChild(child, killTimeout); + const stderrTail = stderrChunks.join("").trim().split("\n").slice(-5).join("\n"); + const suffix = stderrTail ? `\nssh stderr (tail):\n${stderrTail}` : ""; + const exitSuffix = earlyExit + ? `\nssh exited early with code=${earlyExit.code} signal=${earlyExit.signal}` + : ""; + throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`); + } + + const agent = new SocksProxyAgent(`socks5h://${SSH_LOOPBACK_HOST}:${port}`); + + const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = []; + let closed = false; + const close = async (): Promise => { + if (closed) return; + closed = true; + for (const { signal, handler } of signalHandlers) { + process.off(signal, handler); + } + await killChild(child, killTimeout); + }; + + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + const handler = () => { + void close(); + }; + process.once(signal, handler); + signalHandlers.push({ signal, handler }); + } + + return { port, agent, close }; +} + +async function killChild(child: ChildProcessByStdio, killTimeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + try { + child.kill("SIGTERM"); + } catch { + /* already dead */ + } + + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already dead */ + } + }, killTimeoutMs); + + try { + await exited; + } finally { + clearTimeout(timer); + } +} + +export async function withSshTunnel( + config: NormalizedRemotePublishConfig, + fn: (agent: SocksProxyAgent) => Promise, + options?: StartSshTunnelOptions, +): Promise { + const tunnel = await startSshTunnel(config, options); + try { + return await fn(tunnel.agent); + } finally { + await tunnel.close(); + } +}