mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
co-authored by
Claude Opus 4.8
parent
843ec732d5
commit
b7009a50eb
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user