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>
230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
import net from "node:net";
|
|
import tls from "node:tls";
|
|
import { URL } from "node:url";
|
|
import { SocksClient } from "socks";
|
|
|
|
import type {
|
|
WechatClient,
|
|
WechatHttpInit,
|
|
WechatHttpResponse,
|
|
} from "./wechat-http.ts";
|
|
|
|
export interface SocksProxyEndpoint {
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient {
|
|
if (!proxy.host) throw new Error("SOCKS proxy host required");
|
|
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
|
|
throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`);
|
|
}
|
|
|
|
return async (url, init = {}) => {
|
|
return wechatHttpViaSocks(url, init, proxy);
|
|
};
|
|
}
|
|
|
|
async function wechatHttpViaSocks(
|
|
rawUrl: string,
|
|
init: WechatHttpInit,
|
|
proxy: SocksProxyEndpoint,
|
|
): Promise<WechatHttpResponse> {
|
|
const url = new URL(rawUrl);
|
|
const isHttps = url.protocol === "https:";
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`);
|
|
}
|
|
const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80;
|
|
|
|
const { socket: tcpSocket } = await SocksClient.createConnection({
|
|
proxy: { host: proxy.host, port: proxy.port, type: 5 },
|
|
command: "connect",
|
|
destination: { host: url.hostname, port: targetPort },
|
|
});
|
|
|
|
let stream: net.Socket | tls.TLSSocket;
|
|
if (isHttps) {
|
|
const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname });
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onSecure = () => {
|
|
tlsSocket.removeListener("error", onError);
|
|
resolve();
|
|
};
|
|
const onError = (err: Error) => {
|
|
tlsSocket.removeListener("secureConnect", onSecure);
|
|
try {
|
|
tlsSocket.destroy();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
try {
|
|
tcpSocket.destroy();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
reject(err);
|
|
};
|
|
tlsSocket.once("secureConnect", onSecure);
|
|
tlsSocket.once("error", onError);
|
|
});
|
|
stream = tlsSocket;
|
|
} else {
|
|
stream = tcpSocket;
|
|
}
|
|
|
|
try {
|
|
return await sendRequestAndReadResponse(stream, url, init);
|
|
} finally {
|
|
try {
|
|
stream.destroy();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendRequestAndReadResponse(
|
|
stream: net.Socket | tls.TLSSocket,
|
|
url: URL,
|
|
init: WechatHttpInit,
|
|
): Promise<WechatHttpResponse> {
|
|
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
|
const body =
|
|
init.body === undefined
|
|
? undefined
|
|
: Buffer.isBuffer(init.body)
|
|
? init.body
|
|
: Buffer.from(init.body, "utf-8");
|
|
|
|
const userHeaders = init.headers ?? {};
|
|
const headerMap = new Map<string, string>();
|
|
for (const [k, v] of Object.entries(userHeaders)) {
|
|
headerMap.set(k.toLowerCase(), `${k}: ${v}`);
|
|
}
|
|
if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`);
|
|
if (!headerMap.has("user-agent")) {
|
|
headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api");
|
|
}
|
|
headerMap.set("connection", "Connection: close");
|
|
if (body && !headerMap.has("content-length")) {
|
|
headerMap.set("content-length", `Content-Length: ${body.length}`);
|
|
}
|
|
|
|
const path = `${url.pathname || "/"}${url.search}`;
|
|
const requestHeader = Buffer.from(
|
|
`${method} ${path} HTTP/1.1\r\n` +
|
|
Array.from(headerMap.values()).join("\r\n") +
|
|
"\r\n\r\n",
|
|
"utf-8",
|
|
);
|
|
|
|
await writeAll(stream, requestHeader);
|
|
if (body) await writeAll(stream, body);
|
|
|
|
const raw = await readToEnd(stream);
|
|
return parseHttpResponse(raw);
|
|
}
|
|
|
|
function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
stream.write(data, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function readToEnd(stream: net.Socket | tls.TLSSocket): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
stream.once("end", () => resolve(Buffer.concat(chunks)));
|
|
stream.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function parseHttpResponse(raw: Buffer): WechatHttpResponse {
|
|
const headerEnd = raw.indexOf("\r\n\r\n");
|
|
if (headerEnd < 0) {
|
|
throw new Error("Malformed HTTP response: missing header terminator");
|
|
}
|
|
const headerText = raw.subarray(0, headerEnd).toString("utf-8");
|
|
let bodyBytes = raw.subarray(headerEnd + 4);
|
|
|
|
const lines = headerText.split("\r\n");
|
|
const statusLine = lines.shift() ?? "";
|
|
const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/);
|
|
if (!statusMatch) {
|
|
throw new Error(`Malformed HTTP status line: ${statusLine}`);
|
|
}
|
|
const status = Number.parseInt(statusMatch[1]!, 10);
|
|
const statusText = statusMatch[2] ?? "";
|
|
|
|
const headers: Record<string, string | string[] | undefined> = {};
|
|
const lowercaseHeaders: Record<string, string> = {};
|
|
for (const line of lines) {
|
|
const colon = line.indexOf(":");
|
|
if (colon < 0) continue;
|
|
const key = line.slice(0, colon).trim();
|
|
const value = line.slice(colon + 1).trim();
|
|
const lower = key.toLowerCase();
|
|
const existing = headers[lower];
|
|
if (existing === undefined) {
|
|
headers[lower] = value;
|
|
} else if (Array.isArray(existing)) {
|
|
existing.push(value);
|
|
} else {
|
|
headers[lower] = [existing, value];
|
|
}
|
|
lowercaseHeaders[lower] = value;
|
|
}
|
|
|
|
const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase();
|
|
if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) {
|
|
bodyBytes = dechunk(bodyBytes);
|
|
} else if (lowercaseHeaders["content-length"] !== undefined) {
|
|
const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10);
|
|
if (Number.isFinite(length) && length >= 0) {
|
|
bodyBytes = bodyBytes.subarray(0, length);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status,
|
|
statusText,
|
|
headers,
|
|
async buffer() {
|
|
return bodyBytes;
|
|
},
|
|
async text() {
|
|
return bodyBytes.toString("utf-8");
|
|
},
|
|
async json<T = unknown>() {
|
|
return JSON.parse(bodyBytes.toString("utf-8")) as T;
|
|
},
|
|
};
|
|
}
|
|
|
|
function dechunk(raw: Buffer): Buffer {
|
|
const parts: Buffer[] = [];
|
|
let offset = 0;
|
|
while (offset < raw.length) {
|
|
const lineEnd = raw.indexOf("\r\n", offset);
|
|
if (lineEnd < 0) break;
|
|
const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim();
|
|
const size = Number.parseInt(sizeText, 16);
|
|
if (!Number.isFinite(size) || size < 0) {
|
|
throw new Error(`Invalid chunked-encoding size: ${sizeText}`);
|
|
}
|
|
offset = lineEnd + 2;
|
|
if (size === 0) break;
|
|
if (offset + size > raw.length) {
|
|
throw new Error("Chunked-encoding body truncated");
|
|
}
|
|
parts.push(raw.subarray(offset, offset + size));
|
|
offset += size + 2;
|
|
}
|
|
return Buffer.concat(parts);
|
|
}
|