From 3cfb3da498cf71075742d26e2b37b2474ac3d03c Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Thu, 21 May 2026 19:49:23 +0200 Subject: [PATCH] feat(export): encrypted diagnostic report for issue reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Download diagnostic report" button on the Health tab that produces an age-encrypted .age file the user can attach to a GitHub issue without exposing sensitive data. Archive contents (tar.gz, then age-encrypted with the maintainer's SSH ed25519 public key): - diagnostic.json structured health/device summary (no secrets) - datastore/…/*.xml raw on-disk XML verbatim for diff vs HTTP - http/service/… live service HTTP responses per account/device - http/speaker/… live speaker API responses (port 8090) - ssh/speaker/… CA bundles + logread (last 20 min, 127.0.0.1 filtered) + dmesg fetched via SSH - system/ca.pem service CA cert - system/resolv.conf host DNS resolver config - settings.json service settings (OAuth secrets redacted) - env.txt filtered process environment - logs/service.txt in-memory service log buffer Supporting tooling: - scripts/setup-diagnostic-key.sh one-time SSH key-pair generation - scripts/decrypt-diagnostic.go go run helper for maintainer decryption - keys/public/diagnostic.pub committed public key (matches github.com/gesellix.keys) - docs/DIAGNOSTIC-EXPORT.md maintainer setup + user workflow guide - docs/concepts/ENCRYPTED-EXPORT.md research notes and architecture rationale Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + cmd/soundtouch-service/main.go | 1 + .../testdata/router_routes.txt | 1 + docs/DEVICE-LOCAL-INSTALL.md | 277 ++++++++ docs/DIAGNOSTIC-EXPORT.md | 138 ++++ docs/SUMMARY.md | 3 + docs/concepts/ENCRYPTED-EXPORT.md | 454 ++++++++++++ go.mod | 3 + go.sum | 8 + keys/public/diagnostic.pub | 1 + pkg/service/export/diagnostic.pub | 1 + pkg/service/export/encrypt.go | 54 ++ pkg/service/export/encrypt_test.go | 20 + pkg/service/handlers/handlers_export.go | 654 ++++++++++++++++++ pkg/service/handlers/handlers_export_test.go | 65 ++ pkg/service/handlers/web/index.html | 10 +- pkg/service/handlers/web/js/script.js | 31 + scripts/decrypt-diagnostic.go | 66 ++ scripts/setup-diagnostic-key.sh | 55 ++ 19 files changed, 1842 insertions(+), 3 deletions(-) create mode 100644 docs/DEVICE-LOCAL-INSTALL.md create mode 100644 docs/DIAGNOSTIC-EXPORT.md create mode 100644 docs/concepts/ENCRYPTED-EXPORT.md create mode 100644 keys/public/diagnostic.pub create mode 100644 pkg/service/export/diagnostic.pub create mode 100644 pkg/service/export/encrypt.go create mode 100644 pkg/service/export/encrypt_test.go create mode 100644 pkg/service/handlers/handlers_export.go create mode 100644 pkg/service/handlers/handlers_export_test.go create mode 100644 scripts/decrypt-diagnostic.go create mode 100755 scripts/setup-diagnostic-key.sh diff --git a/.gitignore b/.gitignore index bcb6e1d..ca06d9d 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,6 @@ DONE.md # Living document; commit history of the checks themselves is the # source of truth for what shipped. SERVICE-HEALTH.md + +# Diagnostic encryption keys — private key stays local with the maintainer +keys/private/ diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 223e2ea..35eeaeb 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1198,6 +1198,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/health", server.HandleHealthChecks) r.Post("/health/fix", server.HandleHealthFix) + r.Get("/export/diagnostic", server.HandleExportDiagnostic) r.Get("/logs", server.HandleGetLogs) // Serve Stockholm setup wizard pages for paths not matched by the management API. diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 81128ff..b6a9cc0 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -60,6 +60,7 @@ GET /setup/devices/{deviceId}/events handlers.( GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm +GET /setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm GET /setup/health handlers.(*Server).HandleHealthChecks-fm GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm diff --git a/docs/DEVICE-LOCAL-INSTALL.md b/docs/DEVICE-LOCAL-INSTALL.md new file mode 100644 index 0000000..99ba475 --- /dev/null +++ b/docs/DEVICE-LOCAL-INSTALL.md @@ -0,0 +1,277 @@ +# Device-Local Install: Four User Journeys + +A user-journey-shaped view of where AfterTouch sits today and where it could go. The same speaker, the same constraints, but four different audiences with non-overlapping needs: + +1. **Initial setup / install** — getting AfterTouch onto a fresh or freshly-orphaned speaker. +2. **Less-technical admin** — migration, maintenance, and recovery without a terminal. +3. **Daily usage** — playing music, switching presets, on the couch or on the phone. +4. **Automation** — driving the speaker from scripts, home automation, schedules. + +Each journey is served by a different surface (CLI, web UI, GUI app, REST). Some surfaces serve more than one journey; some journeys are served badly today. This doc is informational; nothing here is a roadmap commitment. + +Cross-cutting reference material — lessons from `GameTec-live/soundtouch-tiny`, plus a per-surface capability map — lives in the appendix. + +--- + +## Journey 1: Initial setup / install + +**Who.** Someone with a Bose speaker whose cloud just died. Could be technical (knows what SSH is) or not (knows what a USB stick is). Wants the speaker to play Internet Radio again with minimum fuss. + +**Goal.** Get an AfterTouch instance reachable from the speaker, whether that instance lives on a separate host or on the speaker itself. + +**Surfaces.** Shell (today), GUI installer (planned), pre-flashed stick (commercial offering, hypothetical). + +### The three install patterns + +#### Pattern A — External host + +A separate machine (Raspberry Pi, NAS, always-on laptop) runs `soundtouch-service`. Speakers point at it via DNS rewrite at the router. No code on the speaker, no firmware risk. + +- **Pros:** zero invasiveness, easy update (single host), unified for many speakers, no per-speaker storage limit. +- **Cons:** requires an always-on host on the LAN, DNS rewrite at router scope, single point of failure. + +#### Pattern B — SSH-curl on-device (current `scripts/on-device-install/`) + +User SSHes in once, pipes the installer. Installs to `/mnt/nv/aftertouch`, symlinks `/opt/aftertouch`, registers `/etc/init.d/aftertouch` via `update-rc.d`. Daemon serves `:8000` on the speaker's own LAN address. + +- **Pros:** no separate host, per-speaker isolation, survives router replacement. +- **Cons:** SSH required for install and updates, ~12 MB binary stresses tiny rootfs partitions, no in-process restart on crash, some firmware images bind only loopback (issue #196). + +#### Pattern C — Stick-driven on-device (*not* implemented here) + +USB stick holds binary + bootstrap scripts. First install needs SSH (placing `/mnt/nv/rc.local`). After that, the NAND `rc.local` auto-syncs from any stick inserted with newer files. Stick can also carry one-shot configs (`wlan.conf`, `region.conf`, `name.conf`) consumed and wiped during boot. + +- **Pros:** post-bootstrap updates need no SSH, stick wipe behavior keeps credentials short-lived, watchdog inside the bootstrap script restarts the agent on crash without a reboot. +- **Cons:** first install still needs SSH; FAT32 stick on the speaker is unreliable for writes; user has to keep a stick around. + +### The technical underpinning: `/mnt/nv/rc.local` + +Both pattern C and any "shepherd-less" install on stock firmware depend on a single line in the stock init scripts: + +``` +# /etc/init.d/shelby_local, start case +[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local +``` + +`shelby_local` is a stock Bose SysV script. Its `start` case fires at every boot from an `S`-symlink in `rcS.d/` (the misleading `K99shelby_local` symlink in `rc1.d/` is the *shutdown* path — same script, different case). `/mnt/nv` is the persistent read-write NAND partition; `rc.local` is intentionally exposed as an extension point. By the time it runs, rootfs is mounted read-only, `/mnt/nv` is read-write, network is configured, and `/media/sda1` is *typically* mounted by udev if a USB stick is present — but the mount is asynchronous and races the hook (polling for up to 30 s is one way to handle this). + +**Stock firmware does not auto-copy anything from a USB stick into `/mnt/nv/rc.local`.** Inserting a stick alone is not enough. There is no udev rule, no autorun convention, no `shelby_usb` branch that handles this; `shelby_usb` only manages USB ethernet-gadget mode (`g_ether`) and the `microbswitch` helper on certain variants. + +Placement happens one of two ways: + +1. **Manual SSH bootstrap, once.** Shell access (via the `remote_services` stick trick) runs an installer that writes `/mnt/nv/rc.local`, makes it executable, and exits. After that single SSH session, the stick is no longer required to *trigger* anything — the NAND copy fires on every boot. +2. **Self-update from a newer stick, after step 1.** Once `/mnt/nv/rc.local` exists *and contains the self-update logic*, inserting a stick with a newer `rc.local` (compared by mtime) lets the running NAND copy overwrite itself for the next boot. This gives the stick its "repair channel" property. + +**The very first placement requires SSH.** Any zero-SSH install would need either a different stock-firmware hook (we have not found one usable across SoundTouch variants) or a custom firmware image. The `remote_services` stick is the only stick-content convention the stock firmware honors out of the box, and all it does is enable `sshd`. + +### App-driven install (the missing middle) + +The SSH session does **not** have to be a human SSH session. `pkg/ssh` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`) is already used by `pkg/service/setup/` to drive migration probes; the same primitives can drive an installer. The user never sees a terminal. + +User-visible flow: + +1. User runs an admin app on their laptop or phone. +2. App walks them through preparing a `remote_services` stick — or writes one for them, if it can reach the host's USB subsystem. +3. User inserts the stick into the speaker and power-cycles it. Stock firmware's `sshd` starts. +4. App discovers the speaker via mDNS, dials SSH, runs the installer steps that today live behind `curl ... \| sh`. No `ssh` invocation, no `rw &&`, no copy-pasted IP. +5. App verifies `curl http://:8000` from inside the speaker via SSH and surfaces a clear success / failure state. +6. App optionally removes `remote_services` from the stick and reboots the speaker, closing the SSH backdoor automatically. + +Mapping each step to existing code: + +| Step | Today's installer | App equivalent (`pkg/ssh`) | +|---------------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| Remount rootfs rw | `mount -o remount,rw /` (inside init script) | `Client.Run("mount -o remount,rw /")` | +| Make NAND dir | `mkdir -p $INSTALL_DIR` | `Client.Run("mkdir -p /mnt/nv/aftertouch")` | +| Download binary | `curl -sSL ... -o binary` | local download on the app side, then `Client.UploadContent(bytes, "/mnt/nv/aftertouch/aftertouch-service")` | +| Mark executable | `chmod +x` | `Client.Run("chmod +x ...")` | +| Symlink `/opt` | `ln -sf $INSTALL_DIR /opt/aftertouch` | `Client.Run("ln -sf ...")` | +| Install init script | `curl ... -o /etc/init.d/aftertouch && update-rc.d aftertouch defaults` | `Client.UploadContent` + `Client.Run` | +| Start | `/etc/init.d/aftertouch start` | `Client.Run("/etc/init.d/aftertouch start")` | +| Verify listener | `curl -fsS http://localhost:8000` inside the box | `Client.Run("curl -fsS http://localhost:8000")` | + +No new SSH plumbing required. The pieces already exist for the setup probes. + +### Storage budget + +The on-device patterns share one hard constraint: storage. ST20 stock rootfs has ~4 MB free (issue #268); even with `/mnt/nv` (~30 MB free) the budget is tight, and a second binary for safe OTA updates doubles it. This is the primary motivation for a slimmer `soundtouch-service-mini` build target — see the appendix. + +### Open decisions for this journey + +- Do we keep pattern B as the technical-user path while building a Gio admin app for the rest? +- Do we add a pattern-C-style "register a stick-update hook in `/mnt/nv/rc.local`" option as an opt-in, so users who do want a repair stick get one? +- Pre-flashed sticks shipped as a kit: in scope or out? + +--- + +## Journey 2: Less-technical admin (migration + maintenance) + +**Who.** The person who already has AfterTouch installed somewhere and now needs to do something *after* install. They are comfortable opening apps and clicking buttons; they are not comfortable opening a terminal. The whole-household admin: parent, partner, roommate doing it for the household. + +**Goal.** Migrate a speaker to a new AfterTouch instance, update the agent, view what's going on, recover a stuck device, change WLAN credentials, reapply config after factory reset — all without SSH. + +**Surfaces.** GUI admin app (Gio, planned), `soundtouch-service` embedded web UI (today, technical-leaning), CLI (today, technical-only). + +### What "admin" covers in practice + +- **Migration of a new (or factory-reset) speaker** to an AfterTouch instance: rewrite the server URLs in `/mnt/nv/persistence.json`, restart the device, verify it talks to us. +- **Agent update on an on-device install** (pattern B or C): push a new binary, restart, verify. +- **Status and diagnostics**: is `aftertouch` running, is `:8000` listening, did the last preset save succeed, what does syslog say? +- **Recovery**: speaker is stuck (won't respond to web UI, won't pair, lost WLAN). Today this almost always means SSH; with `pkg/ssh` behind a GUI, it can mean "click 'Diagnose' in the app." +- **Bulk operations**: do all of the above across several speakers at once. +- **Configuration drift**: WLAN password changed, region changed, speaker name changed, hosts file got rewritten — restore the AfterTouch overlay. + +### How the GUI admin app shape would serve this + +Same `pkg/ssh` primitives as Journey 1's installer, applied to post-install tasks. mDNS discovers all speakers on the LAN; the app fans operations out across them; SSH-driven actions stay hidden behind buttons. On a phone, the same app is the "speakers are unreachable, what now" diagnostic tool from another room. + +Where today's surfaces fall short for this user: + +- `soundtouch-service` web UI assumes the service is running and reachable. It cannot recover a broken installation or a stuck device. +- CLI works but presumes terminal comfort. +- The setup wizard in `soundtouch-service` handles initial migration well, but reapplying after factory reset is not first-class — see `docs/analysis/FACTORY-RESET-PROTOCOL.md`. + +### Open decisions for this journey + +- Does the admin app subsume the service web UI's admin tab, or do they coexist (admin app = onboarding + recovery; service web UI = ongoing operations once everything is healthy)? +- WASM as a fallback surface: today's service web UI is browser-accessible from anywhere. Does a Gio admin app sacrifice that, or do we ship both? +- Multi-household / multi-speaker: how much does the admin app need to know about distinguishing speakers vs distinguishing AfterTouch instances? + +--- + +## Journey 3: Daily usage + +**Who.** Anyone in the household using the speaker. Children pressing a preset button. The user opening a phone to switch from kitchen to living room. Guests asked to "just put on some jazz." Zero awareness of AfterTouch as a thing; the speaker is the speaker. + +**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction. + +**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up. + +### What this layer needs to be good at + +- **Preset playback works first try, every time.** The reliability bar is "is the kitchen radio still working?" Anything that fails on cold boot or after a Wi-Fi outage breaks the user's trust in the whole system. +- **Switching stations quickly**, including discovery of new ones (e.g. `radio-browser.info`-style search). +- **Volume and play / pause from any device the user has in hand.** Phone in pocket, laptop on table, browser tab open — all should work. +- **Multi-room awareness** if the household has more than one speaker: which speaker is playing what, can I send this to the bedroom. +- **Looking good.** This is the surface that gets seen daily by non-technical users. Visual polish matters more here than anywhere else in the stack. + +### How surfaces map + +- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped. +- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode. +- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback. +- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts). + +### Open decisions for this journey + +- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app? +- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path. +- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt. + +--- + +## Journey 4: Automation + +**Who.** The same household, but acting through code: a Home Assistant config, a NodeRED flow, a cron job, a shell script, a webhook from a smart doorbell. The user is not present at the speaker; they want music to start when something else happens. + +**Goal.** Headless, scriptable control. "Play preset 2 at 7:00 every weekday." "When the kids' bedtime alarm fires, fade volume to zero." "If I get home and the speaker is on, switch to my dinner playlist." + +**Surfaces.** `soundtouch-cli` (today), REST endpoints on `soundtouch-service` (today), MQTT bridge / webhook outputs (hypothetical), Home Assistant integration (community). + +### What this layer needs to be good at + +- **Stable, versioned API surface.** Scripts and home automation flows live for years; breaking changes are expensive for users. +- **CLI that works in pipelines.** Exit codes, machine-readable output (JSON), stable flag names. The reverse of the daily UI: zero polish, full predictability. +- **Discoverability of capabilities.** Users need to find out what's possible (`soundtouch-cli help`, openapi spec on the service, examples in the docs). +- **Idempotency.** Calling "set volume to 40" twice should not result in volume 80. Calling "switch to preset 3" when already on preset 3 should be a no-op. + +### How surfaces map + +- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API. +- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation. +- Home Assistant: external integration; track but do not own. +- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces. + +### Open decisions for this journey + +- Stability commitments for the CLI and REST API: do we adopt semver for the public surface separately from the service version? +- Authentication for the REST surface when exposed beyond loopback: needed before any internet exposure is sane. +- OpenAPI / typed-client output for the service: nice-to-have for integration developers. + +--- + +## Appendix: which surface serves which journey + +| Surface | Journey 1 (install) | Journey 2 (admin) | Journey 3 (daily) | Journey 4 (automation) | +|------------------------------------|---------------------|-------------------|-------------------|------------------------| +| `soundtouch-cli` | partial (today) | partial (today) | no | primary | +| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) | +| `soundtouch-web` | no | no | primary | no | +| GUI admin app (Gio, planned) | primary | primary | mobile mode | no | +| Pre-flashed stick (hypothetical) | primary | recovery | no | no | +| Physical preset buttons | no | no | primary | no | +| Home Assistant / webhooks (future) | no | no | no | primary | + +The diagonal isn't full because some journeys lack a polished surface today (Journey 1 mostly works but is shell-only; Journey 2 has gaps for recovery scenarios). The journey frame is what tells us *which* gaps to fill first. + +## Appendix: per-surface capability constraints + +The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints: + +- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`. +- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap. +- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is. +- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface. + +The pattern to follow is to write code so each capability degrades automatically based on what the runtime actually offers, rather than gating with build tags. + +## Appendix: lessons from adjacent projects + +### soundtouch-tiny (GameTec-live) + +Minimal on-device cloud replacement: Internet Radio + TuneIn proxy + optional presets. Go stdlib only, small binary. Inspired by AfterTouch but trimmed. The author offered collaboration in PR #292. + +This is the gap a **`soundtouch-service-mini` build target** would fill. The full `soundtouch-service` is justified for the external-host pattern (Pattern A) where space is not pressed; on-device (patterns B and C) the calculus is different — many users only need Internet Radio because that's the surface most affected by the cloud shutdown. + +A mini build target in this repo would look like: + +- same codebase, different `cmd/` entry point, +- compiled with only the packages needed for Internet Radio + TuneIn shim + presets, +- no Spotify, no parity tests, no setup wizard, no Bose-protocol-level proxy, +- target size: under 4 MB so it fits the rootfs without `/mnt/nv` gymnastics, leaving room for a second binary for safe updates. + +Open questions before committing: + +1. Collaborate upstream with soundtouch-tiny, or build our own mini that shares code with the full service? +2. Where to draw the feature line — "Internet Radio only" is clear; "Spotify too" would already blow the budget on ST20. +3. Mini ships via Pattern B (SSH-curl) or Pattern C (stick)? +4. Full service and mini service coexisting on the same LAN — mDNS service name, port choice, web UI port. + +### Wails vs Gio + +Both are Go. Different tradeoffs: + +- **Wails v2**: bundles a WebView per OS, frontend is HTML/CSS/JS. Faster to a working UI if the team is comfortable with HTML. Targets Windows / macOS / Linux. No mobile, no WASM. +- **Gio**: immediate-mode pure-Go UI. Smaller binaries, no WebView dependency. Targets Windows / macOS / Linux / iOS / Android / WASM. Steeper UI learning curve, mitigated by `gio-mw`. + +The deciding factor is **mobile + WASM** (Journey 2 and Journey 3), not desktop alone. If "use a phone to set up a speaker" or "open the admin tool from any browser" is on the roadmap, Wails does not get us there. + +## Appendix: documentation gap to close + +Separate user-facing material to produce when we are ready (not in this comparison doc): + +- **The `/mnt/nv/rc.local` hook** explained in user terms: what it does, when it fires, when *not* to use it, how to remove it cleanly. Bridges Journey 1 and Journey 2. +- **Hooks we already maintain** at OS level: resolv.conf stability, `/etc/hosts` overlay, anything in `pkg/service/setup/` that touches device state. Reference, not narrative. Journey 2 troubleshooting. +- **Storage budget per model**: rootfs free, `/mnt/nv` free, where the binary lands, which path applies to which ST model. Journey 1 sizing. +- **Decision matrix**: external host vs on-device vs mini, plus "do I need Spotify? do I need migration? do I want one host or per-speaker isolation?" Journey 1 entry point. +- **Stick file conventions**: what the `remote_services` stick does today, what we *might* add (presets / wlan / region) if we build a stick-driven path, and how that interacts with FAT credentials residency. Journey 1. +- **Automation cookbook**: example Home Assistant config, example shell scripts, common pitfalls. Journey 4. + +## Cross-references + +- AfterTouch installer: `scripts/on-device-install/install.sh`, `scripts/on-device-install/aftertouch` (init script), `scripts/on-device-install/README.md`. +- AfterTouch SSH client: `pkg/ssh/ssh.go` (`NewClient`, `Run`, `ReadFile`, `ReadDir`, `UploadContent`), already used by `pkg/service/setup/`. +- Storage limitations: issue #268 (ST20 rootfs free space), issue #196 (loopback-only bind), issue #250 (status reports running but unreachable). +- soundtouch-tiny: `https://github.com/GameTec-live/soundtouch-tiny`, raised in PR #292 (`https://github.com/gesellix/Bose-SoundTouch/pull/292`). +- opencloudtouch parallel discussion: `https://github.com/scheilch/opencloudtouch/discussions/201`. +- Existing parity doc shape: `docs/PARITY-OPENCLOUDTOUCH.md` is the precedent for cross-project comparison documents. diff --git a/docs/DIAGNOSTIC-EXPORT.md b/docs/DIAGNOSTIC-EXPORT.md new file mode 100644 index 0000000..73b73ae --- /dev/null +++ b/docs/DIAGNOSTIC-EXPORT.md @@ -0,0 +1,138 @@ +# Encrypted Diagnostic Export + +AfterTouch can produce an encrypted diagnostic report that users can download and +send to the project maintainer without exposing sensitive data to third parties. +The report is encrypted with an SSH public key using +[`age`](https://github.com/FiloSottile/age); only the holder of the matching +private key can read it. + +--- + +## What the report contains + +The encrypted `.age` file decrypts to a `.tar.gz` archive with: + +- `diagnostic.json` — structured summary: + - Service version and build info + - Full health-check results (same data as the Health tab) + - Per-device state: sources (IDs, names, SourceKeyTypes), presets (slot, name, + Source, SourceID, location), device product code, firmware version, IP, name +- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from + the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …) + +Having both the structured JSON and the raw XML lets you compare what the +service serves via HTTP against what is actually stored on disk. + +**What is excluded from the JSON:** authentication tokens, credentials, OAuth +secrets, Spotify refresh tokens. The raw XML files are included as-is. + +--- + +## Maintainer setup (one-time) + +> This section is for the project maintainer only. +> Users never need to touch keys. + +### 1. Generate the key pair + +```bash +bash scripts/setup-diagnostic-key.sh +``` + +This creates: +- `keys/private/diagnostic` — SSH ed25519 private key (**gitignored**, never commit) +- `keys/private/diagnostic.pub` — copy for reference (**gitignored**) +- `keys/public/diagnostic.pub` — public key committed to the repo + +### 2. Add the public key to GitHub + +Go to and paste the contents of +`keys/public/diagnostic.pub`. This makes the key visible at + so users can independently verify that the +key embedded in the binary matches a key actually controlled by the maintainer. + +### 3. Embed the public key in the binary + +Open `pkg/service/export/encrypt.go` and update the `DiagnosticPublicKey` +constant to match the new public key: + +```go +const DiagnosticPublicKey = "ssh-ed25519 AAAA... aftertouch-diagnostic@gesellix" +``` + +### 4. Commit + +```bash +git add keys/public/diagnostic.pub pkg/service/export/encrypt.go +git commit -m "keys: add diagnostic SSH public key" +``` + +`keys/private/` is `.gitignore`d — the private key will not be committed. + +### 5. Back up the private key + +The private key is **not** stored in git. Keep a copy in a secure location +(password manager, encrypted USB drive, etc.). If it is lost, a new key pair +must be generated and the constant in `encrypt.go` updated. + +--- + +## Verifying the embedded key (users) + +Users who want to confirm that the key embedded in their running binary matches +the maintainer's GitHub SSH keys can run: + +```bash +# Compare the raw key text — both should show the same line: +curl -s https://github.com/gesellix.keys +cat keys/public/diagnostic.pub +``` + +The key should appear verbatim in both outputs. + +--- + +## Decrypting a received report (maintainer) + +When a user sends you an `aftertouch-diagnostic-*.age` file, use the helper +script (no extra tools needed — only Go and the private key). Run from the +repository root directory: + +```bash +# Decrypt and extract in one step: +go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-.age | tar xz + +# Or decrypt to a .tar.gz first, then inspect: +go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-.age > report.tar.gz +tar xzf report.tar.gz +# → diagnostic.json +# → datastore/accounts/{id}/devices/{id}/Presets.xml (and Sources.xml, Recents.xml, …) +``` + +The script uses only the `filippo.io/age` Go module — no separate `age` CLI +installation required. + +--- + +## User workflow + +1. Open the AfterTouch admin UI and go to the **Health** tab. +2. Click **Download diagnostic report**. +3. The browser downloads `aftertouch-diagnostic-.age`. +4. Attach the file to the GitHub issue or send it via a direct channel. + +The file is opaque binary — the user cannot read it. All they see is that the +report was generated and downloaded. + +--- + +## Key rotation + +If the private key is compromised or lost: + +1. Run `scripts/setup-diagnostic-key.sh` (delete the old `keys/private/diagnostic` first). +2. Add the new public key to GitHub and remove the old one. +3. Update `DiagnosticPublicKey` in `encrypt.go`. +4. Commit and tag a new release. + +Old reports encrypted with the previous key cannot be decrypted with the new key. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 13a2df2..e4cc98d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -3,6 +3,7 @@ * [Introduction](README.md) ## User Guides +* [Device-Local Install Journeys](DEVICE-LOCAL-INSTALL.md) * [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md) * [Self-Hosting AfterTouch](guides/SELF-HOSTING.md) * [Connecting Music Services](guides/MUSIC-SERVICES.md) @@ -54,6 +55,8 @@ * [Request Recording](REQUEST_RECORDING_CONCEPT.md) * [Spotify Priming Strategy](concepts/spotify-priming-strategy.md) * [Spotify OAuth](concepts/spotify-oauth.md) +* [Encrypted Export](concepts/ENCRYPTED-EXPORT.md) +* [Diagnostic Export (Maintainer Setup)](DIAGNOSTIC-EXPORT.md) * [soundtouch-web Roadmap](soundtouch-web-roadmap.md) ## Analysis & Research diff --git a/docs/concepts/ENCRYPTED-EXPORT.md b/docs/concepts/ENCRYPTED-EXPORT.md new file mode 100644 index 0000000..dd0241c --- /dev/null +++ b/docs/concepts/ENCRYPTED-EXPORT.md @@ -0,0 +1,454 @@ +# Encrypting Sensitive Data Exports with SSH/age or GPG + +## Problem + +Allow users of our software to export potentially sensitive data, encrypt it locally, and send it to us. We decrypt on our side. Goal: no key exchange, minimal user friction. + +Two viable options are documented here: **Option A — `age`** (simpler, modern) and **Option B — GPG** (widely known, interoperable with existing tooling). Both support fetching a recipient key from GitHub so users don't need to hand us anything. + +## Key Findings + +### GPG via SSH keys: not possible + +- GitHub's `https://github.com/.keys` serves SSH public keys, not GPG keys. +- SSH and GPG/OpenPGP use different formats, capability flags, and key material (auth vs. encrypt/sign/certify). +- Ed25519 SSH keys can't be directly reused for GPG encryption (encryption requires X25519/ECDH). + +### GPG via published GPG keys: possible + +- GitHub exposes GPG public keys at `https://github.com/.gpg` — these are real OpenPGP armored keys, not SSH keys. +- Any key the user has uploaded to their GitHub account (or a keyserver like `keys.openpgp.org`) can be used directly for encryption. +- Decryption requires the matching GPG private key on our side. +- The `github.com/ProtonMail/go-crypto/openpgp` package is the actively maintained Go OpenPGP implementation (`golang.org/x/crypto/openpgp` is deprecated and points to it). + +### `age` with SSH or native keys: possible (simpler) + +- [`age`](https://github.com/FiloSottile/age) natively supports `ssh-rsa` and `ssh-ed25519` public keys as recipients, fetched from `https://github.com/.keys`. +- Also supports its own `age1...` native keys (`age-keygen`), which are X25519-based. +- Written in Go; library is `filippo.io/age` + `filippo.io/age/agessh`. +- Output is age format (not GPG-interoperable). Decrypt with `age -i key file.age` or the Go library. + +--- + +## Option A: `age` + +### Architecture + +1. Generate a dedicated age key: `age-keygen -o decrypt.key` (produces `age1...` public key). +2. Embed the public key as a constant in the binary — users need no setup. +3. Optionally accept a GitHub username and fetch their SSH keys as recipients so the user can verify independently. +4. Store the private key securely (secret manager, HSM, offline backup). + +### Workflow + +**User side:** +``` +soundtouch-cli export --encrypt +# or: soundtouch-cli export --encrypt-for github:gesellix +``` +The CLI encrypts the export using the embedded key (or fetched SSH keys) and writes `export.age`. +The user sends that file through any channel. + +**Maintainer side:** +```bash +age -d -i decrypt.key -o export.tar.gz export.age +# or with an SSH private key: +age -d -i ~/.ssh/id_ed25519 -o export.tar.gz export.age +``` + +### Go Implementation + +#### Encrypt with embedded key + +```go +import ( + "io" + "os" + + "filippo.io/age" +) + +const recipientKey = "age1..." // embedded public key + +func exportEncrypted(plaintext io.Reader, outPath string) error { + recipient, err := age.ParseX25519Recipient(recipientKey) + if err != nil { + return err + } + out, err := os.Create(outPath) + if err != nil { + return err + } + defer out.Close() + + w, err := age.Encrypt(out, recipient) + if err != nil { + return err + } + defer w.Close() + + _, err = io.Copy(w, plaintext) + return err +} +``` + +#### Encrypt to a GitHub user's SSH keys (alternative / verification path) + +```go +import ( + "bufio" + "io" + "log" + "net/http" + "strings" + + "filippo.io/age" + "filippo.io/age/agessh" +) + +func recipientsFromGitHub(user string) ([]age.Recipient, error) { + resp, err := http.Get("https://github.com/" + user + ".keys") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var recipients []age.Recipient + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + r, err := agessh.ParseRecipient(line) + if err != nil { + log.Printf("skipping unsupported key: %v", err) + continue + } + recipients = append(recipients, r) + } + return recipients, nil +} +``` + +#### Decrypt (maintainer side) + +With a native age key: + +```go +package main + +import "filippo.io/age" + +func decryptAge(encryptedReader io.Reader, privateKeyString string) (io.Reader, error) { + identity, err := age.ParseX25519Identity(privateKeyString) + if err != nil { + return nil, err + } + return age.Decrypt(encryptedReader, identity) +} +``` + +With an SSH private key: + +```go +package main + +import ( + "io" + "os" + + "filippo.io/age" + "filippo.io/age/agessh" +) + +func decryptAgeSSH(encryptedReader io.Reader, sshKeyPath string) (io.Reader, error) { + pemBytes, err := os.ReadFile(sshKeyPath) + if err != nil { + return nil, err + } + identity, err := agessh.ParseIdentity(pemBytes) + if err != nil { + return nil, err + } + return age.Decrypt(encryptedReader, identity) +} +``` + +--- + +## Option B: GPG (OpenPGP) + +### Architecture + +1. Generate a dedicated GPG encryption subkey: `gpg --full-gen-key` (choose RSA or Ed25519+X25519). +2. Export and publish the public key, or embed the armored block directly in the binary. +3. Optionally fetch the user's GPG key from `https://github.com/.gpg` or `keys.openpgp.org` so they can confirm the recipient. +4. Store the private key securely. Decryption is `gpg --decrypt export.gpg`. + +### Workflow + +**User side:** +``` +soundtouch-cli export --encrypt-gpg +# or: soundtouch-cli export --encrypt-gpg-for github:gesellix +``` +The CLI encrypts the export as an OpenPGP binary message and writes `export.gpg`. +The user sends that file through any channel. + +**Maintainer side:** +```bash +# GPG must have the matching private key in its keyring +gpg --decrypt -o export.tar.gz export.gpg + +# Or with a specific key file (without importing into the keyring): +gpg --no-default-keyring --secret-keyring ./decrypt.gpg \ + --decrypt -o export.tar.gz export.gpg +``` + +### Go Implementation + +Uses `github.com/ProtonMail/go-crypto/openpgp` (the maintained successor to the deprecated `golang.org/x/crypto/openpgp`; API is compatible). + +#### Fetch public key from GitHub + +```go +package main + +import ( + "io" + "net/http" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +func gpgKeyFromGitHub(user string) (openpgp.EntityList, error) { + resp, err := http.Get("https://github.com/" + user + ".gpg") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + block, err := armor.Decode(resp.Body) + if err != nil { + return nil, err + } + return openpgp.ReadKeyRing(block.Body) +} +``` + +#### Encrypt with embedded or fetched public key + +```go +package main + +import ( + "io" + "os" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +const embeddedPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- +... +-----END PGP PUBLIC KEY BLOCK-----` + +func exportEncryptedGPG(plaintext io.Reader, outPath string) error { + block, err := armor.Decode(strings.NewReader(embeddedPublicKey)) + if err != nil { + return err + } + recipients, err := openpgp.ReadKeyRing(block.Body) + if err != nil { + return err + } + + out, err := os.Create(outPath) + if err != nil { + return err + } + defer out.Close() + + // Encrypt directly (binary, no ASCII armor — smaller output) + w, err := openpgp.Encrypt(out, recipients, nil, nil, nil) + if err != nil { + return err + } + defer w.Close() + + _, err = io.Copy(w, plaintext) + return err +} +``` + +To produce ASCII-armored output (easier to paste into emails/issues), wrap `out` with `armor.Encode`: + +```go +package main + +import ( + "io" + "os" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +func exportEncryptedGPGArmored(plaintext io.Reader, outPath, embeddedPublicKey string) error { + block, err := armor.Decode(strings.NewReader(embeddedPublicKey)) + if err != nil { + return err + } + recipients, err := openpgp.ReadKeyRing(block.Body) + if err != nil { + return err + } + + out, err := os.Create(outPath) + if err != nil { + return err + } + defer out.Close() + + armorWriter, err := armor.Encode(out, "PGP MESSAGE", nil) + if err != nil { + return err + } + defer armorWriter.Close() + + w, err := openpgp.Encrypt(armorWriter, recipients, nil, nil, nil) + if err != nil { + return err + } + defer w.Close() + + _, err = io.Copy(w, plaintext) + return err +} +``` + +#### Decrypt (maintainer side) + +```go +package main + +import ( + "io" + "os" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +func decryptGPG(encryptedPath, privateKeyArmored string) (io.ReadCloser, error) { + block, err := armor.Decode(strings.NewReader(privateKeyArmored)) + if err != nil { + return nil, err + } + keyring, err := openpgp.ReadKeyRing(block.Body) + if err != nil { + return nil, err + } + + f, err := os.Open(encryptedPath) + if err != nil { + return nil, err + } + + msg, err := openpgp.ReadMessage(f, keyring, nil, nil) + if err != nil { + return nil, err + } + return msg.UnverifiedBody, nil +} +``` + +--- + +## Comparison and Recommendation + +| Criterion | `age` | GPG | +|-------------------------------------|------------------------------|-----------------------------------------------| +| User familiarity | Low (newer tool) | High (widely known) | +| User already has a key to use | Maybe (SSH on GitHub) | Often (GPG on GitHub/keyserver) | +| Go library quality | Excellent (`filippo.io/age`) | Good (`ProtonMail/go-crypto`) | +| Output interoperability | age format only | Standard OpenPGP — any GPG client can decrypt | +| CLI decrypt UX (maintainer) | `age -d -i key file.age` | `gpg --decrypt file.gpg` | +| Key embedding in binary | Native `age1...` string | Armored PEM block | +| Key rotation story | `age-keygen`, swap constant | Standard GPG subkey rotation | +| Anonymous recipients | Yes (native age keys) | No (key ID visible) | +| Streaming large exports | Yes | Yes | + +**Recommendation:** use `age` with an embedded native key for the primary path — simpler dependency, cleaner API, no GPG keyring management needed. Add GPG as an opt-in flag (`--gpg` or `--encrypt-gpg-for github:`) for users who already manage GPG keys and want their own tooling to verify or store the export. + +--- + +## Binary Size + +Measured on macOS arm64, stripped binaries (`-ldflags="-s -w"`). + +### Standalone cost (no shared deps) + +| Option | Binary size | Added vs no-crypto baseline | +|-------------------------------------|-------------|-----------------------------| +| Baseline (no crypto) | 1.44 MB | — | +| `age` native key only (no `agessh`) | 2.40 MB | +0.96 MB | +| `age` + `agessh` (SSH recipients) | 3.06 MB | +1.63 MB | +| GPG (`ProtonMail/go-crypto`) | 3.59 MB | +2.15 MB | + +### Marginal cost for this project + +This project already imports `golang.org/x/crypto/ssh`, which `agessh` depends on. That ~660 KB is shared and doesn't count against `age`. Against the ~12.9 MB `soundtouch-service` binary: + +| Option | Marginal cost | % of service binary | +|------------------|---------------|---------------------| +| `age` + `agessh` | +0.64 MB | ~5% | +| GPG | +1.30 MB | ~10% | + +### Why GPG is larger + +`age` pulls in only what it needs: `chacha20poly1305`, `hkdf`, `edwards25519`, and `filippo.io/hpke` (post-quantum). `ProtonMail/go-crypto` must ship the full OpenPGP spec: `cloudflare/circl` (Ed448, X448, Goldilocks curves), `bitcurves`, `brainpool`, `EAX`, `OCB`, `CAST5`, `BLAKE2b`, `SHA3`, `Argon2`, S2K key derivation, and zlib/bzip2 compression. Go's dead-code elimination works at the function level but can't remove entire algorithm families wired through a shared codec dispatch. + +--- + +## Gotchas & Risks + +| Concern | Mitigation | +|--------------------------------------------------|-------------------------------------------------------------------------------------| +| MITM / GitHub account compromise swaps the key | Pin expected key fingerprint(s); prefer embedded key over runtime fetch | +| SSH key rotation breaks old `agessh` decryption | Use a dedicated long-lived age key, not the user's SSH key, as primary | +| ECDSA SSH keys not supported by `agessh` | Handle "no usable key" gracefully; warn and fall back | +| `agessh` recipients leak a 32-bit key ID | Accept, or use native age keys for full anonymity | +| GPG key expiry breaks encryption | Use a non-expiring encryption subkey, or check and warn before encrypting | +| GPG key without encryption capability | Filter `EntityList` to keys with `EncryptCommunications` flag set | +| Encryption ≠ authentication | Authenticate via the send channel, or require a detached signature | +| Sensitive data leaks via logs or memory dumps | Audit all egress paths; the export must be the only cleartext exit | +| Large exports | Both `age` and `openpgp.Encrypt` stream — never buffer the whole payload | + +--- + +## Dependencies + +```bash +# age +go get filippo.io/age +go get filippo.io/age/agessh # only if supporting SSH recipients + +# GPG +go get github.com/ProtonMail/go-crypto/openpgp +``` + +## References + +- `age` project: https://github.com/FiloSottile/age +- `age` Go docs: https://pkg.go.dev/filippo.io/age +- `agessh` docs: https://pkg.go.dev/filippo.io/age/agessh +- ProtonMail go-crypto: https://github.com/ProtonMail/go-crypto +- OpenPGP Go docs: https://pkg.go.dev/github.com/ProtonMail/go-crypto/openpgp +- GitHub GPG key endpoint: `https://github.com/.gpg` +- OpenPGP keyserver: https://keys.openpgp.org diff --git a/go.mod b/go.mod index 897bc54..e65692e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch go 1.26.3 require ( + filippo.io/age v1.3.1 github.com/chromedp/chromedp v0.15.1 github.com/go-chi/chi/v5 v5.2.5 github.com/google/gopacket v1.1.19 @@ -20,6 +21,8 @@ require ( ) require ( + filippo.io/edwards25519 v1.2.0 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect diff --git a/go.sum b/go.sum index 761151b..b129528 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4= github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= diff --git a/keys/public/diagnostic.pub b/keys/public/diagnostic.pub new file mode 100644 index 0000000..2821da5 --- /dev/null +++ b/keys/public/diagnostic.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix diff --git a/pkg/service/export/diagnostic.pub b/pkg/service/export/diagnostic.pub new file mode 100644 index 0000000..2821da5 --- /dev/null +++ b/pkg/service/export/diagnostic.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix diff --git a/pkg/service/export/encrypt.go b/pkg/service/export/encrypt.go new file mode 100644 index 0000000..b60707b --- /dev/null +++ b/pkg/service/export/encrypt.go @@ -0,0 +1,54 @@ +// Package export provides encryption helpers for the diagnostic export feature. +package export + +import ( + "bytes" + _ "embed" + "fmt" + "strings" + + "filippo.io/age" + "filippo.io/age/agessh" +) + +// diagnosticPublicKey is embedded from diagnostic.pub at compile time. +// diagnostic.pub is kept in sync with keys/public/diagnostic.pub by +// scripts/setup-diagnostic-key.sh. To verify it matches the maintainer's +// GitHub SSH keys: +// +// curl -s https://github.com/gesellix.keys | grep "$(awk '{print $2}' keys/public/diagnostic.pub)" +// +//go:embed diagnostic.pub +var diagnosticPublicKeyRaw string + +// diagnosticPublicKey returns the trimmed SSH public key line. +func diagnosticPublicKey() string { + return strings.TrimSpace(diagnosticPublicKeyRaw) +} + +// EncryptDiagnostic encrypts plaintext using the embedded SSH public key +// and returns the age-encrypted bytes. The result can only be decrypted +// with the corresponding SSH private key (keys/private/diagnostic). +func EncryptDiagnostic(plaintext []byte) ([]byte, error) { + recipient, err := agessh.ParseRecipient(diagnosticPublicKey()) + if err != nil { + return nil, fmt.Errorf("parse recipient key: %w", err) + } + + var buf bytes.Buffer + + w, err := age.Encrypt(&buf, recipient) + if err != nil { + return nil, fmt.Errorf("age encrypt: %w", err) + } + + if _, err := w.Write(plaintext); err != nil { + return nil, fmt.Errorf("write plaintext: %w", err) + } + + if err := w.Close(); err != nil { + return nil, fmt.Errorf("close age writer: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/service/export/encrypt_test.go b/pkg/service/export/encrypt_test.go new file mode 100644 index 0000000..feb011b --- /dev/null +++ b/pkg/service/export/encrypt_test.go @@ -0,0 +1,20 @@ +package export + +import ( + "testing" + + "filippo.io/age/agessh" +) + +// TestDiagnosticPublicKeyParseable ensures the embedded diagnostic.pub is a +// valid SSH public key that age can use as a recipient. Catches a truncated or +// corrupted embed before it reaches a user trying to send a report. +func TestDiagnosticPublicKeyParseable(t *testing.T) { + key := diagnosticPublicKey() + if key == "" { + t.Fatal("embedded diagnostic.pub is empty") + } + if _, err := agessh.ParseRecipient(key); err != nil { + t.Errorf("embedded diagnostic.pub is not a valid age SSH recipient: %v", err) + } +} diff --git a/pkg/service/handlers/handlers_export.go b/pkg/service/handlers/handlers_export.go new file mode 100644 index 0000000..b82645a --- /dev/null +++ b/pkg/service/handlers/handlers_export.go @@ -0,0 +1,654 @@ +package handlers + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/export" + "github.com/gesellix/bose-soundtouch/pkg/service/health" + speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh" +) + +// diagnosticReport is the structured summary included as diagnostic.json +// inside the encrypted archive. Raw datastore XML files are added verbatim +// alongside it so the maintainer can compare on-disk state with what the +// service serves. +type diagnosticReport struct { + GeneratedAt string `json:"generated_at"` + ServiceVersion map[string]string `json:"service_version"` + HealthChecks []health.CheckResult `json:"health_checks"` + Devices []deviceDiagnostic `json:"devices"` +} + +type deviceDiagnostic struct { + AccountID string `json:"account_id"` + DeviceID string `json:"device_id"` + ProductCode string `json:"product_code,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` + Name string `json:"name,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + Sources []sourceDiagnostic `json:"sources,omitempty"` + Presets []presetDiagnostic `json:"presets,omitempty"` +} + +type sourceDiagnostic struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + SourceKeyType string `json:"source_key_type,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + Status string `json:"status,omitempty"` +} + +type presetDiagnostic struct { + Slot string `json:"slot"` + Name string `json:"name,omitempty"` + Source string `json:"source,omitempty"` + SourceID string `json:"source_id,omitempty"` + Location string `json:"location,omitempty"` +} + +// HandleExportDiagnostic builds a tar.gz archive containing a structured JSON +// summary plus the raw datastore XML files verbatim, encrypts the archive with +// the maintainer's embedded SSH public key (age/agessh), and returns it as a +// downloadable .age file. Credentials and authentication tokens are +// intentionally excluded from the JSON summary; XML files are included as-is. +func (s *Server) HandleExportDiagnostic(w http.ResponseWriter, _ *http.Request) { + archive, err := s.buildDiagnosticArchive() + if err != nil { + log.Printf("[Export] build archive: %v", err) + writeJSONError(w, http.StatusInternalServerError, "failed to build diagnostic archive") + + return + } + + encrypted, err := export.EncryptDiagnostic(archive) + if err != nil { + log.Printf("[Export] encrypt: %v", err) + writeJSONError(w, http.StatusInternalServerError, "failed to encrypt diagnostic archive") + + return + } + + ts := time.Now().UTC().Format("2006-01-02T15-04-05Z") + filename := fmt.Sprintf("aftertouch-diagnostic-%s.age", ts) + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + + if _, err := w.Write(encrypted); err != nil { + log.Printf("[Export] write response: %v", err) + } +} + +// buildDiagnosticArchive returns a gzipped tar archive containing: +// - diagnostic.json — structured health/device summary +// - datastore/accounts/{account}/devices/{device}/*.xml — raw XML verbatim +// - http/service/... — HTTP responses from the local service endpoints +// - http/speaker/... — HTTP responses from each speaker's local API (port 8090) +// - system/ca.pem — service CA certificate (if configured) +// - system/resolv.conf — host DNS resolver configuration +// - settings.json — service settings with secrets redacted +// - env.txt — filtered process environment +func (s *Server) buildDiagnosticArchive() ([]byte, error) { + var buf bytes.Buffer + + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + report := s.buildDiagnosticReport() + + jsonBytes, err := json.MarshalIndent(report, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal summary: %w", err) + } + + if addErr := addTarBytes(tw, "diagnostic.json", jsonBytes); addErr != nil { + return nil, fmt.Errorf("add diagnostic.json: %w", addErr) + } + + devices, err := s.ds.ListAllDevices() + if err != nil { + log.Printf("[Export] list devices: %v", err) + } + + for i := range devices { + dev := &devices[i] + dir := s.ds.AccountDeviceDir(dev.AccountID, dev.DeviceID) + + entries, err := os.ReadDir(dir) + if err != nil { + log.Printf("[Export] read dir %s: %v", dir, err) + + continue + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".xml") { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + log.Printf("[Export] read %s: %v", entry.Name(), err) + + continue + } + + archivePath := "datastore/accounts/" + dev.AccountID + "/devices/" + dev.DeviceID + "/" + entry.Name() + if err := addTarBytes(tw, archivePath, data); err != nil { + log.Printf("[Export] add %s: %v", archivePath, err) + } + } + } + + client := diagHTTPClient() + s.addServiceHTTP(tw, client, devices) + s.addSpeakerHTTP(tw, client, devices) + addSpeakerSSH(tw, devices) + s.addSystemFiles(tw) + s.addServiceLog(tw) + s.addSettingsJSON(tw) + addEnvVars(tw) + + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("close tar: %w", err) + } + + if err := gz.Close(); err != nil { + return nil, fmt.Errorf("close gzip: %w", err) + } + + return buf.Bytes(), nil +} + +// diagHTTPClient returns an HTTP client suited for internal diagnostic fetches: +// short timeout and TLS verification skipped so it can call the service's own +// HTTPS endpoint without the CA being trusted by the host OS. +func diagHTTPClient() *http.Client { + return &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + }, + } +} + +func diagFetch(client *http.Client, rawURL string) ([]byte, error) { + resp, err := client.Get(rawURL) //nolint:noctx + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + return io.ReadAll(resp.Body) +} + +// addServiceHTTP appends HTTP responses from the local service into the archive +// under http/service/. Each fetch is best-effort; errors are logged and skipped. +func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []models.ServiceDeviceInfo) { + base := strings.TrimRight(s.serverURL, "/") + + tryAdd := func(archivePath, url string) { + data, err := diagFetch(client, url) + if err != nil { + log.Printf("[Export] fetch %s: %v", url, err) + + return + } + + if err := addTarBytes(tw, archivePath, data); err != nil { + log.Printf("[Export] add %s: %v", archivePath, err) + } + } + + tryAdd("http/service/sourceproviders.xml", base+"/streaming/sourceproviders") + + seenAccounts := map[string]bool{} + + for i := range devices { + dev := &devices[i] + + if !seenAccounts[dev.AccountID] { + seenAccounts[dev.AccountID] = true + pfx := "http/service/account-" + dev.AccountID + acct := base + "/streaming/account/" + dev.AccountID + tryAdd(pfx+"/full.xml", acct+"/full") + tryAdd(pfx+"/sources.xml", acct+"/sources") + tryAdd(pfx+"/presets.xml", acct+"/presets") + } + + if dev.DeviceID == "" { + continue + } + + dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID + dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID + tryAdd(dpfx+"/presets.xml", dpath+"/presets") + tryAdd(dpfx+"/recents.xml", dpath+"/recents") + } +} + +// addSpeakerHTTP appends HTTP responses fetched directly from each speaker's +// local API (port 8090) into the archive under http/speaker/{deviceID}/. +// Speakers that are unreachable are silently skipped. +func (s *Server) addSpeakerHTTP(tw *tar.Writer, client *http.Client, devices []models.ServiceDeviceInfo) { + endpoints := []string{"sources", "presets", "now_playing", "info", "recents"} + + for i := range devices { + dev := &devices[i] + + if dev.IPAddress == "" { + continue + } + + speakerBase := "http://" + dev.IPAddress + ":8090" + id := dev.DeviceID + + if id == "" { + id = dev.IPAddress + } + + for _, ep := range endpoints { + data, err := diagFetch(client, speakerBase+"/"+ep) + if err != nil { + log.Printf("[Export] speaker %s /%s: %v", id, ep, err) + + continue + } + + archivePath := "http/speaker/" + id + "/" + ep + ".xml" + if err := addTarBytes(tw, archivePath, data); err != nil { + log.Printf("[Export] add %s: %v", archivePath, err) + } + } + } +} + +// speakerSSHPaths lists file paths to retrieve from each speaker via SSH. +var speakerSSHPaths = []string{ + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/certs/ca-certificates.crt", +} + +// speakerLogWindow is the default look-back period for speaker syslog entries. +const speakerLogWindow = 20 * time.Minute + +// speakerLogFormats are the timestamp layouts attempted when parsing a busybox +// syslog line. Busybox logread produces "Mon Jan _2 15:04:05 2006" (with year) +// on newer firmware; older builds omit the year. +var speakerLogFormats = []string{ + "Mon Jan _2 15:04:05 2006", // newer busybox: "Wed Jun 4 12:34:56 2025" + "Mon Jan 02 15:04:05 2006", // zero-padded day variant +} + +// parseSpeakerLogTime extracts the timestamp from the leading field of a busybox +// syslog line. currentYear is used as a fallback when the log line has no year +// field. Returns the zero Time and false when no format matches. +func parseSpeakerLogTime(line string, currentYear int) (time.Time, bool) { + for _, layout := range speakerLogFormats { + if len(line) < len(layout) { + continue + } + + t, err := time.Parse(layout, line[:len(layout)]) + if err == nil { + return t.UTC(), true + } + } + + // Try without year: assume current year. + noYearFmt := "Mon Jan _2 15:04:05" + noYearFmt02 := "Mon Jan 02 15:04:05" + + for _, layout := range []string{noYearFmt, noYearFmt02} { + if len(line) < len(layout) { + continue + } + + withYear := line[:len(layout)] + strings.Repeat(" ", 1) + fmt.Sprintf("%d", currentYear) + fullLayout := layout + " 2006" + + t, err := time.Parse(fullLayout, withYear) + if err == nil { + return t.UTC(), true + } + } + + return time.Time{}, false +} + +// filterSpeakerLog returns only the lines from rawLog whose timestamp falls +// within the given window before now. Lines whose timestamp cannot be parsed +// are kept (fail-open) so that unparseable headers or continuation lines are +// not silently dropped. +func filterSpeakerLog(rawLog string, window time.Duration) string { + cutoff := time.Now().UTC().Add(-window) + currentYear := time.Now().Year() + + var out strings.Builder + + for _, line := range strings.SplitAfter(rawLog, "\n") { + t, ok := parseSpeakerLogTime(line, currentYear) + if !ok || !t.Before(cutoff) { + out.WriteString(line) + } + } + + return out.String() +} + +// addSpeakerSSH connects to each speaker via SSH and copies the speaker-side +// CA certificate files and log output into the archive under ssh/speaker/{deviceID}/. +// Speakers that are unreachable or have SSH disabled are silently skipped. +func addSpeakerSSH(tw *tar.Writer, devices []models.ServiceDeviceInfo) { + for i := range devices { + dev := &devices[i] + + if dev.IPAddress == "" { + continue + } + + id := dev.DeviceID + if id == "" { + id = dev.IPAddress + } + + sc := speakerssh.NewClient(dev.IPAddress) + + for _, remotePath := range speakerSSHPaths { + data, err := sc.ReadFile(remotePath) + if err != nil { + log.Printf("[Export] SSH %s %s: %v", id, remotePath, err) + + continue + } + + archivePath := "ssh/speaker/" + id + remotePath + if err := addTarBytes(tw, archivePath, data); err != nil { + log.Printf("[Export] add %s: %v", archivePath, err) + } + } + + // dmesg and any other plain commands. + for filename, cmd := range map[string]string{"dmesg.txt": "dmesg"} { + out, err := sc.Run(cmd) + if err != nil && strings.TrimSpace(out) == "" { + log.Printf("[Export] SSH %s %q: %v", id, cmd, err) + + continue + } + + archivePath := "ssh/speaker/" + id + "/" + filename + if err := addTarBytes(tw, archivePath, []byte(out)); err != nil { + log.Printf("[Export] add %s: %v", archivePath, err) + } + } + + // Syslog: fetch raw, strip 127.0.0.1 noise, then keep only the last speakerLogWindow. + rawLog, logErr := sc.Run("logread 2>/dev/null | grep -v '127.0.0.1'") + if logErr != nil && strings.TrimSpace(rawLog) == "" { + log.Printf("[Export] SSH %s logread: %v", id, logErr) + } else { + filtered := filterSpeakerLog(rawLog, speakerLogWindow) + archivePath := "ssh/speaker/" + id + "/logread.txt" + + if addErr := addTarBytes(tw, archivePath, []byte(filtered)); addErr != nil { + log.Printf("[Export] add %s: %v", archivePath, addErr) + } + } + } +} + +// addServiceLog appends the in-memory service log buffer as logs/service.txt. +// Each entry is formatted as "2006-01-02T15:04:05Z ". +func (s *Server) addServiceLog(tw *tar.Writer) { + if s.logBuf == nil { + return + } + + entries := s.logBuf.Snapshot() + if len(entries) == 0 { + return + } + + var sb strings.Builder + + for _, e := range entries { + sb.WriteString(e.Time.UTC().Format(time.RFC3339)) + sb.WriteByte(' ') + sb.WriteString(e.Message) + sb.WriteByte('\n') + } + + if err := addTarBytes(tw, "logs/service.txt", []byte(sb.String())); err != nil { + log.Printf("[Export] add logs/service.txt: %v", err) + } +} + +// addSystemFiles appends the service CA cert (if configured) and the host +// resolver configuration into the archive under system/. +func (s *Server) addSystemFiles(tw *tar.Writer) { + if path := s.ownCACertPath(); path != "" { + data, err := os.ReadFile(path) + if err != nil { + log.Printf("[Export] read CA cert %s: %v", path, err) + } else if err := addTarBytes(tw, "system/ca.pem", data); err != nil { + log.Printf("[Export] add system/ca.pem: %v", err) + } + } + + if data, err := os.ReadFile("/etc/resolv.conf"); err == nil { + if err := addTarBytes(tw, "system/resolv.conf", data); err != nil { + log.Printf("[Export] add system/resolv.conf: %v", err) + } + } +} + +// diagSettings is a copy of datastore.Settings with secrets zeroed out so the +// struct can be marshalled into the archive without exposing credentials. +type diagSettings struct { + ServerURL string `json:"server_url"` + HTTPSServerURL string `json:"https_server_url,omitempty"` + RedactLogs bool `json:"redact_logs"` + LogBodies bool `json:"log_bodies"` + RecordInteractions bool `json:"record_interactions"` + DiscoveryInterval string `json:"discovery_interval,omitempty"` + DiscoveryEnabled bool `json:"discovery_enabled"` + DNSEnabled bool `json:"dns_enabled"` + DNSUpstream []string `json:"dns_upstream,omitempty"` + DNSBindAddr string `json:"dns_bind_addr,omitempty"` + InternalPaths []string `json:"internal_paths,omitempty"` + Shortcuts map[string]int `json:"shortcuts,omitempty"` + SpotifyClientID string `json:"spotify_client_id,omitempty"` + SpotifyClientSecret string `json:"spotify_client_secret,omitempty"` + SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"` + AmazonClientID string `json:"amazon_client_id,omitempty"` + AmazonClientSecret string `json:"amazon_client_secret,omitempty"` + AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"` + TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"` + TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"` + TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"` +} + +// addSettingsJSON serialises the service settings into the archive as +// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the +// file is safe to share. +func (s *Server) addSettingsJSON(tw *tar.Writer) { + st, err := s.ds.GetSettings() + if err != nil { + log.Printf("[Export] get settings: %v", err) + + return + } + + redact := func(v string) string { + if v != "" { + return "[REDACTED]" + } + + return "" + } + + ds := diagSettings{ + ServerURL: st.ServerURL, + HTTPSServerURL: st.HTTPServerURL, + RedactLogs: st.RedactLogs, + LogBodies: st.LogBodies, + RecordInteractions: st.RecordInteractions, + DiscoveryInterval: st.DiscoveryInterval, + DiscoveryEnabled: st.DiscoveryEnabled, + DNSEnabled: st.DNSEnabled, + DNSUpstream: st.DNSUpstream, + DNSBindAddr: st.DNSBindAddr, + InternalPaths: st.InternalPaths, + Shortcuts: st.Shortcuts, + SpotifyClientID: st.SpotifyClientID, + SpotifyClientSecret: redact(st.SpotifyClientSecret), + SpotifyRedirectURI: st.SpotifyRedirectURI, + AmazonClientID: st.AmazonClientID, + AmazonClientSecret: redact(st.AmazonClientSecret), + AmazonRedirectURI: st.AmazonRedirectURI, + TrustForwardedHeaders: st.TrustForwardedHeaders, + TrustedProxyCIDRs: st.TrustedProxyCIDRs, + TuneInStreamFormats: st.TuneInStreamFormats, + } + + data, err := json.MarshalIndent(ds, "", " ") + if err != nil { + log.Printf("[Export] marshal settings: %v", err) + + return + } + + if err := addTarBytes(tw, "settings.json", data); err != nil { + log.Printf("[Export] add settings.json: %v", err) + } +} + +// secretEnvKeywords lists substrings that, if present in an env-var name, +// cause the variable to be omitted from the diagnostic export. +var secretEnvKeywords = []string{ + "secret", "password", "passwd", "token", "apikey", "api_key", + "credential", "auth", "private", "passphrase", +} + +// addEnvVars appends a filtered list of environment variables to the archive +// as env.txt. Variables whose names suggest credentials are omitted. +func addEnvVars(tw *tar.Writer) { + raw := os.Environ() + sort.Strings(raw) + + var lines []string + + for _, kv := range raw { + name := strings.ToLower(strings.SplitN(kv, "=", 2)[0]) + skip := false + + for _, kw := range secretEnvKeywords { + if strings.Contains(name, kw) { + skip = true + + break + } + } + + if !skip { + lines = append(lines, kv) + } + } + + data := []byte(strings.Join(lines, "\n") + "\n") + if err := addTarBytes(tw, "env.txt", data); err != nil { + log.Printf("[Export] add env.txt: %v", err) + } +} + +func addTarBytes(tw *tar.Writer, name string, data []byte) error { + hdr := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(data)), + ModTime: time.Now().UTC(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + + _, err := tw.Write(data) + + return err +} + +func (s *Server) buildDiagnosticReport() diagnosticReport { + report := diagnosticReport{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + ServiceVersion: buildVersionInfo(), + } + + if s.healthRegistry != nil { + report.HealthChecks = s.healthRegistry.RunAll() + } + + devices, err := s.ds.ListAllDevices() + if err != nil { + log.Printf("[Export] list devices: %v", err) + + return report + } + + for i := range devices { + dev := &devices[i] + dd := deviceDiagnostic{ + AccountID: dev.AccountID, + DeviceID: dev.DeviceID, + ProductCode: dev.ProductCode, + FirmwareVersion: dev.FirmwareVersion, + Name: dev.Name, + IPAddress: dev.IPAddress, + } + + if sources, err := s.ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); err == nil { + for i := range sources { + src := &sources[i] + dd.Sources = append(dd.Sources, sourceDiagnostic{ + ID: src.ID, + Name: src.Name, + SourceKeyType: src.SourceKeyType, + ProviderID: src.SourceProviderID, + Status: src.Status, + }) + } + } + + if presets, err := s.ds.GetPresets(dev.AccountID, dev.DeviceID); err == nil { + for i := range presets { + p := &presets[i] + dd.Presets = append(dd.Presets, presetDiagnostic{ + Slot: p.ButtonNumber, + Name: p.Name, + Source: p.Source, + SourceID: p.SourceID, + Location: p.Location, + }) + } + } + + report.Devices = append(report.Devices, dd) + } + + return report +} diff --git a/pkg/service/handlers/handlers_export_test.go b/pkg/service/handlers/handlers_export_test.go new file mode 100644 index 0000000..eef0b10 --- /dev/null +++ b/pkg/service/handlers/handlers_export_test.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "strings" + "testing" + "time" +) + +func TestParseSpeakerLogTime(t *testing.T) { + currentYear := 2025 + + cases := []struct { + line string + wantOK bool + wantSub string // substring that should appear in formatted result + }{ + {"Wed Jun 4 12:34:56 2025 daemon.info app: hello", true, "2025"}, + {"Mon Jan 02 15:04:05 2025 kern.info kernel: boot", true, "2025"}, + {"Wed Jun 4 12:34:56 daemon.info app: no year", true, "2025"}, // year injected + {"not a log line at all", false, ""}, + {"", false, ""}, + } + + for _, tc := range cases { + t.Run(tc.line, func(t *testing.T) { + got, ok := parseSpeakerLogTime(tc.line, currentYear) + if ok != tc.wantOK { + t.Errorf("parseSpeakerLogTime(%q) ok=%v, want %v", tc.line, ok, tc.wantOK) + } + + if tc.wantOK && tc.wantSub != "" && !strings.Contains(got.Format(time.RFC3339), tc.wantSub) { + t.Errorf("parseSpeakerLogTime(%q) = %v, expected to contain %q", tc.line, got.Format(time.RFC3339), tc.wantSub) + } + }) + } +} + +func TestFilterSpeakerLog(t *testing.T) { + now := time.Now().UTC() + format := "Mon Jan _2 15:04:05 2006" + + recent := now.Add(-5 * time.Minute).Format(format) + old := now.Add(-30 * time.Minute).Format(format) + + rawLog := strings.Join([]string{ + old + " kern.info kernel: old message", + recent + " daemon.info app: recent message", + "unparseable line — keep it", + "", + }, "\n") + + filtered := filterSpeakerLog(rawLog, 20*time.Minute) + + if strings.Contains(filtered, "old message") { + t.Error("filtered log should not contain old message") + } + + if !strings.Contains(filtered, "recent message") { + t.Error("filtered log should contain recent message") + } + + if !strings.Contains(filtered, "unparseable line") { + t.Error("filtered log should keep unparseable lines (fail-open)") + } +} diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index a967af7..c7b09fc 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -1499,9 +1499,12 @@
-
-

