fix(setup,admin-ui,install): three bugs from #621 follow-up feedback

- setup: resync all four boseurls (not just marge/swUpdate) over telnet
  after an SSH-XML migration. `envswitch boseurls set` persists whatever
  is currently in the runtime layer, so leaving stats/bmx untouched froze
  their stale pre-migration values into the persistence layer permanently
  -- surviving reboot and previously requiring a factory reset to clear.
- admin-ui: Migrate tab's Target Domain edits now propagate into the four
  service URL fields (tracked via a dataset.autofilled flag so real manual
  edits still aren't clobbered), closing the gap where changing Target
  Domain to a new value left the four fields pointed at a stale default.
- install.sh: prune stale binary backups before the download too, not
  only after a successful install, so a backup left by a previously
  aborted (out-of-space) run gets cleaned up instead of compounding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-08-17 19:57:02 +02:00
co-authored by Claude Sonnet 5
parent ba45d997cf
commit 9e56c4f3f4
8 changed files with 200 additions and 30 deletions
@@ -84,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
df -h /mnt/nv # confirm space recovered
```
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
> during every upgrade — manual cleanup should no longer be necessary on
> fresh installs.
+4 -4
View File
@@ -963,7 +963,7 @@
<input
type="text"
id="plan-marge-url"
oninput="validatePlanURLs()"
oninput="onPlanURLFieldEdited(this)"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
@@ -975,7 +975,7 @@
<input
type="text"
id="plan-stats-url"
oninput="validatePlanURLs()"
oninput="onPlanURLFieldEdited(this)"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
@@ -987,7 +987,7 @@
<input
type="text"
id="plan-sw_update-url"
oninput="validatePlanURLs()"
oninput="onPlanURLFieldEdited(this)"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
@@ -999,7 +999,7 @@
<input
type="text"
id="plan-bmx-url"
oninput="validatePlanURLs()"
oninput="onPlanURLFieldEdited(this)"
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
/>
</td>
+30 -3
View File
@@ -2776,6 +2776,26 @@ function onPlanTargetURLChange() {
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
saved.style.color = "#bf6900";
}
// Re-derive the four service URL fields from the new Target URL, same
// as the initial pre-fill on summary render. fillPlanURLInputs still
// only overwrites fields the user hasn't hand-edited (tracked via
// dataset.autofilled), so this doesn't clobber genuinely manual edits.
// Without this, changing Target Domain to e.g. localhost left the four
// fields pointed at a stale default with no warning until the user
// edited them by hand (#621 follow-up).
const soundcork = document.getElementById("plan-soundcork-mode") &&
document.getElementById("plan-soundcork-mode").checked;
fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
}
// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
// fillPlanURLInputs stops treating it as an auto-fillable default, then
// re-validates. Wired from each of the four fields' oninput instead of
// calling validatePlanURLs() directly.
function onPlanURLFieldEdited(el) {
el.dataset.autofilled = "";
validatePlanURLs();
}
// saveTargetURLAsDefault posts the current plan-target-url value to
@@ -2838,8 +2858,12 @@ function defaultServiceURLs(targetUrl, options = {}) {
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
// force=true overwrites existing values (used by Reset and the
// Soundcork toggle); force=false only fills empties (used on summary
// render so manual edits survive a refresh).
// Soundcork toggle); force=false only fills empties and fields still
// flagged dataset.autofilled=true (used on summary render and on Target
// URL changes, so manual edits survive but a still-default value tracks
// Target URL). Every field this function writes to is (re-)flagged
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
// types into a field directly.
function fillPlanURLInputs(urls, {force = false} = {}) {
const fields = [
["plan-marge-url", urls.marge],
@@ -2850,7 +2874,10 @@ function fillPlanURLInputs(urls, {force = false} = {}) {
for (const [id, value] of fields) {
const el = document.getElementById(id);
if (!el) continue;
if (force || !el.value) el.value = value;
if (force || !el.value || el.dataset.autofilled === "true") {
el.value = value;
el.dataset.autofilled = "true";
}
}
validatePlanURLs();
}
+41
View File
@@ -183,6 +183,47 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
return logs.String(), nil
}
// setAllBoseURLsViaTelnet writes all four boseurls (bmx, stats, marge,
// swUpdate) to the runtime layer via `sys configuration ...`, then commits
// them with `envswitch boseurls set`, over the port-17000 shell. Unlike
// setBoseURLsViaTelnet (which only issues the envswitch commit, used by the
// #471 SSH-bootstrap/reset flows that need that specific two-argument
// injection), this mirrors telnetURLs.Commands()'s full sequence so the
// envswitch commit captures fresh values for all four fields, not just two.
func (m *Manager) setAllBoseURLsViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
if banner, _ := t.Probe(); banner != "" {
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
for _, cmd := range urls.Commands() {
resp, err := t.SendCommand(cmd)
if err != nil {
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
}
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
}
}
return logs.String(), nil
}
// fwScript is the speaker's persistent iptables script; appending here makes a
// rule survive reboot (it is re-applied on boot).
const fwScript = "/etc/init.d/Firewalls/update_iptables"
+53
View File
@@ -157,6 +157,59 @@ func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) {
}
}
// TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch is the regression
// test for the stale statsServerUrl/bmxRegistryUrl bug reported in #621: the
// XML migration's telnet resync used to commit `envswitch boseurls set` with
// only marge/swUpdate as arguments, silently freezing whatever stats/bmx
// happened to still be in the runtime layer at that moment. This asserts all
// four `sys configuration` writes land before the single `envswitch` commit,
// matching telnetURLs.Commands()'s known-good sequence.
func TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch(t *testing.T) {
const targetURL = "http://localhost:8000"
urls := telnetURLs{
Marge: targetURL,
Stats: targetURL,
SwUpdate: targetURL + "/updates/soundtouch",
BmxRegistry: targetURL + "/bmx/registry/v1/services",
}
want := urls.Commands()
resp := make(map[string]string, len(want))
for _, c := range want {
resp[c] = "OK\n"
}
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
if _, err := m.setAllBoseURLsViaTelnet("192.0.2.10", urls); err != nil {
t.Fatalf("setAllBoseURLsViaTelnet: %v", err)
}
if len(f.commands) != len(want) {
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
}
for i, c := range want {
if f.commands[i] != c {
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
}
}
envswitchIdx := len(want) - 1
for i, c := range f.commands[:envswitchIdx] {
if !strings.HasPrefix(c, "sys configuration ") {
t.Errorf("command %d = %q, want a `sys configuration ...` runtime write before the envswitch commit", i, c)
}
}
if !strings.HasPrefix(f.commands[envswitchIdx], "envswitch boseurls set ") {
t.Errorf("last command = %q, want the envswitch commit last", f.commands[envswitchIdx])
}
}
func TestClose17000_RunsFirewallSteps(t *testing.T) {
var ran []string
+21 -7
View File
@@ -1089,32 +1089,46 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
}
}
logs += m.resyncBoseURLsAfterXML(deviceIP, cfg.MargeServerUrl, cfg.SwUpdateUrl)
logs += m.resyncBoseURLsAfterXML(deviceIP, telnetURLs{
Marge: cfg.MargeServerUrl,
Stats: cfg.StatsServerUrl,
SwUpdate: cfg.SwUpdateUrl,
BmxRegistry: cfg.BmxRegistryUrl,
})
return logs, nil
}
// resyncBoseURLsAfterXML re-applies the boseurls over telnet so the runtime
// URL layer matches the XML just written by migrateViaXML.
// resyncBoseURLsAfterXML re-applies all four boseurls over telnet so the
// runtime URL layer matches the XML just written by migrateViaXML.
//
// The XML migration only updates the persisted SoundTouchSdkPrivateCfg.xml; it
// does not touch the runtime/persistence layer that `getpdo
// CurrentSystemConfiguration` reports. When SSH was bootstrapped via #471
// (`enable-ssh`), that layer still points at the placeholder boseurls
// (https://aftertouch.invalid), so the preflight cross-check keeps warning that
// margeServerUrl/swUpdateUrl differ between transports until a reboot.
// Re-applying the real boseurls over telnet :17000 reconciles it immediately.
// the URLs differ between transports until a reboot.
//
// All four fields are re-applied, not just marge/swUpdate: the closing
// `envswitch boseurls set` commit persists whatever is currently in the
// runtime layer at the moment it runs, not only its own two arguments (see
// docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md). Committing while
// stats/bmx are still stale in the runtime layer freezes those stale values
// into the persistence layer permanently — a later reboot loads that frozen
// persistence layer, not the XML file, so nothing short of a factory reset
// clears it again. Re-applying the real boseurls over telnet :17000
// reconciles all four immediately.
//
// Best-effort: telnet may be unavailable (no port 17000, or it was closed via
// --close-17000), in which case a reboot still reconciles the layers, so this
// only returns a note and never fails the migration. Returns the log lines to
// append.
func (m *Manager) resyncBoseURLsAfterXML(deviceIP, marge, swUpdate string) string {
func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) string {
if m.NewTelnet == nil {
return ""
}
rlogs, rerr := m.setBoseURLsViaTelnet(deviceIP, marge, swUpdate)
rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls)
if rerr != nil {
return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
}
+27 -11
View File
@@ -2102,25 +2102,41 @@ func TestMigrateViaXML_ReappliesBoseURLsOverTelnet(t *testing.T) {
return &mockSSH{runFunc: func(string) (string, error) { return "", nil }}
}
ft := &fakeTelnet{banner: "->", responses: map[string]string{}}
wantCmds := telnetURLs{
Marge: target,
Stats: target,
SwUpdate: target + "/updates/soundtouch",
BmxRegistry: target + "/bmx/registry/v1/services",
}.Commands()
resp := make(map[string]string, len(wantCmds))
for _, c := range wantCmds {
resp[c] = "OK\n"
}
ft := &fakeTelnet{banner: "->", responses: resp}
m.NewTelnet = func(string) TelnetClient { return ft }
if _, err := m.MigrateSpeaker("192.0.2.10", target, "", nil, MigrationMethodXML); err != nil {
t.Fatalf("MigrateSpeaker: %v", err)
}
want := `envswitch boseurls set "` + target + `" "` + target + `/updates/soundtouch"`
var found bool
for _, c := range ft.commands {
if c == want {
found = true
break
// All four `sys configuration` writes must land before the envswitch
// commit — see enable_ssh.go's setAllBoseURLsViaTelnet — otherwise the
// commit freezes whatever stale value was still in the runtime layer for
// any field not passed to it (the #621 statsServerUrl/bmxRegistryUrl bug).
for _, want := range wantCmds {
var found bool
for _, c := range ft.commands {
if c == want {
found = true
break
}
}
}
if !found {
t.Errorf("expected boseurls re-apply %q after XML migration; sent: %v", want, ft.commands)
if !found {
t.Errorf("expected boseurls re-apply command %q after XML migration; sent: %v", want, ft.commands)
}
}
}
+23 -4
View File
@@ -87,6 +87,24 @@ if [ "$INSTALL_DIR" != "/opt/aftertouch" ]; then
ln -sf "$INSTALL_DIR" /opt/aftertouch
fi
# Prune any *.backup/*.old/*.new artefacts left behind by an earlier install
# attempt, before doing anything else that needs disk space. /mnt/nv is small
# (tens of MB), and if a previous run died between creating its backup and
# reaching the GC step below (e.g. "no space left on device" during the
# download that follows), that backup would otherwise never get cleaned up --
# and low free space is exactly what makes the next attempt likely to die the
# same way. Pruning up front makes cleanup idempotent regardless of where a
# prior run was interrupted.
echo "Disk usage before pre-install GC:"; df -h "$INSTALL_DIR"
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
"$INSTALL_DIR/aftertouch-service".*.old \
"$INSTALL_DIR/aftertouch-service.new"; do
[ -f "$f" ] || continue
rm -f "$f"
echo "Removed stale artefact: $f"
done
echo "Disk usage after pre-install GC:"; df -h "$INSTALL_DIR"
curl \
-sSL \
-o "$UPDATE_TMP_DIR/binary" \
@@ -112,10 +130,11 @@ mv "$UPDATE_TMP_DIR/binary" "$INSTALL_DIR/aftertouch-service"
chmod +x "$INSTALL_DIR/aftertouch-service"
# Keep only the backup we just created; prune all older *.backup, *.old, and
# *.new artefacts left by earlier installs. /mnt/nv is small (tens of MB),
# so accumulation quickly causes "no space left on device" during downloads.
# *.new artefacts left by earlier installs. This is a second, defensive pass:
# it only matters if something wrote a stray artefact between the pre-install
# GC above and here (e.g. a concurrent install run).
if [ -n "$BACKUP_FILE" ]; then
echo "Disk usage before GC:"; df -h "$INSTALL_DIR"
echo "Disk usage before post-install GC:"; df -h "$INSTALL_DIR"
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
"$INSTALL_DIR/aftertouch-service".*.old \
"$INSTALL_DIR/aftertouch-service.new"; do
@@ -124,7 +143,7 @@ if [ -n "$BACKUP_FILE" ]; then
rm -f "$f"
echo "Removed stale artefact: $f"
done
echo "Disk usage after GC:"; df -h "$INSTALL_DIR"
echo "Disk usage after post-install GC:"; df -h "$INSTALL_DIR"
fi
# Settings file sourced by the init script. Written before the service is