长连接保活 + fail2ban 兼容: pssh ssh -o ServerAliveInterval=N 空闲发 keepalive@openssh.com(ssh_keepalive_send, 服务端回包闭环)保持连接不断; psshd 认证失败日志对齐 OpenSSH 格式(Invalid user / Failed password ... from <IP>)并记录对端IP(ssh_session_set_peer_ip), 便于 fail2ban 默认 sshd filter 封禁; 两端实测通过; STATUS/AGENT/CHANGELOG 更新

这个提交包含在:
JGZYES
2026-08-14 14:09:27 +08:00
父节点 82f215fbc3
当前提交 b1ce2b4745
修改 10 个文件,包含 67 行新增3 行删除
+5
查看文件
@@ -91,4 +91,9 @@ session ticket key 支持持久化到文件:新增 `paze_tls_config_set_session_
- `pssh ssh` config 读取优先级:`-F <file>` > `~/.ssh/config`(OpenSSH 兼容) > `~/.pssh/config.conf`;支持 `Host/HostName/Port/User/IdentityFile/ProxyJump/StrictHostKeyChecking` - `pssh ssh` config 读取优先级:`-F <file>` > `~/.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 自洽,防回归。 - 新增 `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>`),并记录对端 IP(`ssh_session_set_peer_ip`),fail2ban 默认 `sshd` filter 可直接匹配封禁。
+5
查看文件
@@ -4,6 +4,11 @@
## [开发主线] — 2026-08-14 ## [开发主线] — 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 <IP> port N ssh2` / `Failed password for X from <IP> port N ssh2`),并记录对端 IP`ssh_session_set_peer_ip`),可用 fail2ban 默认 `sshd` filter 封禁。
### 新增 —— SSH config 兼容读取 + ECDSA KAT 基线 ### 新增 —— SSH config 兼容读取 + ECDSA KAT 基线
- `pssh ssh` config 读取优先级:`-F <file>` > `~/.ssh/config`(OpenSSH 兼容) > `~/.pssh/config.conf`;支持 `Host/HostName/Port/User/IdentityFile/ProxyJump/StrictHostKeyChecking` - `pssh ssh` config 读取优先级:`-F <file>` > `~/.ssh/config`(OpenSSH 兼容) > `~/.pssh/config.conf`;支持 `Host/HostName/Port/User/IdentityFile/ProxyJump/StrictHostKeyChecking`
+2 -2
查看文件
@@ -44,12 +44,12 @@
- 通道:sessionexec/shell 真实进程执行)、direct-tcpip(出站桥接) - 通道:sessionexec/shell 真实进程执行)、direct-tcpip(出站桥接)
- **SFTP 子系统 v3**OPEN/READ/WRITE/OPENDIR/READDIR/STAT/REALPATH 等,与 OpenSSH sftp / scp -s 互操作(Git OpenSSH 实测通过) - **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 scp` — SFTP 优先(OpenSSH 9.x 默认),回退 legacy SCP;`-r` 递归、`-p` 保留时间戳
- `pssh keygen` / `keyscan` / `copy-id` — 密钥工具 - `pssh keygen` / `keyscan` / `copy-id` — 密钥工具
- `pssh agent` / `add` / `ssha` — agent 守护进程与密钥管理 - `pssh agent` / `add` / `ssha` — agent 守护进程与密钥管理
- `pssh sshd` — 服务端:多用户库 + 分级权限 + publickey 认证 + SFTP 子系统 - `pssh sshd` — 服务端:多用户库 + 分级权限 + publickey 认证 + SFTP 子系统;**认证失败日志对齐 OpenSSH 格式**(`Invalid user`/`Failed password ... from <IP>`),可与 fail2ban 默认 `sshd` filter 配合
- 密码套件:chacha20-poly1305@openssh.com、aes128/256-gcm@openssh.com - 密码套件:chacha20-poly1305@openssh.com、aes128/256-gcm@openssh.com
## 应用(✅ 可用) ## 应用(✅ 可用)
+15 -1
查看文件
@@ -9,6 +9,7 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#include <time.h>
#ifdef _WIN32 #ifdef _WIN32
#include <winsock2.h> #include <winsock2.h>
@@ -330,6 +331,7 @@ int pssh_cmd_ssh(int argc, char **argv) {
const char *keyfile = NULL; const char *keyfile = NULL;
int hostkey_mode = 0; int hostkey_mode = 0;
const char *jump_spec = NULL; const char *jump_spec = NULL;
int server_alive = 15; /* ServerAliveInterval 秒;0=禁用 keepalive */
fwd_spec_t fwds[16]; fwd_spec_t fwds[16];
int nfwds = 0; int nfwds = 0;
char arg_user[128], arg_host[256]; /* [user@]host[:port] 解析缓冲 */ 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", "<configfile>") < 0) return 1; if (opt_missing(argc, i, "-F", "<configfile>") < 0) return 1;
user_cfg = argv[++i]; user_cfg = argv[++i];
} else if (strcmp(a, "-o") == 0) { } 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]; const char *kv = argv[++i];
if (strncmp(kv, "StrictHostKeyChecking=", 22) == 0) { if (strncmp(kv, "StrictHostKeyChecking=", 22) == 0) {
const char *v = kv + 22; 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 if (strcmp(v, "accept-new") == 0) hostkey_mode = 1;
else hostkey_mode = 0; else hostkey_mode = 0;
} }
else if (strncmp(kv, "ServerAliveInterval=", 20) == 0) {
server_alive = atoi(kv + 20);
}
} else if (a[0] == '-') { } else if (a[0] == '-') {
continue; continue;
} else if (!host) { } else if (!host) {
@@ -587,7 +592,16 @@ int pssh_cmd_ssh(int argc, char **argv) {
if (sel_sock < 0) goto done; if (sel_sock < 0) goto done;
uint8_t rbuf[32768]; uint8_t rbuf[32768];
time_t last_alive = time(NULL);
for (;;) { 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_set rfds;
FD_ZERO(&rfds); FD_ZERO(&rfds);
int maxfd = 0; int maxfd = 0;
+1
查看文件
@@ -184,6 +184,7 @@ int pssh_cmd_sshd(int argc, char **argv) {
if (!s) { closesocket(conn); free(ps); continue; } if (!s) { closesocket(conn); free(ps); continue; }
ssh_session_set_io(s, ps, tcp_read, tcp_write); ssh_session_set_io(s, ps, tcp_read, tcp_write);
ssh_session_set_users(s, users, nusers); 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); if (verbose) ssh_session_set_verbose(s, 1);
ssh_server_loop(s, (const uint8_t *)ak_text, ak_len); ssh_server_loop(s, (const uint8_t *)ak_text, ak_len);
+6
查看文件
@@ -32,6 +32,8 @@ void ssh_session_free(ssh_session_t *s);
void ssh_session_set_io(ssh_session_t *s, void *ctx, void ssh_session_set_io(ssh_session_t *s, void *ctx,
int (*read_fn)(void *, uint8_t *, size_t), int (*read_fn)(void *, uint8_t *, size_t),
int (*write_fn)(void *, const 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: /* Return the underlying socket handle for event loops:
* Windows: SOCKET, POSIX: int fd. -1 if none. */ * 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 设置的传输上完成握手(代理跳转用) */ /* 在已通过 ssh_session_set_io 设置的传输上完成握手(代理跳转用) */
int ssh_client_connect_io(ssh_session_t *s, const char *host, uint16_t port); 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. */ /* Authenticate with password. Returns 0 on success. */
int ssh_auth_client_loop(ssh_session_t *s, int ssh_auth_client_loop(ssh_session_t *s,
const char *username, const char *username,
+11
查看文件
@@ -226,6 +226,17 @@ int ssh_auth_server_loop(ssh_session_t *s,
} }
/* 认证失败:回 FAILURE 提示可用方法 */ /* 认证失败:回 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); send_auth_failure(s, methods);
free((void *)payload); free((void *)payload);
continue; continue;
+1
查看文件
@@ -282,6 +282,7 @@ typedef struct {
/* Config */ /* Config */
char username[256]; char username[256];
char hostname[256]; char hostname[256];
char peer_ip[64]; /* 对端 IP(服务端 accept 时设置,供日志/fail2ban) */
uint16_t port; uint16_t port;
int is_server; int is_server;
+8
查看文件
@@ -17,6 +17,14 @@
#include "ssh_internal.h" #include "ssh_internal.h"
#include "sftp_server.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 #ifdef _WIN32
#include <winsock2.h> #include <winsock2.h>
#endif #endif
+13
查看文件
@@ -238,3 +238,16 @@ int ssh_transport_kexinit(ssh_session_t *s) {
free((void *)payload); 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;
}