feat(on-device): reach AfterTouch from the LAN without an SSH tunnel

On-device installs were only reachable through an SSH tunnel, and the
docs blamed it on the service binding loopback-only. That was wrong.

Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth
co-processor, and inbound LAN traffic reaches the main Linux SoC only
for a fixed set of Bose's own service ports, a list that appears to be
compiled into the co-processor firmware. AfterTouch's :8000 was never
part of that design, so connections never arrive at the SoC at all.
Confirmed on an ST20: a port sweep from a LAN client showed Bose's
:82/:8080/:8090/:8091/:8200/:17000 all answering while :8000 failed,
and tcpdump on the speaker's own eth0 recorded zero packets for it.
Ruled out along the way: iptables (empty), nft/ebtables (absent), the
router, Wi-Fi isolation, and the binding itself (0.0.0.0 is correct).

The init script now redirects one of the relayed ports to AfterTouch,
so http://<speaker-ip>:17008 works with no tunnel. 17008 is Bose's
software-update listener, whose cloud no longer exists. Only external
traffic is matched, so anything on the speaker still reaches :8000 as
before. Auto-enabled only where has-bco reports the co-processor, and
configurable via AFTERTOUCH_LAN_PORT (auto/none/port) in
aftertouch.conf. The rule is re-applied on every start and removed on
stop and uninstall, so it needs no watchdog; unlike prior art it is not
pinned to the LAN IP, so it also survives DHCP changes.

Credit for the REDIRECT technique goes to the STR / SoundTouch Reborn
project, which documented and shipped it first.

Also de-hardcodes the service port, which was baked independently into
the daemon args, the readiness poll and status, and makes install.sh
print the speaker's real address instead of a <your-device-ip>
placeholder it never filled in.

Adds a model support matrix, since the repo had no per-model
compatibility record and this behaviour is entirely chassis-dependent.
Only the verified ST20 row is filled in; everything else is marked
unknown rather than inferred.

