diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8706cb8..d0a921e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,19 +115,41 @@ jobs: - name: It is actually universal run: | set -euo pipefail - # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle, - # so client/build/hamdeck-qml does not exist - the tools report "no such file", - # which reads as a build that produced nothing rather than a path that is wrong. - BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml - [ -f "$BIN" ] || BIN=client/build/hamdeck-qml + # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle + # is "HamDeck Remote.app" and the executable inside carries the same name, not + # the CMake target name - so every path here has a SPACE in it and must stay + # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no + # such file", which reads as a build that produced nothing. + BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote" + # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and + # it would now hide the thing most likely to break: if OUTPUT_NAME stops + # applying, the bundle is called hamdeck-qml.app again and a fallback would + # quietly build, test and ship the badly-named app that this rename exists to + # prevent. On APPLE the output is always a bundle, so a miss is a real fault. + [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; } echo "architectures: $(lipo -archs "$BIN")" lipo -archs "$BIN" | grep -q arm64 || { echo "missing arm64"; exit 1; } lipo -archs "$BIN" | grep -q x86_64 || { echo "missing x86_64"; exit 1; } + # ⚠️ A BUILD IS NOT A BUNDLE. Everything above proves the binary is universal and + # runs; none of it looks at what macOS actually shows the operator. 0.1.29 passed + # every one of those checks and shipped an app Finder called "hamdeck-qml" with the + # blank generic icon, plus no microphone usage string - which SIGKILLs the app on + # the first PTT. This is the check that was missing. + - name: Is it a properly formed Mac application + run: python3 tools/check_macos_bundle.py "client/build/HamDeck Remote.app" + - name: Run it run: | - # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle, - # so client/build/hamdeck-qml does not exist - the tools report "no such file", - # which reads as a build that produced nothing rather than a path that is wrong. - BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml - [ -f "$BIN" ] || BIN=client/build/hamdeck-qml + # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle + # is "HamDeck Remote.app" and the executable inside carries the same name, not + # the CMake target name - so every path here has a SPACE in it and must stay + # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no + # such file", which reads as a build that produced nothing. + BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote" + # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and + # it would now hide the thing most likely to break: if OUTPUT_NAME stops + # applying, the bundle is called hamdeck-qml.app again and a fallback would + # quietly build, test and ship the badly-named app that this rename exists to + # prevent. On APPLE the output is always a bundle, so a miss is a real fault. + [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; } QT_QPA_PLATFORM=offscreen "$BIN" --selftest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 607b7e5..6d28ffb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -553,11 +553,18 @@ jobs: - name: Run the binary run: | - # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle, - # so client/build/hamdeck-qml does not exist - the tools report "no such file", - # which reads as a build that produced nothing rather than a path that is wrong. - BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml - [ -f "$BIN" ] || BIN=client/build/hamdeck-qml + # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle + # is "HamDeck Remote.app" and the executable inside carries the same name, not + # the CMake target name - so every path here has a SPACE in it and must stay + # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no + # such file", which reads as a build that produced nothing. + BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote" + # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and + # it would now hide the thing most likely to break: if OUTPUT_NAME stops + # applying, the bundle is called hamdeck-qml.app again and a fallback would + # quietly build, test and ship the badly-named app that this rename exists to + # prevent. On APPLE the output is always a bundle, so a miss is a real fault. + [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; } QT_QPA_PLATFORM=offscreen "$BIN" --selftest # ⚠️ ASK THE BINARY, NOT THE FLAG. CMAKE_OSX_ARCHITECTURES is a request, and if Qt @@ -567,15 +574,30 @@ jobs: - name: It is actually universal run: | set -euo pipefail - # ⚠️ THE BINARY IS INSIDE AN .app ON macOS. qt_add_executable produces a bundle, - # so client/build/hamdeck-qml does not exist - the tools report "no such file", - # which reads as a build that produced nothing rather than a path that is wrong. - BIN=client/build/hamdeck-qml.app/Contents/MacOS/hamdeck-qml - [ -f "$BIN" ] || BIN=client/build/hamdeck-qml + # ⚠️ THE BINARY IS INSIDE AN .app ON macOS, UNDER THE DISPLAY NAME. The bundle + # is "HamDeck Remote.app" and the executable inside carries the same name, not + # the CMake target name - so every path here has a SPACE in it and must stay + # quoted. An unquoted one splits into "client/build/HamDeck" and reports "no + # such file", which reads as a build that produced nothing. + BIN="client/build/HamDeck Remote.app/Contents/MacOS/HamDeck Remote" + # ⚠️ NO FALLBACK TO A BARE client/build/hamdeck-qml. There used to be one, and + # it would now hide the thing most likely to break: if OUTPUT_NAME stops + # applying, the bundle is called hamdeck-qml.app again and a fallback would + # quietly build, test and ship the badly-named app that this rename exists to + # prevent. On APPLE the output is always a bundle, so a miss is a real fault. + [ -f "$BIN" ] || { echo "no binary at $BIN - the bundle is not named as expected"; ls client/build; exit 1; } echo "architectures: $(lipo -archs "$BIN")" lipo -archs "$BIN" | grep -q arm64 || { echo "missing arm64"; exit 1; } lipo -archs "$BIN" | grep -q x86_64 || { echo "missing x86_64"; exit 1; } + # ⚠️ A BUILD IS NOT A BUNDLE. Everything above proves the binary is universal and + # runs; none of it looks at what macOS actually shows the operator. 0.1.29 passed + # every one of those checks and shipped an app Finder called "hamdeck-qml" with the + # blank generic icon, plus no microphone usage string - which SIGKILLs the app on + # the first PTT. This is the check that was missing. + - name: Is it a properly formed Mac application + run: python3 tools/check_macos_bundle.py "client/build/HamDeck Remote.app" + # ⚠️ A TEMPORARY KEYCHAIN, not the login keychain. The runner is shared # infrastructure; importing a signing identity into the default keychain leaves it # for whatever runs next. This one is created, used, and destroyed with the job. @@ -631,7 +653,7 @@ jobs: if: ${{ env.APPLE_CERT_P12 != '' }} run: | set -euo pipefail - APP=client/build/hamdeck-qml.app + APP="client/build/HamDeck Remote.app" [ -d "$APP" ] || { echo "no .app was built at $APP"; exit 1; } macdeployqt "$APP" -qmldir=client/qml # ⚠️ --deep signs the frameworks macdeployqt just copied in. Signing only the @@ -644,6 +666,13 @@ jobs: codesign --verify --deep --strict --verbose=2 "$APP" echo "APP=$APP" >> "$GITHUB_ENV" + # ⚠️ AND AGAIN ON THE SIGNED BUNDLE. macdeployqt rewrites the bundle after the + # first check ran - it copies frameworks in and edits Info.plist - so the thing + # verified above is not the thing that goes into the DMG. + - name: Still a properly formed Mac application after macdeployqt + if: ${{ env.APPLE_CERT_P12 != '' }} + run: python3 tools/check_macos_bundle.py "$APP" + # ⚠️ A DMG WITH AN APPLICATIONS ALIAS - the idiomatic macOS install, and safer than a # zip in practice: an unzipped .app tends to get run from Downloads, where quarantine # behaves differently and the app is one cleanup away from vanishing. A DMG steers diff --git a/CARRYOVER.md b/CARRYOVER.md index 7f6935a..c63b85a 100644 --- a/CARRYOVER.md +++ b/CARRYOVER.md @@ -52,11 +52,40 @@ Auth: session cookie, also accepted as `?token=` or `Bearer`. Since v3.4.14 stat audio require a session. `web_admin_only=true` makes `/` serve the admin page and 404s the old browser rig UI. +🔴 **TRAILING SLASHES ARE TRIMMED ON `/api/` PATHS — `ApiServer.cs:764-766`:** + +```csharp +if (path.StartsWith("/api/")) { var trimmed = path.TrimEnd('/'); ... } +``` + +**The Stream Deck sends them.** Its amp tune button requests `/api/tune/amp/`, and the C++ host +matched that against the PREFIX route rather than the exact one — the not-configured catch-all, +which answers 200 and never tunes. The button reported success and did nothing, independent of +any permission. + +⚠️ **A route inventory cannot see this.** `AUDIT-CSHARP.md` ticked `/api/tune/amp` because the +route exists, and the 71/74 sweep drove clean paths. How a path is MATCHED is part of the +contract, not an implementation detail. Normalise where the request is built, so the gates and +the router agree — at the router alone, `/api/ptt/on/` skips `IsTransmitRoute` and still +dispatches. + ⚠️ **`/api/tune` is the rig's INTERNAL ATU and is the wrong tuner for this station.** The right one is `/api/tune/tgxl`. Keep them separate and name them in any confirmation. ⚠️ **`/api/tune/amp` refuses every remote caller** (`AmpTuneOrDeny(isLocal)`). Do not expose it remotely; a button that always errors is worse than a missing one. +⚠️ **AND IT ANSWERS 200 WHEN IT REFUSES.** `AmpTuneOrDeny` returns an error *object*, not an +error status, so a Stream Deck button reads the refusal as success: green tick, no tune. That +is not a detail - it is why this went unnoticed for weeks after the host moved boxes. + +📌 **The C++ host deliberately diverges here, 09/01/2026.** `isLocal` was the right test on the +reference host *because it ran on the station PC*, so loopback proved an operator was present +and all 44 Stream Deck buttons hitting `localhost:5001` were local. Once the rig moved to its +own box, loopback started proving the caller was on the **rig** box - the one place nobody +sits - and amp tune became unreachable from the operating position. The C++ host therefore +asks **who**, not **where**: the loopback console, or a session whose account carries +`is_station`. It refuses with **403**. See `tools/amp_gate_check.sh`. + --- ## 3. Audio — the whole reason a C++ port is interesting @@ -181,7 +210,8 @@ Both lived only in the WPF host and were never ported to Linux. Check for more o needs a second receiver or a net report. - **WPF cannot be cross-compiled.** `Microsoft.NET.Sdk.WindowsDesktop` does not exist for Linux. (Irrelevant to a C++ client, but it is why the .NET client is built on a Windows CI runner.) -- **Amp tune is local-only** — see §2. +- **Amp tune is local-only** — see §2. (On the C++ host: local console *or* an `is_station` + account, and the refusal is a 403. The restriction did not go away; the question changed.) --- diff --git a/CMakeLists.txt b/CMakeLists.txt index 55604e7..1899a27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,13 @@ add_test(NAME cw COMMAND test_cw) add_executable(test_transmit_gate tests/test_transmit_gate.cpp src/transmit_routes.cpp) add_test(NAME transmit_gate COMMAND test_transmit_gate) +# ⚠️ Drives the REAL binary over HTTP, because the amp gate's failure mode was a +# refusal served as HTTP 200 - which every unit test and every route inventory +# read as success while the Stream Deck button sat dead. Needs the host built. +add_test(NAME amp_gate COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/amp_gate_check.sh + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) +set_tests_properties(amp_gate PROPERTIES DEPENDS transmit_gate) + add_executable(test_remote_active tests/test_remote_active.cpp src/auth.cpp) target_link_libraries(test_remote_active PRIVATE OpenSSL::Crypto) add_test(NAME remote_active COMMAND test_remote_active) diff --git a/WIP.md b/WIP.md index 3f5df18..53a1005 100644 --- a/WIP.md +++ b/WIP.md @@ -1264,3 +1264,250 @@ FTDX-101 emits before trusting every one. - Bandmap→QSY (the reverse direction, HTTP :54321 in the C#) is **not** built. Ask before building it: it is unauthenticated remote control of the VFO and the C# bound it to the whole LAN with `Allow-Origin: *`. + +--- + +## 09/01/2026 — the amp tune button, and why it was never "yanked" + +**Symptom:** the Stream Deck amp tune button does nothing. **Cause:** it has been refused +since the rig moved to its own box, and the refusal was served as **HTTP 200**, which a deck +button reads as success. Green tick, no carrier, no complaint, for weeks. + +The restriction was NOT added here. It is the reference host's, ported faithfully — +`Services/ApiServer.cs`: + + private object? AmpTuneOrDeny(bool isLocal) + => isLocal ? _amp.Tune() : ... "Amp tune is only available when connected locally." + +`isLocal` was correct *there* because the C# host ran on the station PC, so loopback proved +an operator was present and all 44 deck buttons hitting `localhost:5001` were local. The gate +never broke. It came to prove the wrong thing: loopback on the rig box means the caller is on +the rig box, which is the one place nobody sits. + +**Fix:** ask WHO, not WHERE. Amp tune needs the loopback console, or a session whose account +carries `is_station` **and** `can_transmit`. Refusals are 403. + +⚠️ **`is_station` is deliberately not implied by `can_transmit`.** "May key the rig, with a +hand on it" and "may start a ten-second unattended carrier into an amplifier" are different +claims. Default false, granted by an explicit admin act, so nothing gains it by upgrading. + +⚠️ **And `can_transmit` is required on top**, found by reading the live user list rather than +assuming: the `pusher` account has `can_transmit=false`, and amp tune predates +`IsTransmitRoute` so it is gated separately. Without that term, a station grant would have +handed a carrier to an account explicitly denied transmit. + +### The test, and the hole in the first draft of the test +`tools/amp_gate_check.sh` drives the real binary over HTTP on both listeners, refuses to run +against anything that is not a simulator, and is wired into ctest. + +⚠️ **Its first version passed against the injected bug.** Step 3 asserted only "not 403", and +the bug being guarded against refuses with **200** — so the assertion could not tell the +working build from the broken one. It now checks the body came from the amp route. This is +the same failure the fix itself addresses, reproduced inside its own test within the hour. + +### Closed 09/01/2026 — granted to `wa0o` +Joe: the pusher logs in as **`wa0o`**, which already held `can_transmit`, so this was the +station grant alone and widened nothing else. Config edited in place (backup +`config.json.bak-station-*`, temp+rename, every unknown key preserved), host restarted, and +the pusher reconnected on its own. + + joe tx=True station=False + listener tx=False station=False + pusher tx=False station=False + wa0o tx=True station=True + +⚠️ **The last step is a button press, and it is Joe's.** `rig_connected` went true during this +work, and `/api/tune/amp` keys the transmitter for ten seconds - so it was NOT fired from here +to "confirm". The binary is proven by `amp_gate_check.sh` against the simulator on both build +hosts; the live path is proven by pressing the button. + + joe admin tx station=false + listener -- station=false + pusher -- station=false <- tx denied + wa0o tx station=false + +Deployed to the VM: build `41d38d6ba96c`, 16/16 tests green there. + +`/api/auth/status` now carries `is_station`, so a client can grey the button out rather than +show a live one that answers 403 - and so the right can be confirmed without keying an amp. + + +### 🔴 The grant that kept vanishing — an admin write flushes the WHOLE user list + +Granting `wa0o` the station right in the config file and restarting did not work, twice, and +the second failure explained the first. + +1. `main.cpp` called `AddUser` without `is_station`. The `= false` default argument made that + compile cleanly, so **every startup dropped the right**. The file said the operator had it; + the running host said they did not. +2. Worse, it did not just fail to load - it **erased the grant**. `persist_users` mirrors the + in-memory user list back over `config.json` on any admin write, so removing a temporary + account rewrote every user from memory, where `is_station` was already false. The grant was + overwritten by the cleanup step of the check that was verifying it. + +⚠️ **An admin write persists ALL users, not the one being changed.** A hand-edit to +`config.json` on a running host survives only until the next admin call. Grant through the API, +or edit and restart before anything else touches a user. + +⚠️ **`AddUser` has no default arguments now.** A missing right is a compile error, not a silent +false. Removing them immediately surfaced seven call sites. + +⚠️ **`ctest` passed while the build was FAILING** during this work - it ran the stale binaries +from the previous build. A green suite after a red build means nothing; read the build result. + +Live state, verified in the running host AND on disk after a flush: + + joe tx=True station=False + listener tx=False station=False + pusher tx=False station=False + wa0o tx=True station=True + +Deployed: build `1c75acfd03ce`. + + +### 🔴 ROOT CAUSE, found last instead of first: a trailing slash + +The amp button sends **`/api/tune/amp/`**. Read straight off the host's journal: + + Sep 02 01:09:53 hamdeck-cpp hamdeck-host[18620]: dash GET /api/tune/amp/ + +That matched the PREFIX route, not the exact one, so it hit the not-configured catch-all - +200, no tune, no rights involved at all. The reference host trims trailing slashes on `/api/` +paths (`ApiServer.cs:766`); this one did not. + +⚠️ **Process failure worth keeping.** Hours went into the permission gate - which was a real +bug and did need fixing - while the thing actually breaking the button was routing. The first +move should have been *what does the deck actually send, and what does the host answer*. One +`journalctl | grep tune` answered it. Reasoning from the code found a true fact that was not +the operative one. + +⚠️ **This may have been breaking other buttons silently.** The 71/74 route sweep used clean +paths, so any button sending a trailing slash was never exercised. + +Verified live without transmitting: `/api/health/` and `/api/health//` now answer 200, and +`/api/tune/amp/` answers 401 (the auth gate) instead of the catch-all's 200 - proving it now +resolves to the real route. Build `ef607ca00190`. + +--- + +## §12 — The radio moved and the station never noticed (09/02/2026) + +Joe moved a cable: unplugged the FTDX-101MP's USB and plugged it back in. The host went on +serving a dashboard, `active (running)`, and reported **`rig_connected:false` indefinitely**. + +**The mechanism, measured, not guessed:** + + /proc/1797/fd/3 -> /dev/ttyUSB0 (deleted) + /proc/1797/fd/5 -> /dev/snd/pcmC0D0c (deleted) + /proc/1797/fd/6 -> /dev/snd/pcmC0D0p (deleted) + +The host opens CAT and the codec **once, at startup**, and has no reconnect path +(`main.cpp` — a failed open is fatal by design; a *dying* open is not handled at all). +So it sat holding three device nodes that no longer existed. + +⚠️ **The held fd is also what renamed the port.** Minor 0 was still in use, so the returning +CP2105 enumerated as `ttyUSB1`/`ttyUSB2`. `radio_port` said `/dev/ttyUSB0`. Every restart +then failed FATAL (restart counter reached **209**) until one happened to catch a moment when +a `ttyUSB0` existed. **A device that "came back on a different number" is a symptom of the +old handle, not of the cable.** + +### The fix — recovery from OUTSIDE the process +The host cannot rescan, so nothing inside it was changed. Three pieces, all in `deploy/`: + +1. **`99-hamdeck-radio.rules`** — `/dev/ttyRIG` symlink matched on **vid:pid + interface 00** + (the CAT half of the dual UART), never a minor number. `radio_port` is now `/dev/ttyRIG`. + The rule also sets `SYSTEMD_WANTS=hamdeck-cpp.service`, so plugging the radio in **starts + the host**. +2. **`hamdeck-cpp.service.d/rig-device.conf`** — `BindsTo=dev-ttyRIG.device`, so unplugging + **stops** the host and drops the stale fds; plus `Restart=always` / + `StartLimitIntervalSec=0` so it keeps trying while the radio is away. +3. **`hamdeck-rig-watchdog`** + timer (30s) — catches a re-enumeration systemd coalesced. + ⚠️ It fires **only** on a signature no healthy host can show: an fd on a *deleted* `/dev` + node, or a CAT fd that is not what `/dev/ttyRIG` points at. Deliberately **not** on + `rig_connected:false` — a radio switched off reads exactly like that, and that watchdog + would restart forever with nothing wrong. + +### The gate: `tools/rig_replug_test.sh` — PROVEN to fail +Unbinds **both** USB devices from the kernel's `usb` driver and binds them back: the same +udev remove/add a physical replug produces, without touching the hypervisor's passthrough. + +- recovery **disabled** → `FAIL: still not connected 30s after the radio came back`, unit + still `active`, three deleted fds. Tonight's bug, reproduced on demand. +- watchdog alone, same broken state → `restarting hamdeck-cpp.service: stale device handle: + /dev/snd/pcmC0D0p (deleted)` → connected. +- recovery **enabled** → unplug leaves the unit `inactive`; replug → `rig_connected=true in + 0s, CAT node /dev/ttyUSB0, stale fds 0`, **PASS**. Back on minor 0, because the fd was + released. + +⚠️ **A bug in the first version of the gate, worth keeping:** the rebind guard tested +`[ -e /sys/bus/usb/devices/$p ]`. Unbinding does **not** remove the device from sysfs — it +only detaches the driver — so the guard skipped the rebind every time and left the station +off the air. Test for `$p/driver`, not for `$p`. + +⚠️ Running the gate restarts the host, which drops the Wavelog pusher's session. Do not run +it while Joe is operating. + +--- + +## 09/02/2026 — the Mac app had no name and no icon + +v0.1.29's DMG installed, launched and worked. Finder called it **`hamdeck-qml`** and drew it +with the blank generic-document icon. Nothing had failed: CMake names a bundle after the +**target**, and its stock `Info.plist` has no icon key, so there was no default that could +have been right and nothing that looked. + +**A Mac app's identity is entirely in the bundle, not the binary.** Fixed in three places: + +1. **`packaging/icons/hamdeck.icns`** — 10 entries, generated by `brand/build.sh` in the same + render-pack-verify pass as the `.ico`, so the two families can never drift. + ⚠️ **Apple's icon grid: the artwork is 824 of 1024, centred, the rest transparent.** A + full-bleed square is the clearest tell of a ported icon — it sits visibly larger than every + neighbour in the Dock. `mark.svg` is already a rounded rect, so it needed the MARGIN, not + new artwork. + ⚠️ **The inset moves the small-art boundary up a slot.** The 32pt slot holds only + `32*824/1024 = 26px` of artwork, below the 32px floor where `mark.svg` turns to mush — so + `mark-small.svg` covers **16 and 32** here where it covers 16 and 24 in the `.ico`. + ⚠️ **The 16pt 1x slot is full-bleed, and that was measured, not assumed.** At 13px + mark-small's reflector merges into the boom: the drawing that exists to survive that size + stops surviving it. Rendered both, magnified, looked. Applies to that slot only — `ic11` + is the same 16pt slot on Retina, where there are 32 real pixels and the grid is kept. +2. **`client/packaging/Info.plist.in`** + the `if(APPLE)` block in `client/CMakeLists.txt` — + `OUTPUT_NAME` renames the bundle to **HamDeck Remote.app**; CFBundleName and + CFBundleDisplayName are set **separately** (set one only and the other falls back to the + executable file name, which is how an app is called two different things in two places). + ⚠️ The target stays `hamdeck-qml` and `OUTPUT_NAME` applies on **APPLE only** — Linux ships + a binary a `.desktop` file points at, and a space in that name would be a gratuitous break. + ⚠️ **The icns is a `target_sources` file with `MACOSX_PACKAGE_LOCATION Resources`, not an + `install(FILES)`.** macdeployqt, codesign and notarisation all run against `client/build` + before anything is installed, so an icon added at install time is signed into nothing. +3. ⚠️ **`NSMicrophoneUsageDescription`, which had not bitten yet and would have.** The + microphone *entitlement* says the app is allowed to ask; the usage string is what it asks + *with*. With the entitlement and no string macOS **SIGKILLs** the process the moment it + opens the mic — first PTT of the day, no dialog, nothing in the log. + +### The gate: `tools/check_macos_bundle.py` — PROVEN to fail +Reads the built `.app` with `plistlib` and `struct` rather than `plutil`/`iconutil`, so it runs +on the Linux leg and on a box with no Xcode. Runs in `build.yml` on every push, and in +`release.yml` **twice** — before signing, and again after macdeployqt, which rewrites the +bundle the first check looked at. + +Reconstructed the 0.1.29 bundle and each defect separately; every one is caught: + +| reintroduced | reported | +|---|---| +| target name + stock plist (0.1.29 exactly) | bundle name, CFBundleName, CFBundleDisplayName, icon key, mic string — 5 findings | +| icns present but not in `Contents/Resources` | "the icon was added at INSTALL time, not build time" | +| 512px art filed under the `ic10` (1024) slot | "artwork is 512x512, the slot needs 1024x1024" | +| `NSMicrophoneUsageDescription` removed | "macOS SIGKILLs the app on the first PTT" | + +⚠️ It trusts each entry's **own PNG header**, not the slot it was filed under: an icns holding +512px art under `ic10` is structurally perfect and looks soft on exactly the Retina display +that entry exists for. `brand/build.sh` carries the same check against the source PNGs. + +⚠️ **Every macOS path in both workflows now has a SPACE in it** and must stay quoted. The old +`[ -f "$BIN" ] || BIN=client/build/hamdeck-qml` fallbacks were **removed**: they would hide the +one thing most likely to regress — if `OUTPUT_NAME` stops applying the bundle is called +`hamdeck-qml.app` again, and a fallback would quietly build, test and ship it. + +**Not yet proven on hardware.** CI checks structure; nobody has opened the renamed bundle in +Finder or keyed up on a Mac. diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 89c9c5b..72b7c12 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -18,6 +18,11 @@ set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network WebSockets Multimedia Quick QuickControls2 Qml Test) qt_standard_project_setup() +# ⚠️ ONE DEFINITION OF THE MAC APP NAME. It is the .app directory name, the executable +# name inside it, CFBundleName, and the install destination of the licence texts - four +# places that must agree or the bundle is subtly broken in a way only a Mac shows. +set(MACOS_APP_NAME "HamDeck Remote") + # ⚠️ The Qt Widgets front end was REMOVED here, not kept as a fallback. # # It worked and was screenshot-verified, but two front ends against one API @@ -105,6 +110,41 @@ target_link_libraries(hamdeck-qml PRIVATE # from a terminal. CI depends on that output. set_target_properties(hamdeck-qml PROPERTIES WIN32_EXECUTABLE TRUE MACOSX_BUNDLE TRUE) +# ── The macOS bundle: its name, its icon, and its microphone string ────────── +# ⚠️ EVERYTHING VISIBLE ABOUT A MAC APP COMES FROM THE BUNDLE, NOT THE BINARY. +# Left alone, CMake names the bundle after the TARGET and writes a stock Info.plist +# with no icon key, so 0.1.29 shipped a signed, notarised, correctly working app that +# Finder called "hamdeck-qml" and drew with the blank generic page. Nothing failed; +# there is simply no default that could have been right. +# +# ⚠️ OUTPUT_NAME IS WHAT RENAMES THE .app - the target stays hamdeck-qml so add_test, +# target_sources and every non-Apple path are untouched. It applies on APPLE only: +# Linux ships an executable called hamdeck-qml that a .desktop file points at, and a +# binary with a space in its name there would be a gratuitous break. +if(APPLE) + set_target_properties(hamdeck-qml PROPERTIES + OUTPUT_NAME "${MACOS_APP_NAME}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/packaging/Info.plist.in" + MACOSX_BUNDLE_BUNDLE_NAME "${MACOS_APP_NAME}" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.wa0o.hamdeck.remote" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + MACOSX_BUNDLE_COPYRIGHT "WA0O" + MACOSX_BUNDLE_ICON_FILE "hamdeck.icns") + + # ⚠️ THE ICNS IS A SOURCE FILE, NOT AN install(FILES). CFBundleIconFile names a file + # that must already be in Contents/Resources of the BUILT bundle - macdeployqt, + # codesign and notarisation all run against client/build before anything is + # installed, so an icon added at install time is signed into nothing and the .app in + # the DMG has no icon anyway. MACOSX_PACKAGE_LOCATION puts it there at build time. + set(HAMDECK_ICNS "${CMAKE_CURRENT_SOURCE_DIR}/../packaging/icons/hamdeck.icns") + if(NOT EXISTS "${HAMDECK_ICNS}") + message(FATAL_ERROR "packaging/icons/hamdeck.icns is missing - run brand/build.sh") + endif() + target_sources(hamdeck-qml PRIVATE "${HAMDECK_ICNS}") + set_source_files_properties("${HAMDECK_ICNS}" PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") +endif() + # ⚠️ Embeds the .ico INTO the exe. Explorer, the taskbar and every shortcut read # the icon from the binary's own resource - the installer's SetupIconFile does # not give the installed app an icon, which is why it shipped blank. @@ -166,9 +206,9 @@ endif() # Resources/, not a Unix docdir. if(APPLE) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/fonts/OFL.txt - DESTINATION hamdeck-qml.app/Contents/Resources RENAME OFL-fonts.txt) + DESTINATION "${MACOS_APP_NAME}.app/Contents/Resources" RENAME OFL-fonts.txt) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/fonts/README.md - DESTINATION hamdeck-qml.app/Contents/Resources RENAME FONTS.md) + DESTINATION "${MACOS_APP_NAME}.app/Contents/Resources" RENAME FONTS.md) else() install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/fonts/OFL.txt DESTINATION ${CMAKE_INSTALL_DOCDIR} RENAME OFL-fonts.txt) diff --git a/client/packaging/Info.plist.in b/client/packaging/Info.plist.in new file mode 100644 index 0000000..9584abe --- /dev/null +++ b/client/packaging/Info.plist.in @@ -0,0 +1,44 @@ + + + + + + CFBundleDevelopmentRegion en + CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion 6.0 + CFBundlePackageType APPL + + + CFBundleName ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleDisplayName ${MACOSX_BUNDLE_BUNDLE_NAME} + + CFBundleShortVersionString ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion ${MACOSX_BUNDLE_BUNDLE_VERSION} + CFBundleIconFile ${MACOSX_BUNDLE_ICON_FILE} + NSHumanReadableCopyright ${MACOSX_BUNDLE_COPYRIGHT} + + + NSHighResolutionCapable + LSMinimumSystemVersion 11.0 + LSApplicationCategoryType public.app-category.utilities + NSPrincipalClass NSApplication + + + NSMicrophoneUsageDescription + HamDeck Remote sends your voice to the radio when you transmit. + + diff --git a/deploy/99-hamdeck-radio.rules b/deploy/99-hamdeck-radio.rules new file mode 100644 index 0000000..46f4002 --- /dev/null +++ b/deploy/99-hamdeck-radio.rules @@ -0,0 +1,13 @@ +# FTDX-101MP CAT link — Silicon Labs CP2105 dual UART bridge (10c4:ea70). +# +# Interface 00 is the CAT port; interface 01 is the bridge's second port and is +# silent. Match on the INTERFACE, never on a minor number: on 09/02/2026 the +# cable was moved and CAT came back as /dev/ttyUSB1, because the host was still +# holding a dead fd on /dev/ttyUSB0 and minor 0 was therefore still taken. The +# host came up pointed at a device that no longer existed. It names /dev/ttyRIG +# now and nothing else. +# +# SYSTEMD_WANTS starts the host when the radio appears. The unit BindsTo the +# same device, so unplugging stops it and drops the stale fd. Together that is +# the "keep looking for the radio" behaviour, with no polling. +SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea70", ENV{ID_USB_INTERFACE_NUM}=="00", SYMLINK+="ttyRIG", TAG+="systemd", ENV{SYSTEMD_WANTS}="hamdeck-cpp.service" diff --git a/deploy/hamdeck-cpp.service.d/rig-device.conf b/deploy/hamdeck-cpp.service.d/rig-device.conf new file mode 100644 index 0000000..907454d --- /dev/null +++ b/deploy/hamdeck-cpp.service.d/rig-device.conf @@ -0,0 +1,21 @@ +# Tie the host to the radio, not to the boot order. +# +# 09/02/2026: the CAT cable was moved. The host kept running with a DEAD fd +# (/proc//fd/3 -> /dev/ttyUSB0 (deleted)) and reported rig_connected:false +# forever — it opens the port once at startup and has no reconnect path. So the +# recovery has to come from outside the process: stop it when the radio leaves, +# start it when the radio comes back. +[Unit] +# BindsTo, not Requires: BindsTo also STOPS this unit when the device vanishes, +# which is what drops the stale fd. 99-hamdeck-radio.rules sets SYSTEMD_WANTS on +# the same device, so plugging the radio back in starts the host again. +BindsTo=dev-ttyRIG.device +After=dev-ttyRIG.device + +[Service] +# Keep trying forever while the radio is away or the codec has not enumerated +# yet. The host exits 1 on a missing CAT port or capture device by design, and +# StartLimit would otherwise let systemd give up on it. +Restart=always +RestartSec=5 +StartLimitIntervalSec=0 diff --git a/deploy/hamdeck-rig-watchdog b/deploy/hamdeck-rig-watchdog new file mode 100755 index 0000000..1650800 --- /dev/null +++ b/deploy/hamdeck-rig-watchdog @@ -0,0 +1,44 @@ +#!/bin/bash +# Belt-and-braces for the 09/02/2026 failure: the host holding a device node +# that no longer exists. +# +# The udev rule + BindsTo drop-in handle a normal unplug/replug. This catches +# the case where the device unit never went away cleanly (a fast re-enumeration +# that systemd coalesced, a codec that came back on a different node) and the +# host is left holding dead handles. +# +# ⚠️ It restarts ONLY on a signature that is impossible for a healthy host: +# a fd on a deleted /dev node, or a CAT fd that is not the node /dev/ttyRIG +# currently points at. It deliberately does NOT restart on rig_connected=false +# alone — a radio that is switched off reads exactly like that, and a watchdog +# that restarts on it would loop forever with nothing wrong. +set -uo pipefail + +UNIT=hamdeck-cpp.service +LINK=/dev/ttyRIG + +systemctl is-active --quiet "$UNIT" || exit 0 + +pid=$(systemctl show -p MainPID --value "$UNIT") +[[ "$pid" =~ ^[0-9]+$ ]] && [ "$pid" -gt 0 ] || exit 0 + +reason="" +cat_fd="" +for l in /proc/"$pid"/fd/*; do + t=$(readlink "$l" 2>/dev/null) || continue + case "$t" in + /dev/*"(deleted)") reason="stale device handle: $t" ;; + /dev/ttyUSB*) cat_fd="$t" ;; + esac +done + +if [ -z "$reason" ] && [ -n "$cat_fd" ] && [ -e "$LINK" ]; then + want=$(readlink -f "$LINK") + [ "$cat_fd" = "$want" ] || reason="CAT fd is $cat_fd but $LINK is now $want" +fi + +[ -n "$reason" ] || exit 0 + +logger -t hamdeck-rig-watchdog "restarting $UNIT: $reason" +echo "restarting $UNIT: $reason" +systemctl restart "$UNIT" diff --git a/deploy/hamdeck-rig-watchdog.service b/deploy/hamdeck-rig-watchdog.service new file mode 100644 index 0000000..11f0277 --- /dev/null +++ b/deploy/hamdeck-rig-watchdog.service @@ -0,0 +1,7 @@ +[Unit] +Description=HamDeck: recover the host from stale radio device handles +ConditionPathExists=/opt/hamdeck-cpp/hamdeck-host + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/hamdeck-rig-watchdog diff --git a/deploy/hamdeck-rig-watchdog.timer b/deploy/hamdeck-rig-watchdog.timer new file mode 100644 index 0000000..719117c --- /dev/null +++ b/deploy/hamdeck-rig-watchdog.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check every 30s that the HamDeck host still holds the real radio + +[Timer] +OnBootSec=60 +OnUnitActiveSec=30 +AccuracySec=5 + +[Install] +WantedBy=timers.target diff --git a/docs/AUDIT-AUDIO-ATTRIBUTION.md b/docs/AUDIT-AUDIO-ATTRIBUTION.md new file mode 100644 index 0000000..739af81 --- /dev/null +++ b/docs/AUDIT-AUDIO-ATTRIBUTION.md @@ -0,0 +1,81 @@ +# Audio attribution: what NetLogger and the logbook can actually tell us + +Measured 09/01/2026 against the live NetLogger API and the 29,578-QSO logbook on the +wavelog-test rig. Nothing here is inferred from documentation alone. + +## The wall: NetLogger records WHO, never WHEN + +A `` carries `SerialNo, Callsign, Status, FirstName, PreferredName, Street, +CityCountry, State, Zip, Country, County, Grid, DXCC, MemberID, Remarks, QSLInfo`. +Pulled live from `GetCheckins.php`, every field listed. **There is no timestamp on a +check-in, and no per-station time anywhere in the API.** `GetPastNetCheckins` returns the +final roster only. + +So a recording cannot be segmented from NetLogger history. That is the same shape as the +7-day wall in [[netlogger-xml-api]]: whatever we want, we capture live or we never have it. + +## The one live signal: `` + +Spec v1.3 line 59, verbatim: **"`` is the SerialNo of the currently working +station."** Returned on every `GetCheckins` call. Observed live: `Pointer=19` of +`CheckinCount=20`. + +⚠️ **The pointer is net control's cursor, not a transmit detector.** It moves when the +operator running the net clicks a station. It lags, it can sit still through a long +exchange, and on a loosely-run net it may not move at all. It is a strong hint about who +is being worked; it is not ground truth about who is making noise. Treat it as evidence, +never as a fact — and keep the raw samples so a better rule can be applied later without +re-recording. + +Rate limit is **3 GetCheckins/min = one sample per 20 seconds**, which is also the +boundary precision. Do not shorten it; v1.2 added server-side anti-flooding. + +## The logbook is the better index — second resolution, genuinely ragged + +`COL_TIME_ON` on net-tagged QSOs (12,001 rows via `qsl_qso_net`): + +- **98.3% carry non-zero seconds.** These are not minute-rounded stamps. +- **753 net sessions; exactly 1 has every QSO at one identical time (0.1%).** + Bulk-logging-at-the-end is not what happens. + +### ⚠️ The duplicate trap that nearly produced the wrong design +The first spacing measurement said median gap **0 seconds**, 70.2% under 20s — which would +have meant the timestamps were useless for slicing. That was too tidy to be true. +**5,900 of 5,902 zero-gap pairs are the SAME CALLSIGN** — the known duplicate-QSO problem +(5,320 groups logged under two station profiles). The duplicates, not the logging, made +the median zero. + +Deduplicated on call+time, the real distribution: + + n=2998 p10=17s median=211s p90=1641s under_20s=11.5% + +**A 3.5-minute median between consecutive net QSOs.** That is a sliceable timeline, and it +is a far finer index than the 20-second pointer poll. + +By year, undeduplicated, the artifact is visible directly — 2025 and 2026 (live-logged via +the NetLogger sync) show medians of 206s and 63s and only ~13% under 20s, while every year +2019-2024 reads median 0. The older years are duplicate-polluted, not differently logged. + +## What this means for the build + +1. **Attribution is captured live or not at all.** A recorder that runs without a + simultaneous pointer/roster capture produces audio that can never be attributed. +2. **The logbook timestamp is the primary index; the pointer track is corroboration.** + Where they disagree, neither is automatically right and the segment should say so + rather than pick a winner silently. +3. **Keep the raw poll responses**, not just the derived segments. The segmentation rule + will change; the recordings and the XML are what was paid for. +4. Segment boundaries are **uncertain by construction** — ±20s at best from the pointer, + and a logbook stamp marks when a contact was logged, not when the audio started. Any + page built on this must show that as a range, never as a precise clip. + +## Not established + +- Whether the pointer actually tracks transmissions closely enough to be useful. That + needs one real net recorded with the track running, then listened to against it. + **Nothing here proves it does.** +- Joe's own logging latency: how long after an exchange he commits the row. That offset is + the single biggest term in the slicing error and it has not been measured. + +Related: `qsl-card-system` (the QR spot on the card is the consumer of this), +[[netlogger-xml-api]], and section 1 of CARRYOVER.md for the recorder itself. diff --git a/packaging/icons/hamdeck.icns b/packaging/icons/hamdeck.icns new file mode 100644 index 0000000..f1ddfce Binary files /dev/null and b/packaging/icons/hamdeck.icns differ diff --git a/src/amp_tuner.h b/src/amp_tuner.h index fe3aa1e..78b9814 100644 --- a/src/amp_tuner.h +++ b/src/amp_tuner.h @@ -18,9 +18,15 @@ // of tuning the amplifier is to then operate through it. Restoring 5 W after // tuning an amplifier is not what anybody pressed the button for. // -// ⚠️ LOCAL CALLERS ONLY, enforced by the route, not here: this keys the -// transmitter for ten unattended seconds. "Local" means the request arrived on -// the loopback listener - a kernel guarantee, not a header a caller can set. +// ⚠️ THE OPERATOR MUST BE AT THE STATION, enforced by the route, not here: this +// keys the transmitter for ten unattended seconds. +// +// The reference host proved that with "did the request arrive on the loopback +// listener", which was a kernel guarantee and a correct one - while the host ran +// ON the station PC. It does not any more: the rig has its own box, and loopback +// there proves the caller is on the rig box, where nobody sits. So the route asks +// the loopback console OR an account carrying is_station. Same restriction, a +// question that still means what it says. // // ⚠️ Ten seconds is a long carrier. Every exit path unkeys: the stop flag is // checked every 100 ms, an exception unkeys and forces 100 W, and the diff --git a/src/api.cpp b/src/api.cpp index c2d050c..5af3422 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -732,10 +732,16 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, WriteJson(res, 200, std::format( R"({{"status":"ok","authenticated":{},"is_admin":{},"can_transmit":{},)" - R"("username":{},"token":null}})", + R"("is_station":{},"username":{},"token":null}})", JsonBool(ok || trusted), JsonBool(ok && deps.auth->IsAdmin(token)), JsonBool(ok && deps.auth->CanTransmit(token)), + // ⚠️ So a client can GREY THE AMP TUNE BUTTON instead of showing a + // live one that answers 403. CARRYOVER.md section 2: "a button that + // always errors is worse than a missing one" - and it is also the + // only way to confirm the right is live without keying an amplifier + // to find out. + JsonBool(trusted || (ok && deps.auth->IsStation(token))), user ? "\"" + *user + "\"" : "null")); }); @@ -1471,27 +1477,66 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, JsonBool(tgxl && tgxl->IsActive()), JsonBool(tgxl && tgxl->configured())); }}); - // ⚠️ AMP TUNE REFUSES EVERY REMOTE CALLER. CARRYOVER.md section 2. The check is - // the LISTENER the request arrived on - the control port is bound to loopback, - // so "local" is a kernel guarantee, not a header a caller can set. + // ── Amp tune ─────────────────────────────────────────────────────────────── + // ⚠️ WHAT THIS GUARDS: a TEN-SECOND UNATTENDED CARRIER at 20 W, ending at 100 W. + // That is why it is the most restricted route on the host, and the restriction + // stays. What changed is the QUESTION it asks. + // + // The reference host asked "did this arrive on the loopback listener": + // private object? AmpTuneOrDeny(bool isLocal) + // => isLocal ? _amp.Tune() : ... "only available when connected locally." + // That was a correct test THERE, because the C# host ran ON the station PC, so + // loopback proved an operator was sitting in front of it. The Stream Deck's 44 + // buttons all point at localhost:5001 and every one of them was local. + // + // ⚠️ THE RIG MOVED TO ITS OWN BOX AND THE TEST STOPPED MEANING THAT. Loopback on + // the rig box proves the caller is on the rig box - which is the one place + // nobody sits. The gate still worked perfectly; it had simply come to prove the + // wrong thing, and the amp tune button went dead with a 200 and no explanation. + // + // So the question is now WHO, not WHERE: the loopback console, or a session + // belonging to an account marked as the operator at the station. is_station is + // granted by a deliberate admin act and defaults to false, so no existing + // account gains this by upgrading. AmpTuner* amp = deps.amp; - auto amp_tune = [amp](bool is_local) { - if (!is_local) { - return std::string(R"({"status":"error",)" - R"("message":"Amp tune is only available when connected locally."})"); + AuthService* amp_auth = deps.auth; + auto amp_tune = [amp, amp_auth, trusted](const HttpRequest& req, HttpResponse& res) { + // ⚠️ BOTH RIGHTS, and can_transmit is not redundant here. Amp tune is not in + // IsTransmitRoute - it predates that list and is gated separately - so without + // this an account with can_transmit=false could start a ten-second carrier the + // moment it was given the station right. Found on the live host, where the + // `pusher` account is exactly that shape: tx denied, and it is the account the + // Stream Deck's session belongs to. + // + // "Denied transmit" has to mean it everywhere, or it means nothing. + const std::string amp_token = ExtractToken(req); + const bool at_station = + trusted || (amp_auth && amp_auth->IsStation(amp_token) && + amp_auth->CanTransmit(amp_token)); + if (!at_station) { + // ⚠️ 403, NOT 200. The reference host answered 200 with an error body, and a + // Stream Deck button reads that as success: it lights up green and the amp + // never tunes. A refusal that looks like a success is how this went unnoticed. + WriteJson(res, 403, + R"({"status":"error","station":false,)" + R"("message":"Amp tune needs the station right. Grant it with )" + R"(/api/admin/user/station/enable/."})"); + return; } if (!amp) { - return std::string(R"({"status":"error","available":false,"tuner":"amp",)" - R"("message":"Amp tuner is not configured on this host"})"); + WriteJson(res, 200, + R"({"status":"error","available":false,"tuner":"amp",)" + R"("message":"Amp tuner is not configured on this host"})"); + return; } const auto r = amp->Tune(); - return std::format( + WriteJson(res, 200, std::format( R"({{"status":"{}","tuner":"amp","available":true,"tuning":{},)" R"("action":"{}","message":"{}"}})", - r.ok ? "ok" : "error", JsonBool(r.tuning), r.action, r.message); + r.ok ? "ok" : "error", JsonBool(r.tuning), r.action, r.message)); }; - generated.push_back({"/api/tune/amp", amp_tune}); - generated.push_back({"/api/amp/tune", amp_tune}); + server.Get("/api/tune/amp", amp_tune); + server.Get("/api/amp/tune", amp_tune); generated.push_back({"/api/tune/amp/status", [amp](bool) { return std::format(R"({{"status":"ok","tuning":{},"available":{}}})", JsonBool(amp && amp->IsActive()), JsonBool(amp != nullptr)); }}); @@ -1717,12 +1762,15 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, // ⚠️ Amp tune again, this time as a prefix. The refusal has to be repeated // here: a caller reaching /api/tune/amp/anything must not slip past the exact // route's check. - server.GetPrefix("/api/tune/amp/", [trusted](const std::string&, const HttpRequest&, - HttpResponse& res) { - if (!trusted) { - WriteJson(res, 200, - R"({"status":"error",)" - R"("message":"Amp tune is only available when connected locally."})"); + server.GetPrefix("/api/tune/amp/", [trusted, amp_auth](const std::string&, + const HttpRequest& req, + HttpResponse& res) { + const std::string amp_token = ExtractToken(req); + if (!(trusted || (amp_auth && amp_auth->IsStation(amp_token) && + amp_auth->CanTransmit(amp_token)))) { + WriteJson(res, 403, + R"({"status":"error","station":false,)" + R"("message":"Amp tune needs the station right."})"); return; } WriteJson(res, 200, @@ -1933,6 +1981,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, cu.username = u.username; cu.is_admin = u.is_admin; cu.can_transmit = u.can_transmit; + cu.is_station = u.is_station; cu.password_hash = auth->PasswordHashOf(u.username); cfg->web_users.push_back(cu); } @@ -1944,8 +1993,10 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, if (auth) { for (const auto& u : auth->ListUsers()) { if (!rows.empty()) rows += ","; - rows += std::format(R"({{"username":"{}","is_admin":{},"can_transmit":{}}})", - u.username, JsonBool(u.is_admin), JsonBool(u.can_transmit)); + rows += std::format( + R"({{"username":"{}","is_admin":{},"can_transmit":{},"is_station":{}}})", + u.username, JsonBool(u.is_admin), JsonBool(u.can_transmit), + JsonBool(u.is_station)); } } WriteJson(res, 200, std::format(R"({{"status":"ok","users":[{}]}})", rows)); @@ -1981,7 +2032,10 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, } // ⚠️ Hashed here, immediately. A plaintext password must never reach the // config file, and the only way to guarantee that is never to store one. - auth->AddUser(user, AuthService::HashPassword(pass), is_admin, can_tx); + // ⚠️ A new account is never a station account. Granting it is a separate, + // named act - /api/admin/user/station/enable/. + auth->AddUser(user, AuthService::HashPassword(pass), is_admin, can_tx, + /*is_station=*/false); std::string err; if (!persist_users(err)) { WriteJson(res, 500, @@ -2100,6 +2154,47 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, user, JsonBool(allow))); }); + // /api/admin/user/station/enable/ and .../disable/ + // + // ⚠️ Deliberately its own route rather than a flag on the tx one. Transmit is + // "may key the rig". This is "may start an unattended carrier into an + // amplifier", and granting the first must never quietly grant the second. + server.GetPrefix("/api/admin/user/station/", + [auth, persist_users](const std::string& suffix, const HttpRequest&, + HttpResponse& res) { + const auto slash = suffix.find('/'); + if (slash == std::string::npos) { + WriteJson(res, 400, + R"({"status":"error","message":"expected enable|disable/"})"); + return; + } + const std::string verb = suffix.substr(0, slash); + const std::string user = suffix.substr(slash + 1); + if (verb != "enable" && verb != "disable") { + WriteJson(res, 400, R"({"status":"error","message":"expected enable or disable"})"); + return; + } + const bool allow = (verb == "enable"); + if (!auth->SetIsStation(user, allow)) { + WriteJson(res, 404, R"({"status":"error","message":"no such user"})"); + return; + } + std::string err; + // ⚠️ An unsaved grant is the friendlier failure; an unsaved REVOKE is a + // right you believe you took away and did not, and it comes back at the + // next power cut. Both are reported rather than assumed to have stuck. + if (!persist_users(err)) { + WriteJson(res, 500, + std::format(R"({{"status":"error","message":"station right changed on )" + R"(the running host but NOT saved - it reverts on restart: )" + R"({}"}})", err)); + return; + } + WriteJson(res, 200, + std::format(R"({{"status":"ok","username":"{}","is_station":{}}})", + user, JsonBool(allow))); + }); + server.GetPrefix("/api/admin/kick/", [auth](const std::string& user, const HttpRequest&, HttpResponse& res) { const int n = auth->KillUserSessions(user); diff --git a/src/auth.cpp b/src/auth.cpp index a1bce81..b16548f 100644 --- a/src/auth.cpp +++ b/src/auth.cpp @@ -87,9 +87,10 @@ bool AuthService::VerifyPassword(const std::string& password, const std::string& } void AuthService::AddUser(const std::string& username, const std::string& password_hash, - bool is_admin, bool can_transmit) { + bool is_admin, bool can_transmit, bool is_station) { std::lock_guard lock(mu_); - users_[LowerTrim(username)] = UserInfo{password_hash, is_admin, can_transmit}; + users_[LowerTrim(username)] = + UserInfo{password_hash, is_admin, can_transmit, is_station}; } bool AuthService::IsConfigured() const { @@ -127,7 +128,8 @@ std::optional AuthService::Login(const std::string& username, const std::string token = ToHexLower(raw, sizeof(raw)); const auto now = std::chrono::steady_clock::now(); - sessions_[token] = SessionInfo{key, it->second.is_admin, it->second.can_transmit, now, now}; + sessions_[token] = SessionInfo{key, it->second.is_admin, it->second.can_transmit, + it->second.is_station, now, now}; return token; } @@ -158,6 +160,12 @@ bool AuthService::CanTransmit(const std::string& token) const { return it != sessions_.end() && it->second.can_transmit; } +bool AuthService::IsStation(const std::string& token) const { + std::lock_guard lock(mu_); + const auto it = sessions_.find(token); + return it != sessions_.end() && it->second.is_station; +} + std::optional AuthService::Username(const std::string& token) const { std::lock_guard lock(mu_); const auto it = sessions_.find(token); @@ -223,6 +231,21 @@ bool AuthService::SetCanTransmit(const std::string& username, bool allow) { return true; } +bool AuthService::SetIsStation(const std::string& username, bool allow) { + const std::string key = LowerTrim(username); + std::lock_guard lock(mu_); + const auto it = users_.find(key); + if (it == users_.end()) return false; + it->second.is_station = allow; + // ⚠️ Same reason as SetCanTransmit, and it matters more here: this is the + // right to start an unattended carrier. Revoking it and leaving live sessions + // holding the old value is a permission you believe you took away and did not. + for (auto& [token, s] : sessions_) { + if (s.username == key) s.is_station = allow; + } + return true; +} + int AuthService::KillUserSessions(const std::string& username) { const std::string key = LowerTrim(username); std::lock_guard lock(mu_); @@ -242,7 +265,7 @@ std::vector AuthService::ListUsers() const { std::lock_guard lock(mu_); std::vector out; for (const auto& [name, u] : users_) { - out.push_back({name, u.is_admin, u.can_transmit}); + out.push_back({name, u.is_admin, u.can_transmit, u.is_station}); } return out; } @@ -255,7 +278,7 @@ std::vector AuthService::ListSessions() const { // ⚠️ Only a PREFIX of the token. A full session token in an admin listing // is a credential in a log, a screenshot and a support ticket. out.push_back({token.substr(0, 8) + "...", s.username, s.is_admin, - s.can_transmit, + s.can_transmit, s.is_station, std::chrono::duration_cast( now - s.last_activity).count()}); } diff --git a/src/auth.h b/src/auth.h index b86552c..8d6fb39 100644 --- a/src/auth.h +++ b/src/auth.h @@ -25,6 +25,9 @@ struct SessionInfo { std::string username; bool is_admin = false; bool can_transmit = true; + // Copied from the user at login, like the two above, so a right taken away + // reaches live sessions through the same path and cannot be left behind. + bool is_station = false; std::chrono::steady_clock::time_point created; std::chrono::steady_clock::time_point last_activity; }; @@ -33,6 +36,9 @@ struct UserInfo { std::string password_hash; bool is_admin = false; bool can_transmit = true; + // ⚠️ See ConfigUser::is_station. "The operator is at the station", which is + // what the amp tune's loopback test used to prove and no longer can. + bool is_station = false; }; class AuthService { @@ -43,8 +49,16 @@ class AuthService { static std::string HashPassword(const std::string& password); static bool VerifyPassword(const std::string& password, const std::string& stored); + // ⚠️ NO DEFAULT ARGUMENTS, deliberately. is_station shipped with `= false` for + // exactly one build, and main.cpp's config loader - which never passed it - + // compiled cleanly and dropped the right on every startup. The config said the + // operator had it, the running host said they did not, and nothing warned. + // + // A missing right must be a COMPILE ERROR, not a silent false. Every call site + // states all three, so adding a fourth right breaks the build until each caller + // has decided what it means. void AddUser(const std::string& username, const std::string& password_hash, - bool is_admin = false, bool can_transmit = true); + bool is_admin, bool can_transmit, bool is_station); bool IsConfigured() const; @@ -55,6 +69,10 @@ class AuthService { bool ValidateSession(const std::string& token); // sliding: refreshes last_activity bool IsAdmin(const std::string& token) const; bool CanTransmit(const std::string& token) const; + // ⚠️ NOT implied by CanTransmit, and deliberately so. Transmit is "may key the + // rig, with a hand on it". This is "may start a ten-second unattended carrier + // into an amplifier". The second is a strictly stronger claim. + bool IsStation(const std::string& token) const; std::optional Username(const std::string& token) const; void Logout(const std::string& token); bool IsLockedOut(const std::string& username) const; @@ -63,9 +81,13 @@ class AuthService { bool RemoveUser(const std::string& username); bool ChangePassword(const std::string& username, const std::string& new_hash); bool SetCanTransmit(const std::string& username, bool allow); + bool SetIsStation(const std::string& username, bool allow); int KillUserSessions(const std::string& username); - struct UserRow { std::string username; bool is_admin; bool can_transmit; }; + struct UserRow { + std::string username; + bool is_admin, can_transmit, is_station; + }; std::vector ListUsers() const; // ⚠️ How many OTHER sessions have touched the host within `within_seconds`. @@ -92,7 +114,7 @@ class AuthService { struct SessionRow { std::string token_short, username; - bool is_admin, can_transmit; + bool is_admin, can_transmit, is_station; long long idle_seconds; }; std::vector ListSessions() const; diff --git a/src/config.cpp b/src/config.cpp index 566a511..2154813 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -109,6 +109,9 @@ bool Config::Load(const std::string& path, Config& out, std::string& error) { Get(u, "password_hash", cu.password_hash); Get(u, "is_admin", cu.is_admin); Get(u, "can_transmit", cu.can_transmit); + // Absent in a config written before station rights existed, and Get + // leaves the default alone - so an upgraded host grants nobody this. + Get(u, "is_station", cu.is_station); if (cu.username.empty() || cu.password_hash.empty()) { // A user entry that cannot authenticate is a mistake, not a disabled // account. Refuse the file rather than start with a user list that is @@ -185,7 +188,8 @@ bool Config::Save(const std::string& path, std::string& error) const { users.push_back({{"username", u.username}, {"password_hash", u.password_hash}, {"is_admin", u.is_admin}, - {"can_transmit", u.can_transmit}}); + {"can_transmit", u.can_transmit}, + {"is_station", u.is_station}}); } j["web_users"] = users; diff --git a/src/config.h b/src/config.h index 227bb8e..1af306e 100644 --- a/src/config.h +++ b/src/config.h @@ -20,6 +20,18 @@ struct ConfigUser { std::string password_hash; // pbkdf2:: bool is_admin = false; bool can_transmit = true; + // ⚠️ "This account is the operator sitting at the station." + // + // It exists because the amp tune's old test - did the request arrive on the + // loopback listener - stopped meaning what it was written to mean. That test + // was correct when the host ran ON the station PC, so loopback proved a human + // was present. The rig now has its own box: loopback there proves the caller + // is on the rig box, which is the one place nobody sits. + // + // So the question moved from WHERE a request came from to WHO sent it. This + // right answers the new one, and it defaults to false: an account gets it by a + // deliberate act, never by upgrading. + bool is_station = false; }; struct Config { diff --git a/src/http.cpp b/src/http.cpp index 8d0d5b6..4d39105 100644 --- a/src/http.cpp +++ b/src/http.cpp @@ -30,6 +30,27 @@ HttpRequest BuildRequest(mg_connection* conn) { const mg_request_info* ri = mg_get_request_info(conn); HttpRequest req; req.path = ri->request_uri ? ri->request_uri : ""; + // ⚠️ TRAILING SLASHES ARE TRIMMED ON /api/ PATHS, because the reference host does + // it and the API is the contract between the two: + // + // if (path.StartsWith("/api/")) { var trimmed = path.TrimEnd('/'); ... } + // Services/ApiServer.cs:764-766 + // + // Without it "/api/tune/amp/" does not match the exact route "/api/tune/amp". + // It fell through to the PREFIX route "/api/tune/amp/" instead - the catch-all + // that answers "Amp tuner is not configured on this host" and never tunes. The + // Stream Deck's amp tune button sends exactly that trailing slash, so it got a + // cheerful 200 and a silent no-op. + // + // ⚠️ It is done HERE, where the path is first built, so the AUTH, ADMIN and + // TRANSMIT gates all see the same normalised path the router will match. Doing + // it at the router instead would let "/api/ptt/on/" skip IsTransmitRoute - the + // gate would look at one string and the dispatcher at another, which is how a + // permission check gets walked around with a keystroke. + if (req.path.rfind("/api/", 0) == 0) { + const auto end = req.path.find_last_not_of('/'); + req.path = (end == std::string::npos) ? "" : req.path.substr(0, end + 1); + } req.method = ri->request_method ? ri->request_method : ""; req.query = ri->query_string ? ri->query_string : ""; for (int i = 0; i < ri->num_headers; ++i) { diff --git a/src/main.cpp b/src/main.cpp index 88d87e0..4eca1e3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -170,12 +170,17 @@ int main(int argc, char** argv) { AuthService auth(config.web_session_timeout); for (const auto& u : config.web_users) { - auth.AddUser(u.username, u.password_hash, u.is_admin, u.can_transmit); + auth.AddUser(u.username, u.password_hash, u.is_admin, u.can_transmit, + u.is_station); } // Env override, for a throwaway run without writing a config file. It does not // replace the configured users, it adds to them. if (const char* hash = std::getenv("HAMDECK_ADMIN_HASH")) { - auth.AddUser("admin", hash, /*is_admin=*/true); + auth.AddUser("admin", hash, /*is_admin=*/true, /*can_transmit=*/true, + // ⚠️ NOT a station account. This is the break-glass override for a + // throwaway run; it must not carry the right to start an unattended + // carrier just because it happens to be admin. + /*is_station=*/false); } // Synthetic RX audio: the codec is passed through to the reference host, so diff --git a/tests/test_admin.cpp b/tests/test_admin.cpp index 8204e51..c11fdcf 100644 --- a/tests/test_admin.cpp +++ b/tests/test_admin.cpp @@ -13,8 +13,8 @@ int main() { setvbuf(stdout, nullptr, _IONBF, 0); AuthService a(480); - a.AddUser("boss", AuthService::HashPassword("pw1"), /*is_admin=*/true, true); - a.AddUser("op", AuthService::HashPassword("pw2"), /*is_admin=*/false, true); + a.AddUser("boss", AuthService::HashPassword("pw1"), /*is_admin=*/true, true, false); + a.AddUser("op", AuthService::HashPassword("pw2"), /*is_admin=*/false, true, false); CHECK(a.ListUsers().size() == 2); CHECK(a.AdminCount() == 1); @@ -56,7 +56,7 @@ int main() { std::printf("remove: user gone, and their live session with them\n"); // ── Kick ───────────────────────────────────────────────────────────────── - a.AddUser("op2", AuthService::HashPassword("pw4"), false, true); + a.AddUser("op2", AuthService::HashPassword("pw4"), false, true, false); const auto k1 = a.Login("op2", "pw4"); const auto k2 = a.Login("op2", "pw4"); CHECK(k1 && k2); diff --git a/tests/test_auth.cpp b/tests/test_auth.cpp index c46da80..9017d3d 100644 --- a/tests/test_auth.cpp +++ b/tests/test_auth.cpp @@ -51,7 +51,8 @@ int main() { // Sessions. AuthService auth(480); CHECK(!auth.IsConfigured()); - auth.AddUser("Joe", AuthService::HashPassword("s3cret"), /*is_admin=*/true); + auth.AddUser("Joe", AuthService::HashPassword("s3cret"), /*is_admin=*/true, + /*can_transmit=*/true, /*is_station=*/false); CHECK(auth.IsConfigured()); CHECK(!auth.Login("joe", "wrong").has_value()); @@ -69,7 +70,7 @@ int main() { // Throttle: five failures locks the account. AuthService t(480); - t.AddUser("bob", AuthService::HashPassword("pw")); + t.AddUser("bob", AuthService::HashPassword("pw"), false, true, false); for (int i = 0; i < AuthService::kMaxLoginFails; ++i) { CHECK(!t.Login("bob", "nope").has_value()); } diff --git a/tests/test_remote_active.cpp b/tests/test_remote_active.cpp index ba36472..2c75c7b 100644 --- a/tests/test_remote_active.cpp +++ b/tests/test_remote_active.cpp @@ -25,8 +25,8 @@ int main() { setvbuf(stdout, nullptr, _IONBF, 0); AuthService a(480); - a.AddUser("pusher", AuthService::HashPassword("pw1"), false, true); - a.AddUser("op", AuthService::HashPassword("pw2"), false, true); + a.AddUser("pusher", AuthService::HashPassword("pw1"), false, true, false); + a.AddUser("op", AuthService::HashPassword("pw2"), false, true, false); // ── The desktop pusher logs in, and is the only thing on the host ──────── const auto pusher = a.Login("pusher", "pw1"); diff --git a/tools/amp_gate_check.sh b/tools/amp_gate_check.sh new file mode 100755 index 0000000..e09fc7c --- /dev/null +++ b/tools/amp_gate_check.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Does the amp tune gate actually refuse, and actually open? +# +# ⚠️ THIS EXISTS BECAUSE THE ROUTE'S OWN REPLY PROVES NOTHING. The amp tune +# button was dead for weeks while every check looked healthy: the host answered +# HTTP 200 with an error body, and a Stream Deck button reads 200 as success. A +# gate that refuses with a success code is indistinguishable from one that works. +# +# So this drives the REAL binary over HTTP on both listeners and asserts the +# STATUS CODE, which is the thing the deck actually reacts to. +# +# 1 a session with no station right -> 403 +# 2 the same account, right granted -> not 403 +# 3 the loopback control listener -> not 403, with no session at all +# 4 granting transmit does NOT grant station +# +# ⚠️ It refuses to run against anything but a simulator, the same fail-closed +# check tools/walk_all_routes.py makes, because step 2 can key a transmitter. +set -u + +FAIL=0 +say() { printf '%s\n' "$*"; } +ok() { printf ' ok %s\n' "$*"; } +bad() { printf ' FAIL %s\n' "$*"; FAIL=1; } + +DASH=18502 +CTRL=18501 +DIR="$(mktemp -d)" +CFG="$DIR/config.json" +trap 'kill %1 2>/dev/null; rm -rf "$DIR"' EXIT + +# ⚠️ Hash generated here, not pasted: the parameters must match src/auth.cpp and +# a stale copy in a test fails in a way that looks like a broken gate. +HASH=$(python3 - <<'PY' +import hashlib, os, binascii +salt = os.urandom(16) +h = hashlib.pbkdf2_hmac('sha256', b'gatecheck', salt, 350000, 32) +print("pbkdf2:%s:%s" % (binascii.hexlify(salt).decode(), binascii.hexlify(h).decode())) +PY +) + +cat > "$CFG" <"$DIR/host.log" 2>&1 & +for _ in $(seq 1 50); do + curl -fsS "http://127.0.0.1:$DASH/api/health" >/dev/null 2>&1 && break + sleep 0.2 +done + +# ── Fail closed: only ever run this against the simulator ──────────────────── +if ! curl -fsS "http://127.0.0.1:$CTRL/api/backend" 2>/dev/null | grep -q '"simulated":[[:space:]]*true'; then + say "REFUSING: target did not prove it is a simulator (/api/backend simulated:true)" + say "step 2 of this check can key a transmitter. There is deliberately no --force." + exit 2 +fi + +login() { # login -> prints token + curl -fsS -X POST "http://127.0.0.1:$DASH/api/auth/login" \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"$1\",\"password\":\"gatecheck\"}" -D - -o /dev/null 2>/dev/null \ + | sed -n 's/.*hamdeck_session=\([^;]*\).*/\1/p' | tr -d '\r' +} +code() { curl -s -o /dev/null -w '%{http_code}' "$@"; } + +DECK=$(login deckop) +BOSS=$(login boss) +[ -n "$DECK" ] && [ -n "$BOSS" ] || { say "could not log in - check $DIR/host.log"; exit 2; } + +say "0. the right arrives from the CONFIG FILE, not only from an admin call" +# ⚠️ THE CHECK THAT WAS MISSING, and its absence shipped a broken host. +# Every other step here grants the right through the admin API, which exercises +# SetIsStation on a RUNNING host. The other way in - config -> AuthService at +# startup - was never touched, and main.cpp did not pass is_station to AddUser at +# all. A default argument of `false` made that compile silently, so the file said +# the operator had the right and the running host said they did not. +# Two mechanisms are two tests. cfgop gets it from the file and nothing else. +CFG_TOK=$(login cfgop) +b=$(curl -s "http://127.0.0.1:$DASH/api/tune/amp/probe?token=$CFG_TOK") +c=$(code "http://127.0.0.1:$DASH/api/tune/amp/probe?token=$CFG_TOK") +[ "$c" != "403" ] && ok "config-declared station right reached the running host" \ + || bad "is_station in the config did not load (HTTP $c): $b" +# and /api/auth/status must agree, since that is what a client greys the button on +printf '%s' "$(curl -s "http://127.0.0.1:$DASH/api/auth/status?token=$CFG_TOK")" \ + | grep -q '"is_station":true' \ + && ok "/api/auth/status reports it too" \ + || bad "/api/auth/status does not report is_station for a station account" + +say "1. no station right" +c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK") +[ "$c" = "403" ] && ok "refused with 403 (not a 200 the deck would read as success)" \ + || bad "expected 403, got $c" + +say "2. transmit rights do NOT imply station rights" +# deckop already has can_transmit=true and must still be refused above. +c=$(code "http://127.0.0.1:$DASH/api/ptt/off?token=$DECK") +[ "$c" = "200" ] && ok "the same account CAN transmit, and still cannot amp tune" \ + || bad "expected the transmit route to work for this account, got $c" + +say "3. station right granted" +# ⚠️ NOT "is it non-403". The bug being guarded against ANSWERS 200 WITH A +# REFUSAL, so a status-only assertion here passes while the gate is broken - +# which is exactly what happened the first time this check was run against an +# injected bug. The body has to show the amp route actually ran. +curl -fsS "http://127.0.0.1:$DASH/api/admin/user/station/enable/deckop?token=$BOSS" >/dev/null +b=$(curl -s "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK") +c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK") +if [ "$c" = "200" ] && printf '%s' "$b" | grep -q '"tuner":"amp"' \ + && ! printf '%s' "$b" | grep -qi 'station right\|connected locally'; then + ok "allowed, and the reply came from the amp route: $b" +else + bad "expected the amp route to answer, got HTTP $c body: $b" +fi + +say "3b. station right is NOT enough on its own - transmit must also be allowed" +# ⚠️ The live host has an account shaped exactly like this: the `pusher` +# account the Stream Deck session belongs to has can_transmit=false. Amp tune is +# gated separately from IsTransmitRoute, so without this check a station grant +# would hand a ten-second carrier to an account explicitly denied transmit. +curl -fsS "http://127.0.0.1:$DASH/api/admin/user/tx/disable/deckop?token=$BOSS" >/dev/null +c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK") +[ "$c" = "403" ] && ok "station right alone does not key the rig" \ + || bad "an account denied transmit could amp tune, got $c" +curl -fsS "http://127.0.0.1:$DASH/api/admin/user/tx/enable/deckop?token=$BOSS" >/dev/null + +say "4. revoking reaches the LIVE session, not just the stored user" +curl -fsS "http://127.0.0.1:$DASH/api/admin/user/station/disable/deckop?token=$BOSS" >/dev/null +c=$(code "http://127.0.0.1:$DASH/api/tune/amp?token=$DECK") +[ "$c" = "403" ] && ok "refused again without a re-login" \ + || bad "revoke did not reach the live session, got $c" + +say "5. the loopback control listener still needs no session" +b=$(curl -s "http://127.0.0.1:$CTRL/api/tune/amp") +c=$(code "http://127.0.0.1:$CTRL/api/tune/amp") +if [ "$c" = "200" ] && printf '%s' "$b" | grep -q '"tuner":"amp"'; then + ok "local console unchanged: $b" +else + bad "control listener did not reach the amp route, HTTP $c body: $b" +fi + +say "5b. THE ACTUAL STREAM DECK URL - note the trailing slash" +# ⚠️ This is the bug the operator kept reporting. The deck button sends +# "/api/tune/amp/", which did not match the exact route and fell through to the +# prefix catch-all - answering 200 "Amp tuner is not configured" and never tuning. +# The reference host trims trailing slashes on /api/ paths (ApiServer.cs:766); +# this host did not. Measured from the live journal, not guessed: +# dash GET /api/tune/amp/ +b=$(curl -s "http://127.0.0.1:$CTRL/api/tune/amp/") +if printf '%s' "$b" | grep -q '"action":"started"\|"action":"stopped"'; then + ok "trailing-slash URL reaches the real tuner: $b" +else + bad "trailing slash did not reach the tuner: $b" +fi + +say "6. the prefix guard agrees with the exact route" +c=$(code "http://127.0.0.1:$DASH/api/tune/amp/anything?token=$DECK") +[ "$c" = "403" ] && ok "/api/tune/amp/... refuses too" || bad "prefix guard disagrees, got $c" + +[ "$FAIL" = "0" ] && say "PASS" || say "FAILED" +exit "$FAIL" diff --git a/tools/check_macos_bundle.py b/tools/check_macos_bundle.py new file mode 100755 index 0000000..a43bc68 --- /dev/null +++ b/tools/check_macos_bundle.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Is the built .app actually a named, iconned, mic-capable Mac application? + +⚠️ WRITTEN BECAUSE 0.1.29 SHIPPED SIGNED, NOTARISED AND WRONG. The DMG installed +and the app ran, and macOS called it "hamdeck-qml" and drew it with the blank +generic-document icon, because CMake names a bundle after the target and its +stock Info.plist has no icon key. Nothing failed. There was no default that could +have been right, and no check that looked. + +It also covers the one that had not bitten yet: an app with the microphone +ENTITLEMENT but no NSMicrophoneUsageDescription is SIGKILLed by macOS the moment +it opens the mic. That is the first PTT of the day, on the operator's machine, +with nothing in the log - so it is checked here, on every push, on Linux too. + +Everything is read with plistlib and struct rather than plutil/iconutil, so this +same gate runs on the Linux CI leg and on a workstation with no Xcode. + +Usage: check_macos_bundle.py [expected name] +""" +import os, plistlib, struct, sys + +# The OSTypes brand/build.sh packs, and the pixel size each one must contain. +ICNS_TYPES = {b'icp4': 16, b'ic11': 32, b'icp5': 32, b'ic12': 64, b'ic07': 128, + b'ic13': 256, b'ic08': 256, b'ic14': 512, b'ic09': 512, b'ic10': 1024} + + +def check_icns(path, fails): + d = open(path, 'rb').read() + if d[:4] != b'icns': + fails.append(f"{path} is not an icns (magic {d[:4]!r})") + return + declared = struct.unpack('>I', d[4:8])[0] + if declared != len(d): + fails.append(f"icns declares {declared} bytes but the file is {len(d)}") + off, seen = 8, {} + while off + 8 <= len(d): + t = d[off:off + 4] + n = struct.unpack('>I', d[off + 4:off + 8])[0] + if n < 8 or off + n > len(d): + fails.append(f"icns entry {t!r} has a bad length {n}") + return + blob = d[off + 8:off + n] + off += n + # ⚠️ Trust the PNG's own header, not the slot it was filed under. An icns + # holding the 512px art under ic10 is structurally perfect and looks soft + # on exactly the Retina display the 1024 entry exists for. + if blob[:8] == b'\x89PNG\r\n\x1a\n': + w, h = struct.unpack('>II', blob[16:24]) + want = ICNS_TYPES.get(t) + if want and (w, h) != (want, want): + fails.append(f"icns {t.decode()}: artwork is {w}x{h}, the slot needs {want}x{want}") + seen[t] = (w, h) + else: + fails.append(f"icns {t.decode()} is not PNG data") + missing = sorted(t.decode() for t in ICNS_TYPES if t not in seen) + if missing: + fails.append("icns is missing types: " + " ".join(missing) + + " (macOS then scales a smaller entry up, on Retina, forever)") + return len(seen) + + +def main(): + if not 2 <= len(sys.argv) <= 3: + print("usage: check_macos_bundle.py [expected name]") + return 2 + app = sys.argv[1].rstrip('/') + want_name = sys.argv[2] if len(sys.argv) == 3 else "HamDeck Remote" + fails = [] + + if not os.path.isdir(app): + print(f"FAIL: no bundle at {app}") + return 1 + base = os.path.basename(app) + if base != f"{want_name}.app": + fails.append(f"the bundle is called {base}, not {want_name}.app " + f"- OUTPUT_NAME did not apply, and Finder shows this name") + + plist_path = os.path.join(app, "Contents", "Info.plist") + if not os.path.isfile(plist_path): + print(f"FAIL: no Info.plist in {app}") + return 1 + with open(plist_path, 'rb') as fh: + info = plistlib.load(fh) + + for key in ("CFBundleName", "CFBundleDisplayName"): + got = info.get(key) + if got != want_name: + fails.append(f"{key} is {got!r}, not {want_name!r} " + f"(this is the name in the menu bar and under the Dock icon)") + if not info.get("CFBundleIdentifier"): + fails.append("CFBundleIdentifier is empty") + if not info.get("CFBundleShortVersionString"): + fails.append("CFBundleShortVersionString is empty - the Finder Get Info version") + if info.get("NSHighResolutionCapable") is not True: + fails.append("NSHighResolutionCapable is not true - the app renders at 1x and is scaled") + + mic = info.get("NSMicrophoneUsageDescription") + if not mic: + fails.append("NSMicrophoneUsageDescription is MISSING - macOS SIGKILLs the app " + "on the first PTT, with no prompt and no log line") + + exe = info.get("CFBundleExecutable") + exe_path = os.path.join(app, "Contents", "MacOS", exe or "") + if not exe or not os.path.isfile(exe_path): + fails.append(f"CFBundleExecutable is {exe!r}, and Contents/MacOS/{exe} is not a file") + + entries = None + icon = info.get("CFBundleIconFile") + if not icon: + fails.append("CFBundleIconFile is MISSING - this is the blank generic icon, exactly " + "what 0.1.29 shipped") + else: + if not icon.endswith(".icns"): + icon += ".icns" + icon_path = os.path.join(app, "Contents", "Resources", icon) + if not os.path.isfile(icon_path): + fails.append(f"CFBundleIconFile names {icon}, which is not in Contents/Resources " + f"- the icon was added at INSTALL time, not build time") + else: + entries = check_icns(icon_path, fails) + + if fails: + print(f"FAIL: {os.path.basename(app)} is not a properly formed Mac application") + for f in fails: + print(f" - {f}") + return 1 + print(f'ok: "{base}" - CFBundleName/DisplayName {want_name!r}, id {info["CFBundleIdentifier"]}, ' + f'v{info["CFBundleShortVersionString"]}, {entries} icns entries, mic string present') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/rig_replug_test.sh b/tools/rig_replug_test.sh new file mode 100755 index 0000000..7c30766 --- /dev/null +++ b/tools/rig_replug_test.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# GATE: prove the station recovers by itself when the radio is unplugged and +# plugged back in. +# +# 09/02/2026 it did not. The cable moved, CAT came back as a different minor +# number, and the host sat there holding /dev/ttyUSB0 (deleted) reporting +# rig_connected:false until someone noticed. +# +# The replug is simulated by unbinding BOTH USB devices from the kernel's usb +# driver and binding them back. That produces the same udev remove/add events a +# physical replug does, which is what the recovery is built on. It does not +# touch the hypervisor's passthrough. +# +# Run it ON THE HOST BOX (VM 105). sudo ./tools/rig_replug_test.sh +set -uo pipefail + +HEALTH=http://127.0.0.1:5001/api/health +CAT_ID=10c4:ea70 # CP2105 dual UART (CAT) +CODEC_ID=08bb:29c3 # PCM2903C (audio) +DEADLINE=${DEADLINE:-90} + +[ "$(id -u)" = 0 ] || { echo "FAIL: run with sudo"; exit 1; } + +connected() { curl -s -m3 "$HEALTH" | grep -q '"rig_connected":true'; } + +# The sysfs name (e.g. "2-1") of a usb device, by vendor:product. +usb_path() { + local vid=${1%:*} pid=${1#*:} d + for d in /sys/bus/usb/devices/*; do + [ -r "$d/idVendor" ] || continue + if [ "$(cat "$d/idVendor")" = "$vid" ] && [ "$(cat "$d/idProduct")" = "$pid" ]; then + basename "$d"; return 0 + fi + done + return 1 +} + +cat_path=$(usb_path $CAT_ID) || { echo "FAIL: CP2105 not present - nothing to test"; exit 1; } +codec_path=$(usb_path $CODEC_ID) || { echo "FAIL: codec not present - nothing to test"; exit 1; } +echo "CAT at $cat_path, codec at $codec_path" + +connected || { echo "FAIL: rig is not connected BEFORE the test - fix that first"; exit 1; } +echo "before: rig_connected=true" + +# Always try to put the radio back, even if the script dies mid-way. +# +# ⚠️ Test for the DRIVER symlink, not for the device directory. Unbinding does +# not remove the device from /sys/bus/usb/devices - it only detaches the driver - +# so a "does the device still exist" guard here skips the rebind every time and +# leaves the station off the air. That happened on the first run of this script. +rebind() { + for p in "$codec_path" "$cat_path"; do + [ -e "/sys/bus/usb/devices/$p/driver" ] && continue + echo -n "$p" > /sys/bus/usb/drivers/usb/bind 2>/dev/null + sleep 1 + done +} +trap rebind EXIT + +echo -n "$cat_path" > /sys/bus/usb/drivers/usb/unbind +echo -n "$codec_path" > /sys/bus/usb/drivers/usb/unbind +sleep 3 +echo "unplugged: /dev/ttyRIG exists? $([ -e /dev/ttyRIG ] && echo yes || echo no); unit $(systemctl is-active hamdeck-cpp.service)" + +rebind +trap - EXIT + +start=$SECONDS +while [ $((SECONDS - start)) -lt $DEADLINE ]; do + if connected; then + took=$((SECONDS - start)) + pid=$(systemctl show -p MainPID --value hamdeck-cpp.service) + stale=$(ls -l /proc/"$pid"/fd 2>/dev/null | grep -c "(deleted)") + node=$(readlink -f /dev/ttyRIG) + echo "after: rig_connected=true in ${took}s, CAT node $node, stale fds $stale" + [ "$stale" = 0 ] || { echo "FAIL: recovered but still holding $stale dead handles"; exit 1; } + # Connected is not the same as reading the rig. Ask it something. + curl -s -m3 http://127.0.0.1:5001/api/status | grep -q '"freq":[1-9]' \ + || { echo "FAIL: rig_connected=true but /api/status has no frequency"; exit 1; } + echo "PASS" + exit 0 + fi + sleep 2 +done + +echo "FAIL: still not connected ${DEADLINE}s after the radio came back" +systemctl is-active hamdeck-cpp.service +curl -s -m3 "$HEALTH"; echo +exit 1