mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
Compare commits
@@ -0,0 +1,87 @@
|
||||
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, then open a draft PR for review.
|
||||
|
||||
## Step 1 — Determine lookback window
|
||||
|
||||
Run:
|
||||
```
|
||||
git log --format="%ad" --date=short -- docs/content/blog/ | grep -v '_index' | head -1
|
||||
```
|
||||
|
||||
If a date is returned, use it as SINCE.
|
||||
If the output is empty (no posts yet), compute SINCE = 30 days before today:
|
||||
- macOS: `date -v-30d +%Y-%m-%d`
|
||||
- Linux: `date -d '30 days ago' +%Y-%m-%d`
|
||||
|
||||
## Step 2 — Collect commits since SINCE
|
||||
|
||||
Run:
|
||||
```
|
||||
git log --format="%ad %h %s" --date=short --since="$SINCE" --no-merges
|
||||
```
|
||||
|
||||
Exclude these (they are noise):
|
||||
- Subjects matching: `^(ci|chore|deps|bump|Bump|test|lint|style|code style|debug)`
|
||||
- Dependabot bumps (subject contains "bump" and includes a package name pattern)
|
||||
- Routine doc link/URL fixes
|
||||
|
||||
Group the remaining commits into categories:
|
||||
- **NEW FEATURES** — subjects starting with `feat(` or `feat:`
|
||||
- **BUG FIXES** — subjects starting with `fix(` or `fix:`
|
||||
- **SECURITY** — subjects starting with `sec` or containing "security", "inject", "path expression"
|
||||
- **DOCS** — user-visible doc changes only (new guides, major restructures)
|
||||
- **MAINTENANCE** — everything else that passed the filter
|
||||
|
||||
Omit empty categories entirely.
|
||||
|
||||
## Step 3 — Current version
|
||||
|
||||
Run: `git tag --sort=-version:refname | head -1`
|
||||
|
||||
## Step 4 — Determine the period label
|
||||
|
||||
Use the first and last commit dates from Step 2 to produce a human-readable label,
|
||||
e.g. "May 2026" or "April – May 2026".
|
||||
|
||||
## Step 5 — Write the blog post
|
||||
|
||||
Create the file at: `docs/content/blog/YYYY-MM-slug.md`
|
||||
- YYYY-MM = today's year-month
|
||||
- slug = short kebab-case summary of the biggest theme
|
||||
|
||||
Use this exact frontmatter shape:
|
||||
```yaml
|
||||
---
|
||||
title: "AfterTouch PERIOD: <one-line theme>"
|
||||
date: YYYY-MM-DD
|
||||
description: "<one sentence, ≤200 chars, suitable as a standalone teaser>"
|
||||
tags:
|
||||
- <up to 4 tags from: security, tls, discovery, docs, cli, web, spotify, amazon, health, migration, fixes, ci>
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
```
|
||||
|
||||
Body structure:
|
||||
1. Opening paragraph (3–5 sentences) explaining what happened and why it matters to someone running AfterTouch.
|
||||
2. One `##` section per non-empty category. Use bullet points written for an operator audience — no raw git subjects, no internal Go package paths.
|
||||
3. End with: `**Current release:** vX.Y.Z`
|
||||
|
||||
Target length: 300–600 words. Never include real IPs, MAC addresses, account IDs, or device names.
|
||||
|
||||
## Step 6 — Create a branch and open a draft PR
|
||||
|
||||
```bash
|
||||
git checkout -b blog/YYYY-MM-update
|
||||
git add docs/content/blog/YYYY-MM-slug.md
|
||||
git commit -m "docs(blog): add PERIOD update post"
|
||||
git push -u origin blog/YYYY-MM-update
|
||||
gh pr create --draft \
|
||||
--title "Blog: PERIOD update post" \
|
||||
--body "Automated draft from /blog-update skill. Review content before merging — deployment is automatic on merge to main."
|
||||
```
|
||||
|
||||
If the `documentation` label exists on the repo, add `--label documentation`.
|
||||
|
||||
## Step 7 — Done
|
||||
|
||||
Report the PR URL. Do not merge, approve, or request review.
|
||||
+12
-57
@@ -1,74 +1,29 @@
|
||||
# CodeQL configuration for enhanced security analysis
|
||||
# See: https://docs.github.com/en/code-security/codeql-cli/using-the-codeql-cli/creating-codeql-query-suites
|
||||
# CodeQL configuration
|
||||
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
|
||||
|
||||
name: "Go Security Analysis"
|
||||
|
||||
disable-default-queries: false
|
||||
|
||||
queries:
|
||||
# Include default security queries
|
||||
- uses: security-extended
|
||||
- uses: security-and-quality
|
||||
|
||||
# Additional Go-specific security queries
|
||||
- name: go-security-extra
|
||||
uses:
|
||||
- go/bad-redirect-check
|
||||
- go/clear-text-logging
|
||||
- go/incorrect-integer-conversion
|
||||
- go/log-injection
|
||||
- go/missing-regexp-anchor
|
||||
- go/path-injection
|
||||
- go/request-forgery
|
||||
- go/sensitive-package-import
|
||||
- go/sql-injection
|
||||
- go/uncontrolled-allocation-size
|
||||
- go/unsafe-quoting
|
||||
- go/useless-regexp-character-escape
|
||||
- go/zip-slip
|
||||
|
||||
# Configure paths to exclude from analysis
|
||||
paths-ignore:
|
||||
- "**/*.pb.go" # Generated protobuf files
|
||||
- "**/*_gen.go" # Generated code
|
||||
- "**/vendor/**" # Vendor dependencies
|
||||
- "**/build/**" # Build artifacts
|
||||
- "**/scripts/**" # Build scripts
|
||||
- "**/*_test.go" # Test files (optional - remove if you want to analyze tests)
|
||||
|
||||
# Configure paths to include (if not specified, all Go files are included)
|
||||
# Paths to include
|
||||
paths:
|
||||
- "cmd/**/*.go"
|
||||
- "pkg/**/*.go"
|
||||
- "*.go"
|
||||
|
||||
# Query filters to reduce noise
|
||||
# Paths to exclude from analysis
|
||||
paths-ignore:
|
||||
- "**/*.pb.go" # Generated protobuf files
|
||||
- "**/*_gen.go" # Generated code
|
||||
- "**/vendor/**" # Vendor dependencies
|
||||
- "**/build/**" # Build artifacts
|
||||
- "**/scripts/**" # Build scripts
|
||||
- "**/*_test.go" # Test files
|
||||
|
||||
query-filters:
|
||||
- exclude:
|
||||
id: go/unused-variable
|
||||
reason: "Can be noisy in development"
|
||||
- exclude:
|
||||
id: go/hardcoded-credentials
|
||||
reason: "Will be handled by separate secret scanning"
|
||||
|
||||
# Configuration for specific query packs
|
||||
packs:
|
||||
# Use the official CodeQL Go queries
|
||||
- codeql/go-queries
|
||||
|
||||
# Additional community query packs for enhanced security
|
||||
- codeql/go-queries@~0.0.0 # Latest version
|
||||
|
||||
# Custom configuration for specific queries
|
||||
query-config:
|
||||
go/path-injection:
|
||||
# Configure severity levels
|
||||
severity: "error"
|
||||
go/sql-injection:
|
||||
severity: "error"
|
||||
go/request-forgery:
|
||||
severity: "warning"
|
||||
go/log-injection:
|
||||
severity: "warning"
|
||||
go/clear-text-logging:
|
||||
severity: "note"
|
||||
|
||||
@@ -38,6 +38,69 @@ updates:
|
||||
patterns:
|
||||
- "golang.org/*"
|
||||
|
||||
# Hugo module dependency updates (docs site)
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/docs"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
- "docs"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
# Example module dependency updates
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/examples/navigation-station-demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/examples/preset-management"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
# GitHub Actions workflow dependency updates
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"pattern": "^https://pkg.go.dev.*badge"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
|
||||
"pattern": "^/images/"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
|
||||
|
||||
+11
-11
@@ -77,7 +77,7 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
@@ -215,8 +215,8 @@ jobs:
|
||||
)
|
||||
|
||||
for img in "${IMAGES[@]}"; do
|
||||
if [ ! -f "docs/images/$img" ]; then
|
||||
echo "::warning file=docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/images/"
|
||||
if [ ! -f "docs/static/images/$img" ]; then
|
||||
echo "::warning file=docs/content/docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/static/images/"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
echo "Validating API documentation consistency..."
|
||||
|
||||
# Check API cookbook
|
||||
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
|
||||
if [ -f "docs/content/docs/reference/API-COOKBOOK.md" ]; then
|
||||
echo "✓ API Cookbook exists"
|
||||
else
|
||||
echo "✗ API Cookbook missing"
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check getting started guide
|
||||
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
|
||||
if [ -f "docs/content/docs/guides/GETTING-STARTED.md" ]; then
|
||||
echo "✓ Getting Started guide exists"
|
||||
else
|
||||
echo "✗ Getting Started guide missing"
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -332,7 +332,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '36 6 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: manual
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install libpcap (required for Go build)
|
||||
if: matrix.language == 'go'
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
|
||||
|
||||
- name: Build Go (required for manual build-mode)
|
||||
if: matrix.language == 'go'
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -22,12 +22,18 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
hugo-version: 'latest'
|
||||
extended: true
|
||||
- name: Build with Hugo
|
||||
run: hugo --source docs/ --minify --destination ../_site --baseURL "${{ steps.pages.outputs.base_url }}"
|
||||
env:
|
||||
HUGO_ENVIRONMENT: production
|
||||
HUGO_PARAMS_GITHASH: ${{ github.sha }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
|
||||
@@ -524,10 +524,10 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -544,7 +544,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
@@ -557,7 +557,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
|
||||
@@ -29,10 +29,8 @@ jobs:
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install security scanning tools
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
go install github.com/sonatypecommunity/nancy@latest
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Run govulncheck (Official Go vulnerability scanner)
|
||||
run: |
|
||||
@@ -40,21 +38,6 @@ jobs:
|
||||
govulncheck ./...
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Run Nancy vulnerability scanner
|
||||
run: |
|
||||
echo "::group::Running Nancy dependency scanner"
|
||||
go list -json -deps ./... | nancy sleuth
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Upload vulnerability scan results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vulnerability-scan-results
|
||||
path: |
|
||||
vulnerability-report.json
|
||||
nancy-report.json
|
||||
|
||||
static-analysis:
|
||||
name: Static Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
@@ -95,40 +78,11 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
|
||||
codeql-analysis:
|
||||
name: CodeQL Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: go
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -150,7 +104,7 @@ jobs:
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [vulnerability-scan, static-analysis, codeql-analysis]
|
||||
needs: [vulnerability-scan, static-analysis]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -173,17 +127,11 @@ jobs:
|
||||
echo "❌ **Static Analysis**: FAILED" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.codeql-analysis.result }}" == "success" ]]; then
|
||||
echo "✅ **CodeQL Analysis**: PASSED" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **CodeQL Analysis**: FAILED" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "For detailed results, check the individual job logs above." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Fail on security issues
|
||||
if: needs.vulnerability-scan.result == 'failure' || needs.static-analysis.result == 'failure' || needs.codeql-analysis.result == 'failure'
|
||||
if: needs.vulnerability-scan.result == 'failure' || needs.static-analysis.result == 'failure'
|
||||
run: |
|
||||
echo "Security scan detected issues. Please review the results above."
|
||||
exit 1
|
||||
|
||||
+15
-1
@@ -48,7 +48,8 @@ node_modules/
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/commands/
|
||||
.junie/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -115,7 +116,20 @@ stockholm_zip/*.zip
|
||||
NEXT.md
|
||||
DONE.md
|
||||
|
||||
# Code-scanning working notes — snapshot + remediation plan; not committed
|
||||
# until the sweep is complete and the notes are stable.
|
||||
CODE-SCANNING-NOTES.md
|
||||
|
||||
# Plan/tracking note for the Health-tab debug-utility programme.
|
||||
# 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/
|
||||
|
||||
# Hugo (docs site)
|
||||
# Hugo build artifacts (docs site)
|
||||
docs/.hugo_build.lock
|
||||
docs/public/
|
||||
docs/resources/
|
||||
|
||||
@@ -182,6 +182,12 @@ unless the user has already authorised that specific action in this
|
||||
session. Prefer reversible alternatives (`git stash` over
|
||||
`git reset --hard`).
|
||||
|
||||
**Force-flags also require explicit approval.** `git add -f` (force-add
|
||||
a gitignored file), `git push --force`, `git push --force-with-lease`,
|
||||
and any other flag that overrides a git safety mechanism must be
|
||||
proposed and confirmed before running, for the same reason: they
|
||||
bypass protections that exist intentionally.
|
||||
|
||||
## What never goes into this repo
|
||||
|
||||
This repository is public. The following must never be committed:
|
||||
|
||||
+3
-3
@@ -88,7 +88,7 @@ When filing a bug report, include:
|
||||
Feature requests are welcome! Please:
|
||||
|
||||
1. **Check if the feature already exists** in documentation
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/content/docs/reference/API-ENDPOINTS.md))
|
||||
3. **Explain the use case** and how it benefits users
|
||||
|
||||
### 🔧 Contributing Code
|
||||
@@ -489,8 +489,8 @@ Sponsorship is entirely optional. Code, docs, and bug reports remain the most us
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
|
||||
- [Project Architecture](docs/PROJECT-PATTERNS.md)
|
||||
- [Bose SoundTouch API Documentation](docs/content/docs/reference/API-ENDPOINTS.md)
|
||||
- [Project Architecture](docs/content/docs/appendix/PROJECT-PATTERNS.md)
|
||||
- [Development Status](docs/archive/STATUS.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps
|
||||
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
@@ -454,6 +454,22 @@ screenshots:
|
||||
@echo "Capturing documentation screenshots..."
|
||||
@bash scripts/screenshots/run.sh
|
||||
|
||||
# Documentation site (Hugo + Hextra via Docker)
|
||||
# First run: make dev-docs-tidy (downloads Hextra, writes docs/go.sum)
|
||||
# Then: make dev-docs (http://localhost:1313, live reload)
|
||||
dev-docs:
|
||||
HUGO_PARAMS_GITHASH=$(shell git rev-parse HEAD) docker compose -f docker-compose.docs.yml up
|
||||
|
||||
dev-docs-tidy:
|
||||
docker compose -f docker-compose.docs.yml run --rm hugo mod tidy --source docs/
|
||||
|
||||
# Run any hugo CLI command inside the docs container:
|
||||
# make hugo ARGS="version"
|
||||
# make hugo ARGS="new content/docs/guides/my-guide.md"
|
||||
ARGS ?=
|
||||
hugo:
|
||||
docker compose -f docker-compose.docs.yml run --rm hugo --source docs/ $(ARGS)
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@@ -478,6 +494,9 @@ help:
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-docs - Serve documentation site locally via Docker (http://localhost:1313)"
|
||||
@echo " dev-docs-tidy - Run hugo mod tidy (first run, or after hugo.toml module changes)"
|
||||
@echo " hugo ARGS=... - Run any hugo CLI command via Docker (e.g. make hugo ARGS=version)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
> by, or otherwise connected to Bose Corporation.** See
|
||||
> [Disclaimer](#disclaimer) for the full statement.
|
||||
|
||||
## Context: Cloud Shutdown
|
||||
## The Bose Cloud Has Shut Down
|
||||
|
||||
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that, music service browsing, preset sync, and the official SoundTouch app stop working. This toolkit lets you keep your speakers fully functional.
|
||||
Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music service browsing, and stereo pairing no longer work through Bose's infrastructure. AfterTouch restores all of these — no Bose infrastructure required.
|
||||
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html) for the full picture.
|
||||
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
|
||||
|
||||
[](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,15 +25,13 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
Not sure which approach fits your situation? See the [Deployment Overview](./docs/content/docs/guides/DEPLOYMENT-OVERVIEW.md) — it compares running AfterTouch on a Raspberry Pi or other always-on host against running it directly on the SoundTouch speaker, with links to step-by-step walkthroughs for each path.
|
||||
|
||||
**Two scenarios:**
|
||||
**Getting started:**
|
||||
|
||||
**Before shutdown — migrate your existing setup**
|
||||
While the Bose cloud is still running, use `soundtouch-backup` to save your account data. The local service web UI then helps with the migration so your speaker keeps its presets and credentials.
|
||||
**Already migrated before May 6** — your presets and credentials are preserved. AfterTouch picks up where the Bose cloud left off.
|
||||
|
||||
**After shutdown or factory reset — start fresh**
|
||||
Create a local account, configure your speakers, and start using them immediately. No Bose infrastructure required.
|
||||
**Starting fresh (or after a factory reset)** — create a local account, configure your speakers, and start using them immediately.
|
||||
|
||||
**Redirecting your speaker**
|
||||
|
||||
@@ -50,7 +50,7 @@ The web UI walks you through each method. DNS redirect requires HTTPS — the se
|
||||
|
||||
Some setup steps require SSH access to the speaker. Enable it once per device: create a file named `remote_services` on a FAT-formatted USB drive (the drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172)), and insert it while the speaker is powered on. After reboot, root SSH is available with no password.
|
||||
|
||||
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html) for step-by-step instructions.
|
||||
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/DEVICE-INITIAL-SETUP/) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/) for step-by-step instructions.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +66,7 @@ See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
|
||||
|
||||
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
|
||||
|
||||
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) for full usage.
|
||||
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,21 +86,21 @@ See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
|
||||
go get github.com/gesellix/bose-soundtouch
|
||||
```
|
||||
|
||||
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
|
||||
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html)
|
||||
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html)
|
||||
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html)
|
||||
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html)
|
||||
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html)
|
||||
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html)
|
||||
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html)
|
||||
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/docs/guides/GETTING-STARTED/)
|
||||
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/)
|
||||
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/)
|
||||
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/DEVICE-INITIAL-SETUP/)
|
||||
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-SAFETY/)
|
||||
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/)
|
||||
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SOUNDTOUCH-SERVICE/)
|
||||
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/HTTPS-SETUP/)
|
||||
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -43,10 +43,10 @@ func main() {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
log.Printf("fake speaker HTTP listening on http://%s", sanitizeLog(s.HTTPAddr()))
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", sanitizeLog(addr))
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
@@ -58,7 +58,7 @@ func main() {
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
log.Printf("registered %s with service at %s", sanitizeLog(target), sanitizeLog(*register))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func main() {
|
||||
defer close(entries)
|
||||
|
||||
if *verbose {
|
||||
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", *service, *timeout)
|
||||
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", sanitizeLog(*service), *timeout)
|
||||
}
|
||||
|
||||
// Query for services
|
||||
@@ -196,7 +196,7 @@ func parseServiceEntry(entry *mdns.ServiceEntry, verbose bool) *ServiceInfo {
|
||||
|
||||
if verbose {
|
||||
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
|
||||
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
|
||||
sanitizeLog(entry.Name), sanitizeLog(entry.Host), entry.Port, entry.AddrV4, entry.AddrV6)
|
||||
}
|
||||
|
||||
service := &ServiceInfo{
|
||||
|
||||
@@ -207,6 +207,6 @@ Running `cloud` and `local` separately produces two archives. To combine them, u
|
||||
|
||||
## See also
|
||||
|
||||
- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context
|
||||
- [Cloud Shutdown Survival Guide](../../docs/content/docs/guides/SURVIVAL-GUIDE.md) — full migration context
|
||||
- [`soundtouch-cli`](../soundtouch-cli/) — live device control
|
||||
- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// cloudCommand assembles the `soundtouch-cli cloud …` command group.
|
||||
// All subcommands talk to the AfterTouch service (not the speaker directly)
|
||||
// and require --service-url.
|
||||
func cloudCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cloud",
|
||||
Usage: "Manage AfterTouch service data (sources, accounts, devices)",
|
||||
Subcommands: []*cli.Command{
|
||||
cloudSourceCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloudSourceCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "source",
|
||||
Usage: "Manage sources stored in AfterTouch",
|
||||
Subcommands: []*cli.Command{
|
||||
cloudSourceRemoveCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloudSourceRemoveCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "remove",
|
||||
Usage: "Remove a source from AfterTouch's datastore for a specific device",
|
||||
Flags: append(CloudCommonFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "account",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Account ID",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "device",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Device ID",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "Source ID to remove (e.g. 10002)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Source type to remove (e.g. INTERNET_RADIO). Resolved to a canonical ID; fails if multiple sources share the type.",
|
||||
},
|
||||
),
|
||||
Action: cloudSourceRemove,
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalSourceID maps well-known SourceKeyType values to their canonical IDs.
|
||||
// Used to resolve --type to an ID without requiring a round-trip GET.
|
||||
// TODO We need to ensure that ids here are consistent with the ones used in the AfterTouch service.
|
||||
var canonicalSourceID = map[string]string{
|
||||
"AUX": "10001",
|
||||
"INTERNET_RADIO": "10002",
|
||||
"LOCAL_INTERNET_RADIO": "10003",
|
||||
"TUNEIN": "10004",
|
||||
"RADIO_BROWSER": "10005",
|
||||
}
|
||||
|
||||
func cloudSourceRemove(c *cli.Context) error {
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
account := c.String("account")
|
||||
device := c.String("device")
|
||||
sourceID := c.String("id")
|
||||
sourceType := strings.ToUpper(c.String("type"))
|
||||
|
||||
if sourceID == "" && sourceType == "" {
|
||||
return fmt.Errorf("one of --id or --type is required")
|
||||
}
|
||||
|
||||
if sourceID != "" && sourceType != "" {
|
||||
return fmt.Errorf("only one of --id or --type may be given")
|
||||
}
|
||||
|
||||
if sourceType != "" {
|
||||
id, ok := canonicalSourceID[sourceType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown source type %q; use --id for non-canonical sources", sourceType)
|
||||
}
|
||||
|
||||
sourceID = id
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/setup/sources/%s/%s/%s", serviceURL, account, device, sourceID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
PrintSuccess(fmt.Sprintf("Removed source %s from device %s (account %s)", sourceID, device, account))
|
||||
|
||||
if sourceType != "" {
|
||||
fmt.Printf(" Type: %s\n", sourceType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
|
||||
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
func discoverDevices(c *cli.Context) error {
|
||||
fmt.Printf("Discovering SoundTouch devices...\n")
|
||||
|
||||
// CLI discovery is interactive — flip on verbose protocol logging
|
||||
// so operators can see per-packet / per-header detail. The service
|
||||
// binary leaves this off so its log stays terse.
|
||||
discovery.SetVerbose(c.Bool("verbose"))
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
|
||||
@@ -568,7 +568,7 @@ type VerboseLogger struct{}
|
||||
|
||||
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
type SilentLogger struct{}
|
||||
|
||||
+220
-35
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
|
||||
setupWaitAPCmd(),
|
||||
setupWaitOnlineCmd(),
|
||||
setupSSHCheckCmd(),
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRebootCmd(),
|
||||
@@ -536,6 +537,51 @@ func setupSSHCheckCmd() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func setupRemoteServicesCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "remote-services",
|
||||
Usage: "Enable (default) or disable the remote_services SSH-enablement marker on the speaker",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "remove",
|
||||
Usage: "Remove all remote_services marker files (disables SSH after next reboot)",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
var (
|
||||
logs string
|
||||
err error
|
||||
)
|
||||
if c.Bool("remove") {
|
||||
logs, err = m.RemoveRemoteServices(cfg.Host)
|
||||
} else {
|
||||
logs, err = m.EnsureRemoteServices(cfg.Host)
|
||||
}
|
||||
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Bool("remove") {
|
||||
PrintSuccess("remote_services removed — SSH will no longer be enabled after next reboot")
|
||||
} else {
|
||||
PrintSuccess("remote_services enabled at a persistent location")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupInstallCACmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "install-ca",
|
||||
@@ -549,6 +595,11 @@ func setupInstallCACmd() *cli.Command {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
certPEM, err := fetchCACert(serviceURL, c.String("auth"))
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
@@ -698,11 +749,17 @@ func setupMigrateCmd() *cli.Command {
|
||||
method := setup.MigrationMethod(c.String("method"))
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
// For DNS-redirect methods, prove AfterTouch's DNS listener
|
||||
// is alive by sending it a real query — that's the truth,
|
||||
// regardless of what its settings claim.
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
// For DNS-redirect methods check that AfterTouch's DNS listener
|
||||
// is reachable — both from this machine and from the speaker.
|
||||
if !c.Bool("skip-preflight") && (method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts) {
|
||||
if err := requireAfterTouchDNSReachable(serviceURL); err != nil {
|
||||
if err := runDNSPreflight(cfg.Host, serviceURL, m.NewSSH); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
@@ -720,8 +777,6 @@ func setupMigrateCmd() *cli.Command {
|
||||
|
||||
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
@@ -762,31 +817,53 @@ func preInstallCAForCLI(deviceIP, serviceURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireAfterTouchDNSReachable sends a real DNS query to AfterTouch's
|
||||
// port-53 listener and confirms it responds. This is the ground-truth
|
||||
// preflight for DNS-redirect migration methods — config inspection (the
|
||||
// previous approach via GET /setup/settings) can lag the actual listener
|
||||
// state and can't tell us whether queries succeed end-to-end.
|
||||
//
|
||||
// We query a known-intercepted hostname (streaming.bose.com). Any IP in
|
||||
// the response proves AfterTouch's DNS is alive on :53; if the listener
|
||||
// is down the custom Dial just times out and the user gets a clear error.
|
||||
func requireAfterTouchDNSReachable(serviceURL string) error {
|
||||
// validateServiceURL returns an error if serviceURL cannot be parsed or has no
|
||||
// hostname. A common mistake is a single-slash scheme (https:/host instead of
|
||||
// https://host); the error message hints at the correction in that case.
|
||||
func validateServiceURL(serviceURL string) error {
|
||||
parsed, err := url.Parse(serviceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preflight: parse service URL %q: %w", serviceURL, err)
|
||||
return fmt.Errorf("invalid --service-url %q: %w", serviceURL, err)
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("preflight: service URL %q has no hostname", serviceURL)
|
||||
if parsed.Hostname() == "" {
|
||||
hint := ""
|
||||
if parsed.Scheme != "" && parsed.Opaque != "" {
|
||||
hint = fmt.Sprintf(" (did you mean %s://%s?)", parsed.Scheme, strings.TrimPrefix(parsed.Opaque, "/"))
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid --service-url %q: no hostname found%s", serviceURL, hint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dnsCheckResult holds the outcome of one DNS reachability probe.
|
||||
type dnsCheckResult struct {
|
||||
ok bool
|
||||
unknown bool // SSH unavailable or nslookup not present — result indeterminate
|
||||
detail string // "works" on success, error reason otherwise
|
||||
}
|
||||
|
||||
func (r dnsCheckResult) label() string {
|
||||
switch {
|
||||
case r.ok:
|
||||
return "✓ works"
|
||||
case r.unknown:
|
||||
return "? " + r.detail
|
||||
default:
|
||||
return "✗ " + r.detail
|
||||
}
|
||||
}
|
||||
|
||||
// cliDNSCheck sends a real DNS query for streaming.bose.com through the
|
||||
// AfterTouch DNS listener to verify it is alive from this machine.
|
||||
func cliDNSCheck(dnsHost string) dnsCheckResult {
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 3 * time.Second}
|
||||
return d.DialContext(ctx, "udp", net.JoinHostPort(host, "53"))
|
||||
return d.DialContext(ctx, "udp", net.JoinHostPort(dnsHost, "53"))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -795,14 +872,91 @@ func requireAfterTouchDNSReachable(serviceURL string) error {
|
||||
|
||||
ips, err := resolver.LookupHost(ctx, "streaming.bose.com")
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"preflight: DNS query to %s:53 failed: %w. AfterTouch's DNS listener is unreachable or not bound to port 53. Use --skip-preflight to bypass once you've verified DNS some other way",
|
||||
host, err,
|
||||
)
|
||||
return dnsCheckResult{detail: err.Error()}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("preflight: %s:53 returned no answers for streaming.bose.com — listener may be misconfigured", host)
|
||||
return dnsCheckResult{detail: "no answers for streaming.bose.com — listener may be misconfigured"}
|
||||
}
|
||||
|
||||
return dnsCheckResult{ok: true, detail: "works"}
|
||||
}
|
||||
|
||||
// speakerDNSCheck SSHes into the speaker and runs nslookup streaming.bose.com
|
||||
// against the AfterTouch DNS server to verify reachability from the device.
|
||||
func speakerDNSCheck(deviceIP, dnsHost string, newSSH func(string) setup.SSHClient) dnsCheckResult {
|
||||
addrs, err := net.LookupHost(dnsHost)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("cannot resolve %s locally to run speaker-side check", dnsHost)}
|
||||
}
|
||||
|
||||
dnsIP := addrs[0]
|
||||
|
||||
client := newSSH(deviceIP)
|
||||
|
||||
out, sshErr := client.Run(fmt.Sprintf("nslookup streaming.bose.com %s", dnsIP))
|
||||
if sshErr != nil {
|
||||
if strings.Contains(out, "not found") || strings.Contains(out, "No such file") {
|
||||
return dnsCheckResult{unknown: true, detail: "nslookup not available on speaker"}
|
||||
}
|
||||
|
||||
if strings.Contains(sshErr.Error(), "dial") || strings.Contains(sshErr.Error(), "connect") {
|
||||
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("SSH unavailable: %s", sshErr)}
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(out)
|
||||
if msg == "" {
|
||||
msg = sshErr.Error()
|
||||
}
|
||||
|
||||
return dnsCheckResult{detail: msg}
|
||||
}
|
||||
|
||||
return dnsCheckResult{ok: true, detail: "works"}
|
||||
}
|
||||
|
||||
// runDNSPreflight checks AfterTouch DNS reachability from both the CLI machine
|
||||
// and the speaker, prints a table, and returns an error only when the speaker
|
||||
// side definitively cannot reach the DNS listener (CLI-only failures are
|
||||
// informational — the speaker's perspective is authoritative).
|
||||
func runDNSPreflight(deviceIP, serviceURL string, newSSH func(string) setup.SSHClient) error {
|
||||
parsed, _ := url.Parse(serviceURL)
|
||||
dnsHost := parsed.Hostname()
|
||||
|
||||
type result struct {
|
||||
cli dnsCheckResult
|
||||
speaker dnsCheckResult
|
||||
}
|
||||
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
cliCh := make(chan dnsCheckResult, 1)
|
||||
speakerCh := make(chan dnsCheckResult, 1)
|
||||
|
||||
go func() { cliCh <- cliDNSCheck(dnsHost) }()
|
||||
go func() { speakerCh <- speakerDNSCheck(deviceIP, dnsHost, newSSH) }()
|
||||
|
||||
ch <- result{cli: <-cliCh, speaker: <-speakerCh}
|
||||
}()
|
||||
|
||||
r := <-ch
|
||||
|
||||
if r.cli.ok && r.speaker.ok {
|
||||
fmt.Printf("DNS preflight (%s:53) ✓ works\n", dnsHost)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("DNS preflight (%s:53)\n", dnsHost)
|
||||
fmt.Printf(" CLI host %s\n", r.cli.label())
|
||||
fmt.Printf(" Speaker %s\n", r.speaker.label())
|
||||
fmt.Println()
|
||||
|
||||
if !r.speaker.ok && !r.speaker.unknown {
|
||||
return fmt.Errorf("AfterTouch DNS unreachable from speaker — %s migration would fail", serviceURL)
|
||||
}
|
||||
|
||||
if r.speaker.unknown && !r.cli.ok {
|
||||
return fmt.Errorf("cannot confirm DNS reachability (SSH unavailable from speaker, CLI probe also failed) — use --skip-preflight to bypass")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -822,6 +976,11 @@ func setupVerifyCmd() *cli.Command {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
summary, err := m.GetMigrationSummary(cfg.Host, serviceURL, c.String("proxy-url"), nil)
|
||||
@@ -1013,6 +1172,11 @@ func setupPlanCmd() *cli.Command {
|
||||
wifiSSID := c.String("wifi-ssid")
|
||||
includePair := c.Bool("include-pair")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
fmt.Printf("Probing %s …\n\n", cfg.Host)
|
||||
@@ -1036,7 +1200,7 @@ func setupPlanCmd() *cli.Command {
|
||||
renderPreResetNote()
|
||||
}
|
||||
|
||||
renderPlanSteps(steps)
|
||||
renderPlanSteps(steps, includePair)
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -1093,6 +1257,10 @@ func renderPlanState(deviceIP string, inspect *setup.InspectReport, summary *set
|
||||
check(summary.IsPaired), check(summary.IsMigrated),
|
||||
yesNo(summary.TelnetMigrated), yesNo(summary.XMLMigrated),
|
||||
yesNo(summary.HostsMigrated), yesNo(summary.ResolvMigrated))
|
||||
|
||||
if summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
|
||||
fmt.Println(" [⚠] remote_services enabled but not persistent (will be lost on reboot)")
|
||||
}
|
||||
}
|
||||
|
||||
func firmwareOf(info *setup.DeviceInfoXML) string {
|
||||
@@ -1135,7 +1303,19 @@ func buildPlanSteps(
|
||||
host = "<NEW_IP>" // subsequent commands target the discovered IP
|
||||
}
|
||||
|
||||
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) {
|
||||
// Persist remote_services before anything else when it's only in /tmp.
|
||||
// SSH is reachable now, but the marker would be lost on the next reboot —
|
||||
// which could happen mid-migration if power is cut or the reboot step runs
|
||||
// before persistence is confirmed.
|
||||
if !reset && summary != nil && summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
|
||||
steps = append(steps, planStep{
|
||||
title: "Persist remote_services so SSH survives a reboot",
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup remote-services", host),
|
||||
reason: "Marker is currently in /tmp only — lost on next reboot, which would break SSH mid-migration.",
|
||||
})
|
||||
}
|
||||
|
||||
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) && len(steps) == 0 {
|
||||
return steps
|
||||
}
|
||||
|
||||
@@ -1146,7 +1326,7 @@ func buildPlanSteps(
|
||||
if includePair && (reset || (summary != nil && !summary.IsPaired)) {
|
||||
steps = append(steps, planStep{
|
||||
title: "Pair the device with an AfterTouch account",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup pair --host=%s --service-url=%s", host, serviceURL),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup pair --service-url=%s", host, serviceURL),
|
||||
reason: "Required for preset persistence, streaming services, multi-room zones.",
|
||||
})
|
||||
}
|
||||
@@ -1178,7 +1358,7 @@ func resetSteps(host, wifiSSID string, inspect *setup.InspectReport) []planStep
|
||||
return []planStep{
|
||||
{
|
||||
title: "Factory-reset the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup factory-reset", host),
|
||||
reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.",
|
||||
},
|
||||
{
|
||||
@@ -1236,20 +1416,20 @@ func migrationSteps(host, serviceURL string, summary *setup.MigrationSummary, re
|
||||
if dnsRedirect && summary != nil && !summary.CACertTrusted {
|
||||
steps = append(steps, planStep{
|
||||
title: "Install AfterTouch's CA cert on the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", host, serviceURL),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup install-ca --service-url=%s", host, serviceURL),
|
||||
reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.",
|
||||
})
|
||||
}
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: fmt.Sprintf("Apply URL migration using method=%s", method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", host, serviceURL, method),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup migrate --service-url=%s --method=%s", host, serviceURL, method),
|
||||
reason: methodReason,
|
||||
})
|
||||
|
||||
steps = append(steps, planStep{
|
||||
title: "Reboot the speaker",
|
||||
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
|
||||
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup reboot", host),
|
||||
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
|
||||
})
|
||||
|
||||
@@ -1314,9 +1494,14 @@ func renderPreResetNote() {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func renderPlanSteps(steps []planStep) {
|
||||
func renderPlanSteps(steps []planStep, includePair bool) {
|
||||
if len(steps) == 0 {
|
||||
PrintSuccess("Speaker is already migrated and paired. No action required.")
|
||||
if includePair {
|
||||
PrintSuccess("Speaker is already migrated and paired. No action required.")
|
||||
} else {
|
||||
PrintSuccess("Speaker is already migrated. No action required.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
@@ -685,3 +687,51 @@ func boolToStatus(b bool) string {
|
||||
|
||||
return "❌ No"
|
||||
}
|
||||
|
||||
// notifySourcesUpdated POSTs a sourcesUpdated notification directly to the
|
||||
// speaker's :8090/notification endpoint. The speaker re-fetches its source
|
||||
// list from AfterTouch immediately. Requires network access to the speaker.
|
||||
func notifySourcesUpdated(c *cli.Context) error {
|
||||
if err := RequireHost(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device info from %s: %w", clientConfig.Host, err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, info.DeviceID)
|
||||
notifyURL := fmt.Sprintf("http://%s:8090/notification", clientConfig.Host)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, notifyURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post to speaker: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
|
||||
return fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Sent sourcesUpdated to %s (%s)", info.DeviceID, clientConfig.Host))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,16 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// CloudCommonFlags defines flags for commands that talk to the AfterTouch service.
|
||||
var CloudCommonFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "AfterTouch service URL",
|
||||
Required: true,
|
||||
EnvVars: []string{"AFTERTOUCH_URL"},
|
||||
},
|
||||
}
|
||||
|
||||
// CommonFlags defines flags that are shared across multiple commands
|
||||
var CommonFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -322,7 +332,7 @@ func PrintSuccess(message string) {
|
||||
|
||||
// PrintError prints a standard error message
|
||||
func PrintError(message string) {
|
||||
fmt.Printf("✗ %s\n", message)
|
||||
fmt.Printf("✗ %s\n", sanitizeLog(message))
|
||||
}
|
||||
|
||||
// PrintWarning prints a standard warning message
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -132,6 +132,11 @@ func main() {
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Show detailed information for all devices",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"v"},
|
||||
Usage: "Print per-packet/per-header SSDP and mDNS trace logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1141,6 +1146,12 @@ func main() {
|
||||
Action: introspectAllServices,
|
||||
Before: RequireHost,
|
||||
},
|
||||
{
|
||||
Name: "notify-updated",
|
||||
Usage: "Tell the speaker to re-fetch its source list from AfterTouch",
|
||||
Action: notifySourcesUpdated,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Bass commands
|
||||
@@ -2233,6 +2244,10 @@ func main() {
|
||||
// Defined in cmd_setup.go to keep the top-level command list readable.
|
||||
app.Commands = append(app.Commands, setupCommand())
|
||||
|
||||
// AfterTouch service management (sources, accounts, devices).
|
||||
// Defined in cmd_cloud.go.
|
||||
app.Commands = append(app.Commands, cloudCommand())
|
||||
|
||||
// Sort commands alphabetically (including subcommands and flags recursively)
|
||||
sortCommands(app.Commands)
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
for i := range allDevices {
|
||||
dev := &allDevices[i]
|
||||
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
|
||||
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
|
||||
log.Printf("Initializing default Sources.xml for existing device %s", sanitizeLog(dev.DeviceID))
|
||||
|
||||
// Find default sources and merge them if missing or outdated tokens.
|
||||
// claimed tracks which stored sources have already been matched by a default,
|
||||
@@ -103,13 +103,13 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
claimed[foundIdx] = true
|
||||
|
||||
if sources[foundIdx].Secret == "" && def.Secret != "" {
|
||||
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
|
||||
log.Printf("Initializing missing token for source %s on device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(dev.DeviceID))
|
||||
sources[foundIdx].Secret = def.Secret
|
||||
sources[foundIdx].SecretType = def.SecretType
|
||||
modified = true
|
||||
}
|
||||
} else {
|
||||
log.Printf("Adding missing default source %s (providerID=%s) to device %s", def.SourceKeyType, def.SourceProviderID, dev.DeviceID)
|
||||
log.Printf("Adding missing default source %s (providerID=%s) to device %s", sanitizeLog(def.SourceKeyType), sanitizeLog(def.SourceProviderID), sanitizeLog(dev.DeviceID))
|
||||
sources = append(sources, def)
|
||||
modified = true
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func initializeDefaultSources(ds *datastore.DataStore) {
|
||||
|
||||
if modified {
|
||||
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
|
||||
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
|
||||
log.Printf("Failed to save updated sources for %s: %v", sanitizeLog(dev.DeviceID), errSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
|
||||
}
|
||||
|
||||
if config.amazonClientID != "" {
|
||||
@@ -172,7 +172,7 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Amazon Music service initialized (client ID: %s...)", clientIDPrefix)
|
||||
log.Printf("Amazon Music service initialized (client ID: %s...)", sanitizeLog(clientIDPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", raw, defaultCap)
|
||||
log.Printf("[Logs] Invalid SOUNDTOUCH_LOG_BUFFER_LINES=%q, using default %d", sanitizeLog(raw), defaultCap)
|
||||
return defaultCap
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func main() {
|
||||
persisted := applyPersistedSettings(ds, &config)
|
||||
|
||||
if persisted.ServerURL == "" {
|
||||
log.Printf("Creating default settings.json in %s", config.dataDir)
|
||||
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
@@ -468,7 +468,7 @@ func main() {
|
||||
server.SetShortcuts(persisted.Shortcuts)
|
||||
|
||||
for path, status := range persisted.Shortcuts {
|
||||
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
|
||||
log.Printf("Warning: configured shortcut: %s -> %d", sanitizeLog(path), status)
|
||||
}
|
||||
|
||||
recorder := proxy.NewRecorder(config.dataDir)
|
||||
@@ -477,11 +477,11 @@ func main() {
|
||||
|
||||
patterns, err := proxy.LoadPatterns(patternsPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
|
||||
log.Printf("Warning: Failed to load patterns from %s: %v", sanitizeLog(patternsPath), err)
|
||||
}
|
||||
|
||||
if len(patterns) == 0 {
|
||||
log.Printf("Creating default patterns at %s", patternsPath)
|
||||
log.Printf("Creating default patterns at %s", sanitizeLog(patternsPath))
|
||||
|
||||
patterns = proxy.DefaultPatterns()
|
||||
|
||||
@@ -512,17 +512,17 @@ func main() {
|
||||
} else {
|
||||
stockholmHandler = sh
|
||||
|
||||
log.Printf("Stockholm frontend enabled from %s", config.stockholmDir)
|
||||
log.Printf("Stockholm frontend enabled from %s", sanitizeLog(config.stockholmDir))
|
||||
}
|
||||
}
|
||||
|
||||
r := setupRouter(server, stockholmHandler)
|
||||
|
||||
log.Printf("Go service starting on %s", config.serverURL)
|
||||
log.Printf("Go service starting on %s", sanitizeLog(config.serverURL))
|
||||
|
||||
// TLS cert generation can be slow on constrained hardware; run it in the
|
||||
// background so the HTTP server is available immediately.
|
||||
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
|
||||
log.Printf("HTTPS setup running in background; %s will be available shortly", sanitizeLog(config.httpsServerURL))
|
||||
|
||||
go func() {
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
@@ -652,7 +652,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", discoveryIntervalStr, err)
|
||||
log.Printf("Warning: Failed to parse discovery interval %s, using default 5m: %v", sanitizeLog(discoveryIntervalStr), err)
|
||||
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
@@ -747,6 +747,22 @@ func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string)
|
||||
domainsMap[strings.ToLower(u.Hostname())] = true
|
||||
}
|
||||
|
||||
// The speaker firmware constructs the OAuth host by appending `oauth`
|
||||
// to the first label of the streaming hostname (see issue #337 and
|
||||
// pkg/discovery/dns.go DeriveOAuthHostnames). The DNS hijack catches
|
||||
// it; the TLS cert must also cover it, otherwise the speaker rejects
|
||||
// the handshake and Spotify / Amazon Music OAuth dies before reaching
|
||||
// AfterTouch. Derive once from each of serverURL and httpsServerURL —
|
||||
// they typically share a hostname but a multi-homed deployment may
|
||||
// differ.
|
||||
for _, h := range discovery.DeriveOAuthHostnames(serverURL) {
|
||||
domainsMap[h] = true
|
||||
}
|
||||
|
||||
for _, h := range discovery.DeriveOAuthHostnames(httpsServerURL) {
|
||||
domainsMap[h] = true
|
||||
}
|
||||
|
||||
// Explicit overrides / additions for multi-homed hosts, reverse proxies,
|
||||
// or browsing the admin UI via a LAN IP that isn't part of serverURL.
|
||||
for _, h := range extraHosts {
|
||||
@@ -810,9 +826,44 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
// CLI/env args take precedence; only apply persisted credentials when not set via CLI.
|
||||
applyPersistedMusicServiceCredentials(config, persisted)
|
||||
|
||||
config.tlsExtraHosts = mergeTLSExtraHosts(config.tlsExtraHosts, persisted.TLSExtraHosts)
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
// mergeTLSExtraHosts merges the CLI/env-supplied hosts with the persisted
|
||||
// list. CLI/env wins (so an operator who pinned a host via systemd unit
|
||||
// always sees it applied); persisted values are additive. Returns a
|
||||
// deduplicated, order-preserving slice with CLI/env entries first.
|
||||
func mergeTLSExtraHosts(cli, persisted []string) []string {
|
||||
seen := make(map[string]bool, len(cli)+len(persisted))
|
||||
out := make([]string, 0, len(cli)+len(persisted))
|
||||
|
||||
for _, h := range cli {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
for _, h := range persisted {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || seen[h] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[h] = true
|
||||
|
||||
out = append(out, h)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// applyPersistedMusicServiceCredentials fills in music service credentials from persisted
|
||||
// settings when they have not been supplied via CLI flags or environment variables.
|
||||
func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted datastore.Settings) {
|
||||
@@ -954,6 +1005,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/v1/navigate", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
|
||||
r.Get("/v1/search", server.HandleTuneInSearch)
|
||||
r.Get("/v1/search/next", server.HandleTuneInSearchNext)
|
||||
r.Post("/v1/favorite/{stationID}", server.HandleTuneInFavorite)
|
||||
r.Delete("/v1/favorite/{stationID}", server.HandleTuneInDeleteFavorite)
|
||||
})
|
||||
@@ -1192,12 +1244,14 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
r.Delete("/sources/{account}/{device}/{sourceID}", server.HandleDeleteSource)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/device-summary/{deviceId}", server.HandleDeviceSummary)
|
||||
|
||||
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.
|
||||
@@ -1240,7 +1294,7 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
return &tlsConfig.Certificates[0], nil
|
||||
}
|
||||
|
||||
log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName)
|
||||
log.Printf("[TLS] ❌ No certificate available for %s", sanitizeLog(clientHello.ServerName))
|
||||
|
||||
return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName)
|
||||
}
|
||||
@@ -1252,7 +1306,7 @@ func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, h
|
||||
ErrorLog: log.Default(), // Ensure error logging is enabled
|
||||
}
|
||||
|
||||
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
|
||||
log.Printf("Go service starting HTTPS on %s", sanitizeLog(httpsServerURL))
|
||||
|
||||
go func() {
|
||||
listener, err := net.Listen("tcp", httpsAddr)
|
||||
@@ -1303,8 +1357,13 @@ func runHTTPSPreflight(httpsServerURL, serverURL string, dnsEnabled bool, resolv
|
||||
|
||||
guidance := handlers.FormatPreflightGuidance(port, res)
|
||||
if guidance == "" {
|
||||
if !res.Skipped {
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", res.LANHost)
|
||||
switch {
|
||||
case res.Skipped:
|
||||
// Listener already on :443 — nothing to say.
|
||||
case res.NotApplicable:
|
||||
log.Printf("HTTPS pre-flight: :443 check skipped — %s", sanitizeLog(res.Reason))
|
||||
default:
|
||||
log.Printf("HTTPS pre-flight: :443 reachable at localhost and %s ✓", sanitizeLog(res.LANHost))
|
||||
}
|
||||
|
||||
return
|
||||
@@ -1379,7 +1438,7 @@ func (c *loggingTLSConn) Read(b []byte) (n int, err error) {
|
||||
if strings.Contains(err.Error(), "tls:") ||
|
||||
strings.Contains(err.Error(), "handshake") ||
|
||||
strings.Contains(err.Error(), "certificate") {
|
||||
log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err)
|
||||
log.Printf("[TLS] ❌ Handshake failed from %s: %v", sanitizeLog(c.addr.String()), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,3 +90,100 @@ func TestApplyPersistedSettings(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeTLSExtraHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cli []string
|
||||
persisted []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "CLI only",
|
||||
cli: []string{"a.example"},
|
||||
persisted: nil,
|
||||
want: []string{"a.example"},
|
||||
},
|
||||
{
|
||||
name: "Persisted only",
|
||||
cli: nil,
|
||||
persisted: []string{"b.example"},
|
||||
want: []string{"b.example"},
|
||||
},
|
||||
{
|
||||
name: "CLI wins ordering, persisted appended",
|
||||
cli: []string{"a.example"},
|
||||
persisted: []string{"b.example"},
|
||||
want: []string{"a.example", "b.example"},
|
||||
},
|
||||
{
|
||||
name: "Dedupes overlap",
|
||||
cli: []string{"a.example", "b.example"},
|
||||
persisted: []string{"b.example", "c.example"},
|
||||
want: []string{"a.example", "b.example", "c.example"},
|
||||
},
|
||||
{
|
||||
name: "Drops empty + whitespace",
|
||||
cli: []string{" ", "a.example", ""},
|
||||
persisted: []string{"", " b.example "},
|
||||
want: []string{"a.example", "b.example"},
|
||||
},
|
||||
{
|
||||
name: "Both empty",
|
||||
cli: nil,
|
||||
persisted: nil,
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_IncludesOAuthDerivation(t *testing.T) {
|
||||
// Hostname-based serverURL: the derived OAuth variant must end up
|
||||
// in the served TLS cert SAN list, otherwise the speaker rejects
|
||||
// the TLS handshake on Spotify / Amazon Music token refresh.
|
||||
got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil)
|
||||
|
||||
want := "macoauth.fritz.box"
|
||||
if !contains(got, want) {
|
||||
t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) {
|
||||
// IP-based serverURL deliberately yields no derivation (the speaker's
|
||||
// `<first-label>oauth.<rest>` construction would be malformed for an
|
||||
// IP and no DNS resolver can answer for it). The cert SAN list must
|
||||
// not pretend to cover something that can never be queried.
|
||||
got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil)
|
||||
|
||||
for _, h := range got {
|
||||
if h == "192oauth.168.0.30" {
|
||||
t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ DELETE /setup/devices/{deviceId} handlers.(
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
@@ -31,6 +32,7 @@ GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
@@ -60,6 +62,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,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func main() {
|
||||
}
|
||||
|
||||
if rawBind != "" && bindAddr != rawBind {
|
||||
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
|
||||
log.Printf("Resolved --bind %q to %s", sanitizeLog(rawBind), sanitizeLog(bindAddr))
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
@@ -94,7 +94,7 @@ func main() {
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
|
||||
log.Printf("Defaulting --interface to %q from --bind", sanitizeLog(ifaceName))
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
@@ -131,7 +131,7 @@ func main() {
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r, discoveryService)
|
||||
|
||||
log.Printf("AfterTouch Web UI starting on http://%s", addr)
|
||||
log.Printf("AfterTouch Web UI starting on http://%s", sanitizeLog(addr))
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -573,7 +573,7 @@ type VerboseLogger struct{}
|
||||
|
||||
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// SilentLogger provides no-op WebSocket logging
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Local Hugo/Hextra documentation server.
|
||||
#
|
||||
# Usage:
|
||||
# make dev-docs # start the live-reload server (http://localhost:1313)
|
||||
# make dev-docs-tidy # run hugo mod tidy (required on first run, or after
|
||||
# # changing hugo.toml module imports)
|
||||
# make hugo ARGS="..." # run any other hugo CLI command, e.g.
|
||||
# # make hugo ARGS="version"
|
||||
# # make hugo ARGS="new content/blog/my-post.md"
|
||||
#
|
||||
# The hugomods/hugo:exts image bundles Hugo extended + Go so Hugo modules
|
||||
# (Hextra) work without any extra tooling on the host.
|
||||
|
||||
services:
|
||||
hugo:
|
||||
image: hugomods/hugo:exts
|
||||
# --source docs/ because docs/ is the Hugo root inside the repo.
|
||||
# --baseURL / overrides the production subpath (/Bose-SoundTouch/) so
|
||||
# absolute links work at http://localhost:1313/ during local development.
|
||||
# The full repo is mounted so enableGitInfo can read git history.
|
||||
command: server --source docs/ --baseURL / --bind 0.0.0.0 --buildDrafts --navigateToChanged
|
||||
ports:
|
||||
- "1313:1313"
|
||||
volumes:
|
||||
- .:/src
|
||||
# Persist the Hugo module cache across runs so 'hugo mod tidy' only
|
||||
# downloads Hextra once.
|
||||
- hugo-mod-cache:/root/.cache/hugo_cache
|
||||
working_dir: /src
|
||||
environment:
|
||||
- HUGO_PARAMS_GITHASH
|
||||
|
||||
volumes:
|
||||
hugo-mod-cache:
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
# Table of Contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
|
||||
## User Guides
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
|
||||
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
|
||||
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
* [Backup Tool](../cmd/soundtouch-backup/README.md)
|
||||
* [Getting Started](guides/GETTING-STARTED.md)
|
||||
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
* [Capture Device Pairing Traffic](guides/CAPTURE-DEVICE-PAIRING.md)
|
||||
* [Capture Migration Traffic](guides/CAPTURE-MIGRATION-TRAFFIC.md)
|
||||
* [Device Setup Flow](DEVICE-SETUP.md)
|
||||
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
|
||||
* [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
* [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md)
|
||||
* [Migration Guide](guides/MIGRATION-GUIDE.md)
|
||||
* [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md)
|
||||
* [Useful Links](#useful-links)
|
||||
|
||||
### Useful Links
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
|
||||
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Spotify Account Addition](reference/spotify-account-addition.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
* [Device Pairing Flow](reference/DEVICE-PAIRING-FLOW.md)
|
||||
* [Discovery](reference/DISCOVERY.md)
|
||||
* [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
* [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
* [Source Selection](reference/SOURCE-SELECTION.md)
|
||||
* [Volume Controls](reference/VOLUME-CONTROLS.md)
|
||||
* [RadioBrowser](reference/radio-browser.md)
|
||||
* [Bass Controls](reference/BASS-CONTROLS.md)
|
||||
* [Key Controls](reference/KEY-CONTROLS.md)
|
||||
* [Feature Mapping](reference/FEATURE-MAPPING.md)
|
||||
|
||||
## Concepts
|
||||
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
* [Supported URLs](analysis/SUPPORTED-URLS.md)
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
|
||||
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
|
||||
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
|
||||
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
|
||||
* [Bose App ADB Emulator](analysis/BOSE-APP-ADB-Emulator.md)
|
||||
* [Community Tools](analysis/bose-soundtouch-community-tools.md)
|
||||
|
||||
## Parity Analysis
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
|
||||
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
|
||||
* [Device Logging](DEVICE-LOGGING.md)
|
||||
* [Feature History](FEATURE_HISTORY.md)
|
||||
* [Host/Port Parsing](HOST-PORT-PARSING.md)
|
||||
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
|
||||
* [Navigation Guide](NAVIGATION-GUIDE.md)
|
||||
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
|
||||
* [Preset Quickstart](PRESET-QUICKSTART.md)
|
||||
* [Project Patterns](PROJECT-PATTERNS.md)
|
||||
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
|
||||
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
|
||||
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
|
||||
* [Preset Store](preset-store.md)
|
||||
* [SCMUDC Enrichment Implementation](SCMUDC-ENRICHMENT-IMPLEMENTATION.md)
|
||||
* [Device Lifecycle and Power On Enhancement](device-lifecycle-and-power-on-enhancement.md)
|
||||
* [Device Lifecycle Summary](device-lifecycle-summary.md)
|
||||
* [Power On Implementation Guide](power-on-implementation-guide.md)
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
* [Stockholm Port Guide](stockholm-port-guide.md)
|
||||
@@ -1,11 +0,0 @@
|
||||
title: Bose SoundTouch Toolkit
|
||||
description: Documentation for controlling and preserving Bose SoundTouch devices
|
||||
remote_theme: pages-themes/minimal@v0.2.0
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
- jekyll-relative-links
|
||||
relative_links:
|
||||
enabled: true
|
||||
collections: true
|
||||
include:
|
||||
- SUMMARY.md
|
||||
@@ -784,4 +784,4 @@ docker compose up # Mock devices + web app
|
||||
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
|
||||
- [Go Embed Directive](https://pkg.go.dev/embed)
|
||||
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
|
||||
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
- [PROJECT-PATTERNS.md](../content/docs/appendix/PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: AfterTouch
|
||||
layout: hextra-home
|
||||
---
|
||||
|
||||
{{< hextra/hero-badge >}}
|
||||
<div class="hx-w-2 hx-h-2 hx-rounded-full hx-bg-primary-400"></div>
|
||||
<span>Free, open source</span>
|
||||
{{< icon name="arrow-circle-right" attributes="height=14" >}}
|
||||
{{< /hextra/hero-badge >}}
|
||||
|
||||
<div class="hx-mt-6 hx-mb-6">
|
||||
{{< hextra/hero-headline >}}
|
||||
Keep Your Bose SoundTouch Speakers Alive
|
||||
{{< /hextra/hero-headline >}}
|
||||
</div>
|
||||
|
||||
<div class="hx-mb-12">
|
||||
{{< hextra/hero-subtitle >}}
|
||||
Bose shut down SoundTouch cloud services on May 6, 2026. <br class="sm:hx-block hx-hidden" />AfterTouch replaces the cloud — presets, music browsing, stereo pairing, all restored.
|
||||
{{< /hextra/hero-subtitle >}}
|
||||
</div>
|
||||
|
||||
<div class="hx-mb-6">
|
||||
{{< hextra/hero-button text="Get Started" link="docs/guides/MIGRATION-GUIDE" >}}
|
||||
{{< hextra/hero-button text="Survival Guide" link="docs/guides/SURVIVAL-GUIDE" style="outline" >}}
|
||||
</div>
|
||||
|
||||
<div class="hx-mt-6">
|
||||
{{< hextra/feature-grid >}}
|
||||
{{< hextra/feature-card
|
||||
title="Presets Restored"
|
||||
subtitle="Preset buttons, long-press assignment, and recently-played sync — fully working."
|
||||
icon="star"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Music Browsing"
|
||||
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-web and soundtouch-cli."
|
||||
icon="speakerphone"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Stereo Pairing"
|
||||
subtitle="SoundTouch 10 stereo pairing via soundtouch-cli, no Bose cloud required."
|
||||
icon="adjustments"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Three Deployment Options"
|
||||
subtitle="Run on a Raspberry Pi, a VPS, or directly on the speaker itself."
|
||||
icon="server"
|
||||
link="docs/guides/DEPLOYMENT-OVERVIEW"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="CLI Control"
|
||||
subtitle="soundtouch-cli for scripting, home automation, and direct device control."
|
||||
icon="terminal"
|
||||
link="docs/guides/CLI-REFERENCE"
|
||||
>}}
|
||||
{{< hextra/feature-card
|
||||
title="Open Source"
|
||||
subtitle="MIT licensed. Not affiliated with Bose Corporation."
|
||||
icon="shield-check"
|
||||
link="https://github.com/gesellix/Bose-SoundTouch"
|
||||
>}}
|
||||
{{< /hextra/feature-grid >}}
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: "Welcome to AfterTouch: Your SoundTouch Speakers, Still Alive"
|
||||
date: 2026-05-24
|
||||
description: "Bose shut down SoundTouch cloud services in May 2026. AfterTouch replaces everything your speakers relied on — migration, radio, Spotify, presets, and more."
|
||||
tags:
|
||||
- migration
|
||||
- web
|
||||
- spotify
|
||||
- cli
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
On May 6, 2026, Bose shut down the SoundTouch cloud services that millions of speakers
|
||||
depended on for account sync, presets, internet radio, and streaming. Speakers kept
|
||||
working locally, but remote features stopped and first-time setup became impossible.
|
||||
|
||||
AfterTouch was built to change that. It is a self-hosted replacement for the Bose
|
||||
cloud infrastructure — a drop-in local service that your speakers talk to instead of
|
||||
`streaming.bose.com`. This post covers what works today and how to get started.
|
||||
|
||||
## What works right now
|
||||
|
||||
### Migration and first-time setup
|
||||
|
||||
If your speaker was registered with Bose before the shutdown, AfterTouch can **migrate
|
||||
your existing account and presets** in a single step — no reconfiguration on the
|
||||
speaker side. If you are setting up a factory-reset or brand-new speaker, AfterTouch
|
||||
handles that path too, guiding you through Wi-Fi pairing and account creation locally.
|
||||
See the [Migration Guide](../docs/guides/MIGRATION-GUIDE.md) for step-by-step instructions.
|
||||
|
||||
### Internet radio — TuneIn and RadioBrowser
|
||||
|
||||
Both **TuneIn** and **RadioBrowser** are fully supported for browsing and playback.
|
||||
Navigate categories and search for stations exactly as you did with the original Bose
|
||||
app. TuneIn delivers the same station catalogue; RadioBrowser provides an open,
|
||||
community-maintained alternative.
|
||||
|
||||
### Spotify
|
||||
|
||||
**Spotify** works via both OAuth (account linking) and Spotify Connect (the ZeroConf
|
||||
"connect to device" flow from the Spotify app). Once linked, playback and device
|
||||
selection behave the same as before.
|
||||
|
||||
### Presets
|
||||
|
||||
Your six preset buttons work. AfterTouch stores preset bindings locally and serves them
|
||||
back to the speaker on request. You can also **save new presets** — via the API,
|
||||
via `soundtouch-cli`, or through the soundtouch-web UI.
|
||||
|
||||
### ST-10 stereo pairing
|
||||
|
||||
**SoundTouch 10 stereo pairs** (and other ST pairing configurations) are supported
|
||||
end-to-end: creation, management, and playback routing all go through AfterTouch.
|
||||
|
||||
### soundtouch-web — browser UI
|
||||
|
||||
**soundtouch-web** is an early-stage but functional browser UI bundled with AfterTouch.
|
||||
It gives you:
|
||||
|
||||
- TuneIn and RadioBrowser browsing and playback
|
||||
- Speaker management and device discovery
|
||||
- Recent tracks panel
|
||||
- Multi-room zone management
|
||||
|
||||
It runs as part of the AfterTouch service — no separate install needed.
|
||||
|
||||

|
||||
|
||||
### Automation with soundtouch-cli
|
||||
|
||||
The **`soundtouch-cli`** command-line tool covers every speaker control: play, pause,
|
||||
volume, source selection, preset recall, group management, migration, and more.
|
||||
It is well-suited for home-automation scripts, cron jobs, and shell one-liners.
|
||||
|
||||
## Three ways to install
|
||||
|
||||
AfterTouch runs on any machine your speakers can reach:
|
||||
|
||||
1. **On the speaker itself** — install directly on supported SoundTouch hardware via
|
||||
the on-device installer. The speaker hosts its own replacement cloud, with no
|
||||
additional hardware required.
|
||||
|
||||
2. **On a local network host** — run AfterTouch on any machine on your LAN. A
|
||||
**Raspberry Pi Zero 2W** handles the load without breaking a sweat, making this
|
||||
path remarkably low-cost and low-power.
|
||||
|
||||
3. **On a cloud or VPS host** — deploy to a remote server for access outside your
|
||||
home network. AfterTouch handles TLS certificate generation and DNS configuration
|
||||
for this scenario.
|
||||
|
||||
All three paths are documented in the [Deployment Overview](../docs/guides/DEPLOYMENT-OVERVIEW.md).
|
||||
|
||||
## Current release
|
||||
|
||||
**v0.93.1** — released May 24, 2026
|
||||
|
||||
## Community
|
||||
|
||||
AfterTouch would not be where it is without the people who opened issues, tested
|
||||
pre-release builds, reported edge cases, and contributed code. A significant share of
|
||||
the fixes and features shipped in the lead-up to the cloud shutdown were driven by
|
||||
real-world feedback from the community — from migration quirks to stereo-pair
|
||||
specifics to Spotify Connect timing issues. Thank you to everyone who helped.
|
||||
|
||||
If you run into something or have an idea, the
|
||||
[GitHub issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
|
||||
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are the
|
||||
right places to start.
|
||||
|
||||
## What's next
|
||||
|
||||
The soundtouch-web UI will gain richer preset management — browsing, editing, and
|
||||
reordering presets directly from the browser. Longer term, merging
|
||||
`soundtouch-service` and `soundtouch-web` into a single binary is on the table,
|
||||
which would simplify deployment to a single process with no extra flags.
|
||||
|
||||
This blog will be updated monthly — or whenever something significant ships.
|
||||
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
|
||||
for individual version notes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: News & Updates
|
||||
---
|
||||
|
||||
Project updates, release notes, and development notes for AfterTouch — the local replacement for the Bose SoundTouch cloud.
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
title: Introduction
|
||||
sidebar:
|
||||
open: true
|
||||
---
|
||||
|
||||
# Bose SoundTouch Toolkit Documentation
|
||||
|
||||
Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026, with enhanced local management and monitoring capabilities.
|
||||
@@ -10,7 +16,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
|
||||
|
||||
### For Existing Users
|
||||
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
|
||||
- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
|
||||
- **[Backup Tool](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
|
||||
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
|
||||
|
||||
## 📋 Essential Documentation
|
||||
@@ -41,7 +47,7 @@ The documentation is organized into three main categories:
|
||||
### Advanced Features
|
||||
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - Device identification
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md) - Command-line tools
|
||||
- [Backup Tool](../cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
|
||||
- [Backup Tool](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
|
||||
- [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md) - IoT integrations
|
||||
- [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md) - MQTT setup
|
||||
|
||||
@@ -61,19 +67,18 @@ The documentation is organized into three main categories:
|
||||
- [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md) - Configuration summaries
|
||||
|
||||
### Device Lifecycle & Network Independence
|
||||
- **[Device Lifecycle and /power_on Enhancement](device-lifecycle-and-power-on-enhancement.md)** - Complete analysis of device registration and network independence improvements
|
||||
- [/power_on Implementation Guide](power-on-implementation-guide.md) - Technical implementation details for enhanced device management
|
||||
- **[Device Lifecycle and /power_on Enhancement](appendix/device-lifecycle-and-power-on-enhancement.md)** - Complete analysis of device registration and network independence improvements
|
||||
- [/power_on Implementation Guide](appendix/power-on-implementation-guide.md) - Technical implementation details for enhanced device management
|
||||
|
||||
## 🏗 Concept Documentation
|
||||
|
||||
### Enhanced Service Architecture
|
||||
- **[Concept Overview](concepts/README.md)** - High-level architecture vision
|
||||
- [Upstream Service Simulation](concepts/upstream-service-simulation.md) - Complete concept design
|
||||
- [Implementation Plan](concepts/implementation-plan.md) - Development roadmap
|
||||
- [Technical Specification](concepts/technical-specification.md) - Detailed specifications
|
||||
- [Spotify Overview](concepts/spotify-overview.md) — mental model, Spotify Connect vs OAuth-intercept, DNS rewrite gotcha
|
||||
- [Spotify OAuth](concepts/spotify-oauth.md) — flows and management endpoints
|
||||
- [Amazon Music OAuth](concepts/amazon-music-oauth.md) — companion to Spotify OAuth; same protocol shape, different scopes
|
||||
- [Encrypted Export](concepts/ENCRYPTED-EXPORT.md) — `.age`-encrypted diagnostic bundles
|
||||
- [Request Recording](appendix/REQUEST_RECORDING_CONCEPT.md) — how the proxy captures live device traffic for parity testing
|
||||
|
||||
### Development Planning
|
||||
- [Implementation Roadmap](concepts/implementation-roadmap.md) - Project phases and milestones
|
||||
Older planning artefacts ("Enhanced State Management System", "Upstream Service Simulation") live under [docs/archive/](../../archive/) — kept for the record, no longer current.
|
||||
|
||||
## 💡 Quick Reference
|
||||
|
||||
@@ -89,4 +94,4 @@ The documentation is organized into three main categories:
|
||||
- **Documentation**: Check troubleshooting guides first
|
||||
- **Community**: Share experiences and help others
|
||||
|
||||
For a complete list of all documents, see the [Summary](SUMMARY.md).
|
||||
For a complete list of all documents, browse the sections in the sidebar.
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Placeholder values for examples
|
||||
|
||||
---
|
||||
title: "Placeholder values for examples"
|
||||
---
|
||||
This repo is public. Documentation, READMEs, example configs, and test
|
||||
fixtures must never carry real LAN IPs, real device MACs, real Bose
|
||||
account IDs, or personal device names from any maintainer or
|
||||
@@ -1,5 +1,6 @@
|
||||
# Bose SoundTouch API Coverage Analysis
|
||||
|
||||
---
|
||||
title: "Bose SoundTouch API Coverage Analysis"
|
||||
---
|
||||
**Last Updated:** February 2026
|
||||
**API Version:** Official Bose SoundTouch Web API v1.0
|
||||
**Implementation Status:** 100% Official Coverage + Extended Features
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Bose SoundTouch Traffic Interception Runbook
|
||||
|
||||
---
|
||||
title: "Bose SoundTouch Traffic Interception Runbook"
|
||||
---
|
||||
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
|
||||
|
||||
## Automated Setup
|
||||
@@ -1,5 +1,6 @@
|
||||
# Bose SoundTouch – Traffic Analysis Runbook
|
||||
|
||||
---
|
||||
title: "Bose SoundTouch – Traffic Analysis Runbook"
|
||||
---
|
||||
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app – specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
|
||||
|
||||
---
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Device Redirect Methods & Custom Service Setup
|
||||
|
||||
---
|
||||
title: "Device Redirect Methods & Custom Service Setup"
|
||||
---
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# What a SoundTouch speaker does during factory reset
|
||||
|
||||
---
|
||||
title: "What a SoundTouch speaker does during factory reset"
|
||||
---
|
||||
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
|
||||
|
||||
## Sequence
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# IoT Configuration Quick Reference
|
||||
|
||||
---
|
||||
title: "IoT Configuration Quick Reference"
|
||||
---
|
||||
## Key Files and Locations
|
||||
|
||||
| File/Location | Purpose | Notes |
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# IoT Configuration Analysis
|
||||
|
||||
---
|
||||
title: "IoT Configuration Analysis"
|
||||
---
|
||||
## Overview
|
||||
|
||||
This document provides a detailed analysis of the AWS IoT configuration system used by Bose SoundTouch devices, based on firmware backup analysis from ST10 and ST20 models.
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Spotify Account Addition Implementation Status
|
||||
|
||||
---
|
||||
title: "Spotify Account Addition Implementation Status"
|
||||
---
|
||||
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
|
||||
|
||||
## 1. OAuth Token Exchange (Bose Cloud)
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
|
||||
|
||||
---
|
||||
title: "Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?"
|
||||
---
|
||||
## Why we are doing this
|
||||
|
||||
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START` → `SETUP_ENTER` → `SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
|
||||
@@ -1,5 +1,6 @@
|
||||
# SoundTouch supportedURLs Endpoint Analysis
|
||||
|
||||
---
|
||||
title: "SoundTouch supportedURLs Endpoint Analysis"
|
||||
---
|
||||
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
|
||||
|
||||
## Discovery Summary
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
# Bose SoundTouch Telnet (Port 17000) Command Reference
|
||||
|
||||
---
|
||||
title: "Bose SoundTouch Telnet (Port 17000) Command Reference"
|
||||
---
|
||||
A consolidated reference for the diagnostic shell that listens on TCP port
|
||||
17000 across the SoundTouch line. Compiled from multiple community sources
|
||||
to give a single map of what's been observed in the wild — useful both for
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
# Telnet (Port 17000) Migration Method — Analysis
|
||||
|
||||
---
|
||||
title: "Telnet (Port 17000) Migration Method — Analysis"
|
||||
---
|
||||
This document captures the use cases, community findings, and feasibility analysis
|
||||
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
|
||||
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
|
||||
@@ -8,7 +9,7 @@ deprecated and is intentionally kept off the visible UI options.
|
||||
> **Sources** — community discussion synthesised from
|
||||
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
|
||||
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/opencloudtouch/opencloudtouch/issues/167),
|
||||
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
|
||||
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
|
||||
> the post-EOS walkthrough PDF in `docs/`,
|
||||
@@ -1,5 +1,6 @@
|
||||
# Upstream URLs & Domains Analysis
|
||||
|
||||
---
|
||||
title: "Upstream URLs & Domains Analysis"
|
||||
---
|
||||
This document provides a comprehensive overview of the upstream Bose cloud services and domains that SoundTouch devices communicate with. These details were gathered from firmware analysis of ST10/ST20 devices, binary string extraction, and community research from the **SoundCork** project (Issue #128).
|
||||
|
||||
## Core Service Domains
|
||||
@@ -1,5 +1,6 @@
|
||||
# SoundTouch API Comparison: Community Wiki vs Current Implementation
|
||||
|
||||
---
|
||||
title: "SoundTouch API Comparison: Community Wiki vs Current Implementation"
|
||||
---
|
||||
**Date:** January 2026
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Our Implementation:** Bose-SoundTouch Go Library v1.0
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Analysis & Research"
|
||||
weight: 4
|
||||
---
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
# Bose SoundTouch — Community Tools for Post-EOL Preservation
|
||||
|
||||
---
|
||||
title: "Bose SoundTouch — Community Tools for Post-EOL Preservation"
|
||||
---
|
||||
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
|
||||
|
||||
---
|
||||
@@ -233,7 +234,7 @@ A design document exists (`docs/guides/MQTT-INTEGRATION-DESIGN.md`) but no code
|
||||
|
||||
## soundcork ↔ AfterTouch
|
||||
|
||||
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../PARITY-SOUNDCORK.md).
|
||||
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../appendix/PARITY-SOUNDCORK.md).
|
||||
|
||||
### Architectural differences (not gaps)
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Navigation API Reference
|
||||
|
||||
---
|
||||
title: "Navigation API Reference"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
This document provides a complete API reference for the Bose SoundTouch navigation and station management functionality. For usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).
|
||||
@@ -1,13 +1,15 @@
|
||||
# CLAUDE.md - Development Guidelines for Bose SoundTouch Project
|
||||
|
||||
---
|
||||
title: "CLAUDE.md - Development Guidelines for Bose SoundTouch Project"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Documentation Overview
|
||||
|
||||
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
|
||||
|
||||
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
|
||||
- **[PLAN.md](../../../archive/PLAN.md)** - Project planning and roadmap
|
||||
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
|
||||
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
|
||||
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
|
||||
- **[API-ENDPOINTS.md](../reference/API-ENDPOINTS.md)** - API endpoints overview
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
+8
-5
@@ -1,5 +1,8 @@
|
||||
# Content Selection Implementation Summary
|
||||
|
||||
---
|
||||
title: "Content Selection Implementation Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
|
||||
|
||||
## ✅ Implementation Status: COMPLETE
|
||||
@@ -212,9 +215,9 @@ soundtouch-cli --host 192.0.2.100 source internet-radio \
|
||||
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
|
||||
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
|
||||
- [Content Selection Example](../examples/content-selection/README.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Content Selection Example (Direct)](../examples/content-selection/)
|
||||
- [Content Selection Example](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/content-selection/README.md)
|
||||
- [CLI Reference](../guides/CLI-REFERENCE.md)
|
||||
- [Content Selection Example (Direct)](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/content-selection)
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Device Customization Setup Guide
|
||||
|
||||
---
|
||||
title: "Device Customization Setup Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This guide documents the manual steps required to configure your Bose SoundTouch device for customization using the SoundCork approach.
|
||||
|
||||
Based on: https://github.com/deborahgu/soundcork
|
||||
@@ -1,5 +1,8 @@
|
||||
# Device Logging & Troubleshooting
|
||||
|
||||
---
|
||||
title: "Device Logging & Troubleshooting"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
|
||||
|
||||
## Log Types
|
||||
@@ -80,7 +83,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
|
||||
### "IsItBose" Validation Failures
|
||||
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
|
||||
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
|
||||
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
|
||||
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](../analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
|
||||
|
||||
### Disappearing Sources (TuneIn/Local Radio)
|
||||
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
|
||||
@@ -1,5 +1,8 @@
|
||||
# Bose SoundTouch Device Setup Flow
|
||||
|
||||
---
|
||||
title: "Bose SoundTouch Device Setup Flow"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
|
||||
|
||||
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: "Encrypted Diagnostic Export"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
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.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Technical Proposal: External Service Provider Abstraction
|
||||
|
||||
---
|
||||
title: "Technical Proposal: External Service Provider Abstraction"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
|
||||
|
||||
## 1. Problem Statement
|
||||
@@ -1,5 +1,8 @@
|
||||
# Feature Development History
|
||||
|
||||
---
|
||||
title: "Feature Development History"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document tracks the detailed evolution of features and capabilities in the Bose SoundTouch API client library.
|
||||
|
||||
## Development Timeline
|
||||
@@ -1,5 +1,8 @@
|
||||
# Host:Port Parsing Feature
|
||||
|
||||
---
|
||||
title: "Host:Port Parsing Feature"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document describes the automatic host:port parsing functionality added to the SoundTouch CLI, which allows users to specify both host and port in a single `-host` flag.
|
||||
|
||||
## Overview
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Manual Network Discovery on macOS
|
||||
|
||||
---
|
||||
title: "Manual Network Discovery on macOS"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
|
||||
|
||||
## Overview
|
||||
@@ -1,5 +1,8 @@
|
||||
# Navigation and Station Management Guide
|
||||
|
||||
---
|
||||
title: "Navigation and Station Management Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
The Bose SoundTouch Go client provides comprehensive navigation and station management functionality that allows you to:
|
||||
@@ -895,4 +898,4 @@ For additional help:
|
||||
|
||||
---
|
||||
|
||||
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
|
||||
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](../reference/PRESET-MANAGEMENT.md).*
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Official SoundTouch Web API Verification
|
||||
|
||||
---
|
||||
title: "Official SoundTouch Web API Verification"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
**Source**: Official Bose SoundTouch Web API v1.0 Documentation (January 7, 2026)
|
||||
**Verification Date**: January 9, 2026
|
||||
**Project Status**: Complete API coverage verification
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
title: "Parity Improvements"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
|
||||
### Overview of Recent Improvements and Next Steps
|
||||
|
||||
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
|
||||
@@ -1,5 +1,8 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
|
||||
|
||||
---
|
||||
title: "Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
@@ -1,5 +1,8 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
|
||||
|
||||
---
|
||||
title: "Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
@@ -54,4 +57,4 @@ Group support and ZeroConf Spotify priming are now feature-complete in AfterTouc
|
||||
- **BMX service extensibility**: the `bmx_services.json` registry makes it trivial to add or mock new streaming providers without code changes (step C above).
|
||||
- **Group pairing logic**: master/slave relationship management for SoundTouch 10 stereo pairs goes beyond the CRUD AfterTouch implements.
|
||||
|
||||
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](analysis/bose-soundtouch-community-tools.md).
|
||||
For the broader ecosystem context (feature matrix across all community projects, AfterTouch open tasks, and cross-project observations) see [docs/analysis/bose-soundtouch-community-tools.md](../analysis/bose-soundtouch-community-tools.md).
|
||||
@@ -1,5 +1,8 @@
|
||||
# Preset Management Quick Start Guide
|
||||
|
||||
---
|
||||
title: "Preset Management Quick Start Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
**Save your favorite music, radio stations, and playlists as 1-6 presets for instant access.**
|
||||
|
||||
## Overview
|
||||
@@ -332,11 +335,11 @@ soundtouch-cli --host 192.0.2.100 info
|
||||
|
||||
## Next Steps
|
||||
|
||||
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
|
||||
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
|
||||
- 💻 [Preset Management Example](../examples/preset-management/)
|
||||
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
|
||||
- 📖 [Complete CLI Reference](../guides/CLI-REFERENCE.md)
|
||||
- 🔧 [Full Implementation Guide](../reference/PRESET-MANAGEMENT.md)
|
||||
- 📡 [WebSocket Events Documentation](../reference/WEBSOCKET-EVENTS.md)
|
||||
- 💻 [Preset Management Example](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/preset-management)
|
||||
- 📚 [API Endpoints Overview](../reference/API-ENDPOINTS.md)
|
||||
|
||||
## Need Help?
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Project Structure Patterns: Bose SoundTouch API Client
|
||||
---
|
||||
title: "Project Structure Patterns: Bose SoundTouch API Client"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Summary for Reuse in API Client Projects
|
||||
|
||||
This document describes the most important patterns for the Bose SoundTouch API client, especially for XML-based API clients with Web UI, CLI tool, and WASM support.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Request Recording Concept
|
||||
|
||||
---
|
||||
title: "Request Recording Concept"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Problem Statement
|
||||
|
||||
The current request recording system has fundamental issues when dealing with request cloning, body consumption, and multiple response scenarios. Specifically:
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# SCMUDC Enrichment Implementation Summary
|
||||
|
||||
---
|
||||
title: "SCMUDC Enrichment Implementation Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of SCMUDC (Sound Control Management Usage Data Collection) event enrichment in the AfterTouch toolkit. The enhancement provides human-readable analysis of device telemetry data to improve usability and debugging capabilities.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Service Availability Implementation Summary
|
||||
|
||||
---
|
||||
title: "Service Availability Implementation Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of the `/serviceAvailability` endpoint support in the Bose SoundTouch Go client library. This feature enables applications to query which music services and input sources are available on a SoundTouch device, providing better user feedback about supported stations and sources.
|
||||
+12
-9
@@ -1,5 +1,8 @@
|
||||
# 🎉 Introducing SoundTouch Service: Local Cloud Service Emulation
|
||||
|
||||
---
|
||||
title: "🎉 Introducing SoundTouch Service: Local Cloud Service Emulation"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
**Date**: February 2026
|
||||
**Version**: v2.0.0+
|
||||
**Status**: Production Ready
|
||||
@@ -144,10 +147,10 @@ LOG_PROXY_BODY=true soundtouch-service
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
|
||||
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
|
||||
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
|
||||
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
|
||||
- **[Complete Service Guide](../guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
|
||||
- **[API Reference](../guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
|
||||
- **[Migration Guide](../guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
|
||||
- **[Troubleshooting](../guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -172,9 +175,9 @@ The collaborative spirit of reverse engineering and documentation in the SoundTo
|
||||
## 🔗 Links
|
||||
|
||||
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
|
||||
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
|
||||
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
|
||||
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
|
||||
- **[Service Documentation](../guides/SOUNDTOUCH-SERVICE.md)**
|
||||
- **[CLI Documentation](../guides/CLI-REFERENCE.md)**
|
||||
- **[Getting Started Guide](../guides/GETTING-STARTED.md)**
|
||||
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
# Undocumented Community Features & API Discoveries
|
||||
---
|
||||
title: "Undocumented Community Features & API Discoveries"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document captures advanced API endpoints and device behaviors discovered by the SoundTouch community through reverse engineering projects like **SoundCork** and **ÜberBöse API**. These features are not documented in the official Bose SoundTouch Web API v1.0 but are crucial for full device emulation and offline operation.
|
||||
## Cloud Emulation (Marge/BMX) Discoveries
|
||||
While the local `/8090` API is well-documented, the cloud-side service emulation reveals deeper device integration points.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Unimplemented SoundTouch API Endpoints
|
||||
|
||||
---
|
||||
title: "Unimplemented SoundTouch API Endpoints"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
**Last Updated:** January 2026
|
||||
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
**Current Implementation:** 35 endpoints (including preset & navigation management discovered via SoundTouch Plus Wiki)
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Appendix"
|
||||
weight: 99
|
||||
sidebar:
|
||||
open: false
|
||||
---
|
||||
|
||||
Internal design documents, implementation notes, and historical records.
|
||||
These are developer and maintainer references, not user guides.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Device Lifecycle and /power_on Enhancement
|
||||
|
||||
---
|
||||
title: "Device Lifecycle and /power_on Enhancement"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive analysis of the current SoundTouch device registration and lifecycle management implementation, and proposes enhancements using the `/power_on` endpoint to reduce dependency on local network connectivity.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Device Lifecycle Analysis - Executive Summary
|
||||
|
||||
---
|
||||
title: "Device Lifecycle Analysis - Executive Summary"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Current State Assessment
|
||||
|
||||
The SoundTouch service currently relies heavily on local network connectivity for device discovery and management:
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# /power_on Implementation Guide
|
||||
|
||||
---
|
||||
title: "/power_on Implementation Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
This guide provides detailed technical specifications for implementing `/power_on` endpoint enhancements to reduce network dependency and improve device lifecycle management in the SoundTouch service.
|
||||
@@ -1,5 +1,8 @@
|
||||
# SoundTouch `/storePreset` Implementation Guide
|
||||
|
||||
---
|
||||
title: "SoundTouch `/storePreset` Implementation Guide"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
This document analyzes the feasibility and implementation approach for adding `/storePreset` functionality to the Bose SoundTouch API client, based on [GitHub Issue #14](https://github.com/gesellix/Bose-SoundTouch/issues/14) and endpoints discovered through the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# SCMUDC Events Analysis
|
||||
|
||||
---
|
||||
title: "SCMUDC Events Analysis"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
## Overview
|
||||
|
||||
SCMUDC (Sound Control Management Usage Data Collection) events are telemetry data sent from SoundTouch devices to `events.api.bosecm.com` via `/v1/scmudc/{deviceId}` endpoints. These events track user interactions and device behaviors for analytics and monitoring.
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
# soundtouch-web: remaining features
|
||||
|
||||
---
|
||||
title: "soundtouch-web: remaining features"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
Four features complete the parity gap between soundtouch-web and the Stockholm
|
||||
app's local-control functionality. Everything else in Stockholm (OAuth flows,
|
||||
setup wizard, service account linking, onboarding, analytics) is cloud
|
||||
@@ -1,5 +1,8 @@
|
||||
# Stockholm Backend — Port Guide for Bose-SoundTouch (Go)
|
||||
|
||||
---
|
||||
title: "Stockholm Backend — Port Guide for Bose-SoundTouch (Go)"
|
||||
sidebar:
|
||||
exclude: true
|
||||
---
|
||||
This document describes everything needed to integrate the
|
||||
[krahl/soundcork-stockholm-app](https://github.com/krahl/soundcork-stockholm-app)
|
||||
functionality into the Go service. It is written as a reference; nothing here
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
title: "Device-Local Install: Four User Journeys"
|
||||
---
|
||||
> **Looking for how to actually install AfterTouch?**
|
||||
> See the [Deployment Overview](../guides/DEPLOYMENT-OVERVIEW.md) for user-friendly
|
||||
> step-by-step guides for both deployment scenarios (external host and on-device).
|
||||
>
|
||||
> This document is an **architectural analysis** — user journeys, install patterns,
|
||||
> technology tradeoffs, and future directions. It is aimed at contributors and
|
||||
> project planning, not at end users.
|
||||
|
||||
---
|
||||
|
||||
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,4 @@
|
||||
---
|
||||
title: "Architecture"
|
||||
weight: 5
|
||||
---
|
||||
@@ -0,0 +1,455 @@
|
||||
---
|
||||
title: "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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user