From 6c8a50c049fd59ac8cede3ec0ac1debf6f9db050 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 7 Jun 2026 20:29:16 +0200 Subject: [PATCH] feat(cli): opt-in hardening for setup enable-ssh (--close-17000, --authorized-key) (refs #471) 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 : 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) --- cmd/soundtouch-cli/cmd_setup.go | 43 +++++++++++++- pkg/service/setup/enable_ssh.go | 87 ++++++++++++++++++++++++++++ pkg/service/setup/enable_ssh_test.go | 63 +++++++++++++++++++- 3 files changed, 191 insertions(+), 2 deletions(-) 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)) + } +}