From b1ce2b4745dc725d10576d0aec8a19e41b0249e4 Mon Sep 17 00:00:00 2001 From: JGZYES Date: Fri, 14 Aug 2026 14:09:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=95=BF=E8=BF=9E=E6=8E=A5=E4=BF=9D=E6=B4=BB?= =?UTF-8?q?=20+=20fail2ban=20=E5=85=BC=E5=AE=B9:=20pssh=20ssh=20-o=20Serve?= =?UTF-8?q?rAliveInterval=3DN=20=E7=A9=BA=E9=97=B2=E5=8F=91=20keepalive@op?= =?UTF-8?q?enssh.com(ssh=5Fkeepalive=5Fsend,=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=9B=9E=E5=8C=85=E9=97=AD=E7=8E=AF)=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E4=B8=8D=E6=96=AD;=20psshd=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=A4=B1=E8=B4=A5=E6=97=A5=E5=BF=97=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20OpenSSH=20=E6=A0=BC=E5=BC=8F(Invalid=20user=20/=20Failed=20p?= =?UTF-8?q?assword=20...=20from=20)=E5=B9=B6=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=AF=B9=E7=AB=AFIP(ssh=5Fsession=5Fset=5Fpeer=5Fip),=20?= =?UTF-8?q?=E4=BE=BF=E4=BA=8E=20fail2ban=20=E9=BB=98=E8=AE=A4=20sshd=20fil?= =?UTF-8?q?ter=20=E5=B0=81=E7=A6=81;=20=E4=B8=A4=E7=AB=AF=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E9=80=9A=E8=BF=87;=20STATUS/AGENT/CHANGELOG=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENT.MD | 5 +++++ CHANGELOG.md | 5 +++++ STATUS.md | 4 ++-- apps/ssh/main.c | 16 +++++++++++++++- apps/sshd/main.c | 1 + include/paze/ssh.h | 6 ++++++ src/ssh/ssh_auth.c | 11 +++++++++++ src/ssh/ssh_internal.h | 1 + src/ssh/ssh_server.c | 8 ++++++++ src/ssh/ssh_transport.c | 13 +++++++++++++ 10 files changed, 67 insertions(+), 3 deletions(-) diff --git a/AGENT.MD b/AGENT.MD index 7dfcc7d..24d00d7 100644 --- a/AGENT.MD +++ b/AGENT.MD @@ -91,4 +91,9 @@ session ticket key 支持持久化到文件:新增 `paze_tls_config_set_session_ - `pssh ssh` config 读取优先级:`-F ` > `~/.ssh/config`(OpenSSH 兼容) > `~/.pssh/config.conf`;支持 `Host/HostName/Port/User/IdentityFile/ProxyJump/StrictHostKeyChecking`。 - 新增 `build/run_ecdsa_selfcheck.ps1`(KAT 基线):对 P-256/384/521 跑 ECDSA sign→verify 自洽,防回归。 +### 8. 长连接保活 + fail2ban 兼容(✅ 已完成 2026-08-14) + +- `pssh ssh -o ServerAliveInterval=N`(默认15s):空闲发 `keepalive@openssh.com` global request(ssh_keepalive_send, fire-and-forget),保持空闲连接不中断;服务端回包形成保活闭环。 +- `psshd` 认证失败日志对齐 OpenSSH 格式(`Invalid user`/`Failed password ... from `),并记录对端 IP(`ssh_session_set_peer_ip`),fail2ban 默认 `sshd` filter 可直接匹配封禁。 + diff --git a/CHANGELOG.md b/CHANGELOG.md index 845112b..97f6afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [开发主线] — 2026-08-14 +### 新增 —— 长连接保活 + fail2ban 兼容 + +- **keepalive 保活**:`pssh ssh` 新增 `-o ServerAliveInterval=N`(默认 15s,0=禁用),空闲时定时发 `keepalive@openssh.com` global request(`ssh_keepalive_send`,fire-and-forget),保持空闲连接不被 NAT/防火墙回收;服务端对 GLOBAL_REQUEST 回包形成保活闭环。 +- **fail2ban 兼容**:`psshd` 认证失败日志对齐 OpenSSH 格式(`Invalid user X from port N ssh2` / `Failed password for X from port N ssh2`),并记录对端 IP(`ssh_session_set_peer_ip`),可用 fail2ban 默认 `sshd` filter 封禁。 + ### 新增 —— SSH config 兼容读取 + ECDSA KAT 基线 - `pssh ssh` config 读取优先级:`-F ` > `~/.ssh/config`(OpenSSH 兼容) > `~/.pssh/config.conf`;支持 `Host/HostName/Port/User/IdentityFile/ProxyJump/StrictHostKeyChecking`。 diff --git a/STATUS.md b/STATUS.md index ec2bcf2..cb8a3ea 100644 --- a/STATUS.md +++ b/STATUS.md @@ -44,12 +44,12 @@ - 通道: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` 端口转发,`-J` 代理跳板(单跳)、`-W host:port` stdio 转发、`-N` 仅转发;config 读取 `-F` / `~/.ssh/config`(OpenSSH 兼容) / `~/.pssh/config.conf` + - `pssh ssh` — 远程 shell / 执行命令,`-L`/`-R`/`-D` 端口转发,`-J` 代理跳板(单跳)、`-W host:port` stdio 转发、`-N` 仅转发;config 读取 `-F` / `~/.ssh/config`(OpenSSH 兼容) / `~/.pssh/config.conf`;`-o ServerAliveInterval=N` 空闲心跳保活(keepalive@openssh.com) - `pssh scp` — SFTP 优先(OpenSSH 9.x 默认),回退 legacy SCP;`-r` 递归、`-p` 保留时间戳 - `pssh keygen` / `keyscan` / `copy-id` — 密钥工具 - `pssh agent` / `add` / `ssha` — agent 守护进程与密钥管理 - - `pssh sshd` — 服务端:多用户库 + 分级权限 + publickey 认证 + SFTP 子系统 + - `pssh sshd` — 服务端:多用户库 + 分级权限 + publickey 认证 + SFTP 子系统;**认证失败日志对齐 OpenSSH 格式**(`Invalid user`/`Failed password ... from `),可与 fail2ban 默认 `sshd` filter 配合 - 密码套件:chacha20-poly1305@openssh.com、aes128/256-gcm@openssh.com ## 应用(✅ 可用) diff --git a/apps/ssh/main.c b/apps/ssh/main.c index 26df2ef..27fbb13 100644 --- a/apps/ssh/main.c +++ b/apps/ssh/main.c @@ -9,6 +9,7 @@ #include #include #include +#include #ifdef _WIN32 #include @@ -330,6 +331,7 @@ int pssh_cmd_ssh(int argc, char **argv) { const char *keyfile = NULL; int hostkey_mode = 0; const char *jump_spec = NULL; + int server_alive = 15; /* ServerAliveInterval 秒;0=禁用 keepalive */ fwd_spec_t fwds[16]; int nfwds = 0; char arg_user[128], arg_host[256]; /* [user@]host[:port] 解析缓冲 */ @@ -428,7 +430,7 @@ int pssh_cmd_ssh(int argc, char **argv) { if (opt_missing(argc, i, "-F", "") < 0) return 1; user_cfg = argv[++i]; } else if (strcmp(a, "-o") == 0) { - if (opt_missing(argc, i, "-o", "StrictHostKeyChecking=no|accept-new|yes") < 0) return 1; + if (opt_missing(argc, i, "-o", "StrictHostKeyChecking=no|accept-new|yes|ServerAliveInterval=N") < 0) return 1; const char *kv = argv[++i]; if (strncmp(kv, "StrictHostKeyChecking=", 22) == 0) { const char *v = kv + 22; @@ -436,6 +438,9 @@ int pssh_cmd_ssh(int argc, char **argv) { else if (strcmp(v, "accept-new") == 0) hostkey_mode = 1; else hostkey_mode = 0; } + else if (strncmp(kv, "ServerAliveInterval=", 20) == 0) { + server_alive = atoi(kv + 20); + } } else if (a[0] == '-') { continue; } else if (!host) { @@ -587,7 +592,16 @@ int pssh_cmd_ssh(int argc, char **argv) { if (sel_sock < 0) goto done; uint8_t rbuf[32768]; + time_t last_alive = time(NULL); for (;;) { + /* 定时 keepalive(ServerAliveInterval):保持空闲连接存活 */ + if (server_alive > 0) { + time_t now = time(NULL); + if (now - last_alive >= server_alive) { + last_alive = now; + if (ssh_keepalive_send(s) < 0) { fprintf(stderr, "pssh: keepalive send failed\n"); break; } + } + } fd_set rfds; FD_ZERO(&rfds); int maxfd = 0; diff --git a/apps/sshd/main.c b/apps/sshd/main.c index a022fae..7c2b10a 100644 --- a/apps/sshd/main.c +++ b/apps/sshd/main.c @@ -184,6 +184,7 @@ int pssh_cmd_sshd(int argc, char **argv) { if (!s) { closesocket(conn); free(ps); continue; } ssh_session_set_io(s, ps, tcp_read, tcp_write); ssh_session_set_users(s, users, nusers); + ssh_session_set_peer_ip(s, inet_ntoa(client.sin_addr)); /* 供失败日志/fail2ban */ if (verbose) ssh_session_set_verbose(s, 1); ssh_server_loop(s, (const uint8_t *)ak_text, ak_len); diff --git a/include/paze/ssh.h b/include/paze/ssh.h index 83cfee7..cc14c02 100644 --- a/include/paze/ssh.h +++ b/include/paze/ssh.h @@ -32,6 +32,8 @@ void ssh_session_free(ssh_session_t *s); void ssh_session_set_io(ssh_session_t *s, void *ctx, int (*read_fn)(void *, uint8_t *, size_t), int (*write_fn)(void *, const uint8_t *, size_t)); +/* Set peer IP(服务端 accept 后设置,用于认证失败日志的 fail2ban 兼容) */ +void ssh_session_set_peer_ip(ssh_session_t *s, const char *ip); /* Return the underlying socket handle for event loops: * Windows: SOCKET, POSIX: int fd. -1 if none. */ @@ -83,6 +85,10 @@ int ssh_client_connect(ssh_session_t *s, const char *host, uint16_t port); /* 在已通过 ssh_session_set_io 设置的传输上完成握手(代理跳转用) */ int ssh_client_connect_io(ssh_session_t *s, const char *host, uint16_t port); +/* 发送 keepalive(keepalive@openssh.com global request,fire-and-forget)。 + * 用于保持空闲连接不被 NAT/防火墙回收;对端回复仅表明连接存活。 */ +int ssh_keepalive_send(ssh_session_t *s); + /* Authenticate with password. Returns 0 on success. */ int ssh_auth_client_loop(ssh_session_t *s, const char *username, diff --git a/src/ssh/ssh_auth.c b/src/ssh/ssh_auth.c index b5dde27..5f9d4b9 100644 --- a/src/ssh/ssh_auth.c +++ b/src/ssh/ssh_auth.c @@ -226,6 +226,17 @@ int ssh_auth_server_loop(ssh_session_t *s, } /* 认证失败:回 FAILURE 提示可用方法 */ + /* OpenSSH 兼容日志(fail2ban sshd filter 匹配): + * 用户名不存在 -> "Invalid user X from IP port N ssh2" + * 存在但密码错 -> "Failed password for X from IP port N ssh2" */ + { + char unamez[256]; + size_t un = ulen < sizeof(unamez) - 1 ? ulen : sizeof(unamez) - 1; + memcpy(unamez, uname, un); unamez[un] = '\0'; + fprintf(stderr, "%s %s from %s port %u ssh2\n", + u ? "Failed password for" : "Invalid user", + u ? unamez : unamez, s->peer_ip, (unsigned)s->port); + } send_auth_failure(s, methods); free((void *)payload); continue; diff --git a/src/ssh/ssh_internal.h b/src/ssh/ssh_internal.h index cbfda82..aa56e38 100644 --- a/src/ssh/ssh_internal.h +++ b/src/ssh/ssh_internal.h @@ -282,6 +282,7 @@ typedef struct { /* Config */ char username[256]; char hostname[256]; + char peer_ip[64]; /* 对端 IP(服务端 accept 时设置,供日志/fail2ban) */ uint16_t port; int is_server; diff --git a/src/ssh/ssh_server.c b/src/ssh/ssh_server.c index 5755c52..2ce4e54 100644 --- a/src/ssh/ssh_server.c +++ b/src/ssh/ssh_server.c @@ -17,6 +17,14 @@ #include "ssh_internal.h" #include "sftp_server.h" +void ssh_session_set_peer_ip(ssh_session_t *s, const char *ip) { + if (!s || !ip) return; + size_t n = strlen(ip); + if (n >= sizeof(s->peer_ip)) n = sizeof(s->peer_ip) - 1; + memcpy(s->peer_ip, ip, n); + s->peer_ip[n] = '\0'; +} + #ifdef _WIN32 #include #endif diff --git a/src/ssh/ssh_transport.c b/src/ssh/ssh_transport.c index d9a79ef..42a17e6 100644 --- a/src/ssh/ssh_transport.c +++ b/src/ssh/ssh_transport.c @@ -238,3 +238,16 @@ int ssh_transport_kexinit(ssh_session_t *s) { free((void *)payload); } } + +/* 发送 keepalive(OpenSSH 约定的 keepalive@openssh.com global request)。 + * fire-and-forget:want_reply=1 但不阻塞等待;对端回复(SUCCESS/FAILURE) + * 仅作为"连接仍存活"的信号,由上层在事件循环中消费/超时判定。 + * 返回 0 表示已发出,-1 出错。 */ +int ssh_keepalive_send(ssh_session_t *s) { + if (!s) return -1; + uint8_t pkt[512]; + size_t off = 0; + off += ssh_str_write(pkt + off, "keepalive@openssh.com"); + off += ssh_bool_write(pkt + off, 1); + return ssh_packet_send(s, SSH_MSG_GLOBAL_REQUEST, pkt, off) < 0 ? -1 : 0; +}