Qt集成SSH客户端开发:libssh2封装与异步SFTP实践
2026/9/16 6:25:05 网站建设 项目流程

简介:本资源是基于Qt框架实现SSH与FTP双协议通信的完整开源项目,面向C++/Qt中级开发者及跨平台网络应用实践者,解决在GUI程序中安全集成远程命令执行、文件上传下载等运维功能的工程难题。压缩包共182个文件,含59个头文件(.h)与43个源码文件(.cpp)构成核心SSH会话、SFTP通道、远程进程控制等模块;11个.pro工程配置文件支持qmake构建,另有exe可执行示例、ui界面资源及libQSsh.a静态库等,整体15.92MB,结构清晰便于二次开发与模块复用。已有1130人学习下载,读者可直接获取brassyyb维护的QSsh-master稳定版本,包含sftpchannel.cpp、sshconnection.cpp、remoteprocesstest.cpp等关键实现,以及sftptest.cpp和botan.cpp等加解密与测试用例,配套qssh.pro工程及用户配置模板,开箱即用,适合快速构建远程服务器管理工具、自动化部署客户端或嵌入式设备调试GUI。

1. 用 Qt 封装 SSH 客户端不是“写个按钮连上就行”,而是要绕开 QSsh 的缺失、填平 libssh2 与 Qt 事件循环的鸿沟、再把 FTP 文件操作嵌进异步会话里

你手头有个QSsh-master.zip,解压后发现它根本不是 Qt 官方组件,也不是 Qt Add-on,而是一个社区维护的、基于 libssh2 的轻量封装——它不提供QFtp那样的高层 API,也不像QNetworkAccessManager那样自动适配 Qt 的信号槽机制。更现实的是:Qt 5.15+ 已彻底移除QFtp类,官方明确建议用QNetworkAccessManager+ SFTP(通过 SSH 隧道)或独立 libssh2 实现;但QSsh-master恰好填补了这个断层:它把 libssh2 的 C 接口转成QObject子类,支持QEventLoop驱动的非阻塞读写,还能在QThread中安全复用会话。适合需要在 Qt 界面中嵌入终端交互、文件上传下载、命令批量执行,且不愿引入 QProcess 调用外部 ssh 命令(跨平台兼容差、权限控制弱、无法捕获完整 stderr 流)的开发者。典型场景包括:工业设备远程配置面板、嵌入式固件升级工具、内网运维助手、带进度条的 FTP/SFTP 混合传输客户端——注意,这里的 “FTP” 是指传统 FTP 协议,而QSsh-master本身只做 SSH 连接,必须配合QSftpSession或自建 FTP over SSH 隧道才能实现 FTP 功能。


2. 编译 QSsh-master 的三个硬性前提:libssh2 必须静态链接、Qt 版本需匹配 moc 输出、Windows 下要禁用 OpenSSL 自动探测

2.1 为什么不能直接 qmake && make?—— libssh2 的 ABI 兼容陷阱

QSsh-master依赖 libssh2 1.9.0+,但它的CMakeLists.txt默认启用LIBSSH2_OPENSSL,而 Qt 5.15+ 在 Windows 上默认使用 OpenSSL 1.1.1,Ubuntu 20.04 自带的是 OpenSSL 1.1.1f,但 macOS Homebrew 安装的 libssh2 可能链接 LibreSSL。若本地 OpenSSL 版本与编译时链接的版本不一致,运行时会出现undefined symbol: SSL_CTX_set_ciphersuiteslibssh2_session_handshake failed: -12正确做法是强制静态链接 libssh2 并关闭 OpenSSL 依赖

# Ubuntu 20.04 下编译 libssh2(无 OpenSSL,用内置 crypto) wget https://libssh2.org/download/libssh2-1.10.0.tar.gz tar -xzf libssh2-1.10.0.tar.gz cd libssh2-1.10.0 ./configure --disable-shared --enable-static --without-openssl --with-wincng --with-libgcrypt make -j$(nproc) sudo make install

提示:--with-wincng是 Windows 下启用系统 CryptoAPI 的关键;--with-libgcrypt在 Linux/macOS 替代 OpenSSL,避免 ABI 冲突;--disable-shared确保生成libssh2.a,防止运行时动态库版本错配。

