diff --git a/CMakeLists.txt b/CMakeLists.txt index ea474a9b5..c0b73e2e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -831,6 +831,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 @@ -2142,6 +2143,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/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 new file mode 100644 index 000000000..2f62c3f05 --- /dev/null +++ b/src/core/TciPeerProcess.cpp @@ -0,0 +1,488 @@ +#include "TciPeerProcess.h" + +#include + +#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 +#include +#include +#include +#include +#include +#include +#elif defined(Q_OS_WIN) +#include +#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 +// 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 {}; + return QHostAddress(qFromBigEndian(w)); + } + 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 {}; + memcpy(&a6[i * 4], &w, sizeof(w)); + } + return QHostAddress(a6); + } + return {}; +} + +// 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"}) { + 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; + const quint64 inode = col[9].toULongLong(); + if (inode == 0) continue; // TIME_WAIT/orphaned row, same port + *inodeOut = inode; + return true; + } + } + 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)); +} + +// 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 dpkgVersionForExecutableUncached(const QString& exePath) +{ + if (exePath.isEmpty()) return {}; + // 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); + while (it.hasNext()) { + QFile f(it.next()); + if (!f.open(QIODevice::ReadOnly)) continue; + const QByteArray all = f.readAll(); + if (!all.contains(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 {}; +} + +// 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; + quint64 inode = 0; + if (!findSocketInode(peer, port, &inode) || inode == 0) return info; + const QString target = QStringLiteral("socket:[%1]").arg(inode); + + // 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); + for (const QString& fd : fds) { + 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(); + 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; + } + } + return info; +} + +#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. 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/")); + 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); + 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) +{ + 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; + // 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 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; + std::vector pids(static_cast(bytes) / sizeof(pid_t) + 16); + 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); + + std::vector fds; + for (size_t i = 0; i < count; ++i) { + const pid_t pid = pids[i]; + if (pid <= 0) continue; + 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); + if (!info.exePath.isEmpty()) + info.version = bundleVersionForExecutable(info.exePath); // empty off-bundle + 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 {}; + + // 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. (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 + // 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; + if (VerQueryValueW(buf.data(), L"\\VarFileInfo\\Translation", + reinterpret_cast(&translations), &tLen) + && translations && tLen >= sizeof(LangCodePage)) { + const UINT count = tLen / sizeof(LangCodePage); + 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; + } + } + } + + 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; + if (r.dwOwningPid == 0) continue; // TIME_WAIT rows own no process + *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; + if (r.dwOwningPid == 0) continue; // TIME_WAIT rows own no process + *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..131752ccd --- /dev/null +++ b/src/core/TciPeerProcess.h @@ -0,0 +1,34 @@ +#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. +// +// 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); + +} // namespace AetherSDR diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index 80a42b160..90623cb2a 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" @@ -23,6 +24,8 @@ #include #include #include +#include +#include #include #include #include @@ -129,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) @@ -807,6 +849,7 @@ void TciServer::onNewConnection() qCInfo(lcCat) << "TciServer: client connected from" << ws->peerAddress().toString(); + resolvePeerProcess(ws); emit clientCountChanged(m_clients.size()); emit clientsChanged(); @@ -814,6 +857,55 @@ 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 + 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)); +} + void TciServer::onClientDisconnected() { auto* ws = qobject_cast(sender()); @@ -896,15 +988,11 @@ 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; + info.processVersion = cs.processVersion; info.audio = cs.audioEnabled; info.audioReceiver= cs.audioReceiver; info.iq = !cs.iqReceivers.isEmpty(); @@ -981,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/src/core/TciServer.h b/src/core/TciServer.h index 1d5cdc89e..572f4aab0 100644 --- a/src/core/TciServer.h +++ b/src/core/TciServer.h @@ -34,10 +34,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}; @@ -244,6 +249,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) @@ -309,6 +317,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 14a27c747..e870a676e 100644 --- a/src/gui/NetworkDiagnosticsDialog.cpp +++ b/src/gui/NetworkDiagnosticsDialog.cpp @@ -1272,8 +1272,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/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);