mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
9312e27019
commit
3cfb3da498
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBX6szVJwwBCTTCdLiqkfJiwjEFOx/HwdQgsf/aPHsUN aftertouch-diagnostic@gesellix
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+55
@@ -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."
|
||||
Reference in New Issue
Block a user