feat(export): encrypted diagnostic report for issue reporting

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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-21 20:02:57 +02:00
co-authored by Claude Sonnet 4.6
parent 9312e27019
commit 3cfb3da498
19 changed files with 1842 additions and 3 deletions
+3
View File
@@ -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/
+1
View File
@@ -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.
+1
View File
@@ -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
+277
View File
@@ -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://<box>: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.
+138
View File
@@ -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 <https://github.com/settings/ssh/new> and paste the contents of
`keys/public/diagnostic.pub`. This makes the key visible at
<https://github.com/gesellix.keys> 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-<timestamp>.age | tar xz
# Or decrypt to a .tar.gz first, then inspect:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.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-<timestamp>.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.
+3
View File
@@ -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
+454
View File
@@ -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/<user>.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/<user>.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/<user>.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/<user>.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:<user>`) 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/<user>.gpg`
- OpenPGP keyserver: https://keys.openpgp.org
+3
View File
@@ -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
+8
View File
@@ -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=
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
+54
View File
@@ -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
}
+20
View File
@@ -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)
}
}
+654
View File
@@ -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 <message>".
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
}
@@ -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)")
}
}
+7 -3
View File
@@ -1499,9 +1499,12 @@
<!-- Tab 7: Health -->
<div id="tab-health" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>Service Health Checks</h2>
<button onclick="fetchHealth()">Refresh</button>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<h2 style="margin: 0;">Service Health Checks</h2>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
<button onclick="fetchHealth()">Refresh</button>
<button onclick="downloadDiagnostic()" title="Download an encrypted diagnostic report to share with the project maintainer">Download diagnostic report</button>
</div>
</div>
<p style="font-size: 0.9em; color: #555;">
Runs a set of checks against the local datastore and flags
@@ -1509,6 +1512,7 @@
for issues the service knows how to remediate.
</p>
<div id="health-generated-at" style="font-size: 0.8em; color: #888; margin-bottom: 10px;"></div>
<div id="health-diagnostic-status" style="font-size: 0.85em; margin-bottom: 8px;"></div>
<div id="health-findings">Loading…</div>
</div>
+31
View File
@@ -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}`;
+66
View File
@@ -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-<timestamp>.age | tar xz
//
// # Or decrypt to a .tar.gz file first:
// go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.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 <file.age>")
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)
}
}
+55
View File
@@ -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/<you>.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."