feat(tci): log the process behind local TCI clients. Principle VIII. - #5130
Conversation
…on (aethersdr#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 aethersdr#5087
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.
…eep can match (aethersdr#5087) QFile::symLinkTarget() absolutizes a relative-looking link target against the link's directory, so every /proc/<pid>/fd socket entry came back as "/proc/<pid>/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.
…hersdr#5087) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…hersdr#5087) 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/<pkg>.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 <noreply@anthropic.com>
…ntry lies (aethersdr#5087) 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 <noreply@anthropic.com>
…thersdr#5087) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@AetherClaude please review |
jensenpat
left a comment
There was a problem hiding this comment.
This is an important discovery tool for TCI applications, but changes the dynamics between our Microsoft Store publication (system process data) and privacy implications for the project. Jeremy will need to rule on the approach before this can merge.
|
@jensenpat maybe a less invasive alternative could be when the user files a bug report or issue and it involves TCI - aetherSDR could prompt/remind the user to provide version and build info for connected apps (like WSJT-X). Just a thought. |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
#5087: TCI has no client-identification message and the handshake is a bare WebSocket upgrade, so a support bundle held nothing but client connected from "::ffff:127.0.0.1". For same-machine clients the OS knows the owning process, and asking it is the only way to get that answer. Three platform implementations, each going to the primary source rather than guessing.
Verified empirically on macOS/arm64: the full AetherSDR target builds clean and tci_peer_process_test passes (0.35 s).
No blockers. One thing for @ten9876 that is not this PR's defect but lands here, and some things worth recording as correct.
Principle VII holds
/proc/net/tcp6 parsing is bounds-checked at every step — col.size() < 10 before indexing col[1], loc.size() != 2 before loc[0]/loc[1], toUShort(&ok, 16) with the ok flag actually tested rather than discarded. That is the right posture for text that could be truncated or reformatted by a kernel change.
Two details that clearly came from measurement rather than documentation, and both have their evidence in the comment:
26d7ebfb—QFile::symLinkTarget()absolutizes a relative-looking target, so/proc/<pid>/fd/N's rawsocket:[N]comes back as/proc/<pid>/fd/socket:[N]and can never match the inode tag. Comparing by rawreadlinkis the fix, and "measured" in the comment is doing real work there.b3d25350— real Windows executables shipTranslationentries that do not match the block their strings actually live in, so trying the declared pairs plus040904b0/000004b0is what makes the version lookup work in practice. PreferringProductVersionoverFileVersionbecause that is where build metadata lives (WSJT-X's reads"3.0.1 c04dd8") is the kind of thing only a bench session tells you.
The IPv4-appears-v4-mapped-in-tcp6 note is also the right level of detail for the next person, since it explains why both files are read rather than one.
Scope
Everything is explained by #5087. No CHANGELOG.md entry — correct. The NetworkDiagnosticsDialog change is the minimum consumer — endpoint plus process name inline, exe path and version in the tooltip.
For the maintainer, not a change request
This logs third-party install paths, and redactPii() has no rule for filesystem paths. (inline: TciServer.cpp:765) The qCInfo emits exe="<full path>", every log line passes through redactPii() (AsyncLogWriter.cpp:122), and that function covers IPv4, radio serials, auth tokens, first_name/last_name-style fields, GPS coordinates and MAC addresses — not paths.
I checked whether this is new exposure before writing it up, and it mostly is not: QsoRecorder, CatPort, EibiClient, ThemeManager and others already log paths that sit under the user's home, so the redactor has never covered this and the practice is established. So I am not asking you to change anything here.
What is new is the source. Until now the app logged its own paths; this logs the install path of whatever else is running on the machine, and on Windows a per-user install is routinely C:\Users\<name>\AppData\Local\…. Given redactPii() exists because of GHSA-ccrg-j8cp-qhc4 and already trims a MAC to its last octet, an OS username arriving by a new route seems worth a deliberate yes or no rather than arriving as a side effect. A homePath()-prefix rule in redactPii() would cover this and the pre-existing sites together — that is a separate issue, and I would rather it were filed than folded in here.
Nits
resolveLoopbackPeerProcess()reads/proc/*/fdfor every PID until it finds the inode. For processes owned by another user that fails withEACCESper-entry, which is correct and silent — worth one line saying so, since a reader wondering why there is no permission check deserves the answer.- The macOS path walks
proc_listpidsthenPROC_PIDLISTFDSper process. Same shape, same cost, and it runs once per connection rather than per message, so no concern — noting only that a machine with many processes pays it on every TCI connect.
Verified vs. read
Built and ran: the full app target and tci_peer_process_test. Traced: redactPii()'s full rule set and its application point, plus the existing path-logging sites that establish the practice. Read: the /proc parser bounds and the fd-comparison fix. Not verified: no Linux or Windows host here, so the /proc walk, the GetExtendedTcpTable path and the StringFileInfo block fallback all rest on your measurements — and those are the three that could not have been reasoned out.
What process-level data this PR actually collects, and how to minimize itShort answer. The PR records three things about the one process that connected to the TCI port: its short name, its full executable path, and a best-effort version. Nothing is retained about any other process. The path is the only field with a privacy cost, and it can be dropped or trimmed without losing the diagnostic value. The lookup mechanism does enumerate other processes' descriptors on Linux and macOS to find the match, and that is the part behind the "system process data" concern. What is retained (per connected loopback client, in the log and the Network Diagnostics tooltip):
Remote peers get nothing. No command line, environment, user, PID, or window title is read. Nothing is executed. The log is local and only leaves the machine if the user shares a bundle. What is touched but not retained, which is where the platforms differ:
So on Linux and macOS the code briefly enumerates the descriptor tables of unrelated processes. Nothing from that walk leaves the function, but a reader of the source sees a process sweep, and a Store reviewer reading a privacy declaration would ask whether the app "accesses information about other apps". On Windows, where the Store question actually lives, the lookup is already minimal. How to minimize without losing usefulness. The goal in #5087 is to know which client is on the socket and, ideally, which version. In order of impact:
On the prompt-the-user alternative (@skerker): asking the user to type the client version when filing a bug is what happens today, and #5087 exists because it fails in practice. Users report "WSJT-X" without the build, and the interesting cases are the ones where they are not sure what connected at all. Ruling. Approach is approved with two changes: log the basename only (keep the full path in memory for the tooltip if you like, but never log it), and add the macOS UID filter. That turns the log line into |
…able path (aethersdr#5087). Principle VII. The client-identity line carried exe="<full path>". A per-user install (Windows: C:\Users\<name>\AppData\Local\...) puts the OS account name in every support bundle, and the path adds nothing to the question aethersdr#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 aethersdr#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBNAMYy5ixbhenCpDfqqqV
…ersdr#5087). Principle VII. 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 aethersdr#5130 (2026-09-07). tci_peer_process_test's self-connection is same-uid and still resolves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBNAMYy5ixbhenCpDfqqqV
|
Both changes pushed on
Items 2 and 4 are in the body: a "What is collected" paragraph stating that the version comes from file reads scoped to the one connecting executable, nothing executed, and your one-sentence privacy text quoted for the Store listing. The support-dialog text is untouched; if you want that sentence in the app as well, say so and it is one more small commit. Context only: #5481 landed the home-directory → Proof on the new head: clean RelWithDebInfo build (macOS Intel, Qt 6.8.3), About
— authored by agent (Claude Code) on behalf of @skerker |
…ps with exit 77 (aethersdr#5087). Principle VIII. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M12LaQeTs7jjf49Xq3ovX5
…les are the proof (aethersdr#5087). Principle VIII. 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 74a92bf. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M12LaQeTs7jjf49Xq3ovX5
|
— authored by agent (Claude Code) on behalf of @skerker |
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
#5087 asks for the owning local process (name, exe, best-effort version) behind each loopback TCI connection, resolved by the OS, degrading to today's line on any failure. The diff does that on all three platforms, off the accept path, with the two changes from the 2026-09-07 ruling applied (exe= out of the log, macOS sweep limited to this uid). Verified on Linux (this review's build, Arch, Qt 6.10): a v4 loopback client, a ::1 client and the in-process bridge client all resolve about 20 ms after connect, the Network Diagnostics cell reads 127.0.0.1:44268 (python3), and 45 rapid connect/close cycles plus a quit with lookups in flight produced no crash and an orderly shutdown trace.
No merge blockers by the rubric (nothing breaks users, violates canon, or fails on main). Two reproduced defects in the feature's own purpose are cheap to fix and I would want them in before merge; they are inline with suggestions.
Test-boundary preflight
The net diff adds no test and touches neither tests.cmake nor any socket-owning source. The branch's own tci_peer_process_test (self-connected QTcpServer) was added and then removed in 9476eb04 before reaching main, so nothing gated execution. Noting the removal here per AGENTS.md; reviewed normally.
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/TciPeerProcess.{h,cpp} (new) |
OS socket→process resolver, three backends, version lookups | Yes | In scope |
src/core/TciServer.{h,cpp} |
async lookup after connect, identity on ClientState/TciClientInfo, second log line |
Yes | In scope |
src/gui/NetworkDiagnosticsDialog.cpp |
endpoint cell suffix + tooltip | Yes | In scope |
CMakeLists.txt |
new TU in CORE_SOURCES; iphlpapi version on Win32 |
Yes | In scope |
All 12 commits are dated 2026-08-21 to 2026-09-08 and every message names #5087. No CHANGELOG entry (correct). Nothing removed on the - side except the dialog's plain endpoint text, which nothing else read (the alias key is UserRole on column 0). New public surface: none (no protocol verb, no settings key). The body's checklist holds.
Findings (reproduced, non-blocking, fix recommended)
1. A stale TIME_WAIT row with the same local port aborts the Linux lookup (inline: TciPeerProcess.cpp:100, same shape on Windows at :373/:392). findSocketInode() returns the first row whose local endpoint matches and resolveLinux() treats inode == 0 as terminal, but a TIME_WAIT/orphaned row for that port carries inode 0 and can precede the live row. Reproduced 6/6 on this box with a client that reused its source port for a second local connection:
port 40501 rows for local port in /proc/net/tcp (order as listed):
local 0100007F:9E35 rem 0100007F:C3D3 st 06 inode 0
local 0100007F:9E35 rem 0100007F:C3D2 st 01 inode 1222178
Those six connections were held 1.5 s each (the lookup takes ~20 ms) and logged client connected from but never a process= line; 39 connect lines total, 3 identity lines. Skipping zero-inode rows (continue instead of return) fixes it; on Windows the analogue is dwOwningPid == 0 for TIME_WAIT rows (reasoned from the API, not run — no Windows host here).
2. A 4-part authored version string is masked by the log sanitizer (inline: TciServer.cpp:772, comment at TciPeerProcess.cpp:310). The IPv4 rule's exemption is the literal lookbehind (?<!ver=); version=" does not satisfy it. Run against the rule at AsyncLogWriter.cpp:274:
version="3.0.1 c04dd8" -> version="3.0.1 c04dd8" (survives)
version="2.2.159.0" -> version="*.*.*. 0" (JTDX-style ProductVersion)
version="4.2.6.0" -> version="*.*.*. 0"
So the comment's "authored strings are not [masked]" holds only for non-quad strings; any Windows client whose ProductVersion is a dotted quad (common) logs version="*.*.*. 0" in the bundle, which is the symptom the PR set out to remove. docs/log-redaction.md:54 lists "software version (including 4-part build numbers)" under Deliberately NOT redacted, so the right-depth fix is one more lookbehind in AsyncLogWriter.cpp:274 ((?<!ver=)(?<!version=")) plus a case in tests/async_log_writer_test.cpp next to the existing software_ver= one. Renaming the field will not do it: the lookbehind needs ver= immediately before the digits.
Nits (non-blocking)
- The tcp6-first comment describes the wrong row (inline
TciPeerProcess.cpp:82). The client's own AF_INET socket lives in/proc/net/tcp; it is our accepted socket that appears v4-mapped intcp6. Observed live: client0100007F:A9D0only intcp, theFFFF00000100007F:C3D2row intcp6is ours. The code works via the fall-through; the comment invites someone to drop the second file. parseProcNetAddressis little-endian-host-only (inline:62).qFromBigEndian<quint32>(w)and amemcpyof the native word say what the kernel actually printed and are correct on both endiannesses. Low practical impact.- "Same-user only" is enforced in code on macOS only. On Linux it is a side effect of unprivileged
readlink; an AetherSDR run as root or withCAP_SYS_PTRACEresolves other users' clients, and an elevated Windows process can open other sessions' processes. Worth one honest clause in the body's What is collected paragraph, or ast_uid == getuid()check before the fd walk if the guarantee is meant literally. docs/log-redaction.md"Deliberately NOT redacted" does not list the newprocess=/version=fields; one bullet citing #5087 and the ruling keeps a future redaction rule from silently eating them.info.nameis client-controlled (/proc/<pid>/commviaprctl(PR_SET_NAME)) and is spliced into a.noquote()line; a name containing"or\ncan forge a record. Same-user process, so bounded; escaping quotes/control characters closes it.- dpkg sweep per connect. Every loopback connect re-reads all
/var/lib/dpkg/info/*.list(thousands of files) on the global QtConcurrent pool. Astatic QHash<QString,QString>keyed by exe path (mtime-invalidated) bounds it to once per client binary. - Main has moved under the base:
origin/mainnow hasTciServer::clientStateFor(QWebSocket*)anddisconnectSnapshot(). The lambda's hand-rolledm_clientsscan becomes a second copy of the helper after merge, and the disconnect snapshot will not carryprocessName/processVersion. Merge is conflict-free; consider folding both in. - Squash message: the PR title is 79 characters and lacks the
Principle <N>.suffix AGENTS.md asks for; the merger can trim it.
What I verified vs. read
Built and drove the PR head (9476eb04) in a scratch worktree, offscreen, isolated AETHER_SETTINGS_DIR, connected to the built-in demo DEMO-0001 ("AetherSDR Demo", family sim), TCI server on port 50130, AETHER_AUTOMATION_NO_TX=1. Clients: a bare-upgrade Python WebSocket client over 127.0.0.1 and ::1, and the bridge's in-process tci start sim (resolves to process="AetherSDR"). Read back via the file log and a grab of the Network Diagnostics TCI Clients page. Tried and held: 30 zero-hold connect/close cycles twice (guard path, no crash, no line for gone sockets); quit with 15 lookups in flight (phase=application event=complete); v6 and v4-mapped matching in sameHost; the /proc/net/tcp column layout. Read only: the macOS and Windows backends, the StringFileInfo block fallback, and the uid filter — no host here, so those rest on the author's bundles. Not testable here: the dpkg path (Arch), and the root/CAP_SYS_PTRACE case.
One observation for the maintainer rather than the author: aether.cat defaults to QtWarningMsg (LogManager.cpp:26), so on a fresh store neither the existing client connected from line nor the new identity line reaches the log until the operator enables the "TCI / CAT / rigctld" category. Pre-existing, and #5087's own bundle evidently had it on, but it bounds how often this lands in a bundle unasked.
Automated pass (/code-review, medium) ran; findings above that came from it were each re-verified here, the rest dropped.
Instance driven: my own offscreen PR build against the demo simulator only; closed at the end of the session.
— Claude Code review on behalf of @ten9876
…names (aethersdr#5087). Principle VIII. Review round on aethersdr#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/<pid> 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 <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Approved at 7ff41b5e
Second pass on the fixed head. Everything from the 2026-09-10 review landed in one commit on top of a clean merge of origin/main, and each item was re-verified on a fresh build against the built-in demo simulator (offscreen, isolated settings, TX pinned off):
- TIME_WAIT reuse: the same six source-port-reuse connections that resolved 0/6 before now resolve 6/6.
- 4-part versions:
async_log_writer_testpasses with the newversion="…"exemption case; the quoted-IP-still-redacted case is unchanged. - Name escaping: a client with
commset tox"\nINF forgedlogs as one field on one line. - Address spelling: the
::1client logs as127.0.0.1:<port>, matching the Network Diagnostics cell. - v4,
::1and the in-process bridge client still resolve in ~20 ms; quit with lookups in flight shuts down cleanly.
All review threads are resolved with a note on what landed. The stale pending-ruling block from before the 2026-09-07 ruling is dismissed. macOS and Windows backends compile in CI; their runtime behaviour rests on the author's three-platform bundles.
Summary
Fixes #5087
TCI has no client-identification message and the WebSocket handshake is a bare
upgrade, so
TciServer: client connected from "::ffff:127.0.0.1"was all asupport bundle ever held about a misbehaving client. For the common same-machine
case the OS knows which process owns that socket; this asks it.
What changed:
src/core/TciPeerProcess.{h,cpp}—resolveLoopbackPeerProcess(peerAddr, peerPort): pure OS lookup, no Qt networking beyondQHostAddress. Linux:/proc/net/tcp6then/proc/net/tcp(a loopback IPv4 client on the Any-boundlistener appears v4-mapped in
tcp6) → socket inode →/proc/*/fd→comm+exe. macOS:proc_listpids→PROC_PIDLISTFDS→PROC_PIDFDSOCKETINFOmatching the client's local port/address →
proc_name+proc_pidpath.Windows:
GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)for AF_INET and AF_INET6→
QueryFullProcessImageNameW; version from the exe's authoredStringFileInfostrings — ProductVersion first (it is where build metadata lives: WSJT-X's reads
"3.0.1 c04dd8"), then FileVersion, trying the declared Translation pairs plus the
standard Unicode blocks 040904b0/000004b0 (real exes ship Translation entries that
don't match their actual block — measured on WSJT-X, Windows proof below; .NET's
FileVersionInfo carries the same fallbacks); the numeric
VS_FIXEDFILEINFOquadonly as a last resort — its always-4-part shape is rewritten by the log
sanitizer's IPv4 rule, authored strings are not. macOS: the bundle's
Info.plist,found by walking up from
…/Foo.app/Contents/MacOS/foo— a file read, never anexecution of the client; reported as
CFBundleShortVersionStringplusCFBundleVersionin the form "3.0.1 (123)" when the build number differs. Linux:an ELF binary embeds no version; for distro-installed clients the dpkg database,
read as plain files (
/var/lib/dpkg/info/*.listmaps the exe path to its package,/var/lib/dpkg/statusmaps the package to its version, which carries thepackaging build, e.g. "2.6.1+repack-2build1"), supplies it — never a package
tool executed; home-built binaries match no
.listand stay version-less; rpm'sdatabase is not plain text, so non-dpkg distros remain a follow-up.
Non-loopback peers and every failure return unresolved.
26d7ebfb(found by the Linux leg of the per-platform proof below): the Linuxfd sweep now compares
/proc/<pid>/fdlink targets by rawreadlink(2).QFile::symLinkTarget()absolutizes a relative-looking target against thelink's directory, so the raw
socket:[N]came back as/proc/<pid>/fd/socket:[N]and the comparison never matched — the Linuxresolver resolved nothing, ever. macOS/Windows use native APIs, untouched.
TciServer::onNewConnection()logs today's line unchanged, thenresolvePeerProcess()runs the lookup onQtConcurrentwith aQFutureWatcher— the descriptor sweep is unbounded and must not delaysendInitBurst(). When it lands, a second line:TciServer: client ::ffff:127.0.0.1:51234 process="wsjtx"(+
version="…"when known), andclientsChanged()fires. The executable pathis never logged; it is kept in memory for the Network Diagnostics tooltip only
(maintainer ruling, 2026-09-07). A non-loopback peer
gets one
qCDebugsaying identity is unavailable, so the absent field isself-explaining in a bundle. Identity is kept on
ClientState/TciClientInfo.127.0.0.1:51234 (wsjtx)with exe path (+ version) as its tooltip. No newcolumn; nothing changes when unresolved.
owning process) needs a real socket, and the self-connected
QTcpServertestthat carried it was removed in
9476eb04: AGENTS.md routes positive convergenceto the automation bridge, and the three-platform bundles below prove it with
real clients. The negative guard (remote peer, port 0 never resolve) is a
one-line check documented at the top of
resolveLoopbackPeerProcess()..cppin the aethercore list; Windows linksiphlpapi version.Limitations
What is collected. Only the one process that opened a TCP connection to the TCI port, and only three fields: its short name, its executable path, and a best-effort version. The log line carries the name and version; the path appears only in the Network Diagnostics tooltip, in-app. The version comes from file reads scoped to that one executable (
Info.plist, the dpkg status file, the exe's resource strings) — nothing is executed. Remote peers get nothing. On Linux and macOS the lookup briefly reads other same-user processes' descriptor lists to find the match (whatlsof -idoes); nothing from that walk is kept. The log is local and leaves the machine only if the user shares a bundle. Privacy text, as the maintainer proposed: "When a TCI client connects from this computer, AetherSDR records the connecting program's name and version in its local log."Packaged clients now report a version — with build metadata where the packager
authored it — on all three platforms. The version is read from what the OS or
package manager records about the binary; the client is never executed. The
honest remainder:
A bare binary's version usually exists only as compiled-in strings (fldigi's
Help→Build info stamp, for example), which no metadata query can see — so for
self-built clients the enhancement: log which local process is behind each TCI client connection — name, executable, version when discoverable #5087 support question ("which version is the user
running?") still comes back empty.
Info.plistkeys; a build hash appears only if thepackager put one there (WSJT-X's mac plist carries
v3.0.2/3.0.2, not theccdfafits own About shows).Constitution principle honored
Principle VIII — Evidence Over Assertion: the bundle now records which program
was connected instead of the operator reconstructing it from memory.
Test plan
(the TCI server listens without one); verified with a live local client via
the agent automation bridge, below
build/check-macos/check-windows). This PR registers no test (see "Whatchanged"); the proof is the bridge bundles below
Proof —
b3d25350on three platforms (agent automation bridge)The quoted lines below were read at
b3d25350; as ofad427370the log line has noexe=field (the tooltip still carries the path).Each platform leg: clean configure + full build at
b3d25350, Help→About SHAverified (screenshots in the zips), real local clients over
ws://…:50001,radio (where connected) RX only — nothing keyed. Readings verbatim from the
attached complete unedited logs:
TciServer: client ::1:61576 process="wsjtx" exe="C:\WSJT\wsjtx\bin\wsjtx.exe" version="3.0.1 c04dd8"— authored ProductVersion: version AND build hash, unmasked, 1 ms after connectTciServer: client ::ffff:127.0.0.1:59908 process="wsjtx" exe="/usr/bin/wsjtx" version="3.0.1"— the dpkg-recorded version (independently confirmed on the box:dpkg-query -W wsjtx→3.0.1), 116 msprocess="fldigi" exe="…/fldigi-tci/src/fldigi"— noversion=clause (owned by no package — the designed version-less path), 159 msInfo.plistkeys differ:v3.0.2vs3.0.2)TciServer: client ::1:50159 process="wsjtx" exe="/Applications/wsjtx.app/Contents/MacOS/wsjtx" version="v3.0.2 (3.0.2)"— both keys,short (build)form, 11 msprocess="Python" exe="…/Python.app/Contents/MacOS/Python" version="3.14.4"— equal keys de-duplicate to the short version aloneprocess="fldigi" exe="…/fldigi-tci/src/fldigi"— noversion=clause: a bare binary carries no OS-queryable version metadata (its git stamp is a compiled-in string, visible only in its own Help→Build info)Network
Diagnostics → TCI Clients shows the endpoint cell as
127.0.0.1:<port> (wsjtx)with exe path (+ version) in the tooltip:
How the per-platform legs got here (before-readings live in the attached zips):
26d7ebfb): atcfb74fa2no Linux client ever resolved — theQFile::symLinkTarget()defect under "What changed"; the unit test failed6 checks on the box.
1897df2f,b3d25350): the numeric-quad-only read rendered everyWindows version sanitizer-masked (
version="*.*.*. 0"), and WSJT-X declares a\VarFileInfo\Translationblock it doesn't use — both measured with theversion-resource probe in the Windows zip, both described under "What changed".
v3.0.2 ccdfaf; its plist carriesv3.0.2/3.0.2) — stated under Limitations.Evidence zips (complete unedited logs, About screenshots, unit-test outputs,
probe/client scripts):
aethersdr-pr5130-linux-version-2026-08-24.zip
aethersdr-pr5130-windows-d2-2026-08-24.zip
aethersdr-pr5130-mac-version-2026-08-24.zip
aethersdr-pr5130-ruling-revision-2026-09-08.zip — head
d1e69c7e(About SHA, Python client log line withoutexe=, TCI Clients cell,tci_peer_process_test12/12, quit-with-dialog-open trace), sha256c8fdb2a4b6b76f2d1539c28683a62aef7d7db91aaf1200efaabf757993b8da4bReview round (2026-09-08).
ad427370name + version only in the log;d1e69c7emacOS sweep limited to this user's pids. Maintainer ruling 2026-09-07.74a92bf7then9476eb04: the socket-owning unit test first met the AGENTS.md disclosure/exit-77 obligations, then was removed — the bridge bundles are the proof.Checklist
AppSettingscalls — n/a, no settingsMeterSmoother— n/alog line + one table cell; described above
name/path of a program that connected to us on the operator's own machine
(what
ss -tp/lsofshow any local user); same-user processes only.— authored by agent (Claude Code) on behalf of @skerker
Review round (2026-09-10),
7ff41b5e. Every finding and nit from the second review pass, plus a merge oforigin/main:findSocketInode()skips zero-inode (TIME_WAIT/orphaned) rows instead of returning them — measured 6/6 misses before, 6/6 resolves after, with a client that reused its source port; same guard on both Windows tables (dwOwningPid == 0).redactPii()now exempts the literal prefixversion="alongsidever=, so a 4-part authored version (2.2.159.0) reaches the bundle intact; new case inasync_log_writer_test, and the field is listed indocs/log-redaction.mdunder Deliberately NOT redacted.::1/::ffff:127.0.0.1→127.0.0.1) and escapes quotes, backslashes and control characters in the client-chosen name/version (acommofx"\nINF forgedlogs as one field on one line).st_uidof/proc/<pid>againstgetuid()before reading a descriptor table — "same-user only" is now enforced on Linux and macOS; the header states that Windows is bound only byOpenProcess()rights./proc/nethex words are read back withqFromBigEndian/memcpy(endian-correct); the tcp/tcp6 comment names the right socket.clientStateFor()anddisconnectSnapshot()carriesprocessName/processVersion.The log line above therefore reads
TciServer: client 127.0.0.1:51234 process="wsjtx" version="3.0.1 c04dd8"on all three platforms. Verified on Linux against the built-in demo (v4,::1, in-process client, hostile name, TIME_WAIT reuse, quit with lookups in flight).