Community hardware testing (bitranox, JRpersonal) on 2026-08-09 retracted
the earlier "inter-command delay is necessary" theory and established that
envswitch boseurls set commits the whole runtime layer (not just its two
arguments), has no read form, and doesn't ack with "OK". Corrects
TELNET-MIGRATION-METHOD.md and TELNET-COMMAND-REFERENCE.md accordingly,
retracts the stale "confirmed necessary" command-delay claim in
enable_ssh.go/cmd_setup.go, and lowers DefaultTelnetCommandDelay 5s -> 3s
as a smaller hedge now that the delay itself is known not to be the
mechanism.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#515 comment 5230833551: on a genuinely unpaired (factory-reset) device,
margeServerUrl is reportedly never polled at all, so the boseurls
SSH-enable injection has no read cycle to fire on regardless of any
command delay. enable-ssh now checks /info first and, if
margeAccountUUID is empty, pairs the device via the existing
PairAccount helper (HTTP /setMargeAccount, telnet fallback) before
running the injection.
Adds setup.Manager.EnsureMargeAccountPaired plus --no-auto-pair (skip
entirely) and --account (use a specific 7-digit ID instead of a
generated one, e.g. to match one already in the datastore) flags on
enable-ssh. Pairing failure is a warning, not fatal, since the claim
is unconfirmed on this specific hardware and existing working flows
must not regress.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per #515 comment 5228449448: on a real Lifestyle console, the same 6
commands (5 sys configuration/envswitch + reboot) sent back-to-back left
sshd down after reboot, but succeeded sent one at a time with ~7s gaps —
same commands, same order, same device, minutes apart. Sending fast may
not let the device fully process one command before the next arrives.
Adds --command-delay (setup.DefaultTelnetCommandDelay, 3s), threaded
through EnableSSHViaTelnetFullConfig/runTelnetInjection (pause after each
of the 5 commands) and runEnableSSHInjection (one more pause before the
reboot). 0 restores the old back-to-back behavior. The reporter didn't
try to find the true minimum, just confirmed ~7s works and speculated
"a second or two may well be enough" — 3s is a middle ground, tunable via
the flag if a specific device needs more.
Also prints an approximate total for the injection phase up front (6
steps x delay, ~18s at the default) so the command doesn't look hung —
separate from the existing --wait message for sshd coming up after
reboot, which can take much longer.
Refs #515
setup ssh-check's failure message claimed "those commands were removed"
on FW 27.x and jumped straight to the USB-stick fallback — flatly
contradicted by setup enable-ssh, which exists specifically to bootstrap
SSH over telnet via the port-17000 envswitch trick (#471), and by
TELNET-COMMAND-REFERENCE.md's own notes that the injection is
field-confirmed on several FW 27.x models. Success is model/build-
dependent, not universally impossible — some devices (ST Portable, some
CineMate 520 units) need --full-config instead of the default injection.
Reordered the message to point at `setup enable-ssh` (with the
--full-config caveat) first, USB stick as the fallback if that doesn't
work on a given device — this was the exact point where a user hitting a
closed port 22 would previously be told to go find a USB stick without
ever learning the telnet route exists.
Refs #598
The default `setup enable-ssh` injects the remote_services/sshd payload
only via `envswitch boseurls set` and relies on the speaker re-reading its
boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I,
FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and
persists that injection (getpdo confirms) but sshd never comes up, so :22
stays "Connection refused".
@Henri-be got root on the ST Portable by typing a different sequence by hand
over telnet :17000: the injection rides `sys configuration margeServerUrl`
(the runtime layer) as well as `envswitch`, all four URL keys are written,
and the device is rebooted so it re-parses the config at boot.
Add an opt-in `--full-config` flag that replicates that exact sequence
(EnableSSHViaTelnetFullConfig + telnet reboot via the existing
RebootMethodTelnet). The default single-envswitch path is unchanged, so the
field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa`
variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING)
document both paths and which device models/firmware need `--full-config`.
The flag automation is candidate behaviour awaiting reporter confirmation:
the manual sequence is confirmed on the ST Portable, the flag is not yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups from the #471 field reports on the BETA `setup enable-ssh`:
1. enable-ssh: when sshd (:22) does not come up within the wait window, this is
no longer treated as a hard error. On some devices (e.g. the Wireless Link
Adapter) the envswitch injection is accepted but sshd only starts after the
speaker restarts. The command now prints a warning with power-cycle + retry
guidance (and the exact ssh command), deliberately leaves the injected
boseurls in place so a restart re-triggers the unlock, and exits cleanly
instead of failing.
2. XML migration: re-apply the boseurls over telnet at the end of migrateViaXML
so the runtime layer reported by `getpdo CurrentSystemConfiguration` matches
the persisted SoundTouchSdkPrivateCfg.xml. After enable-ssh bootstraps SSH,
that runtime layer still points at the placeholder (https://aftertouch.invalid),
so the preflight cross-check keeps warning that margeServerUrl/swUpdateUrl
differ between transports until a reboot. The re-apply reconciles it now.
Best-effort: if telnet is unavailable (e.g. port 17000 was closed via
--close-17000), a reboot still reconciles the layers, so it only logs a note
and never fails the migration.
Tests cover the re-apply command and its best-effort (non-fatal) behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):
- --close-17000: blocks port 17000 from the LAN. Manager.Close17000 remounts /
read-write, persists an idempotent iptables rule in
/etc/init.d/Firewalls/update_iptables (keyed on a marker), and applies it
immediately; loopback access is kept.
- --authorized-key <pubkey>: Manager.InstallAuthorizedKey writes the key to
/home/root/.ssh/authorized_keys so root SSH no longer relies on the
empty-password login.
Both run over the SSH the enable step just opened. Default output reminds the
user that 17000 is left open and how to close it. Unit tests cover the
firewall command sequence and the key upload path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `soundtouch-cli setup enable-ssh`, the first iteration of foob61451's #471:
turn on SSH on a speaker that has no prior SSH access and without a USB
recovery stick, then fall into the migration / CA-install flow we already have.
Mechanism (new Manager methods, reusing the existing telnet :17000 client):
- EnableSSHViaTelnet sends `envswitch boseurls set "<url>;touch
/tmp/remote_services;/etc/init.d/sshd start" "<url>/update"`. The injected
shell commands run when the speaker next parses its boseurls (~60s), starting
sshd. The URL is only the vehicle for the injection — it does NOT need a live
server, so this works before any AfterTouch service exists.
- WaitForSSHPort polls :22 until sshd is up.
- ResetBoseURLs restores a clean marge URL afterwards.
- Persistence reuses the existing EnsureRemoteServices (writes the marker over
the now-open SSH so it survives reboot).
CLI flow: inject → wait for :22 → reset clean URLs → persist. `--service-url`
is optional (placeholder used otherwise; set real URLs later via migration).
Securing/closing port 17000 is deliberately OPT-IN and not done here. Unit
tests pin the exact injected/reset command strings and the double-quote guard.
This lands in -cli first (cheapest to iterate); the future soundtouch-app can
reuse the same Manager methods.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Point the CLI's service calls at the new canonical paths: tts speak
(/api/setup/tts/speak) and the CA bundle fetch (/api/setup/ca.crt), plus the
user-facing message and doc comment. No behavior change; legacy paths still work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- setup remote-services subcommand: enables (default) or removes
(--remove) the remote_services SSH-enablement marker via SSH, targeting
persistent locations (/etc or /mnt/nv) before the volatile /tmp fallback
- setup plan now includes a "persist remote_services" step when the marker
is only in /tmp (would be lost on next reboot, breaking SSH mid-migration)
- setup plan state header shows a [⚠] line when remote_services is
enabled but not persistent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single-sided requireAfterTouchDNSReachable with runDNSPreflight
that probes both the CLI machine and the speaker (via SSH nslookup) in
parallel, then renders a two-row table when results differ.
The speaker's perspective is authoritative: a CLI-only failure no longer
blocks the migration (the speaker may reach the DNS listener via a network
path the CLI host cannot). Migration is only aborted when the speaker itself
definitively cannot reach AfterTouch's DNS listener.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- isXMLMigrated and isResolvConfMigrated now guard against empty hostname
(Go's strings.Contains(s, "") is always true, causing any speaker to
appear migrated when --service-url has a malformed single-slash scheme)
- renderPlanSteps message no longer claims "and paired" when --include-pair=false
- validateServiceURL rejects malformed service URLs early with a hint
(e.g. "did you mean https://soundtouch.fritz.box?")
- Generated plan-step commands move --host before the subcommand name
(urfave/cli/v2 requires global flags before the first subcommand token)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The migration-summary preflight always emitted a "resolved from service,
not from device" ❌ row whenever the target was a hostname — even when
SSH was available and could have answered authoritatively. Two
problems compounded: the summary builder passed `nil` for the SSH
client (skipping the device-side ping), and resolveIP's service-side
fallback returned a bare fmt.Errorf the caller couldn't distinguish
from a real failure.
Changes:
- ErrResolvedFromServiceOnly sentinel; service-side fallback wraps
it with fmt.Errorf("%w: ...") so callers can errors.Is()-check.
Apply-path callers that pass a real SSH client keep getting the
same error shape they always did.
- populatePlannedNetworkConfig now takes an SSHClient. GetMigrationSummary
opens one when probe.SSHOK is true and passes it through, so the
summary's resolve call uses the same device-side authority the
apply paths use. Skipping the dial when SSH is known dead keeps
a stale handshake-timeout from burning the preflight budget.
- MigrationSummary gains ResolveIPSource ("device" / "service") and
ResolveIPDurationMS so we can observe the SSH-ping cost in the
wild. The historical comment claimed 2-5 s on firmware-27 devices —
we now have data instead of a guess.
- CLI renderer prints the new source + timing line, and only renders
the ❌ ResolveIPError row for hard failures (both SSH ping AND
service DNS failed).
- Two regression tests cover the sentinel-tagging contract and the
device-success-returns-nil-error path.
Related to https://github.com/gesellix/Bose-SoundTouch/issues/282.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Stockholm app (stockholm/setup/js/workflow_add_devices.js:23,77)
and Zimbo88's OpenCloudTouch USB-less script
(https://github.com/scheilch/opencloudtouch/discussions/201) both send
<boseServer>, <updateServer>, and <accountEmail> alongside the
<accountId>/<userAuthToken> pair. AfterTouch's setMargeAccount
historically sent only the latter two.
Adds:
- MargePairingExtras struct on SessionConfig, opt-in via
BoseServer (UpdateServer + AccountEmail default-derived when
empty).
- DefaultMargeAuthToken constant ("Bearer AfterTouch") and
DefaultMargePairingEmail constant ("local@aftertouch.invalid",
RFC 2606 reserved .invalid TLD).
- buildPairDeviceWithAccountXML helper extracted so tests can
pin both the minimal-payload and extended-payload shapes
without driving a full WebSocket session.
- --token flag on `soundtouch-cli setup pair` so we can override
the placeholder for token-shape experiments.
- runPairBare threads --service-url through to PairingExtras so
`--mode=bare --service-url=...` ships the extended payload too;
runPairFull already used it via applyInitPlanDefaults.
The speaker accepts any non-empty Bearer string (verified during
#195 investigation: "Bearer AfterTouch" passes and the speaker
re-derives its post-pair state from the marge endpoints regardless
of token content). The Stockholm-app payload shape is purely
documentation alignment; it did NOT fix the post-pair AUX/preset
breakage that turned out to be the cloud /full source list (see the
preceding marge commit). Keeping the wiring so the switches are
ready when we want to experiment further.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The speaker confirms AddWirelessProfile then tears down its AP within
~30 s. The default 10 s --request-timeout races that ACK whenever the
speaker is busy reconciling state — and a hard-coded 10 s on the
internal http.Client capped the user-passed timeout silently, so a
longer --request-timeout had no effect.
The CLI default is now 30 s and the inner http.Client lets the
context govern alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
term.ReadPassword takes an int, but syscall.Stdin is syscall.Handle
(uintptr) on Windows. The explicit cast keeps the call building on
Windows while a //nolint:unconvert silences the false positive on Unix
where syscall.Stdin is already int.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the five remaining gocyclo > 20 warnings to zero by extracting
cohesive sub-functions; same observable behaviour, smaller surface to
read at each call site. Bonus: the new helpers are individually testable.
- pkg/models/clockdisplay.go: split ClockDisplay.UnmarshalXML attr
handling into applyClockDisplayOuterAttrs (legacy flat shape) and
applyClockConfigAttrs (current nested shape).
- pkg/service/setup/ssh_probe_apply.go: split applyProbeToSummary into
applyProbeCurrentConfig / applyProbeResolvConf /
applyProbeRemoteServices / applyProbeCACert — one helper per
MigrationSummary axis the probe populates.
- pkg/service/setup/init_plan.go: split ExecuteInitPlan into
applyInitPlanDefaults, runURLRewrite, resolveAccountID, and
verifyPairing. Cleans up several shadowed err variables in the
process.
- cmd/soundtouch-cli/cmd_setup.go: split renderInspectReport into
renderInspectIdentityAndPairing / renderInspectNetwork /
renderInspectSources / renderInspectPresets / renderInspectRuntimeURLs,
and buildPlanSteps into resetSteps + migrationSteps helpers.
golangci-lint run ./pkg/service/setup/... ./pkg/models/...
./cmd/soundtouch-cli/... now reports zero findings. Tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename SetupStateMachine → setup.StateMachine, SetupSessionConfig →
setup.SessionConfig, SetupSession → setup.Session, and
DialSetupSession → setup.DialSession. The Setup* prefix only stutters
in package context (`setup.SetupSession`); the renamed forms read
cleaner at every call site (revive: exported).
- Iterate r.Network.Interfaces.Interfaces by index in cmd_setup.go
rather than by value — each NetworkInterface is 168 bytes and the
per-iteration copy was unnecessary (gocritic: rangeValCopy).
Test fixtures (fakeSetupSession → fakeSession, TestSetupSession_* →
TestSession_*) renamed by the same substring replacement to keep
naming consistent inside the package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the lint hits that pointed at real bugs or dead code; leaves the
remaining style-only suggestions (rangeValCopy micro-copies, gocyclo
informational, intentional name choices like SetupStateMachine) alone.
- pkg/models/clockdisplay.go: restore <clockDisplay> XMLName tag on both
ClockDisplay and ClockDisplayRequest. The earlier `xml:"-"` clashed
with ClockDisplayUpdatedEvent.ClockDisplay's `xml:"clockDisplay"` tag
(SA5008). Custom MarshalXML/UnmarshalXML still own the wire format.
- pkg/service/setup/setup.go: drop the now-unused checkRemoteServices
helper (replaced by applyProbeToSummary) and rename the unused
deviceIP parameter of populatePlannedNetworkConfig to _.
- pkg/service/setup/setup_session.go: collapse sendStep's (string, error)
return to plain error — every caller already discarded the string.
- pkg/service/setup/init_plan.go: rename shadowed err variables to
rwErr / genErr / invalidErr / nilErr / stepErr.
- cmd/soundtouch-cli/cmd_setup.go: drop redundant int(syscall.Stdin)
conversion (already int) and rename a shadowed err to pairErr.
go build ./..., go vet ./..., and tests for the touched packages all
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `soundtouch-cli setup` subcommand group covering the full reset →
re-provision → pair lifecycle as a scriptable alternative to the web UI:
inspect, verify, plan, factory-reset, wait-ap, wifi-push, wait-online,
ssh-check, install-ca, migrate, reboot, pair (bare | full state machine)
Supporting library code lives in pkg/service/setup: factory_reset.go,
wifi_provision.go, inspect.go, init_plan.go, setup_session.go.
Confirmed against ST10 firmware 27.0.6 that bare setMargeAccount over
WebSocket — no SETUP_START/SETUP_ENTER/SETUP_LEAVE bracket — is
sufficient to pair a factory-reset speaker; the firmware materializes
SystemConfigurationDB.xml and Sources.xml itself and the pairing
survives reboot. Result and field-by-field SystemConfigurationDB
comparison documented in docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md.
Captures the device's pre-reset DELETE-to-marge plus its LAN peer
notification flow in docs/analysis/FACTORY-RESET-PROTOCOL.md.
Perf: batch GetMigrationSummary's SSH probes into one Run() call via
ssh_probe.go / ssh_probe_apply.go — was ~8 sequential dials at
500-1000 ms each on FW 27 crypto, now one round-trip. Same data shape,
same MigrationSummary fields populated.
Fixes /clockTime and /clockDisplay wire formats — firmware 27 rejects
the legacy flat XML ("Error parsing request"). ClockTimeRequest now
uses utcTime attribute; ClockDisplayRequest emits the nested
<clockConfig> envelope with timezoneInfo/timeFormat/brightnessLevel.
Removes cmd/example-init-speaker (superseded by setup pair).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>