diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index be36ac5..f6a6993 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -564,6 +564,14 @@ func setupEnableSSHCmd() *cli.Command { Name: "no-persist", Usage: "Skip persisting the remote_services marker (SSH would not survive a reboot)", }, + &cli.StringFlag{ + Name: "authorized-key", + Usage: "Opt-in hardening: install this SSH public key for root (key auth instead of the empty-password login). Pass the key text, e.g. --authorized-key \"$(cat id_ed25519.pub)\"", + }, + &cli.BoolFlag{ + Name: "close-17000", + Usage: "Opt-in hardening: block port 17000 from the LAN (firewall rule applied now + persisted); loopback access is kept", + }, }, Action: func(c *cli.Context) error { cfg := GetClientConfig(c) @@ -630,13 +638,46 @@ func setupEnableSSHCmd() *cli.Command { } } + if key := c.String("authorized-key"); key != "" { + fmt.Println("Installing authorized_keys for root (key auth)...") + + klogs, kerr := m.InstallAuthorizedKey(cfg.Host, key) + if klogs != "" { + fmt.Print(klogs) + } + + if kerr != nil { + PrintError(kerr.Error()) + return kerr + } + } + + closed17000 := c.Bool("close-17000") + if closed17000 { + fmt.Println("Closing port 17000 to the LAN (loopback kept)...") + + clogs, cerr := m.Close17000(cfg.Host) + if clogs != "" { + fmt.Print(clogs) + } + + if cerr != nil { + PrintError(cerr.Error()) + return cerr + } + } + 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).") + if closed17000 { + fmt.Println("Port 17000 is now blocked from the LAN (loopback kept).") + } else { + fmt.Println("Note: port 17000 is left open (opt-in --close-17000 to block it from the LAN).") + } return nil }, diff --git a/pkg/service/setup/enable_ssh.go b/pkg/service/setup/enable_ssh.go index 7347067..30bc89c 100644 --- a/pkg/service/setup/enable_ssh.go +++ b/pkg/service/setup/enable_ssh.go @@ -78,6 +78,93 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string 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" + +// block17000Marker guards the appended rule so Close17000 is idempotent. +const block17000Marker = "# Block 17000 (added by AfterTouch)" + +// Close17000 blocks the port-17000 diagnostic shell from the LAN over SSH: +// it persists an iptables rule in the firewall script (idempotent, keyed on +// block17000Marker) and applies the rule immediately, keeping loopback access. +// Opt-in — the caller decides whether to harden. Needs SSH already enabled. +func (m *Manager) Close17000(deviceIP string) (string, error) { + if m.NewSSH == nil { + return "", errors.New("ssh not configured: Manager.NewSSH is nil") + } + + persist := "grep -q '" + block17000Marker + "' " + fwScript + " 2>/dev/null || cat >> " + fwScript + " <<'AFTEREOF'\n\n" + + block17000Marker + "\n" + + "iptables -I INPUT -p tcp --dport 17000 -j DROP\n" + + "iptables -I INPUT -p tcp --dport 17000 -i lo -j ACCEPT\n" + + "AFTEREOF" + + steps := []struct{ desc, cmd string }{ + {"remount / read-write", "mount / -o rw,remount"}, + {"persist firewall rule", persist}, + {"apply firewall rule now", "iptables -I INPUT -p tcp --dport 17000 -j DROP; iptables -I INPUT -p tcp --dport 17000 -i lo -j ACCEPT"}, + } + + return m.runSSHSteps(deviceIP, steps) +} + +// InstallAuthorizedKey installs an SSH public key for root so access no longer +// relies on the empty-password login. Opt-in. Needs SSH already enabled. +func (m *Manager) InstallAuthorizedKey(deviceIP, publicKey string) (string, error) { + if m.NewSSH == nil { + return "", errors.New("ssh not configured: Manager.NewSSH is nil") + } + + key := strings.TrimSpace(publicKey) + if key == "" { + return "", errors.New("public key is empty") + } + + c := m.NewSSH(deviceIP) + + var logs strings.Builder + + if out, err := c.Run("mount / -o rw,remount && mkdir -p -m 700 /home/root/.ssh"); err != nil { + fmt.Fprintf(&logs, "→ prepare /home/root/.ssh\n%s\n", strings.TrimSpace(out)) + return logs.String(), fmt.Errorf("prepare /home/root/.ssh: %w", err) + } + + if err := c.UploadContent([]byte(key+"\n"), "/home/root/.ssh/authorized_keys"); err != nil { + return logs.String(), fmt.Errorf("upload authorized_keys: %w", err) + } + + if out, err := c.Run("chmod 600 /home/root/.ssh/authorized_keys"); err != nil { + fmt.Fprintf(&logs, "→ chmod authorized_keys\n%s\n", strings.TrimSpace(out)) + return logs.String(), fmt.Errorf("chmod authorized_keys: %w", err) + } + + logs.WriteString("Installed authorized_keys for root.\n") + + return logs.String(), nil +} + +// runSSHSteps runs an ordered list of shell commands over a single-shot SSH +// client, aborting on the first failure. Commands MUST be service-controlled +// literals, never built from untrusted HTTP input. +func (m *Manager) runSSHSteps(deviceIP string, steps []struct{ desc, cmd string }) (string, error) { + c := m.NewSSH(deviceIP) + + var logs strings.Builder + + for _, s := range steps { + out, err := c.Run(s.cmd) + + fmt.Fprintf(&logs, "→ %s\n%s\n", s.desc, strings.TrimSpace(out)) + + if err != nil { + return logs.String(), fmt.Errorf("%s: %w", s.desc, err) + } + } + + 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). diff --git a/pkg/service/setup/enable_ssh_test.go b/pkg/service/setup/enable_ssh_test.go index 24a279b..1a5aab6 100644 --- a/pkg/service/setup/enable_ssh_test.go +++ b/pkg/service/setup/enable_ssh_test.go @@ -1,6 +1,9 @@ package setup -import "testing" +import ( + "strings" + "testing" +) func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) { const svc = "https://192.0.2.10:8443" @@ -43,3 +46,61 @@ func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) { t.Fatal("expected an error when the service URL contains a double quote") } } + +func TestClose17000_RunsFirewallSteps(t *testing.T) { + var ran []string + + m := &Manager{NewSSH: func(string) SSHClient { + return &mockSSH{runFunc: func(cmd string) (string, error) { + ran = append(ran, cmd) + return "", nil + }} + }} + + if _, err := m.Close17000("192.0.2.10"); err != nil { + t.Fatalf("Close17000: %v", err) + } + + joined := strings.Join(ran, "\n") + for _, want := range []string{ + "mount / -o rw,remount", + block17000Marker, + "iptables -I INPUT -p tcp --dport 17000 -j DROP", + "--dport 17000 -i lo -j ACCEPT", + } { + if !strings.Contains(joined, want) { + t.Errorf("Close17000 commands missing %q\nran:\n%s", want, joined) + } + } +} + +func TestInstallAuthorizedKey_UploadsKey(t *testing.T) { + m := &Manager{NewSSH: func(string) SSHClient { + return &mockSSH{runFunc: func(string) (string, error) { return "", nil }} + }} + + if _, err := m.InstallAuthorizedKey("192.0.2.10", " ssh-ed25519 AAAATEST comment "); err != nil { + t.Fatalf("InstallAuthorizedKey: %v", err) + } + // A fresh mockSSH is created per NewSSH call, so re-run against a captured + // one to assert the upload. + var captured *mockSSH + + m.NewSSH = func(string) SSHClient { + captured = &mockSSH{runFunc: func(string) (string, error) { return "", nil }} + return captured + } + + if _, err := m.InstallAuthorizedKey("192.0.2.10", "ssh-ed25519 AAAATEST comment"); err != nil { + t.Fatalf("InstallAuthorizedKey: %v", err) + } + + got, ok := captured.uploaded["/home/root/.ssh/authorized_keys"] + if !ok { + t.Fatal("authorized_keys was not uploaded") + } + + if strings.TrimSpace(string(got)) != "ssh-ed25519 AAAATEST comment" { + t.Errorf("uploaded key = %q", string(got)) + } +}