2.2 Qt 版本与 moc 生成规则的隐式耦合

QSsh-master的头文件中大量使用Q_OBJECT宏,但其CMakeLists.txt未声明set(CMAKE_AUTOMOC ON),导致moc_qsshconnection.cpp不自动生成。Qt 5.12 以下版本要求手动调用moc,而 Qt 5.15+ 默认启用 AUTOMOC,但若项目根目录无CMakeLists.txtqmake工程文件缺失.proCONFIG += c++11,moc 会跳过含Q_OBJECT的类。验证方法:编译报错undefined reference to 'QSshConnection::staticMetaObject'即为 moc 失败。

修复步骤(以 Qt 5.15.2 为例)

# 在 QSsh-master/CMakeLists.txt 开头添加 cmake_minimum_required(VERSION 3.10) project(QSsh LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_AUTOMOC ON) # 必须开启 set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt5 REQUIRED COMPONENTS Core Widgets Network) find_package(LibSSH2 REQUIRED) add_library(qssh STATIC src/qsshconnection.cpp src/qsshchannel.cpp src/qsshsftp.cpp ) target_link_libraries(qssh Qt5::Core Qt5::Network LibSSH2::libssh2) target_include_directories(qssh PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)

然后执行:

mkdir build && cd build cmake -DCMAKE_PREFIX_PATH=/opt/Qt5.15.2/5.15.2/gcc_64/lib/cmake .. # 指向 Qt 安装路径 make -j4

2.3 Windows 下 MinGW 与 MSVC 的 ABI 分裂问题

若用 MinGW 编译QSsh-master,但主 Qt 项目用 MSVC2019 构建,链接时会报LNK2019: unresolved external symbol __imp__libssh2_session_init_ex。这是因为 MinGW 生成的libssh2.a使用__declspec(dllimport)而 MSVC 期望__declspec(dllexport)唯一可靠方案是统一工具链

  • Qt Creator 中设置 Kit → Compiler → Microsoft Visual C++ Compiler 14.29(对应 VS2019)
  • libssh2 编译时用vcvarsall.bat激活环境:
    call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsall.bat" x64 cmake -G "NMake Makefiles" -DENABLE_ZLIB=OFF -DENABLE_CRYPT_NONE=ON -DENABLE_DEBUG_LOGGING=OFF .. nmake
  • QSsh-masterCMakeLists.txttarget_link_libraries必须指定.lib后缀:
    target_link_libraries(qssh Qt5::Core Qt5::Network optimized libssh2.lib debug libssh2d.lib)

3. 用 QSshConnection 建立可中断的 SSH 会话,并通过 QSftpSession 实现带进度回调的文件上传

3.1 初始化连接:超时、密钥认证、错误码映射三者必须同步控制

QSsh-masterQSshConnection不提供connectToHostEncrypted()这类高阶接口,所有连接逻辑需手动驱动。关键点在于:libssh2_session_handshake()是阻塞调用,但QSshConnection将其拆分为startHandshake()(触发)和handshakeDone()(信号),中间靠QTimer::singleShot(0, ...)投递事件。若网络延迟高,需主动设超时:

// connection.h class Connection : public QObject { Q_OBJECT public: explicit Connection(QObject *parent = nullptr); void connectToHost(const QString &host, quint16 port = 22); signals: void connected(); void error(int code, const QString &msg); private slots: void onHandshakeDone(bool success); void onTimeout(); private: QSshConnection *m_ssh; QTimer *m_timeoutTimer; }; // connection.cpp void Connection::connectToHost(const QString &host, quint16 port) { m_ssh = new QSshConnection(this); m_ssh->setHostName(host); m_ssh->setPort(port); m_ssh->setUser("admin"); // 密钥路径必须是绝对路径,且私钥需 chmod 600(Linux/macOS)或 .ppk 格式(Windows) m_ssh->setPrivateKeyFile("/home/user/.ssh/id_rsa"); connect(m_ssh, &QSshConnection::handshakeDone, this, &Connection::onHandshakeDone); connect(m_ssh, &QSshConnection::error, this, [this](int code) { emit error(code, sshErrorToString(code)); // 映射 libssh2 错误码 }); m_timeoutTimer = new QTimer(this); m_timeoutTimer->setSingleShot(true); m_timeoutTimer->setInterval(10000); // 10秒超时 connect(m_timeoutTimer, &QTimer::timeout, this, &Connection::onTimeout); m_ssh->startHandshake(); // 非阻塞启动握手 m_timeoutTimer->start(); } void Connection::onHandshakeDone(bool success) { m_timeoutTimer->stop(); if (success) { emit connected(); } else { emit error(m_ssh->lastError(), "Handshake failed"); } }