Service Health Checks

- +
+

Service Health Checks

+
+ + +

Runs a set of checks against the local datastore and flags @@ -1509,6 +1512,7 @@ for issues the service knows how to remediate.

+
Loading…
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 35f74ec..bcb2b32 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -4013,6 +4013,37 @@ async function fetchHealth() { } } +async function downloadDiagnostic() { + const statusEl = document.getElementById("health-diagnostic-status"); + if (statusEl) statusEl.textContent = "Building diagnostic report…"; + + try { + const resp = await fetch("/setup/export/diagnostic"); + if (!resp.ok) { + const text = await resp.text().catch(() => resp.statusText); + throw new Error(`HTTP ${resp.status}: ${text}`); + } + + const disposition = resp.headers.get("Content-Disposition") || ""; + const match = disposition.match(/filename[^;=\n]*=(?:"([^"]+)"|([^;\n]+))/); + const filename = (match && (match[1] || match[2])) || "aftertouch-diagnostic.age"; + + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + if (statusEl) statusEl.textContent = `Downloaded: ${filename}`; + } catch (e) { + if (statusEl) statusEl.textContent = `Failed to download diagnostic: ${e.message || e}`; + } +} + function renderHealthChecks(data, findingsEl, generatedAtEl) { if (generatedAtEl && data.generatedAt) { generatedAtEl.textContent = `Last run: ${data.generatedAt}`; diff --git a/scripts/decrypt-diagnostic.go b/scripts/decrypt-diagnostic.go new file mode 100644 index 0000000..8ef978a --- /dev/null +++ b/scripts/decrypt-diagnostic.go @@ -0,0 +1,66 @@ +//go:build ignore + +// decrypt-diagnostic.go — maintainer-side helper to decrypt a diagnostic report. +// +// The decrypted content is a .tar.gz archive containing: +// - diagnostic.json structured health/device summary +// - datastore/... raw XML files from the sender's datastore +// +// Usage: +// +// # Decrypt to stdout and extract in one step: +// go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-.age | tar xz +// +// # Or decrypt to a .tar.gz file first: +// go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-.age > report.tar.gz +// tar xzf report.tar.gz +// +// The private key is read from keys/private/diagnostic (relative to the repo root). +package main + +import ( + "fmt" + "io" + "os" + + "filippo.io/age" + "filippo.io/age/agessh" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: go run scripts/decrypt-diagnostic.go ") + os.Exit(1) + } + + keyPath := "keys/private/diagnostic" + privKeyBytes, err := os.ReadFile(keyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "read private key %s: %v\n", keyPath, err) + os.Exit(1) + } + + id, err := agessh.ParseIdentity(privKeyBytes) + if err != nil { + fmt.Fprintf(os.Stderr, "parse identity: %v\n", err) + os.Exit(1) + } + + f, err := os.Open(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "open %s: %v\n", os.Args[1], err) + os.Exit(1) + } + defer f.Close() + + r, err := age.Decrypt(f, id) + if err != nil { + fmt.Fprintf(os.Stderr, "decrypt: %v\n", err) + os.Exit(1) + } + + if _, err := io.Copy(os.Stdout, r); err != nil { + fmt.Fprintf(os.Stderr, "write: %v\n", err) + os.Exit(1) + } +} diff --git a/scripts/setup-diagnostic-key.sh b/scripts/setup-diagnostic-key.sh new file mode 100755 index 0000000..70e61d0 --- /dev/null +++ b/scripts/setup-diagnostic-key.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# setup-diagnostic-key.sh — one-time key generation for the encrypted +# diagnostic export feature. Run this once as the project maintainer. +# +# Output: +# keys/private/diagnostic SSH ed25519 private key (gitignored) +# keys/private/diagnostic.pub Matching public key (gitignored — copy in keys/public/) +# keys/public/diagnostic.pub Public key in version control +# +# After running this script: +# 1. Add the public key to your GitHub account SSH keys so it appears +# at https://github.com/.keys — this lets users verify the key. +# 2. Update the DiagnosticPublicKey constant in +# pkg/service/export/encrypt.go to match keys/public/diagnostic.pub. +# 3. Commit keys/public/diagnostic.pub and the updated constant. +# 4. Keep keys/private/diagnostic somewhere safe (the .gitignore protects +# it from accidental commits, but it is NOT backed up by git). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +KEY_DIR="$REPO_ROOT/keys" +PRIVATE_DIR="$KEY_DIR/private" +PUBLIC_DIR="$KEY_DIR/public" + +mkdir -p "$PRIVATE_DIR" "$PUBLIC_DIR" + +KEY_FILE="$PRIVATE_DIR/diagnostic" + +if [[ -f "$KEY_FILE" ]]; then + echo "Key already exists at $KEY_FILE — delete it first to regenerate." + exit 1 +fi + +ssh-keygen -t ed25519 \ + -C "aftertouch-diagnostic@gesellix" \ + -N "" \ + -f "$KEY_FILE" + +cp "$KEY_FILE.pub" "$PUBLIC_DIR/diagnostic.pub" +cp "$KEY_FILE.pub" "$REPO_ROOT/pkg/service/export/diagnostic.pub" + +echo +echo "Keys generated:" +echo " Private : $KEY_FILE (gitignored — keep safe)" +echo " Public : $PUBLIC_DIR/diagnostic.pub (canonical — add to GitHub)" +echo " Embed : pkg/service/export/diagnostic.pub (compiled into binary)" +echo +echo "Public key:" +cat "$PUBLIC_DIR/diagnostic.pub" +echo +echo "Next steps:" +echo " 1. Add the public key to your GitHub account:" +echo " https://github.com/settings/ssh/new" +echo " 2. Commit keys/public/diagnostic.pub and pkg/service/export/diagnostic.pub."