From 7bb76af47da1d9594371edc576a0627d586f9ae4 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:44:41 -0700 Subject: [PATCH 01/13] feat(tci): log which local process is behind each TCI client connection (#5087) TCI has no client-identification message and the WebSocket handshake is a bare upgrade, so the connect log held only a peer address. For a same- machine client the OS knows which process owns the socket; this asks it. - New src/core/TciPeerProcess.{h,cpp}: resolveLoopbackPeerProcess() maps the client's local endpoint to its owning process. Linux: /proc/net/tcp6 then /proc/net/tcp (a loopback IPv4 client on the Any-bound listener appears v4-mapped in tcp6) -> socket inode -> /proc/*/fd -> comm + exe. macOS: proc_listpids -> PROC_PIDLISTFDS -> PROC_PIDFDSOCKETINFO -> proc_name + proc_pidpath. Windows: GetExtendedTcpTable(OWNER_PID) for AF_INET and AF_INET6 -> QueryFullProcessImageNameW, version from the exe resource. Non-loopback peers and every failure return unresolved. - TciServer::onNewConnection() logs today's line unchanged, then resolves off-thread (QtConcurrent + QFutureWatcher) so the descriptor sweep never delays sendInitBurst(); a second line carries process/exe/version when it lands. A non-loopback peer gets one qCDebug saying identity is unavailable. Identity is kept on ClientState/TciClientInfo. - Network Diagnostics TCI client table: endpoint cell shows "(name)" with exe path (+ version) as tooltip. - tests/tci_peer_process_test: a self-connected QTcpServer/QTcpSocket pair on IPv4 and IPv6 loopback resolves to the test binary; a non-loopback address and port 0 do not. - CMake: new .cpp in aethercore; Windows links iphlpapi and version. Fixes #5087 --- CMakeLists.txt | 6 + src/core/TciPeerProcess.cpp | 299 +++++++++++++++++++++++++++ src/core/TciPeerProcess.h | 28 +++ src/core/TciServer.cpp | 49 +++++ src/core/TciServer.h | 11 +- src/gui/NetworkDiagnosticsDialog.cpp | 14 +- tests/tci_peer_process_test.cpp | 76 +++++++ tests/tests.cmake | 5 + 8 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 src/core/TciPeerProcess.cpp create mode 100644 src/core/TciPeerProcess.h create mode 100644 tests/tci_peer_process_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ee9fb65c9..fe8746b1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -720,6 +720,7 @@ set(CORE_SOURCES src/core/SpotCommandPolicy.cpp src/core/SpotModeResolver.cpp src/core/TciServer.cpp + src/core/TciPeerProcess.cpp src/core/TciProtocol.cpp src/core/TciRoutingState.cpp src/core/RadioCertification.cpp @@ -1970,6 +1971,11 @@ target_link_libraries(aethercore PUBLIC aether_afskdemod ${CMAKE_DL_LIBS} # NvidiaAfxFilter dlopen ) +if(WIN32) + # TciPeerProcess: GetExtendedTcpTable (iphlpapi) + the exe version + # resource (version) for the TCI client-identity log line (#5087). + target_link_libraries(aethercore PRIVATE iphlpapi version) +endif() target_link_libraries(AetherSDR PRIVATE qgeoview # gui/map only diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp new file mode 100644 index 000000000..7c95b842c --- /dev/null +++ b/src/core/TciPeerProcess.cpp @@ -0,0 +1,299 @@ +#include "TciPeerProcess.h" + +#include + +#include + +#if defined(Q_OS_LINUX) +#include +#include +#include +#include +#elif defined(Q_OS_MACOS) +#include +#include +#include +#include +#include +#elif defined(Q_OS_WIN) +#include +#include +#include +#include +#include +#include +#endif + +namespace AetherSDR { + +namespace { + +// Same host, two spellings: a loopback IPv4 client on an Any-bound listener +// shows up as ::ffff:127.0.0.1. Compare on the IPv4 value when both sides +// have one, else on the raw address. +bool sameHost(const QHostAddress& a, const QHostAddress& b) +{ + bool a4 = false, b4 = false; + const quint32 av = a.toIPv4Address(&a4); + const quint32 bv = b.toIPv4Address(&b4); + if (a4 && b4) return av == bv; + if (a4 != b4) return false; + return a == b; +} + +#if defined(Q_OS_LINUX) + +// /proc/net/tcp{,6} print each 32-bit word of the address as %08X of the +// native (little-endian) value, so "0100007F" is 127.0.0.1 and a v4-mapped +// loopback is "0000000000000000FFFF00000100007F". Undo that word by word. +QHostAddress parseProcNetAddress(const QString& hex) +{ + if (hex.size() == 8) { + bool ok = false; + const quint32 w = hex.toUInt(&ok, 16); + if (!ok) return {}; + // Bytes of the LE word in memory order are the IPv4 octets. + const quint32 v4 = ((w & 0xFF) << 24) | ((w & 0xFF00) << 8) + | ((w & 0xFF0000) >> 8) | (w >> 24); + return QHostAddress(v4); + } + if (hex.size() == 32) { + Q_IPV6ADDR a6{}; + for (int i = 0; i < 4; ++i) { + bool ok = false; + const quint32 w = hex.mid(i * 8, 8).toUInt(&ok, 16); + if (!ok) return {}; + a6[i * 4 + 0] = static_cast(w & 0xFF); + a6[i * 4 + 1] = static_cast((w >> 8) & 0xFF); + a6[i * 4 + 2] = static_cast((w >> 16) & 0xFF); + a6[i * 4 + 3] = static_cast(w >> 24); + } + return QHostAddress(a6); + } + return {}; +} + +// The client's OWN row has local_address == our peer endpoint. tcp6 first: +// on an Any-bound listener the common loopback-IPv4 client lives there in +// v4-mapped form, not in /proc/net/tcp. +bool findSocketInode(const QHostAddress& peer, quint16 port, quint64* inodeOut) +{ + for (const char* path : {"/proc/net/tcp6", "/proc/net/tcp"}) { + QFile f(QString::fromLatin1(path)); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) continue; + QTextStream in(&f); + in.readLine(); // header + while (!in.atEnd()) { + const QStringList col = in.readLine().simplified().split(QLatin1Char(' ')); + if (col.size() < 10) continue; + const QStringList loc = col[1].split(QLatin1Char(':')); // HEXADDR:HEXPORT + if (loc.size() != 2) continue; + bool ok = false; + if (loc[1].toUShort(&ok, 16) != port || !ok) continue; + if (!sameHost(parseProcNetAddress(loc[0]), peer)) continue; + *inodeOut = col[9].toULongLong(); + return true; + } + } + return false; +} + +TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) +{ + TciPeerProcessInfo info; + quint64 inode = 0; + if (!findSocketInode(peer, port, &inode) || inode == 0) return info; + const QString target = QStringLiteral("socket:[%1]").arg(inode); + + // Same-user processes only (unprivileged readlink on /proc//fd) — + // the normal case for a client the operator started. Anything else just + // fails to resolve. + const QDir proc(QStringLiteral("/proc")); + const QStringList pids = proc.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString& pid : pids) { + bool numeric = false; + pid.toInt(&numeric); + if (!numeric) continue; + const QDir fdDir(QStringLiteral("/proc/%1/fd").arg(pid)); + const QStringList fds = fdDir.entryList(QDir::Files | QDir::System + | QDir::NoDotAndDotDot); + for (const QString& fd : fds) { + if (QFile::symLinkTarget(fdDir.filePath(fd)) != target) continue; + QFile comm(QStringLiteral("/proc/%1/comm").arg(pid)); + if (comm.open(QIODevice::ReadOnly | QIODevice::Text)) + info.name = QString::fromUtf8(comm.readAll()).trimmed(); + info.exePath = QFile::symLinkTarget(QStringLiteral("/proc/%1/exe").arg(pid)); + if (info.name.isEmpty() && !info.exePath.isEmpty()) + info.name = QFileInfo(info.exePath).fileName(); + info.resolved = !info.name.isEmpty() || !info.exePath.isEmpty(); + return info; + } + } + return info; +} + +#elif defined(Q_OS_MACOS) + +QHostAddress sockinfoLocalAddress(const in_sockinfo& ini) +{ + if (ini.insi_vflag & INI_IPV6) { + Q_IPV6ADDR a6{}; + static_assert(sizeof(a6) == sizeof(ini.insi_laddr.ina_6), "in6_addr size"); + memcpy(&a6, &ini.insi_laddr.ina_6, sizeof(a6)); + return QHostAddress(a6); + } + return QHostAddress(ntohl(ini.insi_laddr.ina_46.i46a_addr4.s_addr)); +} + +TciPeerProcessInfo resolveMac(const QHostAddress& peer, quint16 port) +{ + TciPeerProcessInfo info; + int bytes = proc_listpids(PROC_ALL_PIDS, 0, nullptr, 0); + if (bytes <= 0) return info; + std::vector pids(static_cast(bytes) / sizeof(pid_t) + 16); + bytes = proc_listpids(PROC_ALL_PIDS, 0, pids.data(), + static_cast(pids.size() * sizeof(pid_t))); + if (bytes <= 0) return info; + const size_t count = static_cast(bytes) / sizeof(pid_t); + + std::vector fds; + for (size_t i = 0; i < count; ++i) { + const pid_t pid = pids[i]; + if (pid <= 0) continue; + // Other users' processes refuse the fd listing (EPERM) and simply + // contribute nothing — same-user clients are the normal case. + const int fdBytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nullptr, 0); + if (fdBytes <= 0) continue; + fds.resize(static_cast(fdBytes) / sizeof(proc_fdinfo) + 8); + const int got = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds.data(), + static_cast(fds.size() * sizeof(proc_fdinfo))); + if (got <= 0) continue; + const size_t nfds = static_cast(got) / sizeof(proc_fdinfo); + for (size_t j = 0; j < nfds; ++j) { + if (fds[j].proc_fdtype != PROX_FDTYPE_SOCKET) continue; + socket_fdinfo si{}; + if (proc_pidfdinfo(pid, fds[j].proc_fd, PROC_PIDFDSOCKETINFO, &si, + sizeof(si)) != static_cast(sizeof(si))) + continue; + if (si.psi.soi_kind != SOCKINFO_TCP) continue; + const in_sockinfo& ini = si.psi.soi_proto.pri_tcp.tcpsi_ini; + // Ports are reported in network byte order (as lsof reads them). + if (ntohs(static_cast(ini.insi_lport)) != port) continue; + if (!sameHost(sockinfoLocalAddress(ini), peer)) continue; + + char path[PROC_PIDPATHINFO_MAXSIZE] = {}; + if (proc_pidpath(pid, path, sizeof(path)) > 0) + info.exePath = QString::fromUtf8(path); + char name[2 * MAXCOMLEN + 1] = {}; + if (proc_name(pid, name, sizeof(name)) > 0) + info.name = QString::fromUtf8(name); + if (info.name.isEmpty() && !info.exePath.isEmpty()) + info.name = info.exePath.section(QLatin1Char('/'), -1); + info.resolved = !info.name.isEmpty() || !info.exePath.isEmpty(); + return info; + } + } + return info; +} + +#elif defined(Q_OS_WIN) + +QString fileVersionString(const QString& exePath) +{ + const std::wstring w = exePath.toStdWString(); + DWORD handle = 0; + const DWORD size = GetFileVersionInfoSizeW(w.c_str(), &handle); + if (size == 0) return {}; + std::vector buf(size); + if (!GetFileVersionInfoW(w.c_str(), 0, size, buf.data())) return {}; + VS_FIXEDFILEINFO* ffi = nullptr; + UINT len = 0; + if (!VerQueryValueW(buf.data(), L"\\", reinterpret_cast(&ffi), &len) + || !ffi || len == 0) + return {}; + return QStringLiteral("%1.%2.%3.%4") + .arg(HIWORD(ffi->dwFileVersionMS)).arg(LOWORD(ffi->dwFileVersionMS)) + .arg(HIWORD(ffi->dwFileVersionLS)).arg(LOWORD(ffi->dwFileVersionLS)); +} + +bool findOwnerPid(const QHostAddress& peer, quint16 port, DWORD* pidOut) +{ + const quint16 wantPort = htons(port); + // IPv4 table. + { + DWORD size = 0; + GetExtendedTcpTable(nullptr, &size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + std::vector buf(size ? size : 1); + if (size && GetExtendedTcpTable(buf.data(), &size, FALSE, AF_INET, + TCP_TABLE_OWNER_PID_ALL, 0) == NO_ERROR) { + const auto* t = reinterpret_cast(buf.data()); + for (DWORD i = 0; i < t->dwNumEntries; ++i) { + const MIB_TCPROW_OWNER_PID& r = t->table[i]; + if (static_cast(r.dwLocalPort) != wantPort) continue; + if (!sameHost(QHostAddress(ntohl(r.dwLocalAddr)), peer)) continue; + *pidOut = r.dwOwningPid; + return true; + } + } + } + // IPv6 table (covers ::1 and v4-mapped peers on an Any-bound listener). + { + DWORD size = 0; + GetExtendedTcpTable(nullptr, &size, FALSE, AF_INET6, TCP_TABLE_OWNER_PID_ALL, 0); + std::vector buf(size ? size : 1); + if (size && GetExtendedTcpTable(buf.data(), &size, FALSE, AF_INET6, + TCP_TABLE_OWNER_PID_ALL, 0) == NO_ERROR) { + const auto* t = reinterpret_cast(buf.data()); + for (DWORD i = 0; i < t->dwNumEntries; ++i) { + const MIB_TCP6ROW_OWNER_PID& r = t->table[i]; + if (static_cast(r.dwLocalPort) != wantPort) continue; + Q_IPV6ADDR a6{}; + memcpy(&a6, r.ucLocalAddr, sizeof(a6)); + if (!sameHost(QHostAddress(a6), peer)) continue; + *pidOut = r.dwOwningPid; + return true; + } + } + } + return false; +} + +TciPeerProcessInfo resolveWindows(const QHostAddress& peer, quint16 port) +{ + TciPeerProcessInfo info; + DWORD pid = 0; + if (!findOwnerPid(peer, port, &pid) || pid == 0) return info; + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!h) return info; + wchar_t path[MAX_PATH * 2] = {}; + DWORD len = static_cast(sizeof(path) / sizeof(path[0])); + if (QueryFullProcessImageNameW(h, 0, path, &len) && len > 0) + info.exePath = QString::fromWCharArray(path, static_cast(len)); + CloseHandle(h); + if (info.exePath.isEmpty()) return info; + info.name = QFileInfo(info.exePath).completeBaseName(); + info.version = fileVersionString(info.exePath); // empty when no resource + info.resolved = true; + return info; +} + +#endif + +} // namespace + +TciPeerProcessInfo resolveLoopbackPeerProcess(const QHostAddress& peerAddr, + quint16 peerPort) +{ + if (peerPort == 0 || !peerAddr.isLoopback()) return {}; +#if defined(Q_OS_LINUX) + return resolveLinux(peerAddr, peerPort); +#elif defined(Q_OS_MACOS) + return resolveMac(peerAddr, peerPort); +#elif defined(Q_OS_WIN) + return resolveWindows(peerAddr, peerPort); +#else + return {}; +#endif +} + +} // namespace AetherSDR diff --git a/src/core/TciPeerProcess.h b/src/core/TciPeerProcess.h new file mode 100644 index 000000000..fbce454aa --- /dev/null +++ b/src/core/TciPeerProcess.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace AetherSDR { + +// Best-effort identity of the LOCAL program behind a TCP connection to us. +// TCI carries no client-identification message and the WebSocket handshake +// is a bare upgrade, so for a same-machine client the OS socket→pid map is +// the only source (#5087). Remote peers are never resolved. +struct TciPeerProcessInfo { + bool resolved{false}; + QString name; // "wsjtx" + QString exePath; // "/usr/bin/wsjtx" + QString version; // best-effort; empty when unknown — never guessed +}; + +// Resolve the local process that owns the TCP connection whose endpoint, as +// seen from our side, is peerAddr:peerPort (i.e. the CLIENT's local address +// and port). Returns an unresolved struct for a non-loopback peer or on any +// failure — this is decoration for a diagnostic log line, never a gate. +// Blocking and potentially slow (a per-process descriptor sweep): call it +// off the GUI thread. +TciPeerProcessInfo resolveLoopbackPeerProcess(const QHostAddress& peerAddr, + quint16 peerPort); + +} // namespace AetherSDR diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index d49861c9c..58442ba0d 100644 --- a/src/core/TciServer.cpp +++ b/src/core/TciServer.cpp @@ -6,6 +6,7 @@ #include "AppSettings.h" #include "Resampler.h" #include "LogManager.h" +#include "TciPeerProcess.h" #include "models/RadioModel.h" #include "models/SliceModel.h" #include "models/PanadapterModel.h" @@ -21,6 +22,8 @@ #include #include #include +#include +#include #include #include #include @@ -721,6 +724,7 @@ void TciServer::onNewConnection() qCInfo(lcCat) << "TciServer: client connected from" << ws->peerAddress().toString(); + resolvePeerProcess(ws); emit clientCountChanged(m_clients.size()); emit clientsChanged(); @@ -728,6 +732,48 @@ void TciServer::onNewConnection() } } +void TciServer::resolvePeerProcess(QWebSocket* ws) +{ + // Best-effort identity of the local program that connected (#5087). + // TCI carries no client-id message and the WebSocket handshake is a + // bare upgrade, so the OS socket→pid map is the only source. Resolved + // off-thread: the per-process descriptor sweep is unbounded and must + // not delay sendInitBurst(). A remote peer stays anonymous — say so + // once so the missing field is self-explaining in a support bundle. + const QHostAddress peerAddr = ws->peerAddress(); + const quint16 peerPort = ws->peerPort(); + if (!peerAddr.isLoopback()) { + qCDebug(lcCat) << "TciServer: peer" << peerAddr.toString() + << "is not loopback, process identity unavailable"; + return; + } + QPointer guard(ws); + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcherBase::finished, this, + [this, watcher, guard, peerAddr, peerPort] { + const TciPeerProcessInfo info = watcher->result(); + watcher->deleteLater(); + if (!guard || !info.resolved) return; // decoration, never a gate + for (auto& cs : m_clients) { + if (cs.socket != guard) continue; // socket may have gone + cs.processName = info.name; + cs.processExe = info.exePath; + cs.processVersion = info.version; + qCInfo(lcCat).noquote().nospace() + << "TciServer: client " << peerAddr.toString() << ':' << peerPort + << " process=\"" << info.name << "\"" + << " exe=\"" << info.exePath << "\"" + << (info.version.isEmpty() + ? QString() + : QStringLiteral(" version=\"%1\"").arg(info.version)); + emit clientsChanged(); + return; + } + }); + watcher->setFuture(QtConcurrent::run(resolveLoopbackPeerProcess, + peerAddr, peerPort)); +} + void TciServer::onClientDisconnected() { auto* ws = qobject_cast(sender()); @@ -820,6 +866,9 @@ QVector TciServer::connectedClients() const ha = QHostAddress(QHostAddress::LocalHost); info.peerAddress = ha.toString(); info.peerPort = cs.socket->peerPort(); + info.processName = cs.processName; + info.processExe = cs.processExe; + info.processVersion = cs.processVersion; info.audio = cs.audioEnabled; info.audioReceiver= cs.audioReceiver; info.iq = cs.iqEnabled; diff --git a/src/core/TciServer.h b/src/core/TciServer.h index f6dc9e52c..1af3af30a 100644 --- a/src/core/TciServer.h +++ b/src/core/TciServer.h @@ -33,10 +33,15 @@ class Resampler; // Read-only snapshot of one connected TCI client, surfaced to the Radio // Setup → TCI tab. TCI has no client-identity handshake, so a client is // only ever known by its network endpoint plus the stream subscriptions -// it has requested. +// it has requested — plus, for a same-machine client, the OS's answer to +// "which local process owns that socket" (#5087), resolved best-effort +// after connect and empty until/unless it lands. struct TciClientInfo { QString peerAddress; quint16 peerPort{0}; + QString processName; // empty when unresolved or remote + QString processExe; + QString processVersion; // empty when not discoverable bool audio{false}; int audioReceiver{-1}; // -1 = all receivers bool iq{false}; @@ -230,6 +235,9 @@ private slots: struct ClientState { QWebSocket* socket{nullptr}; TciProtocol* protocol{nullptr}; + QString processName; // #5087 — see TciClientInfo + QString processExe; + QString processVersion; bool audioEnabled{false}; // client sent AUDIO_START int audioReceiver{-1}; // -1 = all receivers, otherwise TCI TRX int audioSampleRate{48000}; // requested output rate (48kHz for WSJT-X compat) @@ -260,6 +268,7 @@ private slots: // for acceptable latency in digital modes. static constexpr int kAccumMinFrames = 512; + void resolvePeerProcess(QWebSocket* ws); // #5087, off-thread lookup void ensureDaxForTci(); void releaseDaxForTci(); void scheduleDaxRelease(); // debounced releaseDaxForTci — cancel on reconnect diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp index 1bff4bd5d..9f6b26c59 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -1897,8 +1897,18 @@ void NetworkDiagnosticsDialog::refreshTciClientTable() "Your own label for this client (saved locally, keyed by IP)")); m_tciClientTable->setItem(r, 0, nameItem); - m_tciClientTable->setItem(r, 1, readOnly( - c.peerAddress + QStringLiteral(":") + QString::number(c.peerPort))); + // Endpoint, plus the owning local process when the OS could name it + // (#5087): "127.0.0.1:51234 (wsjtx)", exe path + version in the tip. + QString endpoint = c.peerAddress + QStringLiteral(":") + QString::number(c.peerPort); + if (!c.processName.isEmpty()) + endpoint += QStringLiteral(" (%1)").arg(c.processName); + auto* endpointItem = readOnly(endpoint); + if (!c.processExe.isEmpty()) { + endpointItem->setToolTip(c.processVersion.isEmpty() + ? c.processExe + : QStringLiteral("%1 — version %2").arg(c.processExe, c.processVersion)); + } + m_tciClientTable->setItem(r, 1, endpointItem); m_tciClientTable->setItem(r, 2, readOnly(tciRoleHint(c))); const QString audio = c.audio ? (c.audioReceiver < 0 diff --git a/tests/tci_peer_process_test.cpp b/tests/tci_peer_process_test.cpp new file mode 100644 index 000000000..80522a1f3 --- /dev/null +++ b/tests/tci_peer_process_test.cpp @@ -0,0 +1,76 @@ +// TciPeerProcess: the OS socket->process lookup behind the TCI +// client-identity log line (#5087). A self-connected TCP pair must resolve +// to THIS test binary; a non-loopback peer must not resolve at all. + +#include "core/TciPeerProcess.h" + +#include +#include +#include +#include +#include + +#include + +using namespace AetherSDR; + +namespace { + +bool expect(bool condition, const char* label) +{ + std::cout << (condition ? "[ OK ] " : "[FAIL] ") << label << '\n'; + return condition; +} + +bool selfConnectResolves(const QHostAddress& listenOn, const char* tag) +{ + QTcpServer server; + if (!server.listen(listenOn, 0)) { + std::cout << "[SKIP] " << tag << ": cannot listen (" + << server.errorString().toStdString() << ")\n"; + return true; + } + QTcpSocket client; + client.connectToHost(listenOn, server.serverPort()); + bool ok = expect(client.waitForConnected(3000), "client connects"); + ok &= expect(server.waitForNewConnection(3000), "server accepts"); + QTcpSocket* accepted = server.nextPendingConnection(); + if (!accepted) return false; + + const TciPeerProcessInfo info = + resolveLoopbackPeerProcess(accepted->peerAddress(), accepted->peerPort()); + std::cout << " " << tag << ": peer " << accepted->peerAddress().toString().toStdString() + << ":" << accepted->peerPort() + << " -> resolved=" << info.resolved + << " name=\"" << info.name.toStdString() << "\"" + << " exe=\"" << info.exePath.toStdString() << "\"" + << " version=\"" << info.version.toStdString() << "\"\n"; + ok &= expect(info.resolved, "self-connected peer resolves"); + const QString self = QFileInfo(QCoreApplication::applicationFilePath()).canonicalFilePath(); + const QString got = QFileInfo(info.exePath).canonicalFilePath(); + ok &= expect(!info.exePath.isEmpty() && got == self, + "resolved exe is this test binary"); + ok &= expect(!info.name.isEmpty(), "resolved name is non-empty"); + client.disconnectFromHost(); + return ok; +} + +} // namespace + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + bool ok = true; + + ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHost), "ipv4 loopback"); + ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHostIPv6), "ipv6 loopback"); + + const TciPeerProcessInfo remote = + resolveLoopbackPeerProcess(QHostAddress(QStringLiteral("192.0.2.1")), 50001); + ok &= expect(!remote.resolved, "a non-loopback peer never resolves"); + const TciPeerProcessInfo noPort = + resolveLoopbackPeerProcess(QHostAddress(QHostAddress::LocalHost), 0); + ok &= expect(!noPort.resolved, "port 0 never resolves"); + + return ok ? 0 : 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index d4800c1e2..5ee83d6ce 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -459,6 +459,11 @@ add_test(NAME icom_session_test COMMAND icom_session_test) # IcomCIV backend seam test — the IRadioBackend implementor against the fake # IC-705, with the TCI/WSJT-X audio contract as the load-bearing assertion. +add_executable(tci_peer_process_test tests/tci_peer_process_test.cpp) +target_include_directories(tci_peer_process_test PRIVATE src) +target_link_libraries(tci_peer_process_test PRIVATE aethercore Qt6::Core Qt6::Network) +add_test(NAME tci_peer_process_test COMMAND tci_peer_process_test) + add_executable(icom_backend_test tests/icom_backend_test.cpp) target_include_directories(icom_backend_test PRIVATE src tests) target_link_libraries(icom_backend_test PRIVATE aethercore Qt6::Core Qt6::Network Qt6::Test) From cfb74fa2018c0f998cb6454636cb7ce0822e24ef Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:00:08 -0700 Subject: [PATCH 02/13] feat(tci): read the client's bundle version on macOS (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A macOS client is usually an app bundle, and its Info.plist carries the version the user sees (WSJT-X: "3.0.1"). Walk up from the resolved executable to Contents/Info.plist and read CFBundleShortVersionString (CFBundleVersion as fallback) — a plain file read, never an execution of the client. A bare executable yields no version, as before. --- src/core/TciPeerProcess.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index 7c95b842c..dc9f30e2e 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #elif defined(Q_OS_WIN) #include @@ -134,6 +135,24 @@ TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) #elif defined(Q_OS_MACOS) +// A macOS program is usually an app bundle, and the bundle's Info.plist +// carries the version the user sees in Finder (WSJT-X: "3.0.1"). Walk up +// from ".../Foo.app/Contents/MacOS/foo" to ".../Foo.app/Contents/Info.plist" +// and read it — a plain file read, never an execution of the client. A bare +// executable (no bundle) yields an empty string. +QString bundleVersionForExecutable(const QString& exePath) +{ + const int macosDir = exePath.lastIndexOf(QStringLiteral("/Contents/MacOS/")); + if (macosDir < 0) return {}; + const QString plist = exePath.left(macosDir) + QStringLiteral("/Contents/Info.plist"); + // NativeFormat on macOS reads property lists (binary or XML). + QSettings info(plist, QSettings::NativeFormat); + QString version = info.value(QStringLiteral("CFBundleShortVersionString")).toString().trimmed(); + if (version.isEmpty()) + version = info.value(QStringLiteral("CFBundleVersion")).toString().trimmed(); + return version; +} + QHostAddress sockinfoLocalAddress(const in_sockinfo& ini) { if (ini.insi_vflag & INI_IPV6) { @@ -189,6 +208,8 @@ TciPeerProcessInfo resolveMac(const QHostAddress& peer, quint16 port) info.name = QString::fromUtf8(name); if (info.name.isEmpty() && !info.exePath.isEmpty()) info.name = info.exePath.section(QLatin1Char('/'), -1); + if (!info.exePath.isEmpty()) + info.version = bundleVersionForExecutable(info.exePath); // empty off-bundle info.resolved = !info.name.isEmpty() || !info.exePath.isEmpty(); return info; } From 26d7ebfb7dc058511280726a29d51ce5ca24cc81 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:32:50 -0700 Subject: [PATCH 03/13] fix(tci): compare /proc fd links by raw readlink so the Linux peer sweep can match (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QFile::symLinkTarget() absolutizes a relative-looking link target against the link's directory, so every /proc//fd socket entry came back as "/proc//fd/socket:[N]" and the inode comparison never matched — the Linux resolver resolved nothing, ever. Found by the Linux leg of the per-platform proof: fldigi-TCI connected and no identity line appeared; the existing tci_peer_process_test fails 6 checks on Linux against the old code and passes 12/12 with the raw readlink(2) comparison. macOS and Windows use native APIs and are untouched. --- src/core/TciPeerProcess.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index dc9f30e2e..8c54d710f 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #elif defined(Q_OS_MACOS) #include #include @@ -99,6 +101,20 @@ bool findSocketInode(const QHostAddress& peer, quint16 port, quint64* inodeOut) return false; } +// QFile::symLinkTarget() absolutizes a relative-looking target against the +// link's own directory, so a /proc fd entry's raw "socket:[N]" comes back as +// "/proc//fd/socket:[N]" and can never equal the inode tag (measured +// live on Linux: the sweep resolved nothing, ever). readlink(2) returns the +// raw link text. +QString rawLinkTarget(const QString& linkPath) +{ + char buf[PATH_MAX]; + const ssize_t n = ::readlink(QFile::encodeName(linkPath).constData(), + buf, sizeof(buf) - 1); + if (n <= 0) return {}; + return QString::fromLocal8Bit(buf, static_cast(n)); +} + TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) { TciPeerProcessInfo info; @@ -119,7 +135,7 @@ TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) const QStringList fds = fdDir.entryList(QDir::Files | QDir::System | QDir::NoDotAndDotDot); for (const QString& fd : fds) { - if (QFile::symLinkTarget(fdDir.filePath(fd)) != target) continue; + if (rawLinkTarget(fdDir.filePath(fd)) != target) continue; QFile comm(QStringLiteral("/proc/%1/comm").arg(pid)); if (comm.open(QIODevice::ReadOnly | QIODevice::Text)) info.name = QString::fromUtf8(comm.readAll()).trimmed(); From 1897df2f6a2d34142b72ce4cd16656368863da9f Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:11:09 -0700 Subject: [PATCH 04/13] feat(tci): prefer the authored StringFileInfo version on Windows (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows backend read only the numeric VS_FIXEDFILEINFO quad, which drops the build metadata authors put in ProductVersion (WSJT-X: "3.0.1 c04dd8") and always renders masked in logs, because a 4-part dotted version matches the log sanitizer's IPv4 rule while authored 2/3-part strings do not. Query the translation table's ProductVersion, then FileVersion, and keep the numeric quad only as a fallback for exes with no string table — the same authored-version semantics the macOS backend gets from CFBundleShortVersionString. Co-Authored-By: Claude Fable 5 --- src/core/TciPeerProcess.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index 8c54d710f..21f74732a 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -243,6 +243,40 @@ QString fileVersionString(const QString& exePath) if (size == 0) return {}; std::vector buf(size); if (!GetFileVersionInfoW(w.c_str(), 0, size, buf.data())) return {}; + + // The authored StringFileInfo strings first — the version the user sees, + // same semantics as the macOS backend's CFBundleShortVersionString. + // ProductVersion before FileVersion because that is where build metadata + // lives (WSJT-X: "3.0.1 c04dd8"). The numeric VS_FIXEDFILEINFO quad is + // only a fallback for exes with no string table; its 4-part shape is + // masked by the log sanitizer's IPv4 rule, authored strings are not. + struct LangCodePage { WORD lang; WORD codePage; }; + LangCodePage* translations = nullptr; + UINT tLen = 0; + if (VerQueryValueW(buf.data(), L"\\VarFileInfo\\Translation", + reinterpret_cast(&translations), &tLen) + && translations && tLen >= sizeof(LangCodePage)) { + const UINT count = tLen / sizeof(LangCodePage); + for (const auto* key : {L"ProductVersion", L"FileVersion"}) { + for (UINT i = 0; i < count; ++i) { + const QString subKey = + QStringLiteral("\\StringFileInfo\\%1%2\\%3") + .arg(translations[i].lang, 4, 16, QLatin1Char('0')) + .arg(translations[i].codePage, 4, 16, QLatin1Char('0')) + .arg(QString::fromWCharArray(key)); + wchar_t* value = nullptr; + UINT vLen = 0; + if (VerQueryValueW(buf.data(), subKey.toStdWString().c_str(), + reinterpret_cast(&value), &vLen) + && value && vLen > 0) { + const QString s = QString::fromWCharArray(value).trimmed(); + if (!s.isEmpty()) + return s; + } + } + } + } + VS_FIXEDFILEINFO* ffi = nullptr; UINT len = 0; if (!VerQueryValueW(buf.data(), L"\\", reinterpret_cast(&ffi), &len) From 594422095451fa40ed7ca8dbb38dd72b5327907e Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:20:17 -0700 Subject: [PATCH 05/13] feat(tci): report the bundle build number on macOS (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFBundleShortVersionString alone answers "which version" but not "which build" — the question a support thread actually ends up asking. Report "3.0.1 (123)" when CFBundleVersion differs from the marketing version, either key alone when only one is set. Co-Authored-By: Claude Fable 5 --- src/core/TciPeerProcess.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index 21f74732a..facc2a7c1 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -155,7 +155,10 @@ TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) // carries the version the user sees in Finder (WSJT-X: "3.0.1"). Walk up // from ".../Foo.app/Contents/MacOS/foo" to ".../Foo.app/Contents/Info.plist" // and read it — a plain file read, never an execution of the client. A bare -// executable (no bundle) yields an empty string. +// executable (no bundle) yields an empty string. CFBundleShortVersionString +// is the marketing version; CFBundleVersion is the build number — report +// both as "3.0.1 (123)" when they differ, since the build is what a support +// thread ends up asking for. QString bundleVersionForExecutable(const QString& exePath) { const int macosDir = exePath.lastIndexOf(QStringLiteral("/Contents/MacOS/")); @@ -163,10 +166,12 @@ QString bundleVersionForExecutable(const QString& exePath) const QString plist = exePath.left(macosDir) + QStringLiteral("/Contents/Info.plist"); // NativeFormat on macOS reads property lists (binary or XML). QSettings info(plist, QSettings::NativeFormat); - QString version = info.value(QStringLiteral("CFBundleShortVersionString")).toString().trimmed(); - if (version.isEmpty()) - version = info.value(QStringLiteral("CFBundleVersion")).toString().trimmed(); - return version; + const QString shortVer = + info.value(QStringLiteral("CFBundleShortVersionString")).toString().trimmed(); + const QString build = info.value(QStringLiteral("CFBundleVersion")).toString().trimmed(); + if (shortVer.isEmpty()) return build; + if (build.isEmpty() || build == shortVer) return shortVer; + return shortVer + QStringLiteral(" (") + build + QLatin1Char(')'); } QHostAddress sockinfoLocalAddress(const in_sockinfo& ini) From aeecc70f85b6130d06b5ff6c726c52006aecc29a Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:20:43 -0700 Subject: [PATCH 06/13] feat(tci): read a distro client's version from the dpkg database (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ELF binary embeds no version, so Linux identity lines stopped at name + exe path. For distro-installed clients the dpkg database has the answer and is plain text: /var/lib/dpkg/info/.list maps the exe path to its owning package, /var/lib/dpkg/status maps the package to its version — which carries the packaging build, e.g. "2.6.1+repack-2build1". File reads only, never a package tool; home-built binaries match no .list and stay version-less; rpm's database is sqlite blobs, so non-dpkg distros remain a follow-up. Co-Authored-By: Claude Fable 5 --- src/core/TciPeerProcess.cpp | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index facc2a7c1..48a6570c3 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -6,6 +6,7 @@ #if defined(Q_OS_LINUX) #include +#include #include #include #include @@ -115,6 +116,51 @@ QString rawLinkTarget(const QString& linkPath) return QString::fromLocal8Bit(buf, static_cast(n)); } +// Linux binaries embed no version. For a distro-installed client, the dpkg +// database — read as plain files, never executing a package tool — maps the +// exe path to its owning package and that package's version, which carries +// the packaging build (e.g. "2.6.1+repack-2build1"). Each +// /var/lib/dpkg/info/[:].list file lists one package's installed +// paths; /var/lib/dpkg/status holds the "Package:"/"Version:" stanzas. +// Home-built binaries appear in no .list and stay version-less. rpm's +// database is not plain text (sqlite blobs), so non-dpkg distros remain a +// follow-up. Runs on the resolver's worker thread, like the /proc sweep. +QString dpkgVersionForExecutable(const QString& exePath) +{ + if (exePath.isEmpty()) return {}; + const QByteArray needle = exePath.toUtf8() + '\n'; + QString pkg; + QDirIterator it(QStringLiteral("/var/lib/dpkg/info"), + {QStringLiteral("*.list")}, QDir::Files); + while (it.hasNext()) { + QFile f(it.next()); + if (!f.open(QIODevice::ReadOnly)) continue; + const QByteArray all = f.readAll(); + if (!all.startsWith(needle) && !all.contains(QByteArray("\n") + needle)) + continue; + pkg = QFileInfo(f.fileName()).fileName(); + pkg.chop(5); // ".list" + const int arch = pkg.indexOf(QLatin1Char(':')); + if (arch > 0) pkg.truncate(arch); // "wsjtx:amd64" -> "wsjtx" + break; + } + if (pkg.isEmpty()) return {}; + + QFile status(QStringLiteral("/var/lib/dpkg/status")); + if (!status.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + const QString wantPkg = QStringLiteral("Package: ") + pkg; + bool inStanza = false; + QTextStream in(&status); + while (!in.atEnd()) { + const QString line = in.readLine(); + if (line.isEmpty()) { inStanza = false; continue; } + if (line == wantPkg) { inStanza = true; continue; } + if (inStanza && line.startsWith(QStringLiteral("Version: "))) + return line.mid(9).trimmed(); + } + return {}; +} + TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) { TciPeerProcessInfo info; @@ -142,6 +188,7 @@ TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) info.exePath = QFile::symLinkTarget(QStringLiteral("/proc/%1/exe").arg(pid)); if (info.name.isEmpty() && !info.exePath.isEmpty()) info.name = QFileInfo(info.exePath).fileName(); + info.version = dpkgVersionForExecutable(info.exePath); info.resolved = !info.name.isEmpty() || !info.exePath.isEmpty(); return info; } From b3d25350c59cf3ad33174df78c3afe02bc11f410 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:39:02 -0700 Subject: [PATCH 07/13] fix(tci): try the standard string-table blocks when the Translation entry lies (#5087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First live Windows run still fell back to the masked numeric quad: a version-resource probe showed WSJT-X 3.0.1 declares Translation lang=0409 cp=004b while its strings actually live under 040904B0, so a Translation-driven lookup alone can never find them. Append the standard en-US and language-neutral Unicode blocks (040904b0, 000004b0) as fallback candidates — the same list .NET's FileVersionInfo uses for the same reason. Block lookup measured case-insensitive. Co-Authored-By: Claude Fable 5 --- src/core/TciPeerProcess.cpp | 43 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index 48a6570c3..bc27bb2c5 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #endif @@ -302,6 +303,14 @@ QString fileVersionString(const QString& exePath) // lives (WSJT-X: "3.0.1 c04dd8"). The numeric VS_FIXEDFILEINFO quad is // only a fallback for exes with no string table; its 4-part shape is // masked by the log sanitizer's IPv4 rule, authored strings are not. + // Candidate string-table blocks: the declared Translation pairs, then the + // standard en-US and language-neutral Unicode blocks. The fallbacks are + // load-bearing: real exes ship a Translation entry that does not match + // their actual block — measured live, WSJT-X 3.0.1 declares 0409004b but + // stores its strings under 040904B0 (the block lookup itself is + // case-insensitive, also measured). .NET's FileVersionInfo carries the + // same fallback list for the same reason. + QStringList blocks; struct LangCodePage { WORD lang; WORD codePage; }; LangCodePage* translations = nullptr; UINT tLen = 0; @@ -309,22 +318,24 @@ QString fileVersionString(const QString& exePath) reinterpret_cast(&translations), &tLen) && translations && tLen >= sizeof(LangCodePage)) { const UINT count = tLen / sizeof(LangCodePage); - for (const auto* key : {L"ProductVersion", L"FileVersion"}) { - for (UINT i = 0; i < count; ++i) { - const QString subKey = - QStringLiteral("\\StringFileInfo\\%1%2\\%3") - .arg(translations[i].lang, 4, 16, QLatin1Char('0')) - .arg(translations[i].codePage, 4, 16, QLatin1Char('0')) - .arg(QString::fromWCharArray(key)); - wchar_t* value = nullptr; - UINT vLen = 0; - if (VerQueryValueW(buf.data(), subKey.toStdWString().c_str(), - reinterpret_cast(&value), &vLen) - && value && vLen > 0) { - const QString s = QString::fromWCharArray(value).trimmed(); - if (!s.isEmpty()) - return s; - } + for (UINT i = 0; i < count; ++i) + blocks << QStringLiteral("%1%2") + .arg(translations[i].lang, 4, 16, QLatin1Char('0')) + .arg(translations[i].codePage, 4, 16, QLatin1Char('0')); + } + blocks << QStringLiteral("040904b0") << QStringLiteral("000004b0"); + for (const auto* key : {L"ProductVersion", L"FileVersion"}) { + for (const QString& block : blocks) { + const QString subKey = QStringLiteral("\\StringFileInfo\\") + block + + QLatin1Char('\\') + QString::fromWCharArray(key); + wchar_t* value = nullptr; + UINT vLen = 0; + if (VerQueryValueW(buf.data(), subKey.toStdWString().c_str(), + reinterpret_cast(&value), &vLen) + && value && vLen > 0) { + const QString s = QString::fromWCharArray(value).trimmed(); + if (!s.isEmpty()) + return s; } } } From a2867148bdbb7c7d32421253a51e5984859ec7b6 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:49:03 -0700 Subject: [PATCH 08/13] test(tci): give tci_peer_process_test its own tests.cmake comment (#5087) Co-Authored-By: Claude Fable 5 --- tests/tests.cmake | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/tests.cmake b/tests/tests.cmake index 5ee83d6ce..2bc1e80f2 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -457,13 +457,16 @@ target_include_directories(icom_session_test PRIVATE src tests) target_link_libraries(icom_session_test PRIVATE Qt6::Core Qt6::Network) add_test(NAME icom_session_test COMMAND icom_session_test) -# IcomCIV backend seam test — the IRadioBackend implementor against the fake -# IC-705, with the TCI/WSJT-X audio contract as the load-bearing assertion. +# TciPeerProcess: the OS socket->process lookup behind the TCI client-identity +# log line (#5087) — self-connected loopback pairs must resolve to the test +# binary itself; non-loopback and port-0 peers must never resolve. add_executable(tci_peer_process_test tests/tci_peer_process_test.cpp) target_include_directories(tci_peer_process_test PRIVATE src) target_link_libraries(tci_peer_process_test PRIVATE aethercore Qt6::Core Qt6::Network) add_test(NAME tci_peer_process_test COMMAND tci_peer_process_test) +# IcomCIV backend seam test — the IRadioBackend implementor against the fake +# IC-705, with the TCI/WSJT-X audio contract as the load-bearing assertion. add_executable(icom_backend_test tests/icom_backend_test.cpp) target_include_directories(icom_backend_test PRIVATE src tests) target_link_libraries(icom_backend_test PRIVATE aethercore Qt6::Core Qt6::Network Qt6::Test) From ad427370b0cf48c422e0d62fc3a98a469007fa80 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:59:12 -0700 Subject: [PATCH 09/13] fix(tci): log the client's process name and version, never its executable path (#5087). Principle VII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-identity line carried exe="". A per-user install (Windows: C:\Users\\AppData\Local\...) puts the OS account name in every support bundle, and the path adds nothing to the question #5087 asks — which client, which version. The line is now process="wsjtx" version="3.0.1 c04dd8". The path stays in memory for the Network Diagnostics tooltip, in-app only. Maintainer ruling on #5130 (2026-09-07). No test: asserting on this line needs a live TciServer and a WebSocket client, which is a socket-based test; the bridge run in the PR body is the proof. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KBNAMYy5ixbhenCpDfqqqV --- src/core/TciServer.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index 58442ba0d..b6defa570 100644 --- a/src/core/TciServer.cpp +++ b/src/core/TciServer.cpp @@ -759,10 +759,14 @@ void TciServer::resolvePeerProcess(QWebSocket* ws) cs.processName = info.name; cs.processExe = info.exePath; cs.processVersion = info.version; + // Name and version only. The executable path stays in memory for + // the Network Diagnostics tooltip but is never logged: a per-user + // install path carries the OS account name into a support bundle, + // and the path adds nothing to "which client, which version" + // (maintainer ruling on #5130). qCInfo(lcCat).noquote().nospace() << "TciServer: client " << peerAddr.toString() << ':' << peerPort << " process=\"" << info.name << "\"" - << " exe=\"" << info.exePath << "\"" << (info.version.isEmpty() ? QString() : QStringLiteral(" version=\"%1\"").arg(info.version)); From d1e69c7e56ab59ce2104f92d971085ffd4f27fb8 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:59:12 -0700 Subject: [PATCH 10/13] fix(tci): the macOS peer sweep lists only this user's processes (#5087). Principle VII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveMac() listed every pid (PROC_ALL_PIDS) and relied on other users' processes refusing the fd listing. It now asks libproc for this uid's pids only (PROC_UID_ONLY, getuid()), so the sweep covers exactly what it can read and the same-user intent is visible in the code — the Linux sweep already behaves that way through unprivileged readlink. Windows never sweeps (kernel connection table, one process opened). Maintainer ruling on #5130 (2026-09-07). tci_peer_process_test's self-connection is same-uid and still resolves. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KBNAMYy5ixbhenCpDfqqqV --- src/core/TciPeerProcess.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index bc27bb2c5..a7ee95bab 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #elif defined(Q_OS_WIN) @@ -236,10 +237,16 @@ QHostAddress sockinfoLocalAddress(const in_sockinfo& ini) TciPeerProcessInfo resolveMac(const QHostAddress& peer, quint16 port) { TciPeerProcessInfo info; - int bytes = proc_listpids(PROC_ALL_PIDS, 0, nullptr, 0); + // This user's processes only, by construction: a client the operator + // started is the case #5087 is about, and other users' processes would + // refuse the fd listing anyway. Listing by uid keeps the sweep to what + // it can read and says so in the code (maintainer ruling on #5130); the + // Linux sweep is same-user by the same effect (unprivileged readlink). + const uid_t uid = getuid(); + int bytes = proc_listpids(PROC_UID_ONLY, uid, nullptr, 0); if (bytes <= 0) return info; std::vector pids(static_cast(bytes) / sizeof(pid_t) + 16); - bytes = proc_listpids(PROC_ALL_PIDS, 0, pids.data(), + bytes = proc_listpids(PROC_UID_ONLY, uid, pids.data(), static_cast(pids.size() * sizeof(pid_t))); if (bytes <= 0) return info; const size_t count = static_cast(bytes) / sizeof(pid_t); @@ -248,8 +255,6 @@ TciPeerProcessInfo resolveMac(const QHostAddress& peer, quint16 port) for (size_t i = 0; i < count; ++i) { const pid_t pid = pids[i]; if (pid <= 0) continue; - // Other users' processes refuse the fd listing (EPERM) and simply - // contribute nothing — same-user clients are the normal case. const int fdBytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nullptr, 0); if (fdBytes <= 0) continue; fds.resize(static_cast(fdBytes) / sizeof(proc_fdinfo) + 8); From 74a92bf7a6a3460ac6a7b2ca18656988dc6424e1 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:12:01 -0700 Subject: [PATCH 11/13] test(tci): tci_peer_process_test declares the socket it binds and skips with exit 77 (#5087). Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AGENTS.md test-layer boundary lets a socket-owning test stand when our own code is the subject, provided it is visible: the tests.cmake block now names the socket (an ephemeral-port QTcpServer on 127.0.0.1 and ::1 with a same-process QTcpSocket client — the kernel lookup under test needs a real socket; no peer process, no fake firmware) and registers SKIP_RETURN_CODE 77. A loopback listen that fails used to print [SKIP] and count as a pass. It is now reported and the run exits 77 after the remaining legs, so ctest shows "Skipped" instead of a green result that proved nothing. Any failed check still exits 1. Measured: forcing the IPv6 leg onto a non-local address gives exit 77 and ctest "***Skipped"; restored, 12/12 and exit 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M12LaQeTs7jjf49Xq3ovX5 --- tests/tci_peer_process_test.cpp | 26 ++++++++++++++++++++++---- tests/tests.cmake | 7 +++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/tci_peer_process_test.cpp b/tests/tci_peer_process_test.cpp index 80522a1f3..1dc6077f2 100644 --- a/tests/tci_peer_process_test.cpp +++ b/tests/tci_peer_process_test.cpp @@ -1,6 +1,13 @@ // TciPeerProcess: the OS socket->process lookup behind the TCI // client-identity log line (#5087). A self-connected TCP pair must resolve // to THIS test binary; a non-loopback peer must not resolve at all. +// +// Socket-owning test (AGENTS.md, test-layer boundary): the lookup under test +// asks the kernel which process owns a socket, so it needs a real one — this +// binary listens on an ephemeral loopback port and connects to itself. No +// peer process, no fake firmware. A loopback listen that fails is reported +// and the run exits 77 (ctest "skipped"), never a silent pass and never a +// wait on the timeout. #include "core/TciPeerProcess.h" @@ -22,12 +29,17 @@ bool expect(bool condition, const char* label) return condition; } -bool selfConnectResolves(const QHostAddress& listenOn, const char* tag) +constexpr int kExitSkip = 77; // tests.cmake: SKIP_RETURN_CODE 77 + +// Returns true on pass; sets *skipped (and returns true) when the loopback +// listen itself is unavailable, so the caller can exit 77 instead of 0. +bool selfConnectResolves(const QHostAddress& listenOn, const char* tag, bool* skipped) { QTcpServer server; if (!server.listen(listenOn, 0)) { std::cout << "[SKIP] " << tag << ": cannot listen (" << server.errorString().toStdString() << ")\n"; + *skipped = true; return true; } QTcpSocket client; @@ -61,9 +73,10 @@ int main(int argc, char** argv) { QCoreApplication app(argc, argv); bool ok = true; + bool skipped = false; - ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHost), "ipv4 loopback"); - ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHostIPv6), "ipv6 loopback"); + ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHost), "ipv4 loopback", &skipped); + ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHostIPv6), "ipv6 loopback", &skipped); const TciPeerProcessInfo remote = resolveLoopbackPeerProcess(QHostAddress(QStringLiteral("192.0.2.1")), 50001); @@ -72,5 +85,10 @@ int main(int argc, char** argv) resolveLoopbackPeerProcess(QHostAddress(QHostAddress::LocalHost), 0); ok &= expect(!noPort.resolved, "port 0 never resolves"); - return ok ? 0 : 1; + if (!ok) return 1; + if (skipped) { + std::cout << "[SKIP] a loopback listen was unavailable; exiting " << kExitSkip << '\n'; + return kExitSkip; + } + return 0; } diff --git a/tests/tests.cmake b/tests/tests.cmake index 2bc1e80f2..317f07b9a 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -460,10 +460,17 @@ add_test(NAME icom_session_test COMMAND icom_session_test) # TciPeerProcess: the OS socket->process lookup behind the TCI client-identity # log line (#5087) — self-connected loopback pairs must resolve to the test # binary itself; non-loopback and port-0 peers must never resolve. +# SOCKET-OWNING TEST (AGENTS.md, test-layer boundary — our own code is the +# subject, not a fake peer): binds a QTcpServer on an ephemeral port at +# 127.0.0.1 and at ::1, and connects a QTcpSocket from the same process to +# each, because the kernel lookup under test needs a real socket to resolve. +# No peer process, no fixed port. Exit 77 == a loopback listen was +# unavailable, reported as skipped rather than as a pass or a timeout. add_executable(tci_peer_process_test tests/tci_peer_process_test.cpp) target_include_directories(tci_peer_process_test PRIVATE src) target_link_libraries(tci_peer_process_test PRIVATE aethercore Qt6::Core Qt6::Network) add_test(NAME tci_peer_process_test COMMAND tci_peer_process_test) +set_tests_properties(tci_peer_process_test PROPERTIES SKIP_RETURN_CODE 77) # IcomCIV backend seam test — the IRadioBackend implementor against the fake # IC-705, with the TCI/WSJT-X audio contract as the load-bearing assertion. From 9476eb04a77f2c7925899093a897ec3baff7e6c9 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:34:54 -0700 Subject: [PATCH 12/13] test(tci): drop tci_peer_process_test; the three-platform bridge bundles are the proof (#5087). Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test's only unique claim — a live loopback socket resolves to its owner — was a self-connected QTcpServer/QTcpSocket pair, a socket-owning test in the default graph. The same claim is proven on the PR with real clients (WSJT-X, fldigi, a bare-WebSocket Python client) on Linux, macOS and Windows through the automation bridge, which is where AGENTS.md routes positive convergence. Its two negative checks (non-loopback peer, port 0) still pass with the guard they name deleted — nothing on the box has a socket with that local endpoint — so they pinned nothing. Removes the target, its tests.cmake block and the exit-77 skip added in 74a92bf7. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M12LaQeTs7jjf49Xq3ovX5 --- tests/tci_peer_process_test.cpp | 94 --------------------------------- tests/tests.cmake | 15 ------ 2 files changed, 109 deletions(-) delete mode 100644 tests/tci_peer_process_test.cpp diff --git a/tests/tci_peer_process_test.cpp b/tests/tci_peer_process_test.cpp deleted file mode 100644 index 1dc6077f2..000000000 --- a/tests/tci_peer_process_test.cpp +++ /dev/null @@ -1,94 +0,0 @@ -// TciPeerProcess: the OS socket->process lookup behind the TCI -// client-identity log line (#5087). A self-connected TCP pair must resolve -// to THIS test binary; a non-loopback peer must not resolve at all. -// -// Socket-owning test (AGENTS.md, test-layer boundary): the lookup under test -// asks the kernel which process owns a socket, so it needs a real one — this -// binary listens on an ephemeral loopback port and connects to itself. No -// peer process, no fake firmware. A loopback listen that fails is reported -// and the run exits 77 (ctest "skipped"), never a silent pass and never a -// wait on the timeout. - -#include "core/TciPeerProcess.h" - -#include -#include -#include -#include -#include - -#include - -using namespace AetherSDR; - -namespace { - -bool expect(bool condition, const char* label) -{ - std::cout << (condition ? "[ OK ] " : "[FAIL] ") << label << '\n'; - return condition; -} - -constexpr int kExitSkip = 77; // tests.cmake: SKIP_RETURN_CODE 77 - -// Returns true on pass; sets *skipped (and returns true) when the loopback -// listen itself is unavailable, so the caller can exit 77 instead of 0. -bool selfConnectResolves(const QHostAddress& listenOn, const char* tag, bool* skipped) -{ - QTcpServer server; - if (!server.listen(listenOn, 0)) { - std::cout << "[SKIP] " << tag << ": cannot listen (" - << server.errorString().toStdString() << ")\n"; - *skipped = true; - return true; - } - QTcpSocket client; - client.connectToHost(listenOn, server.serverPort()); - bool ok = expect(client.waitForConnected(3000), "client connects"); - ok &= expect(server.waitForNewConnection(3000), "server accepts"); - QTcpSocket* accepted = server.nextPendingConnection(); - if (!accepted) return false; - - const TciPeerProcessInfo info = - resolveLoopbackPeerProcess(accepted->peerAddress(), accepted->peerPort()); - std::cout << " " << tag << ": peer " << accepted->peerAddress().toString().toStdString() - << ":" << accepted->peerPort() - << " -> resolved=" << info.resolved - << " name=\"" << info.name.toStdString() << "\"" - << " exe=\"" << info.exePath.toStdString() << "\"" - << " version=\"" << info.version.toStdString() << "\"\n"; - ok &= expect(info.resolved, "self-connected peer resolves"); - const QString self = QFileInfo(QCoreApplication::applicationFilePath()).canonicalFilePath(); - const QString got = QFileInfo(info.exePath).canonicalFilePath(); - ok &= expect(!info.exePath.isEmpty() && got == self, - "resolved exe is this test binary"); - ok &= expect(!info.name.isEmpty(), "resolved name is non-empty"); - client.disconnectFromHost(); - return ok; -} - -} // namespace - -int main(int argc, char** argv) -{ - QCoreApplication app(argc, argv); - bool ok = true; - bool skipped = false; - - ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHost), "ipv4 loopback", &skipped); - ok &= selfConnectResolves(QHostAddress(QHostAddress::LocalHostIPv6), "ipv6 loopback", &skipped); - - const TciPeerProcessInfo remote = - resolveLoopbackPeerProcess(QHostAddress(QStringLiteral("192.0.2.1")), 50001); - ok &= expect(!remote.resolved, "a non-loopback peer never resolves"); - const TciPeerProcessInfo noPort = - resolveLoopbackPeerProcess(QHostAddress(QHostAddress::LocalHost), 0); - ok &= expect(!noPort.resolved, "port 0 never resolves"); - - if (!ok) return 1; - if (skipped) { - std::cout << "[SKIP] a loopback listen was unavailable; exiting " << kExitSkip << '\n'; - return kExitSkip; - } - return 0; -} diff --git a/tests/tests.cmake b/tests/tests.cmake index 317f07b9a..d4800c1e2 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -457,21 +457,6 @@ target_include_directories(icom_session_test PRIVATE src tests) target_link_libraries(icom_session_test PRIVATE Qt6::Core Qt6::Network) add_test(NAME icom_session_test COMMAND icom_session_test) -# TciPeerProcess: the OS socket->process lookup behind the TCI client-identity -# log line (#5087) — self-connected loopback pairs must resolve to the test -# binary itself; non-loopback and port-0 peers must never resolve. -# SOCKET-OWNING TEST (AGENTS.md, test-layer boundary — our own code is the -# subject, not a fake peer): binds a QTcpServer on an ephemeral port at -# 127.0.0.1 and at ::1, and connects a QTcpSocket from the same process to -# each, because the kernel lookup under test needs a real socket to resolve. -# No peer process, no fixed port. Exit 77 == a loopback listen was -# unavailable, reported as skipped rather than as a pass or a timeout. -add_executable(tci_peer_process_test tests/tci_peer_process_test.cpp) -target_include_directories(tci_peer_process_test PRIVATE src) -target_link_libraries(tci_peer_process_test PRIVATE aethercore Qt6::Core Qt6::Network) -add_test(NAME tci_peer_process_test COMMAND tci_peer_process_test) -set_tests_properties(tci_peer_process_test PROPERTIES SKIP_RETURN_CODE 77) - # IcomCIV backend seam test — the IRadioBackend implementor against the fake # IC-705, with the TCI/WSJT-X audio contract as the load-bearing assertion. add_executable(icom_backend_test tests/icom_backend_test.cpp) From 7ff41b5eab5975873639d006d6cf3ee8d865d4ae Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Thu, 10 Sep 2026 09:49:49 -0700 Subject: [PATCH 13/13] fix(tci): skip TIME_WAIT rows, keep 4-part versions unmasked, escape names (#5087). Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #5130 — every finding and nit from the 2026-09-10 pass, verified on the fixed build against the built-in demo: - findSocketInode(): a TIME_WAIT/orphaned row for the same local port carries inode 0 and can be listed before the live row (measured 6/6 with a client that reused its source port: no identity line ever). Skip zero inodes instead of returning them. Same guard on the Windows tables, where TIME_WAIT rows report owning pid 0. After the fix the same six connections all resolve. - The log sanitizer's IPv4 rule now also exempts the literal prefix version=", so a 4-part authored ProductVersion ("2.2.159.0", JTDX-style) reaches the bundle intact instead of as "*.*.*. 0". Quoting alone still exempts nothing (existing test kept); new test pins the field spelling. docs/log-redaction.md lists the new fields under Deliberately NOT redacted. - The identity line normalises the peer address the way the Network Diagnostics table does (::1 and ::ffff:127.0.0.1 -> 127.0.0.1), so a bundle line and a dialog screenshot name one client one way, and escapes quotes, backslashes and control characters in the client-chosen process name and version (measured: comm x"\nINF forged logs as one field on one line). - Linux sweep checks st_uid of /proc/ against getuid() before reading a descriptor table, so "same-user only" is enforced rather than a side effect of unprivileged readlink (root / CAP_SYS_PTRACE instances). The header states the per-platform contract, including that Windows is bound only by OpenProcess rights. - dpkg lookup is cached per executable path + mtime; a reconnecting client no longer re-reads every /var/lib/dpkg/info/*.list. - parseProcNetAddress() reads each /proc/net word back as a native integer (qFromBigEndian / memcpy), correct on either endianness; the tcp6/tcp comment now describes which socket lives in which file. - Merged origin/main: the finished lambda uses clientStateFor(), and disconnectSnapshot() carries processName/processVersion so the last disconnect record says which program went away. Co-Authored-By: Claude Fable 5.1 --- docs/log-redaction.md | 5 ++ src/core/AsyncLogWriter.cpp | 11 ++-- src/core/TciPeerProcess.cpp | 96 +++++++++++++++++++++++++-------- src/core/TciPeerProcess.h | 6 +++ src/core/TciServer.cpp | 93 ++++++++++++++++++++++---------- tests/async_log_writer_test.cpp | 17 ++++++ 6 files changed, 174 insertions(+), 54 deletions(-) diff --git a/docs/log-redaction.md b/docs/log-redaction.md index 014738b61..283d503db 100644 --- a/docs/log-redaction.md +++ b/docs/log-redaction.md @@ -52,6 +52,11 @@ broken real triage before, so do not "fix" them: - Callsign — FCC public record, and the primary way a report is identified - Radio model, firmware and software version (including 4-part build numbers) +- TCI client identity — the `process="…"` and `version="…"` fields on the + `TciServer: client` line (#5087, maintainer ruling on #5130). The client's + executable path is never logged; it lives only in the Network Diagnostics + tooltip. `version="` is one of the two prefixes that exempt a 4-part + number from the IPv4 rule (the other is `ver=`). - Port numbers, slice/stream ids, frequencies, modes - Identifiers that merely end in a keyword, e.g. `keytoken=` - **C++ qualified names** — `WanConnection::sendCommand`, `std::vector`. 48 log diff --git a/src/core/AsyncLogWriter.cpp b/src/core/AsyncLogWriter.cpp index b39a0640b..820882207 100644 --- a/src/core/AsyncLogWriter.cpp +++ b/src/core/AsyncLogWriter.cpp @@ -267,11 +267,14 @@ QString redactPii(const QString& msg) out.replace(*ipv6CompressedRe, QStringLiteral("[v6-redacted]")); // IPv4 addresses: 192.168.50.121 -> *.*.*. 121 (keep last octet). - // The word boundary skips v/V-prefixed version strings; the ver= - // lookbehind and trailing digit check skip firmware/software versions - // with build numbers such as software_ver=4.2.18.41174. + // The word boundary skips v/V-prefixed version strings; the ver= and + // version=" lookbehinds and the trailing digit check skip + // firmware/software versions with build numbers such as + // software_ver=4.2.18.41174 and the TCI client identity line's + // version="2.2.159.0" (#5087). Quoting alone exempts nothing — only + // those two literal prefixes do. static const QRegularExpression* ipRe = new QRegularExpression( - R"((? ****-****-****-7836 diff --git a/src/core/TciPeerProcess.cpp b/src/core/TciPeerProcess.cpp index a7ee95bab..2f62c3f05 100644 --- a/src/core/TciPeerProcess.cpp +++ b/src/core/TciPeerProcess.cpp @@ -5,12 +5,18 @@ #include #if defined(Q_OS_LINUX) +#include #include #include #include #include +#include +#include +#include #include +#include #include +#include #include #elif defined(Q_OS_MACOS) #include @@ -50,18 +56,18 @@ bool sameHost(const QHostAddress& a, const QHostAddress& b) #if defined(Q_OS_LINUX) // /proc/net/tcp{,6} print each 32-bit word of the address as %08X of the -// native (little-endian) value, so "0100007F" is 127.0.0.1 and a v4-mapped -// loopback is "0000000000000000FFFF00000100007F". Undo that word by word. +// word as stored in memory (the kernel's __be32, printed as a native +// integer), so on a little-endian host "0100007F" is 127.0.0.1 and a +// v4-mapped loopback is "0000000000000000FFFF00000100007F". Undo it by +// reading each word back as a native integer: its bytes, in memory order, +// are the address in network order — correct on either endianness. QHostAddress parseProcNetAddress(const QString& hex) { if (hex.size() == 8) { bool ok = false; const quint32 w = hex.toUInt(&ok, 16); if (!ok) return {}; - // Bytes of the LE word in memory order are the IPv4 octets. - const quint32 v4 = ((w & 0xFF) << 24) | ((w & 0xFF00) << 8) - | ((w & 0xFF0000) >> 8) | (w >> 24); - return QHostAddress(v4); + return QHostAddress(qFromBigEndian(w)); } if (hex.size() == 32) { Q_IPV6ADDR a6{}; @@ -69,19 +75,22 @@ QHostAddress parseProcNetAddress(const QString& hex) bool ok = false; const quint32 w = hex.mid(i * 8, 8).toUInt(&ok, 16); if (!ok) return {}; - a6[i * 4 + 0] = static_cast(w & 0xFF); - a6[i * 4 + 1] = static_cast((w >> 8) & 0xFF); - a6[i * 4 + 2] = static_cast((w >> 16) & 0xFF); - a6[i * 4 + 3] = static_cast(w >> 24); + memcpy(&a6[i * 4], &w, sizeof(w)); } return QHostAddress(a6); } return {}; } -// The client's OWN row has local_address == our peer endpoint. tcp6 first: -// on an Any-bound listener the common loopback-IPv4 client lives there in -// v4-mapped form, not in /proc/net/tcp. +// The client's OWN row has local_address == our peer endpoint. Both files +// are needed: an AF_INET client lives in /proc/net/tcp even when our +// Any-bound listener reports it as ::ffff:127.0.0.1 (that v4-mapped row in +// tcp6 is OUR accepted socket, whose local port is the listen port), while a +// ::1 or dual-stack client lives in /proc/net/tcp6. A TIME_WAIT or +// otherwise orphaned row for the same local port carries inode 0 and can be +// listed ahead of the live one (measured: a client that reused its source +// port listed "st 06 inode 0" first every time), so a zero inode is skipped, +// not returned. bool findSocketInode(const QHostAddress& peer, quint16 port, quint64* inodeOut) { for (const char* path : {"/proc/net/tcp6", "/proc/net/tcp"}) { @@ -97,7 +106,9 @@ bool findSocketInode(const QHostAddress& peer, quint16 port, quint64* inodeOut) bool ok = false; if (loc[1].toUShort(&ok, 16) != port || !ok) continue; if (!sameHost(parseProcNetAddress(loc[0]), peer)) continue; - *inodeOut = col[9].toULongLong(); + const quint64 inode = col[9].toULongLong(); + if (inode == 0) continue; // TIME_WAIT/orphaned row, same port + *inodeOut = inode; return true; } } @@ -127,10 +138,12 @@ QString rawLinkTarget(const QString& linkPath) // Home-built binaries appear in no .list and stay version-less. rpm's // database is not plain text (sqlite blobs), so non-dpkg distros remain a // follow-up. Runs on the resolver's worker thread, like the /proc sweep. -QString dpkgVersionForExecutable(const QString& exePath) +QString dpkgVersionForExecutableUncached(const QString& exePath) { if (exePath.isEmpty()) return {}; - const QByteArray needle = exePath.toUtf8() + '\n'; + // Every .list starts with the "/." root entry, so an exe path is always + // a later line: one "\n\n" needle covers it. + const QByteArray needle = '\n' + exePath.toUtf8() + '\n'; QString pkg; QDirIterator it(QStringLiteral("/var/lib/dpkg/info"), {QStringLiteral("*.list")}, QDir::Files); @@ -138,7 +151,7 @@ QString dpkgVersionForExecutable(const QString& exePath) QFile f(it.next()); if (!f.open(QIODevice::ReadOnly)) continue; const QByteArray all = f.readAll(); - if (!all.startsWith(needle) && !all.contains(QByteArray("\n") + needle)) + if (!all.contains(needle)) continue; pkg = QFileInfo(f.fileName()).fileName(); pkg.chop(5); // ".list" @@ -163,6 +176,31 @@ QString dpkgVersionForExecutable(const QString& exePath) return {}; } +// The dpkg sweep reads every installed package's file list — thousands of +// files on a desktop — and a TCI client reconnects far more often than its +// binary changes, so remember the answer per executable. The exe's mtime is +// part of the key: a package upgrade replaces the file, which invalidates +// the entry without any explicit expiry. Worker-thread callers only, hence +// the mutex. +QString dpkgVersionForExecutable(const QString& exePath) +{ + if (exePath.isEmpty()) return {}; + const QString key = exePath + QLatin1Char('@') + + QString::number(QFileInfo(exePath).lastModified().toSecsSinceEpoch()); + static QMutex mutex; + static QHash cache; + { + QMutexLocker lock(&mutex); + const auto hit = cache.constFind(key); + if (hit != cache.constEnd()) return hit.value(); + } + const QString version = dpkgVersionForExecutableUncached(exePath); + QMutexLocker lock(&mutex); + if (cache.size() > 64) cache.clear(); // bounded; a handful of clients in practice + cache.insert(key, version); + return version; +} + TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) { TciPeerProcessInfo info; @@ -170,15 +208,24 @@ TciPeerProcessInfo resolveLinux(const QHostAddress& peer, quint16 port) if (!findSocketInode(peer, port, &inode) || inode == 0) return info; const QString target = QStringLiteral("socket:[%1]").arg(inode); - // Same-user processes only (unprivileged readlink on /proc//fd) — - // the normal case for a client the operator started. Anything else just - // fails to resolve. + // This user's processes only — checked, not assumed: a client the + // operator started is the case #5087 is about, and an unprivileged + // readlink on another user's /proc//fd would fail anyway, but an + // instance running as root or with CAP_SYS_PTRACE could read everyone's + // descriptor tables. The uid of /proc/ is the process owner, so + // the sweep never looks inside a process this user does not own (same + // guarantee the macOS backend gets from PROC_UID_ONLY). + const uid_t uid = ::getuid(); const QDir proc(QStringLiteral("/proc")); const QStringList pids = proc.entryList(QDir::Dirs | QDir::NoDotAndDotDot); for (const QString& pid : pids) { bool numeric = false; pid.toInt(&numeric); if (!numeric) continue; + struct stat st {}; + if (::stat(QFile::encodeName(QStringLiteral("/proc/") + pid).constData(), &st) != 0 + || st.st_uid != uid) + continue; const QDir fdDir(QStringLiteral("/proc/%1/fd").arg(pid)); const QStringList fds = fdDir.entryList(QDir::Files | QDir::System | QDir::NoDotAndDotDot); @@ -241,7 +288,7 @@ TciPeerProcessInfo resolveMac(const QHostAddress& peer, quint16 port) // started is the case #5087 is about, and other users' processes would // refuse the fd listing anyway. Listing by uid keeps the sweep to what // it can read and says so in the code (maintainer ruling on #5130); the - // Linux sweep is same-user by the same effect (unprivileged readlink). + // Linux sweep checks the owner of each /proc/ for the same reason. const uid_t uid = getuid(); int bytes = proc_listpids(PROC_UID_ONLY, uid, nullptr, 0); if (bytes <= 0) return info; @@ -306,8 +353,9 @@ QString fileVersionString(const QString& exePath) // same semantics as the macOS backend's CFBundleShortVersionString. // ProductVersion before FileVersion because that is where build metadata // lives (WSJT-X: "3.0.1 c04dd8"). The numeric VS_FIXEDFILEINFO quad is - // only a fallback for exes with no string table; its 4-part shape is - // masked by the log sanitizer's IPv4 rule, authored strings are not. + // only a fallback for exes with no string table. (Any 4-part form, + // authored or numeric, survives the log sanitizer because the identity + // line spells the field version="…", which its IPv4 rule exempts.) // Candidate string-table blocks: the declared Translation pairs, then the // standard en-US and language-neutral Unicode blocks. The fallbacks are // load-bearing: real exes ship a Translation entry that does not match @@ -370,6 +418,7 @@ bool findOwnerPid(const QHostAddress& peer, quint16 port, DWORD* pidOut) const MIB_TCPROW_OWNER_PID& r = t->table[i]; if (static_cast(r.dwLocalPort) != wantPort) continue; if (!sameHost(QHostAddress(ntohl(r.dwLocalAddr)), peer)) continue; + if (r.dwOwningPid == 0) continue; // TIME_WAIT rows own no process *pidOut = r.dwOwningPid; return true; } @@ -389,6 +438,7 @@ bool findOwnerPid(const QHostAddress& peer, quint16 port, DWORD* pidOut) Q_IPV6ADDR a6{}; memcpy(&a6, r.ucLocalAddr, sizeof(a6)); if (!sameHost(QHostAddress(a6), peer)) continue; + if (r.dwOwningPid == 0) continue; // TIME_WAIT rows own no process *pidOut = r.dwOwningPid; return true; } diff --git a/src/core/TciPeerProcess.h b/src/core/TciPeerProcess.h index fbce454aa..131752ccd 100644 --- a/src/core/TciPeerProcess.h +++ b/src/core/TciPeerProcess.h @@ -22,6 +22,12 @@ struct TciPeerProcessInfo { // failure — this is decoration for a diagnostic log line, never a gate. // Blocking and potentially slow (a per-process descriptor sweep): call it // off the GUI thread. +// +// Same-user only: on Linux and macOS the sweep is restricted to processes +// owned by this uid (checked, not merely a side effect of permissions). On +// Windows the kernel names the owning pid directly and the only limit is +// OpenProcess() rights — an elevated instance can therefore name a client +// running in another user's session. TciPeerProcessInfo resolveLoopbackPeerProcess(const QHostAddress& peerAddr, quint16 peerPort); diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index d6d5e971c..90623cb2a 100644 --- a/src/core/TciServer.cpp +++ b/src/core/TciServer.cpp @@ -132,6 +132,45 @@ QString tciCommandName(const QString& message) // "no TX slice" sentinel semantics are unchanged (see the broadcastPower // call site): -1, not 0, because trx 0 is a legitimate TX slice. +// One spelling per client. An Any-bound listener reports a loopback IPv4 +// client as ::ffff:127.0.0.1 and a ::1 client as ::1; the Network +// Diagnostics table collapses both to 127.0.0.1 so the saved alias key is +// stable, and the identity log line uses the same form so a bundle line and +// a dialog screenshot name one client one way (#5087). +QHostAddress normalisedPeerAddress(QHostAddress ha) +{ + bool isV4 = false; + const quint32 v4 = ha.toIPv4Address(&isV4); + if (isV4) + ha = QHostAddress(v4); + else if (ha.isLoopback()) + ha = QHostAddress(QHostAddress::LocalHost); + return ha; +} + +// A process name is text the client chose (/proc//comm via +// prctl(PR_SET_NAME), proc_name on macOS), and the identity line is emitted +// .noquote() so its key="value" grammar survives the log sanitizer. Escape +// the characters that could forge a record — a quote, a backslash, a line +// break — so a name can never close the field or start a new line (#5087). +QString logFieldValue(const QString& raw) +{ + QString out; + out.reserve(raw.size()); + for (const QChar c : raw) { + if (c == QLatin1Char('"') || c == QLatin1Char('\\')) { + out += QLatin1Char('\\'); + out += c; + } else if (c.unicode() < 0x20 || c.unicode() == 0x7F) { + out += QStringLiteral("\\x%1").arg(static_cast(c.unicode()), 2, 16, + QLatin1Char('0')); + } else { + out += c; + } + } + return out; +} + } // namespace TciServer::TciServer(RadioModel* model, QObject* parent) @@ -840,25 +879,28 @@ void TciServer::resolvePeerProcess(QWebSocket* ws) const TciPeerProcessInfo info = watcher->result(); watcher->deleteLater(); if (!guard || !info.resolved) return; // decoration, never a gate - for (auto& cs : m_clients) { - if (cs.socket != guard) continue; // socket may have gone - cs.processName = info.name; - cs.processExe = info.exePath; - cs.processVersion = info.version; - // Name and version only. The executable path stays in memory for - // the Network Diagnostics tooltip but is never logged: a per-user - // install path carries the OS account name into a support bundle, - // and the path adds nothing to "which client, which version" - // (maintainer ruling on #5130). - qCInfo(lcCat).noquote().nospace() - << "TciServer: client " << peerAddr.toString() << ':' << peerPort - << " process=\"" << info.name << "\"" - << (info.version.isEmpty() - ? QString() - : QStringLiteral(" version=\"%1\"").arg(info.version)); - emit clientsChanged(); - return; - } + ClientState* cs = clientStateFor(guard); // socket may have gone + if (!cs) return; + cs->processName = info.name; + cs->processExe = info.exePath; + cs->processVersion = info.version; + // Name and version only. The executable path stays in memory for + // the Network Diagnostics tooltip but is never logged: a per-user + // install path carries the OS account name into a support bundle, + // and the path adds nothing to "which client, which version" + // (maintainer ruling on #5130). The version field is spelled + // version="…" on purpose: the log sanitizer's IPv4 rule exempts + // exactly that prefix, so a 4-part authored version ("2.2.159.0") + // reaches the bundle intact instead of as "*.*.*. 0". + qCInfo(lcCat).noquote().nospace() + << "TciServer: client " << normalisedPeerAddress(peerAddr).toString() + << ':' << peerPort + << " process=\"" << logFieldValue(info.name) << "\"" + << (info.version.isEmpty() + ? QString() + : QStringLiteral(" version=\"%1\"") + .arg(logFieldValue(info.version))); + emit clientsChanged(); }); watcher->setFuture(QtConcurrent::run(resolveLoopbackPeerProcess, peerAddr, peerPort)); @@ -946,14 +988,7 @@ QVector TciServer::connectedClients() const // alias key: collapse IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain // IPv4, and IPv6 loopback (::1) to 127.0.0.1. Otherwise the same // physical client could key its saved Name under two spellings. - QHostAddress ha = cs.socket->peerAddress(); - bool isV4 = false; - const quint32 v4 = ha.toIPv4Address(&isV4); - if (isV4) - ha = QHostAddress(v4); - else if (ha.isLoopback()) - ha = QHostAddress(QHostAddress::LocalHost); - info.peerAddress = ha.toString(); + info.peerAddress = normalisedPeerAddress(cs.socket->peerAddress()).toString(); info.peerPort = cs.socket->peerPort(); info.processName = cs.processName; info.processExe = cs.processExe; @@ -1034,6 +1069,10 @@ QJsonObject TciServer::disconnectSnapshot( {QStringLiteral("lastSocketErrorAgeMs"), age(client.lastSocketErrorAtMs)}, {QStringLiteral("lastRxCommand"), client.lastRxCommand}, {QStringLiteral("lastTxCommand"), client.lastTxCommand}, + // Which program went away (#5087); empty when never resolved. The + // executable path is deliberately absent — see resolvePeerProcess(). + {QStringLiteral("processName"), client.processName}, + {QStringLiteral("processVersion"), client.processVersion}, {QStringLiteral("ptt"), QJsonObject{ {QStringLiteral("owned"), client.socket == m_tciPttClient}, {QStringLiteral("requestedOn"), m_tciPttRequestedOn}, diff --git a/tests/async_log_writer_test.cpp b/tests/async_log_writer_test.cpp index c821e15a8..a4682be33 100644 --- a/tests/async_log_writer_test.cpp +++ b/tests/async_log_writer_test.cpp @@ -108,6 +108,22 @@ void testIpv4VersionExemption(const QString& dir) contents.contains(QStringLiteral("software_ver=4.2.18.41174"))); } +void testIpv4QuotedVersionFieldExemption(const QString& dir) +{ + // The TCI client identity line (#5087) spells its field version="…"; a + // 4-part authored ProductVersion must reach the bundle intact while the + // peer address on the same line is still masked. + const QString path = dir + "/ipv4_version_field.log"; + const QString contents = writeAndRead(path, QtDebugMsg, + QStringLiteral("aether.x"), + QStringLiteral("TciServer: client 127.0.0.1:61576 " + "process=\"JTDX\" version=\"2.2.159.0\"")); + report("IPv4 redaction skips version=\"…\" client versions", + contents.contains(QStringLiteral("version=\"2.2.159.0\"")) + && contents.contains(QStringLiteral("*.*.*. 1:61576")) + && !contents.contains(QStringLiteral("127.0.0.1"))); +} + void testIpv4ThreeOctetNotRedacted(const QString& dir) { // Three-octet strings like "0.9.8" never match the IPv4 regex (which requires four octets). @@ -969,6 +985,7 @@ int main(int argc, char** argv) testLabelForEachMsgType(dir); testIpv4Redaction(dir); testIpv4VersionExemption(dir); + testIpv4QuotedVersionFieldExemption(dir); testIpv4ThreeOctetNotRedacted(dir); testIpv4QuotedFourOctetIsStillRedacted(dir); testSerialRedaction(dir);