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 <pubkey>: 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) <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 b7009a50eb
commit 6c8a50c049
3 changed files with 191 additions and 2 deletions
+42 -1
View File
@@ -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
},
+87
View File
@@ -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).
+62 -1
View File
@@ -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))
}
}