From ef86a4c201bd92936ef2f30bb2db3956698909c2 Mon Sep 17 00:00:00 2001 From: JGZYES Date: Fri, 14 Aug 2026 11:59:43 +0800 Subject: [PATCH] =?UTF-8?q?pssh=20ssh=20=E8=A1=A5=E5=85=A8=20ProxyJump=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=89=E9=A1=B9=E4=B8=8E=E6=96=87=E6=A1=A3?= =?UTF-8?q?:=20=E6=96=B0=E5=A2=9E=20-W=20host:port(stdio/netcat=20?= =?UTF-8?q?=E8=BD=AC=E5=8F=91)=E4=B8=8E=20-N(=E4=BB=85=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E4=B8=8D=E6=89=A7=E8=A1=8C=E5=91=BD=E4=BB=A4?= =?UTF-8?q?);=20-J=20=E6=94=AF=E6=8C=81=E9=80=97=E5=8F=B7=E5=88=86?= =?UTF-8?q?=E9=9A=94=E5=A4=9A=E8=B7=B3=E8=AF=AD=E6=B3=95=E4=BD=86=E5=AF=B9?= =?UTF-8?q?=E5=A4=9A=E7=BA=A7=E7=BA=A7=E8=81=94=E9=99=8D=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E4=BB=85=E7=AC=AC=E4=B8=80=E8=B7=B3(=E5=90=8C=E6=AD=A5=20I/O?= =?UTF-8?q?=20=E6=9E=B6=E6=9E=84=E4=B8=8B=E5=A4=9A=E8=B7=B3=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E6=95=B0=E6=8D=AE=E9=98=B6=E6=AE=B5=E4=B8=8D=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A,=E5=AE=9E=E6=B5=8B=E9=AA=8C=E8=AF=81=E5=B9=B6?= =?UTF-8?q?=E5=A6=82=E5=AE=9E=E9=99=8D=E7=BA=A7);=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E8=B7=B3=E6=9D=BF=20io=20ctx=20=E6=9C=AA=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E7=9A=84=E6=B3=84=E6=BC=8F;=20=E5=8D=95=E8=B7=B3=20-J/?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=BF=9E=E6=8E=A5=E5=9B=9E=E5=BD=92=E9=80=9A?= =?UTF-8?q?=E8=BF=87,=20=E6=9E=84=E5=BB=BA=E6=97=A0=E8=AD=A6=E5=91=8A;=20S?= =?UTF-8?q?TATUS/AGENT/CHANGELOG=20=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.MD | 10 +- CHANGELOG.md | 1 + STATUS.md | 2 +- apps/ssh/main.c | 247 +++++++++++++++++++++++++++++++++++------------- 4 files changed, 193 insertions(+), 67 deletions(-) diff --git a/AGENT.MD b/AGENT.MD index 089e5a1..a5ea6d0 100644 --- a/AGENT.MD +++ b/AGENT.MD @@ -59,5 +59,11 @@ STATUS.md 完成度总表 AGENT.md 本文件 - `ecdsa.c` `bits2int`:输入短于群阶 qlen 时应右对齐而非左移(否则 P-384+SHA-256 验签错误) - `tls_conn.c` 新增 `paze_tls_store_peer_cert`:对端证书 DER 拷贝到自有缓冲,避免 issuer/subject 指针指向被记录层复用的 `app_buf` 导致链验证失败(TLS 1.3 与 1.2 均在 Certificate 解析处使用) -### 3. SSH ProxyJump -客户端连接管理(经跳板机转发),中等。 +### 3. SSH ProxyJump(✅ 基础完成) + +客户端经跳板机转发连接目标主机,核心链路已实现(`apps/ssh/main.c` 初始版本即含): +- `-J `:`connect_target()` 在跳板会话上用 `direct-tcpip` 通道承载目标 SSH 握手(嵌套会话),主事件循环直接轮询跳板 socket。 +- config 文件 `ProxyJump` 字段解析(`src/ssh/ssh_config.c`)与匹配。 +- 补全(2026-08-14):`pssh ssh -W host:port`(stdio/netcat 转发,目标 TCP 隧道桥接本地 stdio)、`-N`(仅端口转发不执行远程命令,配 `-L/-R/-D`)。 + +已知限制:`-J` 接受逗号分隔的多跳语法,但多级级联跳板在同步 I/O 架构下的应用数据阶段交互不稳定,实测多跳握手可完成但握手后易卡;故当前对多跳打印警告并**退化为仅第一跳的稳定单跳**。单跳与 `-W`/`-N` 均已实测验证。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c79af8..28a1238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - 新增 `CHANGELOG.md`;更新 `STATUS.md` / `AGENT.md`。 - 规则:Git 推送仅发往 parlz(gitea),不再提交 origin(GitHub)。 +- **SSH ProxyJump**:确认 `-J` 代理跳板(单跳)核心链路已实现并同步到文档;新增 `pssh ssh -W host:port`(标准 I/O 转发/netcat 模式,目标 TCP 隧道桥接本地 stdio)与 `-N`(不执行远程命令、仅端口转发,配合 `-L/-R/-D`)。说明:`-J` 接受逗号分隔的多跳语法,但多级级联跳板在同步 I/O 架构下的应用数据阶段交互不稳定,当前会打印警告并退化为**仅第一跳**的稳定单跳。 ## [功能阶段] — 2026-08(TLS 1.3 完善期) diff --git a/STATUS.md b/STATUS.md index 2463d5f..2a0bcec 100644 --- a/STATUS.md +++ b/STATUS.md @@ -41,7 +41,7 @@ - 通道:session(exec/shell 真实进程执行)、direct-tcpip(出站桥接) - **SFTP 子系统 v3**:OPEN/READ/WRITE/OPENDIR/READDIR/STAT/REALPATH 等,与 OpenSSH sftp / scp -s 互操作(Git OpenSSH 实测通过) - 客户端命令: - - `pssh ssh` — 远程 shell / 执行命令,`-L`/`-R`/`-D` 端口转发 + - `pssh ssh` — 远程 shell / 执行命令,`-L`/`-R`/`-D` 端口转发,`-J` 代理跳板(单跳)、`-W host:port` stdio 转发、`-N` 仅转发 - `pssh scp` — SFTP 优先(OpenSSH 9.x 默认),回退 legacy SCP;`-r` 递归、`-p` 保留时间戳 - `pssh keygen` / `keyscan` / `copy-id` — 密钥工具 - `pssh agent` / `add` / `ssha` — agent 守护进程与密钥管理 diff --git a/apps/ssh/main.c b/apps/ssh/main.c index 4593af7..7c664c6 100644 --- a/apps/ssh/main.c +++ b/apps/ssh/main.c @@ -172,11 +172,13 @@ static int auth_session(ssh_session_t *s, const char *user, return ok; } -/* ---------------- 代理跳转 (-J):跳板通道上的嵌套会话 ---------------- */ +/* ---------------- 代理跳转 (-J / -J h1,h2):跳板通道上的嵌套会话 ---------------- */ -typedef struct { - ssh_session_t *jump; +typedef struct jump_ctx { + ssh_session_t *jump; /* 本层 io 数据收发的承载会话 */ uint32_t ch; + ssh_session_t *owned_session; /* 随本 ctx 一并释放的下一跳承载会话(非最顶层) */ + struct jump_ctx *outer; /* 更外层 io ctx(资源释放链) */ } jump_ctx_t; static int jump_read(void *ctx, uint8_t *buf, size_t n) { @@ -198,62 +200,125 @@ static int jump_write(void *ctx, const uint8_t *buf, size_t n) { return ssh_channel_send_data(jc->jump, jc->ch, buf, n) < 0 ? -1 : (int)n; } -/* 连接目标(可选经跳板)。jump_out 输出跳板会话(非 NULL 时由调用方释放)。 - * 返回已通过握手、未认证的会话。 */ +/* 一个跳板实体:[user@]host[:port] */ +typedef struct { + char user[128]; + char host[256]; + int port; +} jump_hop_t; + +#define PSSH_MAX_JUMPS 8 + +/* 解析 -J 逗号分隔链: "h1,h2@u:port,..."。返回跳数(0=无)。 */ +static int parse_jumps(const char *spec, jump_hop_t *hops, int maxhops) { + if (!spec || !*spec) return 0; + int n = 0; + const char *start = spec; + for (const char *p = spec; ; p++) { + if (*p == ',' || *p == '\0') { + if (n >= maxhops) break; + size_t len = (size_t)(p - start); + char buf[512]; + if (len >= sizeof(buf)) len = sizeof(buf) - 1; + memcpy(buf, start, len); buf[len] = '\0'; + int port = 22; + char user[128] = "", host[256] = ""; + parse_userhost(buf, user, sizeof(user), host, sizeof(host), &port); + if (!host[0]) break; + snprintf(hops[n].user, sizeof(hops[n].user), "%s", user); + snprintf(hops[n].host, sizeof(hops[n].host), "%s", host); + hops[n].port = port; + n++; + if (*p == '\0') break; + start = p + 1; + } + } + return n; +} + +/* 连接目标(可选经链式跳板 -J h1,h2,...)。 + * jump_out 输出最顶层跳板会话(有真实 socket,供事件循环轮询), + * 由调用方 ssh_session_free 释放。 + * 返回已通过握手、未认证的目标会话 s;若 s 经跳板建立,其 io ctx(jump_ctx 链) + * 一并承载各中间/最后跳板会话的释放,调用方 free(ssh_session_io_ctx(s)) 时回收。 */ static ssh_session_t *connect_target(const char *host, int port, const char *jump_spec, const char *user, const char *password, const char *keyfile, int hkmode, ssh_session_t **jump_out) { *jump_out = NULL; + jump_hop_t hops[PSSH_MAX_JUMPS]; + int nhops = parse_jumps(jump_spec, hops, PSSH_MAX_JUMPS); + if (nhops > 1) { + /* 多级级联跳板在当前同步 I/O 架构下的应用数据阶段交互不稳定, + * 如实降级为「仅第一跳」的稳定单跳,并明确告知用户。 */ + fprintf(stderr, "pssh: warning: chained multi-hop ProxyJump (%d hops) is unsupported; " + "using only the first jump %s@%s:%d\n", + nhops, hops[0].user[0] ? hops[0].user : (user ? user : ""), + hops[0].host, hops[0].port); + nhops = 1; + } + ssh_session_t *s = ssh_session_new(0); if (!s) return NULL; ssh_session_set_hostkey_check(s, hkmode); ssh_session_set_verbose(s, g_verbose); ssh_session_set_cipher(s, g_cipher); - if (jump_spec && *jump_spec) { - char juser[128], jhost[256]; - int jport = 22; - parse_userhost(jump_spec, juser, sizeof(juser), jhost, sizeof(jhost), &jport); - if (!juser[0]) snprintf(juser, sizeof(juser), "%s", user); - fprintf(stderr, "pssh: proxyjump %s@%s:%d\n", juser, jhost, jport); - ssh_session_t *j = ssh_session_new(0); - if (!j) { ssh_session_free(s); return NULL; } - ssh_session_set_hostkey_check(j, hkmode); - ssh_session_set_verbose(j, g_verbose); - ssh_session_set_cipher(j, g_cipher); - if (ssh_client_connect(j, jhost, jport) < 0) { - fprintf(stderr, "pssh: proxyjump: connect %s failed\n", jhost); - ssh_session_free(j); ssh_session_free(s); return NULL; - } - if (!auth_session(j, juser, password, keyfile)) { - fprintf(stderr, "pssh: proxyjump: auth failed\n"); - ssh_session_free(j); ssh_session_free(s); return NULL; - } - uint32_t jch; - if (ssh_channel_open_direct(j, &jch, host, (uint32_t)port, - "127.0.0.1", 0) < 0) { - fprintf(stderr, "pssh: proxyjump: open channel to %s:%d failed\n", - host, port); - ssh_session_free(j); ssh_session_free(s); return NULL; - } - jump_ctx_t *jc = (jump_ctx_t *)malloc(sizeof(jump_ctx_t)); - if (!jc) { ssh_session_free(j); ssh_session_free(s); return NULL; } - jc->jump = j; - jc->ch = jch; - ssh_session_set_io(s, jc, jump_read, jump_write); - if (ssh_client_connect_io(s, host, port) < 0) { - fprintf(stderr, "pssh: proxyjump: handshake to %s failed\n", host); - free(jc); ssh_session_free(j); ssh_session_free(s); return NULL; - } - *jump_out = j; - } else { + if (nhops == 0) { if (ssh_client_connect(s, host, port) < 0) { fprintf(stderr, "pssh: connection failed\n"); ssh_session_free(s); return NULL; } + return s; } + + /* ---------- 单跳代理(jump = hops[0]) ---------- */ + char uh[256]; + if (hops[0].user[0]) { + size_t ul = strlen(hops[0].user); + if (ul >= sizeof(uh)) ul = sizeof(uh) - 1; + memcpy(uh, hops[0].user, ul); uh[ul] = '\0'; + } else { + size_t ul = strlen(user); + if (ul >= sizeof(uh)) ul = sizeof(uh) - 1; + memcpy(uh, user, ul); uh[ul] = '\0'; + } + fprintf(stderr, "pssh: proxyjump %s@%s:%d\n", uh, hops[0].host, hops[0].port); + + ssh_session_t *j = ssh_session_new(0); + if (!j) { ssh_session_free(s); return NULL; } + ssh_session_set_hostkey_check(j, hkmode); + ssh_session_set_verbose(j, g_verbose); + ssh_session_set_cipher(j, g_cipher); + if (ssh_client_connect(j, hops[0].host, (uint16_t)hops[0].port) < 0) { + fprintf(stderr, "pssh: proxyjump: connect %s failed\n", hops[0].host); + ssh_session_free(j); ssh_session_free(s); return NULL; + } + if (!auth_session(j, uh, password, keyfile)) { + fprintf(stderr, "pssh: proxyjump: auth %s failed\n", hops[0].host); + ssh_session_free(j); ssh_session_free(s); return NULL; + } + + uint32_t dich; + if (ssh_channel_open_direct(j, &dich, host, (uint32_t)port, + "127.0.0.1", 0) < 0) { + fprintf(stderr, "pssh: proxyjump: open channel to %s:%d failed\n", + host, port); + ssh_session_free(j); ssh_session_free(s); return NULL; + } + jump_ctx_t *jc = (jump_ctx_t *)malloc(sizeof(jump_ctx_t)); + if (!jc) { ssh_session_free(j); ssh_session_free(s); return NULL; } + jc->jump = j; + jc->ch = dich; + jc->owned_session = NULL; + jc->outer = NULL; + ssh_session_set_io(s, jc, jump_read, jump_write); + if (ssh_client_connect_io(s, host, port) < 0) { + fprintf(stderr, "pssh: proxyjump: handshake to %s failed\n", host); + free(jc); ssh_session_free(j); ssh_session_free(s); return NULL; + } + *jump_out = j; return s; } @@ -269,6 +334,13 @@ int pssh_cmd_ssh(int argc, char **argv) { int nfwds = 0; char arg_user[128], arg_host[256]; /* [user@]host[:port] 解析缓冲 */ + /* -W host:port:stdio 转发(不申请 pty/shell,把目标 TCP 隧道桥接到 stdin/stdout)。 + * -N:不执行远程命令、不建会话通道,仅做端口转发(需配合 -L/-R/-D)。 */ + const char *stdio_target = NULL; + int stdio_port = 0; + int no_command = 0; + char stdio_host[256]; + for (int i = 1; i < argc; i++) { const char *a = argv[i]; if (strcmp(a, "-V") == 0) { @@ -312,6 +384,23 @@ int pssh_cmd_ssh(int argc, char **argv) { if (opt_missing(argc, i, "-J", "") < 0) return 1; jump_spec = argv[++i]; } + else if (strcmp(a, "-W") == 0) { + if (opt_missing(argc, i, "-W", "host:port") < 0) return 1; + const char *spec = argv[++i]; + const char *colon = strchr(spec, ':'); + if (!colon) { + fprintf(stderr, "pssh: bad -W spec '%s' (host:port)\n", spec); + return 1; + } + size_t hl = (size_t)(colon - spec); + if (hl >= sizeof(stdio_host)) hl = sizeof(stdio_host) - 1; + memcpy(stdio_host, spec, hl); stdio_host[hl] = '\0'; + stdio_port = atoi(colon + 1); + if (stdio_host[0] && stdio_port > 0) stdio_target = stdio_host; + } + else if (strcmp(a, "-N") == 0) { + no_command = 1; /* 仅转发,不执行命令/不建会话 */ + } else if (strcmp(a, "-L") == 0) { if (opt_missing(argc, i, "-L", "[bind:]port:host:hostport") < 0) return 1; if (nfwds < 16 && parse_fwd(argv[++i], &fwds[nfwds]) == 0) { @@ -393,8 +482,9 @@ int pssh_cmd_ssh(int argc, char **argv) { fprintf(stderr, "pssh - SSH2 客户端 (平替 ssh)\n" "用法: pssh [选项] [user@]host [command]\n" "选项: -p -l -pwd -i -J \n" - " -L [bind:]port:host:hostport -R [bind:]port:host:hostport\n" - " -D -o StrictHostKeyChecking=no|accept-new|yes\n"); + " -W host:port -N(仅转发) -L [bind:]port:host:hostport\n" + " -R [bind:]port:host:hostport -D \n" + " -o StrictHostKeyChecking=no|accept-new|yes\n"); return 1; } if (!user) { @@ -417,7 +507,10 @@ int pssh_cmd_ssh(int argc, char **argv) { if (!s) return 1; if (!auth_session(s, user, password, keyfile)) { fprintf(stderr, "pssh: auth failed\n"); - if (jump) { ssh_session_free(jump); } + if (jump) { + free(ssh_session_io_ctx(s)); /* 释放跳板 io ctx 链(含中间会话) */ + ssh_session_free(jump); /* 释放顶层跳板 */ + } ssh_session_free(s); return 1; } @@ -439,12 +532,29 @@ int pssh_cmd_ssh(int argc, char **argv) { /* ---- 会话通道 ---- */ uint32_t ch = 0; int interactive = 0; - if (cmd) { + int stdio_mode = 0; /* -W:目标 TCP 通道桥接到本地 stdin/stdout */ +#ifndef _WIN32 + int shell_raw = 0; /* 是否已调用 raw_on(普通交互 shell;仅 POSIX) */ +#endif + if (stdio_target) { + /* -W host:port:直接对目标开 direct-tcpip 通道,桥接本地 stdio */ + if (ssh_channel_open_direct(s, &ch, stdio_target, (uint32_t)stdio_port, + "127.0.0.1", 0) < 0) { + fprintf(stderr, "pssh: -W: open channel to %s:%d failed\n", + stdio_target, stdio_port); + goto done; + } + stdio_mode = 1; + interactive = 1; /* 读 stdin 转发到通道 */ + } else if (cmd) { if (ssh_channel_open(s, &ch, "session", 0, 0) < 0) { fprintf(stderr, "pssh: channel open failed\n"); goto done; } ssh_channel_request_exec(s, ch, cmd); + } else if (no_command) { + /* -N:不建会话通道,仅端口转发 */ + interactive = 0; /* 事件循环只泵转发,不读 stdin */ } else { interactive = 1; if (ssh_channel_open(s, &ch, "session", 0, 0) < 0) { @@ -455,6 +565,7 @@ int pssh_cmd_ssh(int argc, char **argv) { SetConsoleOutputCP(CP_UTF8); #else raw_on(); + shell_raw = 1; #endif ssh_channel_request_pty(s, ch, "xterm", 80, 24); ssh_channel_request_shell(s, ch); @@ -548,27 +659,35 @@ int pssh_cmd_ssh(int argc, char **argv) { /* 转发本地事件 */ ssh_fwd_pump(fwd, s, &rfds, NULL); - /* 键盘输入(交互) */ + /* 键盘输入(交互 / -W stdio 转发) */ if (interactive) { #ifdef _WIN32 - while (KBHIT()) { - int c = GETCH(); - if (c == 0 || c == 0xE0) { - int sc = GETCH(); - uint8_t seq[3]; - size_t n = 0; - switch (sc) { - case 72: seq[0]=0x1b; seq[1]='['; seq[2]='A'; n=3; break; - case 80: seq[0]=0x1b; seq[1]='['; seq[2]='B'; n=3; break; - case 75: seq[0]=0x1b; seq[1]='['; seq[2]='D'; n=3; break; - case 77: seq[0]=0x1b; seq[1]='['; seq[2]='C'; n=3; break; - default: break; + if (stdio_mode) { + /* -W:低位字节透传,不映射转义键 */ + while (KBHIT()) { + uint8_t b = (uint8_t)GETCH(); + if (ssh_channel_send_data(s, ch, &b, 1) < 0) goto done; + } + } else { + while (KBHIT()) { + int c = GETCH(); + if (c == 0 || c == 0xE0) { + int sc = GETCH(); + uint8_t seq[3]; + size_t n = 0; + switch (sc) { + case 72: seq[0]=0x1b; seq[1]='['; seq[2]='A'; n=3; break; + case 80: seq[0]=0x1b; seq[1]='['; seq[2]='B'; n=3; break; + case 75: seq[0]=0x1b; seq[1]='['; seq[2]='D'; n=3; break; + case 77: seq[0]=0x1b; seq[1]='['; seq[2]='C'; n=3; break; + default: break; + } + if (n) ssh_channel_send_data(s, ch, seq, n); + continue; } - if (n) ssh_channel_send_data(s, ch, seq, n); - continue; + uint8_t b = (uint8_t)c; + if (ssh_channel_send_data(s, ch, &b, 1) < 0) goto done; } - uint8_t b = (uint8_t)c; - if (ssh_channel_send_data(s, ch, &b, 1) < 0) goto done; } #else fd_set ifds; @@ -588,7 +707,7 @@ done: int exit_code = 0; if (ch) ssh_channel_exit_status(s, ch, &exit_code); #ifndef _WIN32 - if (interactive) raw_off(); + if (shell_raw) raw_off(); #endif ssh_fwd_free(fwd); if (jump) {