feat(cli): setup enable-ssh — bootstrap SSH via the port-17000 envswitch trick (refs #471)

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>
This commit is contained in:
Tobias Gesellchen
2026-06-07 21:11:19 +02:00
co-authored by Claude Opus 4.8
parent 843ec732d5
commit b7009a50eb
3 changed files with 252 additions and 0 deletions
+106
View File
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
setupWaitAPCmd(),
setupWaitOnlineCmd(),
setupSSHCheckCmd(),
setupEnableSSHCmd(),
setupRemoteServicesCmd(),
setupInstallCACmd(),
setupMigrateCmd(),
@@ -537,6 +538,111 @@ func setupSSHCheckCmd() *cli.Command {
}
}
func setupEnableSSHCmd() *cli.Command {
return &cli.Command{
Name: "enable-ssh",
Usage: "Bootstrap SSH on a speaker with no prior access via the port-17000 envswitch trick (#471), " +
"then restore clean URLs and persist it",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service base URL to point the speaker at (e.g. https://192.0.2.10:8443). " +
"Optional: enabling SSH does not need a live server (the injection fires when the speaker " +
"parses its boseurls), so you can omit this now and set the real URLs later via migration",
},
&cli.DurationFlag{
Name: "wait",
Value: 90 * time.Second,
Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
},
&cli.BoolFlag{
Name: "no-reset-urls",
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
},
&cli.BoolFlag{
Name: "no-persist",
Usage: "Skip persisting the remote_services marker (SSH would not survive a reboot)",
},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
m := setup.NewManager("", nil, nil)
// The URL is only the vehicle for the command injection; the
// SSH-enable fires when the speaker parses its boseurls, whether
// or not anything answers there. When the user has no service URL
// yet, use a clearly-placeholder value and tell them to set the
// real URLs during migration.
serviceURL := c.String("service-url")
placeholder := serviceURL == ""
if placeholder {
serviceURL = "https://aftertouch.invalid"
}
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host)
logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL)
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
fmt.Printf("Waiting up to %s for sshd (:22) to come up...\n", c.Duration("wait"))
if err := setup.WaitForSSHPort(cfg.Host, c.Duration("wait")); err != nil {
PrintError(err.Error())
return err
}
PrintSuccess("SSH is up on " + cfg.Host)
if !c.Bool("no-reset-urls") {
fmt.Println("Restoring clean boseurls (so the marge URL is usable again)...")
rlogs, rerr := m.ResetBoseURLs(cfg.Host, serviceURL)
if rlogs != "" {
fmt.Print(rlogs)
}
if rerr != nil {
PrintError(rerr.Error())
return rerr
}
}
if !c.Bool("no-persist") {
fmt.Println("Persisting the remote_services marker (SSH survives reboot)...")
plogs, perr := m.EnsureRemoteServices(cfg.Host)
if plogs != "" {
fmt.Print(plogs)
}
if perr != nil {
PrintError(perr.Error())
return perr
}
}
PrintSuccess("Done — SSH enabled on " + cfg.Host + ". From here, the usual migration / CA-install / inspect commands work.")
if placeholder {
fmt.Println("No --service-url was given, so the speaker's boseurls now point at a placeholder; run your migration next to set the real service URLs.")
}
fmt.Println("Note: port 17000 is left open and root login is unchanged (securing/closing 17000 is opt-in, not done here).")
return nil
},
}
}
func setupRemoteServicesCmd() *cli.Command {
return &cli.Command{
Name: "remote-services",
+101
View File
@@ -0,0 +1,101 @@
package setup
import (
"errors"
"fmt"
"net"
"strings"
"time"
)
// remoteServicesInjection is appended to the marge URL in the envswitch
// command. When the speaker next reads its boseurls (within ~60s), the device
// runs these shell commands: it touches the remote_services marker and starts
// sshd. This is the #471 bootstrap — it enables SSH on firmware with no prior
// SSH access and without a USB recovery stick. The whole marge value is
// double-quoted in the telnet command because it now contains spaces and
// semicolons.
const remoteServicesInjection = ";touch /tmp/remote_services;/etc/init.d/sshd start"
// EnableSSHViaTelnet bootstraps SSH on a speaker over its port-17000 shell by
// setting boseurls to an injected value (see remoteServicesInjection). It needs
// no existing SSH and no USB recovery. The injected commands run on the
// speaker's next boseurls check (up to ~60s), so callers should WaitForSSHPort
// afterwards, then ResetBoseURLs (to restore a usable marge URL) and
// EnsureRemoteServices (to persist SSH across reboots).
//
// serviceURL is the AfterTouch service base the speaker should point at
// (e.g. https://192.0.2.10:8443). It must not contain a double quote.
func (m *Manager) EnableSSHViaTelnet(deviceIP, serviceURL string) (string, error) {
return m.setBoseURLsViaTelnet(deviceIP, serviceURL+remoteServicesInjection, serviceURL+"/update")
}
// ResetBoseURLs restores clean boseurls (no injected commands) after SSH has
// been enabled, so the speaker's marge URL is usable again.
func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
return m.setBoseURLsViaTelnet(deviceIP, serviceURL, serviceURL+"/update")
}
// setBoseURLsViaTelnet runs `envswitch boseurls set "<marge>" "<swUpdate>"`
// over the port-17000 shell. Both arguments are double-quoted so values
// containing spaces or semicolons (the SSH-enable injection) survive the
// device's command parser.
func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
}
if strings.Contains(marge, `"`) || strings.Contains(swUpdate, `"`) {
return "", errors.New("boseurls values must not contain a double quote")
}
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))
}
cmd := `envswitch boseurls set "` + marge + `" "` + swUpdate + `"`
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 envswitch)", cmd)
}
return logs.String(), nil
}
// WaitForSSHPort polls TCP :22 on the speaker until it accepts a connection or
// timeout elapses. Used after EnableSSHViaTelnet, since sshd starts only when
// the speaker next reads its boseurls (up to ~60s later).
func WaitForSSHPort(deviceIP string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := net.JoinHostPort(deviceIP, "22")
for {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err == nil {
_ = conn.Close()
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("ssh (:22) on %s not reachable within %s: %w", deviceIP, timeout, err)
}
time.Sleep(3 * time.Second)
}
}
+45
View File
@@ -0,0 +1,45 @@
package setup
import "testing"
func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) {
const svc = "https://192.0.2.10:8443"
want := `envswitch boseurls set "https://192.0.2.10:8443;touch /tmp/remote_services;/etc/init.d/sshd start" "https://192.0.2.10:8443/update"`
f := &fakeTelnet{responses: map[string]string{want: "OK\n"}}
m := newFakeTelnetManager(f)
if _, err := m.EnableSSHViaTelnet("192.0.2.10", svc); err != nil {
t.Fatalf("EnableSSHViaTelnet: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != want {
t.Errorf("sent %q\n want %q", f.commands, want)
}
}
func TestResetBoseURLs_BuildsCleanCommand(t *testing.T) {
const svc = "https://192.0.2.10:8443"
want := `envswitch boseurls set "https://192.0.2.10:8443" "https://192.0.2.10:8443/update"`
f := &fakeTelnet{responses: map[string]string{want: "OK\n"}}
m := newFakeTelnetManager(f)
if _, err := m.ResetBoseURLs("192.0.2.10", svc); err != nil {
t.Fatalf("ResetBoseURLs: %v", err)
}
if len(f.commands) != 1 || f.commands[0] != want {
t.Errorf("sent %q\n want %q", f.commands, want)
}
}
func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) {
m := newFakeTelnetManager(&fakeTelnet{})
if _, err := m.EnableSSHViaTelnet("192.0.2.10", `https://x"evil`); err == nil {
t.Fatal("expected an error when the service URL contains a double quote")
}
}