Verified on hardware: auto-detection, idempotency across restarts,
teardown and restore, persistence across a full reboot, and LAN access
returning the service's health JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-08-16 15:54:00 +02:00
co-authored by Claude Opus 5
parent 1396bb32dc
commit b8427b0bbe
8 changed files with 403 additions and 13 deletions
+47 -1
View File
@@ -56,7 +56,53 @@ After the installation check if you can access AfterTouch from your local device
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
Some firmware images only bind the AfterTouch HTTP port to loopback (see issue #196). The workaround is an SSH tunnel — your machine talks to its own local `:8000`, the SSH connection forwards to the speaker's `:8000` on loopback.
On some device models AfterTouch's port is reachable from other machines on
your LAN out of the box. On others (see issue #196) it isn't, and (unlike
the phrasing this README used to have) that's not AfterTouch or its
firewall configuration choosing to bind loopback-only. AfterTouch itself
binds `0.0.0.0` (all interfaces) correctly, confirmed by inspecting the
running device directly, and there's no firewall rule (`iptables`,
`nftables`, or otherwise) blocking it either.
**Current knowledge (2026-08-16), confirmed on real hardware via a
decrypted firmware backup plus simultaneous packet captures on both the
speaker and a client machine:** some SoundTouch models built around a
"combo" WiFi/Bluetooth co-processor (used for AirPlay) route LAN traffic
through that co-processor before it reaches the main application
processor where AfterTouch actually runs. That co-processor only relays a
fixed set of the device's own original service ports (the same ones the
stock SoundTouch app and companion services always used), a list that,
as far as we can tell, is compiled into the co-processor's own firmware.
AfterTouch's ports were never part of that original design, so they never
got included. This isn't a bug in AfterTouch, a router/firewall setting,
or WiFi client isolation; all three were separately ruled out.
**The installer works around this automatically.** On an affected speaker
it redirects one of the ports the co-processor *does* relay to AfterTouch,
so the UI is reachable from the LAN without any tunnel:
```
http://<IP_ADDRESS_OF_SPEAKER>:17008
```
Port `17008` is Bose's software-update listener; that cloud service no
longer exists, so taking over its inbound traffic costs nothing. Only
traffic from other machines is affected; anything running on the speaker
still reaches AfterTouch on `:8000` as before. Change or disable this with
`AFTERTOUCH_LAN_PORT` (`auto` / `none` / a port number) in
`/opt/aftertouch/aftertouch.conf`, or pass it at install time:
```bash
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | AFTERTOUCH_LAN_PORT=none sh
```
Which models need this, and how to report one that isn't listed yet, is
tracked in
[MODEL-SUPPORT-MATRIX.md](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md).
The SSH tunnel below still works, and remains the better route for
**linking music-service accounts**: Spotify only accepts `https://` or
loopback OAuth redirect URIs, so `http://localhost:8000` through a tunnel
succeeds where a plain LAN address is rejected.
**Open a fresh terminal on your own machine** (Linux/macOS/Windows — NOT another shell inside the speaker's SSH session — see issue #250 for the trap that catches everyone here) and run:
+131 -6
View File
@@ -15,6 +15,7 @@ DESC="Bose AfterTouch service"
DAEMON="/opt/aftertouch/aftertouch-service"
PIDFILE="/var/run/$NAME.pid"
DATADIR="/opt/aftertouch/data"
CONFFILE="/opt/aftertouch/aftertouch.conf"
SCRIPTNAME="/etc/init.d/$NAME"
USER="root"
LOG_TAG="aftertouch"
@@ -24,6 +25,21 @@ LOG_TAG="aftertouch"
export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
# Optional settings written by install.sh (AFTERTOUCH_LAN_PORT, SERVICE_PORT).
# Sourced before the defaults below so it can override either.
# shellcheck source=/dev/null
[ -r "$CONFFILE" ] && . "$CONFFILE"
# Port the daemon binds locally. Kept in one variable because it appears in
# the daemon arguments, the readiness poll and `status` -- three places that
# used to hardcode 8000 independently, so changing one silently broke the
# other two.
SERVICE_PORT="${SERVICE_PORT:-8000}"
# LAN entry port: a port number, "auto" (default), or "none".
LAN_PORT_MODE="${AFTERTOUCH_LAN_PORT:-auto}"
# Sanity check executable
test -x "$DAEMON" || {
echo "ERROR: Cannot execute $DAEMON (check path and permissions)." >&2
@@ -31,6 +47,101 @@ test -x "$DAEMON" || {
}
# ---------------------------------------------------------------------------
# LAN entry-port redirect
#
# On chassis built around a BCO ("SMSC") Wi-Fi/Bluetooth co-processor,
# inbound LAN traffic only reaches this Linux SoC for a fixed set of Bose's
# own service ports, which appears to be compiled into the co-processor's
# firmware. AfterTouch's :8000 is not on that list, so a LAN client's SYN
# never arrives here at all -- confirmed on an ST20 (`spotty`), where
# `tcpdump -i eth0` on the speaker saw zero packets for :8000 while Bose's
# own :8090/:8091/:17000 answered normally from the same client. The usual
# suspects were all ruled out: the service does bind 0.0.0.0 correctly, the
# speaker's iptables is empty, and SSH over the same path works.
#
# Workaround: NAT one of the relayed Bose ports to ours. The default, 17008,
# is Bose's SoftwareUpdate listener -- its cloud is gone, so taking over its
# inbound traffic costs nothing real. Only external traffic is matched
# (`! -i lo`), so anything running on the speaker still reaches both the real
# service on loopback and AfterTouch on :8000 as before.
#
# Credit: the STR / SoundTouch Reborn project (github.com/JRpersonal/streborn)
# documented and shipped this REDIRECT technique first, using the same entry
# port for the same reason.
#
# Which models need this is tracked in
# docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md.
# ---------------------------------------------------------------------------
# Resolve LAN_PORT_MODE into $LAN_PORT. Returns non-zero when no redirect
# should be installed.
lan_redirect_port() {
case "$LAN_PORT_MODE" in
none|off|disabled|0)
return 1
;;
auto|"")
# Only auto-enable where direct LAN access is known not to work.
# has-bco is Bose's own helper: [ "$(cat /proc/module_type)" = scm ]
has-bco >/dev/null 2>&1 || return 1
LAN_PORT=17008
;;
*[!0-9]*)
echo "WARNING: ignoring AFTERTOUCH_LAN_PORT='$LAN_PORT_MODE'; expected a port number, 'auto' or 'none'." >&2
return 1
;;
*)
LAN_PORT="$LAN_PORT_MODE"
;;
esac
return 0
}
# Remove every PREROUTING rule pointing at our service port, whatever entry
# port it used, so changing AFTERTOUCH_LAN_PORT cannot orphan the old rule.
lan_redirect_purge() {
iptables -t nat -S PREROUTING 2>/dev/null \
| grep -- "--to-ports $SERVICE_PORT" \
| sed 's/^-A /-D /' \
| while read -r rule; do
# shellcheck disable=SC2086
iptables -t nat $rule 2>/dev/null || true
done
}
lan_redirect_apply() {
lan_redirect_port || return 0
if ! iptables -t nat -L PREROUTING -n >/dev/null 2>&1; then
echo "WARNING: this kernel has no iptables nat table; :$LAN_PORT was not" >&2
echo " redirected. Reach AfterTouch over an SSH tunnel instead." >&2
return 0
fi
lan_redirect_purge
# Safety net for a kernel whose iptables lacks -S (purge would no-op):
# without this, every restart would stack another duplicate rule.
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
echo "LAN access already active on port $LAN_PORT."
return 0
fi
if iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport "$LAN_PORT" \
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
echo "LAN access: port $LAN_PORT now reaches AfterTouch on :$SERVICE_PORT."
else
echo "WARNING: could not install the :$LAN_PORT -> :$SERVICE_PORT redirect." >&2
fi
}
lan_redirect_remove() {
lan_redirect_purge
}
case "$1" in
start)
echo "Starting $DESC..."
@@ -102,19 +213,22 @@ case "$1" in
--background \
--chuid "$USER" \
--startas "/bin/sh" \
-- -c "logger -t $LOG_TAG <'$LOGFIFO' & \"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m >'$LOGFIFO' 2>&1 & echo \$! >'$PIDFILE'; wait"
-- -c "logger -t $LOG_TAG <'$LOGFIFO' & \"$DAEMON\" --data-dir '$DATADIR' --port '$SERVICE_PORT' --record-interactions=false --discovery-interval=60m >'$LOGFIFO' 2>&1 & echo \$! >'$PIDFILE'; wait"
tries=0
max_tries=60
while [ $tries -lt $max_tries ]; do
if curl -fsS http://localhost:8000 >/dev/null 2>&1; then
if curl -fsS "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
# Only once the service actually answers is it worth pointing LAN
# traffic at it.
lan_redirect_apply
exit 0
fi
sleep 2
tries=$((tries + 1))
done
echo "ERROR: daemon started but http://localhost:8000 never responded within $((max_tries * 2))s." >&2
echo "ERROR: daemon started but http://localhost:$SERVICE_PORT never responded within $((max_tries * 2))s." >&2
echo " Inspect the daemon's syslog output:" >&2
echo " logread | grep $LOG_TAG | tail -20" >&2
exit 1
@@ -122,6 +236,9 @@ case "$1" in
stop)
echo "Stopping $DESC..."
# Drop the LAN redirect first: leaving it in place while nothing listens
# would silently blackhole the entry port.
lan_redirect_remove
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
start-stop-daemon --stop \
@@ -162,11 +279,19 @@ case "$1" in
# (Gustour's ST30: status said running, curl said
# connection-refused). Distinguish the two states here so
# status isn't a false-positive.
if curl -fsS --max-time 3 http://localhost:8000 >/dev/null 2>&1; then
echo "$NAME is running (PID $PID, http://localhost:8000 responding)."
if curl -fsS --max-time 3 "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
echo "$NAME is running (PID $PID, http://localhost:$SERVICE_PORT responding)."
if lan_redirect_port; then
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
echo "LAN access: reachable from other machines on port $LAN_PORT."
else
echo "LAN access: redirect for port $LAN_PORT is NOT installed." >&2
fi
fi
exit 0
else
echo "$NAME PID $PID is alive but http://localhost:8000 is not responding." >&2
echo "$NAME PID $PID is alive but http://localhost:$SERVICE_PORT is not responding." >&2
echo "Recent log:" >&2
logread 2>/dev/null | grep "$LOG_TAG" | tail -10 >&2
exit 3
+51 -3
View File
@@ -127,6 +127,29 @@ if [ -n "$BACKUP_FILE" ]; then
echo "Disk usage after GC:"; df -h "$INSTALL_DIR"
fi
# Settings file sourced by the init script. Written before the service is
# (re)started so the very first start already sees it.
#
# An existing file is left alone on upgrade -- it may carry the operator's own
# choices -- unless AFTERTOUCH_LAN_PORT was passed to this script explicitly.
CONF_FILE="$INSTALL_DIR/aftertouch.conf"
if [ -n "${AFTERTOUCH_LAN_PORT:-}" ] || [ ! -f "$CONF_FILE" ]; then
cat > "$CONF_FILE" <<CONFEOF
# AfterTouch on-device settings. Sourced by /etc/init.d/aftertouch.
#
# AFTERTOUCH_LAN_PORT: how AfterTouch is reached from other machines.
# auto (default) redirect a spare Bose port to AfterTouch, but only on
# speakers whose Wi-Fi co-processor refuses to pass :8000 through.
# none never redirect; use an SSH tunnel instead.
# <port> always redirect this inbound port to AfterTouch.
# See docs: reference/MODEL-SUPPORT-MATRIX.md
AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto}
CONFEOF
echo "Wrote settings to $CONF_FILE (AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto})"
else
echo "Keeping existing settings in $CONF_FILE"
fi
echo "Creating init script..."
curl \
-sSL \
@@ -162,10 +185,35 @@ echo "Installation complete. (Re)starting the service..."
# daemon's stdout/stderr through `logger -t aftertouch`, so panics
# land in busybox syslog and `logread` reads them out.
if curl -fsS --max-time 10 http://localhost:8000 >/dev/null 2>&1; then
# We are running ON the speaker, so print the address people actually need
# rather than a <your-device-ip> placeholder they have to resolve themselves.
LAN_IP=$(ip -4 addr show scope global 2>/dev/null \
| awk '/inet /{sub(/\/.*/,"",$2); print $2; exit}')
[ -n "$LAN_IP" ] || LAN_IP="<your-device-ip>"
# If the init script installed a LAN entry-port redirect, that port -- not
# 8000 -- is the one reachable from other machines.
LAN_PORT=$(iptables -t nat -S PREROUTING 2>/dev/null \
| grep -- '-j REDIRECT' \
| sed -n 's/.*--dport \([0-9][0-9]*\).*--to-ports 8000.*/\1/p' \
| head -1)
echo ""
echo "Installation complete. AfterTouch $VERSION is now running on your device."
echo "Connect to http://<your-device-ip>:8000 from another machine on the LAN."
echo "If the device doesn't expose :8000 directly, port-forward via SSH:"
echo " ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
echo ""
if [ -n "$LAN_PORT" ]; then
echo " Open http://$LAN_IP:$LAN_PORT from any machine on your network."
echo ""
echo " (This speaker's Wi-Fi co-processor does not pass port 8000 through to"
echo " AfterTouch, so port $LAN_PORT is redirected to it instead. Set"
echo " AFTERTOUCH_LAN_PORT in $CONF_FILE to change or disable this.)"
else
echo " Open http://$LAN_IP:8000 from any machine on your network."
fi
echo ""
echo "If that doesn't load, reach it through an SSH tunnel instead:"
echo " ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@$LAN_IP"
echo "then open http://localhost:8000"
else
echo "WARNING: the init script reports AfterTouch as running, but" >&2
echo " http://localhost:8000 isn't responding. The daemon may have" >&2
+12
View File
@@ -5,6 +5,18 @@
set -eu
/etc/init.d/aftertouch stop || true
# `stop` normally removes the LAN entry-port redirect. Repeat it directly in
# case the init script was already gone or failed, so no rule is left behind
# pointing at a service that no longer exists.
iptables -t nat -S PREROUTING 2>/dev/null \
| grep -- '--to-ports 8000' \
| sed 's/^-A /-D /' \
| while read -r rule; do
# shellcheck disable=SC2086
iptables -t nat $rule 2>/dev/null || true
done
rm -f /etc/init.d/aftertouch
update-rc.d -f aftertouch remove