diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index 117e8f6..be36ac5 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -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", diff --git a/pkg/service/setup/enable_ssh.go b/pkg/service/setup/enable_ssh.go new file mode 100644 index 0000000..7347067 --- /dev/null +++ b/pkg/service/setup/enable_ssh.go @@ -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 "" ""` +// 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) + } +} diff --git a/pkg/service/setup/enable_ssh_test.go b/pkg/service/setup/enable_ssh_test.go new file mode 100644 index 0000000..24a279b --- /dev/null +++ b/pkg/service/setup/enable_ssh_test.go @@ -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") + } +}