注意:sshErrorToString()需自行实现,例如case LIBSSH2_ERROR_TIMEOUT: return "Connection timeout"; case LIBSSH2_ERROR_AUTHENTICATION_FAILED: return "Authentication failed";—— 直接用libssh2_error()返回字符串不可靠,因内部 buffer 复用。

3.2 文件上传:用 QSftpSession 绕过 Qt 的 QNetworkReply 限制,实现 chunk 级进度通知

QSsh-master自带QSftpSession,但它不继承QIODevice,无法直接绑定QProgressBar。必须手动分块读取本地文件、调用sftpWrite()、并发射进度信号:

// sftpuploader.h class SftpUploader : public QObject { Q_OBJECT public: explicit SftpUploader(QSftpSession *sftp, QObject *parent = nullptr); void uploadFile(const QString &localPath, const QString &remotePath); signals: void progress(qint64 bytesSent, qint64 totalBytes); void finished(bool success, const QString &msg); private slots: void onWriteDone(qint64 written, qint64 total); private: QSftpSession *m_sftp; QFile m_localFile; QByteArray m_buffer; qint64 m_totalSize; qint64 m_sent; }; // sftpuploader.cpp void SftpUploader::uploadFile(const QString &localPath, const QString &remotePath) { if (!m_localFile.open(QFile::ReadOnly)) { emit finished(false, "Cannot open local file: " + localPath); return; } m_totalSize = m_localFile.size(); m_sent = 0; // 创建远程文件句柄(O_WRONLY | O_CREAT | O_TRUNC) auto handle = m_sftp->open(remotePath, LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC); if (!handle) { emit finished(false, "Cannot open remote file: " + remotePath); m_localFile.close(); return; } m_buffer.resize(32768); // 32KB chunk while (m_sent < m_totalSize && m_localFile.isOpen()) { qint64 toRead = qMin(m_buffer.size(), m_totalSize - m_sent); qint64 read = m_localFile.read(m_buffer.data(), toRead); if (read <= 0) break; // 异步写入,回调 onWriteDone m_sftp->write(handle, m_buffer.constData(), read, [this, handle, read](qint64 written, qint64 total) { m_sent += written; emit progress(m_sent, m_totalSize); if (m_sent >= m_totalSize) { m_sftp->close(handle); m_localFile.close(); emit finished(true, "Upload completed"); } }); } }

提示:QSftpSession::write()的 lambda 回调在QSshConnection的事件循环中执行,因此m_sent更新和progress信号发射是线程安全的;但m_localFile.read()必须在主线程调用,否则 QFile 会崩溃。


4. 在 Qt 界面中集成 SSH 终端输出与 FTP 目录列表:用 QTextEdit 模拟 VT100、用 QStandardItemModel 解析 ls -l

4.1 终端显示:将 SSH channel 的 stdout/stderr 流实时渲染为可复制的等宽文本

