mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-09 20:51:22 +00:00
e0b861c148
The initial remote-api implementation (3b29f3c) relied on
`https.request({ agent: SocksProxyAgent })` to route token/upload/draft
calls through the SSH tunnel. Bun's `https.request` does not honor
Node's `http.Agent` contract, so the agent was silently bypassed and
requests still originated from the local IP — defeating the entire
IP-allowlist purpose. Two follow-on issues compounded it: tests read
the real `~/.baoyu-skills/.env` because Bun's `os.homedir()` ignores
test-time `process.env.HOME` mutations, and invalid config values were
silently coerced to defaults.
P1 — Bun-portable SOCKS routing:
- Drop `socks-proxy-agent` dependency. Add `socks` direct dep.
- New `wechat-socks-http.ts`: raw TCP via `SocksClient.createConnection`
+ `tls.connect({ socket, servername })` + hand-built HTTP/1.1 (status
line parser, case-insensitive headers, chunked & content-length body
framing). Works identically under Node and Bun because it avoids
`http.Agent` entirely.
- Rewrite `wechat-http.ts` as a fetch-based local client and expose
a `WechatClient = (url, init?) => Promise<WechatHttpResponse>`
functional abstraction.
- `wechat-api.ts`: replace `agent?: http.Agent` with
`client: WechatClient = wechatHttp` on the five HTTP-touching
functions; `withSshTunnel` now yields a `WechatClient`.
- New `wechat-socks-http.test.ts` stands up a real SOCKS5 server
stub + HTTP echo server and asserts `connectionCount === 1`,
proving bytes actually traverse the proxy under both runtimes.
P2 — `HOME` honored under Bun:
- `homeDir()` reads `process.env.HOME` / `USERPROFILE` first, falling
back to `os.homedir()`. `loadWechatExtendConfig` and `loadCredentials`
use it, restoring test isolation.
P3 — Strict config validation:
- Replace lenient `toOptional*` helpers with `parsePort` /
`parsePositiveInt` / `parseStrictHostKeyChecking` that throw with
the key name. `loadWechatExtendConfig` only catches file-read
errors so parse errors surface to the caller. Flip the corresponding
test cases.
Verification:
- `npm test`: 261/261 pass.
- `bun test` in `scripts/`: 39/39 pass.
Co-authored-by: Dame5211 <1079825614@qq.com>
275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
import net from "node:net";
|
|
import type { Readable } from "node:stream";
|
|
|
|
import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";
|
|
import type { WechatClient } from "./wechat-http.ts";
|
|
import { createSocksClient } from "./wechat-socks-http.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;
|
|
client: WechatClient;
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
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<number> {
|
|
return new Promise<number>((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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export async function startSshTunnel(
|
|
config: NormalizedRemotePublishConfig,
|
|
options: StartSshTunnelOptions = {},
|
|
): Promise<SshTunnel> {
|
|
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<null, Readable, Readable>;
|
|
|
|
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 client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });
|
|
|
|
const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
|
|
let closed = false;
|
|
const close = async (): Promise<void> => {
|
|
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, client, close };
|
|
}
|
|
|
|
async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return;
|
|
}
|
|
const exited = new Promise<void>((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<T>(
|
|
config: NormalizedRemotePublishConfig,
|
|
fn: (client: WechatClient) => Promise<T>,
|
|
options?: StartSshTunnelOptions,
|
|
): Promise<T> {
|
|
const tunnel = await startSshTunnel(config, options);
|
|
try {
|
|
return await fn(tunnel.client);
|
|
} finally {
|
|
await tunnel.close();
|
|
}
|
|
}
|