mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 5
parent
1396bb32dc
commit
b8427b0bbe
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user