QSsh-masterQSshChannel提供dataReceived()信号,但原始字节流包含 ANSI 转义序列(如\033[1;32m)。直接append(QString::fromUtf8(data))会导致乱码。需用QTextCharFormat解析基础颜色:

// terminalwidget.h class TerminalWidget : public QTextEdit { Q_OBJECT public: explicit TerminalWidget(QWidget *parent = nullptr); void appendRawData(const QByteArray &data); private: struct AnsiState { bool bold = false; int fgColor = 37; // default white }; AnsiState m_ansi; QByteArray m_pendingAnsi; void processAnsiEscape(const QByteArray &seq); }; // terminalwidget.cpp void TerminalWidget::appendRawData(const QByteArray &data) { QTextCursor cursor = textCursor(); cursor.movePosition(QTextCursor::End); for (int i = 0; i < data.size(); ++i) { char c = data[i]; if (c == '\033' && i + 1 < data.size() && data[i + 1] == '[') { // 捕获 CSI 序列,如 \033[1;32m int j = i + 2; while (j < data.size() && data[j] != 'm' && data[j] != 'H' && data[j] != 'J') ++j; if (j < data.size()) { processAnsiEscape(data.mid(i, j - i + 1)); i = j; continue; } } if (c == '\r') continue; // 忽略 CR if (c == '\n') { cursor.insertBlock(); // 新行 } else { QTextCharFormat fmt; if (m_ansi.bold) fmt.setFontWeight(QFont::Bold); fmt.setForeground(Qt::color0); // 根据 m_ansi.fgColor 设置 cursor.insertText(QString(c), fmt); } } verticalScrollBar()->setValue(verticalScrollBar()->maximum()); }

注意:完整 VT100 解析需处理光标定位、清屏、反色等,此处仅实现SGR(Select Graphic Rendition)子集;生产环境建议用QTermWidgetlibvte绑定。

4.2 FTP 目录解析:用ls -l输出构造 QStandardItemModel,支持双击下载

QSsh-master不提供 FTP 协议栈,但可通过QSshChannel执行ls -l /path获取 POSIX 风格目录列表。关键是如何解析drwxr-xr-x 1 user group 4096 Jan 1 12:00 dir/

// ftpmodel.h class FtpDirModel : public QStandardItemModel { Q_OBJECT public: explicit FtpDirModel(QObject *parent = nullptr); void parseLsOutput(const QByteArray &output); signals: void entryClicked(const QString &path, bool isDir); private: struct DirEntry { QString name; QString permissions; bool isDir; qint64 size; QDateTime modified; }; QList<DirEntry> parseEntries(const QByteArray &output); }; // ftpmodel.cpp QList<FtpDirModel::DirEntry> FtpDirModel::parseEntries(const QByteArray &output) { QList<DirEntry> entries; QTextStream stream(output); QString line; while (stream.readLineInto(&line)) { if (line.isEmpty()) continue; // 匹配:^([drwx\-]{10})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w+\s+\d+\s+\d+:\d+)\s+(.+)$ QRegularExpression re(R"(^([drwx\-]{10})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w+\s+\d+\s+\d+:\d+)\s+(.+)$)"); QRegularExpressionMatch match = re.match(line); if (match.hasMatch()) { DirEntry e; e.permissions = match.captured(1); e.isDir = e.permissions.startsWith('d'); e.size = match.captured(2).toLongLong(); // 解析时间:需补全年份("Jan 1 12:00" → "Jan 1 12:00 2024") QString timeStr = match.captured(3) + " " + QString::number(QDateTime::currentDateTime().year()); e.modified = QDateTime::fromString(timeStr, "MMM d h:mm yyyy"); e.name = match.captured(4).trimmed(); entries.append(e); } } return entries; } void FtpDirModel::parseLsOutput(const QByteArray &output) { clear(); setHorizontalHeaderLabels({"Name", "Size", "Modified"}); auto entries = parseEntries(output); for (const auto &e : entries) { auto nameItem = new QStandardItem(e.name); nameItem->setData(e.name, Qt::UserRole); // 存储原始路径 nameItem->setData(e.isDir, Qt::UserRole + 1); auto sizeItem = new QStandardItem(e.isDir ? "<DIR>" : QString::number(e.size)); auto timeItem = new QStandardItem(e.modified.toString("yyyy-MM-dd hh:mm")); appendRow({nameItem, sizeItem, timeItem}); } }

QTreeView中双击触发下载:

connect(treeView, &QTreeView::doubleClicked, this, [this](const QModelIndex &idx) { if (!idx.isValid()) return; auto nameItem = static_cast<QStandardItem*>(model->itemFromIndex(idx)); QString path = nameItem->data(Qt::UserRole).toString(); bool isDir = nameItem->data(Qt::UserRole + 1).toBool(); if (isDir) { // 执行 cd /path && ls -l m_channel->write(QString("cd %1 && ls -l\n").arg(path).toUtf8()); } else { // 触发 SftpUploader m_uploader->downloadFile(path, "/tmp/" + QFileInfo(path).fileName()); } });

5. 跨平台调试 SSH 连接失败的四层排查法:从 DNS 解析到 libssh2 日志,再到 Qt 事件循环阻塞点

5.1 第一层:确认基础网络可达性(绕过 Qt 封装)

不要一上来就调试QSshConnection,先用系统工具验证:

# Ubuntu/macOS ssh -o ConnectTimeout=5 -o ConnectionAttempts=1 admin@192.168.1.100 # 看是否能登录 nc -zv 192.168.1.100 22 # 看端口是否开放 # Windows(PowerShell) Test-NetConnection 192.168.1.100 -Port 22

nc通但ssh不通,说明服务端 SSH 配置限制(如PermitRootLogin no);若nc不通,检查防火墙(ufw status/ Windows Defender 高级防火墙)或目标机器sshd是否运行(sudo systemctl status ssh)。

5.2 第二层:启用 libssh2 底层日志,定位 handshake 卡点

QSsh-master未暴露libssh2_trace()接口,需修改src/qsshconnection.cpp注入日志:

// 在 QSshConnection::startHandshake() 开头添加 #ifdef DEBUG_LIBSSH2 libssh2_trace(m_session, LIBSSH2_TRACE_CONN | LIBSSH2_TRACE_TRANS); FILE *logfp = fopen("/tmp/libssh2.log", "a"); libssh2_trace_fd(m_session, logfp); #endif

然后编译时定义:

cmake -DDEBUG_LIBSSH2=ON ..

日志中关键线索:

  • libssh2_transport_write() wrote 123 bytes→ 发送正常
  • libssh2_transport_read() read 0 bytes→ 服务端未响应(可能密钥不匹配)
  • Failure in HMAC verification→ 加密算法协商失败(服务端禁用 aes256-ctr,客户端强制启用)

此时需在QSshConnection::startHandshake()中插入算法白名单:

libssh2_session_method_pref(m_session, LIBSSH2_METHOD_KEX, "diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha256"); libssh2_session_method_pref(m_session, LIBSSH2_METHOD_HOSTKEY, "ssh-rsa,ecdsa-sha2-nistp256");

5.3 第三层:检查 Qt 事件循环是否被阻塞(最隐蔽的坑)

QSshConnection依赖QEventLoop处理 socket 读写,若主线程执行了QEventLoop::exec()QThread::wait(),会导致dataReceived()信号无法投递。验证方法:在QSshConnection::dataReceived()中加日志,同时用qDebug() << QThread::currentThread();确认信号是否在主线程接收。

修复方案:所有耗时操作(如大文件上传)必须在QThread中执行,但QSshConnection对象必须在主线程创建(因其继承QObject):

// 正确:Connection 对象在主线程,工作在子线程 QThread workerThread; Connection *conn = new Connection; conn->moveToThread(&workerThread); connect(&workerThread, &QThread::started, conn, &Connection::connectToHost); workerThread.start(); // 错误:在子线程 new QSshConnection → moc 元对象注册失败

5.4 第四层:Windows 下证书链验证失败的注册表级修复

QSshConnection在 Windows 上返回LIBSSH2_ERROR_KEY_EXCHANGE_FAILURE,且服务端是 Windows Server 2016+ 的 Bitvise SSH Server,大概率是 TLS 1.2 协商失败。微软 KB4474419 后,Schannel 默认禁用 TLS 1.0/1.1,但旧版 libssh2 编译时未启用WIN32_USE_BUILTIN_SCHANNEL。临时解决:

Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client] "DisabledByDefault"=dword:00000000 "Enabled"=dword:00000001

然后重启应用。长期方案是升级 libssh2 至 1.10.0+ 并启用--with-wincng

提示:QSsh-masterQSftpSession::listDirectory()在 Windows 下可能因路径分隔符(\vs/)返回空列表,务必在remotePath中统一使用正斜杠,如"home/user/docs/"而非"home\\user\\docs\\"

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询