mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-07 17:33:03 +08:00
feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist constraints) with a fully local code path: spawn a background `ssh -N -D` SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through the existing token/upload/draft flow. No Python helper, no SCP, no remote files, no `AppSecret` ever leaving the local process. Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag rather than substring-replacing `src`) naturally avoids the original PR's `html.replace(src, url)` HTML-replacement bug. Changes: - New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port allocation, SOCKS readiness polling, signal-handler-backed cleanup. - New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a `SocksProxyAgent` can be passed through (undici `fetch` ignores agent). - New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse. - `wechat-api.ts`: thread optional `agent?` through token/upload/draft; add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote mode is selected by flag or `default_publish_method: remote-api`. - `wechat-extend-config.ts`: add typed `remote_publish_*` keys with account-over-global fallback and value validation. - Docs: SKILL.md, references/multi-account.md, references/config/ first-time-setup.md, README.md, README.zh.md. - Tests: 17 new node:test cases covering config parsing, SSH arg whitelisting, free-port allocation, multipart assembly, and HTTP agent threading. Co-authored-by: Dame5211 <1079825614@qq.com>
This commit is contained in:
@@ -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<void>((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()));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user