فهرست منبع

Extract the ssh debug commands into a transport neutral commands package (#1958)

Migrate commands from ssh into a transport neutral package

 - add golden tests validating that output remains unchanged
 - move commands to commands.go

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Matt Richardson 1 روز پیش
والد
کامیت
b426843f88
21فایلهای تغییر یافته به همراه2038 افزوده شده و 978 حذف شده
  1. 2 0
      .gitattributes
  2. 756 0
      commands.go
  3. 60 0
      commands/command.go
  4. 28 0
      commands/names.go
  5. 179 0
      commands/registry.go
  6. 97 0
      commands/registry_test.go
  7. 8 1
      commands/writer.go
  8. 6 0
      control.go
  9. 148 0
      e2e/commands_output_test.go
  10. 543 0
      e2e/testdata/commands_output.golden
  11. 0 1
      go.mod
  12. 0 2
      go.sum
  13. 8 2
      main.go
  14. 0 743
      ssh.go
  15. 57 0
      ssh_commands_test.go
  16. 0 161
      sshd/command.go
  17. 9 23
      sshd/server.go
  18. 12 44
      sshd/session.go
  19. 2 1
      sshd_reload_test.go
  20. 29 0
      test/helpers.go
  21. 94 0
      testdata/ssh_commands.golden

+ 2 - 0
.gitattributes

@@ -0,0 +1,2 @@
+# Goldens are compared byte for byte, so they must come out of git the way they went in.
+*.golden -text

+ 756 - 0
commands.go

@@ -0,0 +1,756 @@
+package nebula
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"log/slog"
+	"maps"
+	"net/netip"
+	"sort"
+	"strings"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/header"
+	"github.com/slackhq/nebula/logging"
+)
+
+// The commands nebula serves. attachCommands builds the one registry every surface
+// reads. See the commands package for what a Command is.
+
+type sshListHostMapFlags struct {
+	Json    bool
+	Pretty  bool
+	ByIndex bool
+}
+
+type sshPrintCertFlags struct {
+	Json   bool
+	Pretty bool
+	Raw    bool
+}
+
+type sshPrintTunnelFlags struct {
+	Pretty bool
+}
+
+type sshChangeRemoteFlags struct {
+	Address string
+}
+
+type sshCloseTunnelFlags struct {
+	LocalOnly bool
+}
+
+type sshCreateTunnelFlags struct {
+	Address string
+}
+
+type sshDeviceInfoFlags struct {
+	Json   bool
+	Pretty bool
+}
+
+func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Interface) {
+	reg.Register(&commands.Command{
+		Name:             commands.ListHostmap,
+		ShortDescription: "List all known previously connected hosts",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshListHostMapFlags{}
+			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
+			fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshListHostMap(f.hostMap, fs, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.ListPendingHostmap,
+		ShortDescription: "List all handshaking hosts",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshListHostMapFlags{}
+			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
+			fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshListHostMap(f.handshakeManager, fs, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.ListLighthouseAddrmap,
+		ShortDescription: "List all lighthouse map entries",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshListHostMapFlags{}
+			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshListLighthouseMap(f.lightHouse, fs, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.Reload,
+		ShortDescription: "Reloads configuration from disk, same as sending HUP to the process",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshReload(l, c, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.LogLevel,
+		ShortDescription: "Gets or sets the current log level",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshLogLevel(l, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.LogFormat,
+		ShortDescription: "Gets or sets the current log format",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshLogFormat(l, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.Version,
+		ShortDescription: "Prints the currently running version of nebula",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshVersion(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.DeviceInfo,
+		ShortDescription: "Prints information about the network device.",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshDeviceInfoFlags{}
+			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshDeviceInfo(f, fs, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.PrintCert,
+		ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshPrintCertFlags{}
+			fl.BoolVar(&s.Json, "json", false, "outputs as json")
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
+			fl.BoolVar(&s.Raw, "raw", false, "raw prints the PEM encoded certificate, not compatible with -json or -pretty")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshPrintCert(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.PrintTunnel,
+		ShortDescription: "Prints json details about a tunnel for the provided vpn addr",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshPrintTunnelFlags{}
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshPrintTunnel(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.PrintRelays,
+		ShortDescription: "Prints json details about all relay info",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshPrintTunnelFlags{}
+			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshPrintRelays(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.ChangeRemote,
+		ShortDescription: "Changes the remote address used in the tunnel for the provided vpn addr",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshChangeRemoteFlags{}
+			fl.StringVar(&s.Address, "address", "", "The new remote address, ip:port")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshChangeRemote(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.CloseTunnel,
+		ShortDescription: "Closes a tunnel for the provided vpn addr",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshCloseTunnelFlags{}
+			fl.BoolVar(&s.LocalOnly, "local-only", false, "Disables notifying the remote that the tunnel is shutting down")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshCloseTunnel(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.CreateTunnel,
+		ShortDescription: "Creates a tunnel for the provided vpn address",
+		Help:             "The lighthouses will be queried for real addresses but you can provide one as well.",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			s := sshCreateTunnelFlags{}
+			fl.StringVar(&s.Address, "address", "", "Optionally provide a real remote address, ip:port ")
+			return fl, &s
+		},
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshCreateTunnel(f, fs, a, w)
+		},
+	})
+
+	reg.Register(&commands.Command{
+		Name:             commands.QueryLighthouse,
+		ShortDescription: "Query the lighthouses for the provided vpn address",
+		Help:             "This command is asynchronous. Only currently known udp addresses will be printed.",
+		Callback: func(fs any, a []string, w commands.StringWriter) error {
+			return sshQueryLighthouse(f, fs, a, w)
+		},
+	})
+}
+
+func sshListHostMap(hl controlHostLister, a any, w commands.StringWriter) error {
+	fs, ok := a.(*sshListHostMapFlags)
+	if !ok {
+		return nil
+	}
+
+	var hm []ControlHostInfo
+	if fs.ByIndex {
+		hm = listHostMapIndexes(hl)
+	} else {
+		hm = listHostMapHosts(hl)
+	}
+
+	sort.Slice(hm, func(i, j int) bool {
+		return hm[i].VpnAddrs[0].Compare(hm[j].VpnAddrs[0]) < 0
+	})
+
+	if fs.Json || fs.Pretty {
+		js := json.NewEncoder(w.GetWriter())
+		if fs.Pretty {
+			js.SetIndent("", "    ")
+		}
+
+		err := js.Encode(hm)
+		if err != nil {
+			return nil
+		}
+
+	} else {
+		for _, v := range hm {
+			err := w.WriteLine(fmt.Sprintf("%s: %s", v.VpnAddrs, v.RemoteAddrs))
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func sshListLighthouseMap(lightHouse *LightHouse, a any, w commands.StringWriter) error {
+	fs, ok := a.(*sshListHostMapFlags)
+	if !ok {
+		return nil
+	}
+
+	type lighthouseInfo struct {
+		VpnAddr string    `json:"vpnAddr"`
+		Addrs   *CacheMap `json:"addrs"`
+	}
+
+	lightHouse.RLock()
+	addrMap := make([]lighthouseInfo, len(lightHouse.addrMap))
+	x := 0
+	for k, v := range lightHouse.addrMap {
+		addrMap[x] = lighthouseInfo{
+			VpnAddr: k.String(),
+			Addrs:   v.CopyCache(),
+		}
+		x++
+	}
+	lightHouse.RUnlock()
+
+	sort.Slice(addrMap, func(i, j int) bool {
+		return strings.Compare(addrMap[i].VpnAddr, addrMap[j].VpnAddr) < 0
+	})
+
+	if fs.Json || fs.Pretty {
+		js := json.NewEncoder(w.GetWriter())
+		if fs.Pretty {
+			js.SetIndent("", "    ")
+		}
+
+		err := js.Encode(addrMap)
+		if err != nil {
+			return nil
+		}
+
+	} else {
+		for _, v := range addrMap {
+			b, err := json.Marshal(v.Addrs)
+			if err != nil {
+				return err
+			}
+			err = w.WriteLine(fmt.Sprintf("%s: %s", v.VpnAddr, string(b)))
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func sshVersion(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	return w.WriteLine(fmt.Sprintf("%s", ifce.version))
+}
+
+func sshQueryLighthouse(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	if len(a) == 0 {
+		return w.WriteLine("No vpn address was provided")
+	}
+
+	vpnAddr, err := netip.ParseAddr(a[0])
+	if err != nil {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	if !vpnAddr.IsValid() {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	var cm *CacheMap
+	rl := ifce.lightHouse.Query(vpnAddr)
+	if rl != nil {
+		cm = rl.CopyCache()
+	}
+	return json.NewEncoder(w.GetWriter()).Encode(cm)
+}
+
+func sshCloseTunnel(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	flags, ok := fs.(*sshCloseTunnelFlags)
+	if !ok {
+		return nil
+	}
+
+	if len(a) == 0 {
+		return w.WriteLine("No vpn address was provided")
+	}
+
+	vpnAddr, err := netip.ParseAddr(a[0])
+	if err != nil {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	if !vpnAddr.IsValid() {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
+	if hostInfo == nil {
+		return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
+	}
+
+	if !flags.LocalOnly {
+		ifce.send(
+			header.CloseTunnel,
+			0,
+			hostInfo.ConnectionState,
+			hostInfo,
+			[]byte{},
+			make([]byte, 12, 12),
+			make([]byte, mtu),
+		)
+	}
+
+	ifce.closeTunnel(hostInfo)
+	return w.WriteLine("Closed")
+}
+
+func sshCreateTunnel(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	flags, ok := fs.(*sshCreateTunnelFlags)
+	if !ok {
+		return nil
+	}
+
+	if len(a) == 0 {
+		return w.WriteLine("No vpn address was provided")
+	}
+
+	vpnAddr, err := netip.ParseAddr(a[0])
+	if err != nil {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	if !vpnAddr.IsValid() {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
+	if hostInfo != nil {
+		return w.WriteLine(fmt.Sprintf("Tunnel already exists"))
+	}
+
+	hostInfo = ifce.handshakeManager.QueryVpnAddr(vpnAddr)
+	if hostInfo != nil {
+		return w.WriteLine(fmt.Sprintf("Tunnel already handshaking"))
+	}
+
+	var addr netip.AddrPort
+	if flags.Address != "" {
+		addr, err = netip.ParseAddrPort(flags.Address)
+		if err != nil {
+			return w.WriteLine("Address could not be parsed")
+		}
+	}
+
+	// Set the remote from the callback, which StartHandshake runs under the handshake manager lock
+	// before the hostinfo reaches the handshake loop or the static-host trigger, so the address is
+	// in the RemoteList before the first handshake is sent.
+	ifce.handshakeManager.StartHandshake(vpnAddr, func(hh *HandshakeHostInfo) {
+		if addr.IsValid() {
+			hh.hostinfo.SetRemote(addr)
+		}
+	})
+
+	return w.WriteLine("Created")
+}
+
+func sshChangeRemote(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	flags, ok := fs.(*sshChangeRemoteFlags)
+	if !ok {
+		return nil
+	}
+
+	if len(a) == 0 {
+		return w.WriteLine("No vpn address was provided")
+	}
+
+	if flags.Address == "" {
+		return w.WriteLine("No address was provided")
+	}
+
+	addr, err := netip.ParseAddrPort(flags.Address)
+	if err != nil {
+		return w.WriteLine("Address could not be parsed")
+	}
+
+	vpnAddr, err := netip.ParseAddr(a[0])
+	if err != nil {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	if !vpnAddr.IsValid() {
+		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
+	}
+
+	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
+	if hostInfo == nil {
+		return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
+	}
+
+	hostInfo.SetRemote(addr)
+	return w.WriteLine("Changed")
+}
+
+func sshLogLevel(l *slog.Logger, fs any, a []string, w commands.StringWriter) error {
+	ctrl, ok := l.Handler().(interface {
+		GetLevel() slog.Level
+		SetLevel(slog.Level)
+	})
+	if !ok {
+		return w.WriteLine("Log level is not reconfigurable on this logger")
+	}
+
+	if len(a) == 0 {
+		return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel())))
+	}
+
+	level, err := logging.ParseLevel(strings.ToLower(a[0]))
+	if err != nil {
+		return w.WriteLine(fmt.Sprintf("Unknown log level %s. Possible log levels: trace, debug, info, warn, error", a))
+	}
+
+	ctrl.SetLevel(level)
+	return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel())))
+}
+
+func sshLogFormat(l *slog.Logger, fs any, a []string, w commands.StringWriter) error {
+	ctrl, ok := l.Handler().(interface {
+		GetFormat() string
+		SetFormat(string) error
+	})
+	if !ok {
+		return w.WriteLine("Log format is not reconfigurable on this logger")
+	}
+
+	if len(a) == 0 {
+		return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
+	}
+
+	if err := ctrl.SetFormat(strings.ToLower(a[0])); err != nil {
+		return err
+	}
+	return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
+}
+
+func sshPrintCert(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	args, ok := fs.(*sshPrintCertFlags)
+	if !ok {
+		return nil
+	}
+
+	cert := ifce.pki.getCertState().GetDefaultCertificate()
+	if len(a) > 0 {
+		vpnAddr, err := netip.ParseAddr(a[0])
+		if err != nil {
+			return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
+		}
+
+		if !vpnAddr.IsValid() {
+			return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
+		}
+
+		hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
+		if hostInfo == nil {
+			return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
+		}
+
+		cert = hostInfo.GetCert().Certificate
+	}
+
+	if args.Json || args.Pretty {
+		b, err := cert.MarshalJSON()
+		if err != nil {
+			return nil
+		}
+
+		if args.Pretty {
+			buf := new(bytes.Buffer)
+			err := json.Indent(buf, b, "", "    ")
+			b = buf.Bytes()
+			if err != nil {
+				return nil
+			}
+		}
+
+		return w.WriteBytes(b)
+	}
+
+	if args.Raw {
+		b, err := cert.MarshalPEM()
+		if err != nil {
+			return nil
+		}
+
+		return w.WriteBytes(b)
+	}
+
+	return w.WriteLine(cert.String())
+}
+
+func sshPrintRelays(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	args, ok := fs.(*sshPrintTunnelFlags)
+	if !ok {
+		w.WriteLine(fmt.Sprintf("sshPrintRelays failed to convert args type"))
+		return nil
+	}
+
+	relays := map[uint32]*HostInfo{}
+	ifce.hostMap.Lock()
+	maps.Copy(relays, ifce.hostMap.Relays)
+	ifce.hostMap.Unlock()
+
+	type RelayFor struct {
+		Error          error
+		Type           string
+		State          string
+		PeerAddr       netip.Addr
+		LocalIndex     uint32
+		RemoteIndex    uint32
+		RelayedThrough []netip.Addr
+	}
+
+	type RelayOutput struct {
+		NebulaAddr    netip.Addr
+		RelayForAddrs []RelayFor
+	}
+
+	type CmdOutput struct {
+		Relays []*RelayOutput
+	}
+
+	co := CmdOutput{}
+
+	enc := json.NewEncoder(w.GetWriter())
+
+	if args.Pretty {
+		enc.SetIndent("", "    ")
+	}
+
+	for k, v := range relays {
+		ro := RelayOutput{NebulaAddr: v.vpnAddrs[0]}
+		co.Relays = append(co.Relays, &ro)
+		relayHI := ifce.hostMap.QueryVpnAddr(v.vpnAddrs[0])
+		if relayHI == nil {
+			ro.RelayForAddrs = append(ro.RelayForAddrs, RelayFor{Error: errors.New("could not find hostinfo")})
+			continue
+		}
+		for _, vpnAddr := range relayHI.relayState.CopyRelayForIps() {
+			rf := RelayFor{Error: nil}
+			r, ok := relayHI.relayState.GetRelayForByAddr(vpnAddr)
+			if ok {
+				t := ""
+				switch r.Type {
+				case ForwardingType:
+					t = "forwarding"
+				case TerminalType:
+					t = "terminal"
+				default:
+					t = "unknown"
+				}
+
+				s := ""
+				switch r.State {
+				case Requested:
+					s = "requested"
+				case Established:
+					s = "established"
+				default:
+					s = "unknown"
+				}
+
+				rf.LocalIndex = r.LocalIndex
+				rf.RemoteIndex = r.RemoteIndex
+				rf.PeerAddr = r.PeerAddr
+				rf.Type = t
+				rf.State = s
+				if rf.LocalIndex != k {
+					rf.Error = fmt.Errorf("hostmap LocalIndex '%v' does not match RelayState LocalIndex", k)
+				}
+			}
+			relayedHI := ifce.hostMap.QueryVpnAddr(vpnAddr)
+			if relayedHI != nil {
+				rf.RelayedThrough = append(rf.RelayedThrough, relayedHI.relayState.CopyRelayIps()...)
+			}
+
+			ro.RelayForAddrs = append(ro.RelayForAddrs, rf)
+		}
+	}
+	err := enc.Encode(co)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func sshPrintTunnel(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
+	args, ok := fs.(*sshPrintTunnelFlags)
+	if !ok {
+		return nil
+	}
+
+	if len(a) == 0 {
+		return w.WriteLine("No vpn address was provided")
+	}
+
+	vpnAddr, err := netip.ParseAddr(a[0])
+	if err != nil {
+		return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
+	}
+
+	if !vpnAddr.IsValid() {
+		return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
+	}
+
+	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
+	if hostInfo == nil {
+		return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
+	}
+
+	enc := json.NewEncoder(w.GetWriter())
+	if args.Pretty {
+		enc.SetIndent("", "    ")
+	}
+
+	return enc.Encode(copyHostInfo(hostInfo, ifce.hostMap.GetPreferredRanges()))
+}
+
+func sshDeviceInfo(ifce *Interface, fs any, w commands.StringWriter) error {
+
+	data := struct {
+		Name string         `json:"name"`
+		Cidr []netip.Prefix `json:"cidr"`
+	}{
+		Name: ifce.inside.Name(),
+		Cidr: make([]netip.Prefix, len(ifce.inside.Networks())),
+	}
+
+	copy(data.Cidr, ifce.inside.Networks())
+
+	flags, ok := fs.(*sshDeviceInfoFlags)
+	if !ok {
+		return fmt.Errorf("internal error: expected flags to be sshDeviceInfoFlags but was %+v", fs)
+	}
+
+	if flags.Json || flags.Pretty {
+		js := json.NewEncoder(w.GetWriter())
+		if flags.Pretty {
+			js.SetIndent("", "    ")
+		}
+
+		return js.Encode(data)
+	} else {
+		return w.WriteLine(fmt.Sprintf("name=%v cidr=%v", data.Name, data.Cidr))
+	}
+}
+
+// sshReload reloads the config and writes the result to the client. It reloads even if the client has gone away
+func sshReload(l *slog.Logger, c *config.C, w commands.StringWriter) error {
+	writeErr := w.WriteLine("Reloading config")
+	res := c.ReloadConfig()
+	res.Log(l)
+	if err := w.WriteLine(res.String()); writeErr == nil {
+		writeErr = err
+	}
+	return writeErr
+}

+ 60 - 0
commands/command.go

@@ -0,0 +1,60 @@
+package commands
+
+import (
+	"flag"
+)
+
+// CommandFlags is called before help or command execution to parse command line flags.
+// It should return a flag.FlagSet instance and a pointer to the struct that will contain parsed flags
+type CommandFlags func() (*flag.FlagSet, any)
+
+// CommandCallback is called when your command should execute.
+// fs will be a a pointer to the struct provided by Command.Flags callback, if there was one. -h and -help are reserved
+// and handled automatically for you.
+// a will be any unconsumed arguments, if no Command.Flags was available this will be all the flags passed in.
+// w is the writer to use when sending messages back to the client.
+// If an error is returned by the callback it is logged locally, the callback should handle messaging errors to the user
+// where appropriate
+type CommandCallback func(fs any, a []string, w StringWriter) error
+
+type Command struct {
+	Name             Name
+	ShortDescription string
+	Help             string
+
+	Flags    CommandFlags
+	Callback CommandCallback
+}
+
+func execCommand(c *Command, args []string, w StringWriter) error {
+	var (
+		fl *flag.FlagSet
+		fs any
+	)
+
+	if c.Flags != nil {
+		fl, fs = c.Flags()
+		if fl != nil {
+			// SetOutput() here in case fl.Parse dumps usage.
+			fl.SetOutput(w.GetWriter())
+			err := fl.Parse(args)
+			if err != nil {
+				// fl.Parse has dumped error information to the user via the w writer.
+				return err
+			}
+			args = fl.Args()
+		}
+	}
+
+	return c.Callback(fs, args, w)
+}
+
+func checkHelpArgs(args []string) bool {
+	for _, a := range args {
+		if a == "-h" || a == "-help" {
+			return true
+		}
+	}
+
+	return false
+}

+ 28 - 0
commands/names.go

@@ -0,0 +1,28 @@
+package commands
+
+// Name identifies a command. Every command nebula serves is a constant here, so a
+// surface, a client and a test spell it the same way and are told at compile time when
+// they do not. It is a string rather than an integer so a test can register a throwaway
+// command without adding it here, and so the wire carries the name as the string it has
+// always been.
+type Name string
+
+const (
+	ChangeRemote          Name = "change-remote"
+	CloseTunnel           Name = "close-tunnel"
+	CreateTunnel          Name = "create-tunnel"
+	DeviceInfo            Name = "device-info"
+	Help                  Name = "help"
+	ListHostmap           Name = "list-hostmap"
+	ListLighthouseAddrmap Name = "list-lighthouse-addrmap"
+	ListPendingHostmap    Name = "list-pending-hostmap"
+	LogFormat             Name = "log-format"
+	LogLevel              Name = "log-level"
+	Logout                Name = "logout"
+	PrintCert             Name = "print-cert"
+	PrintRelays           Name = "print-relays"
+	PrintTunnel           Name = "print-tunnel"
+	QueryLighthouse       Name = "query-lighthouse"
+	Reload                Name = "reload"
+	Version               Name = "version"
+)

+ 179 - 0
commands/registry.go

@@ -0,0 +1,179 @@
+package commands
+
+import (
+	"cmp"
+	"fmt"
+	"maps"
+	"slices"
+	"strings"
+	"sync"
+
+	"github.com/anmitsu/go-shlex"
+)
+
+// Registry holds a set of commands. New is the only way to make one.
+//
+// It is safe for concurrent use. Commands may be registered while a surface is serving
+// from the registry: the ssh server clones it per session, and a clone may overlap a
+// Register.
+type Registry struct {
+	mu       sync.RWMutex
+	commands map[Name]*Command
+}
+
+// New returns an empty Registry with only `help` available.
+func New() *Registry {
+	r := &Registry{commands: map[Name]*Command{}}
+	r.registerHelp()
+	return r
+}
+
+func (r *Registry) registerHelp() {
+	r.Register(&Command{
+		Name:             Help,
+		ShortDescription: "prints available commands or help <command> for specific usage info",
+		Callback: func(a any, args []string, w StringWriter) error {
+			return r.help(args, w)
+		},
+	})
+}
+
+// Register adds a command that can be run by a user.
+func (r *Registry) Register(c *Command) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.commands[c.Name] = c
+}
+
+// Get returns the command registered under name, or nil if there is no such command.
+func (r *Registry) Get(name Name) *Command {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	return r.commands[name]
+}
+
+// Match returns the names of every command starting with prefix, sorted, as strings for
+// a prompt to complete and print.
+func (r *Registry) Match(prefix string) []string {
+	cmds := make([]string, 0)
+	r.mu.RLock()
+	for n := range r.commands {
+		if strings.HasPrefix(string(n), prefix) {
+			cmds = append(cmds, string(n))
+		}
+	}
+	r.mu.RUnlock()
+	slices.Sort(cmds)
+	return cmds
+}
+
+// All returns every registered command sorted by name, which is the order help lists them.
+func (r *Registry) All() []*Command {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	cmds := slices.Collect(maps.Values(r.commands))
+	slices.SortFunc(cmds, func(a, b *Command) int { return cmp.Compare(a.Name, b.Name) })
+	return cmds
+}
+
+// Clone returns a copy holding the same commands.
+func (r *Registry) Clone() *Registry {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	return &Registry{commands: maps.Clone(r.commands)}
+}
+
+// Dispatch parses a command line and runs it, reporting problems to w.
+func (r *Registry) Dispatch(line string, w StringWriter) {
+	// A line shlex cannot split, an unbalanced quote, gets no answer. sshd was silent
+	// here too; this is a relocation, so it stays silent.
+	args, err := shlex.Split(line, true)
+	if err != nil {
+		return
+	}
+
+	_ = r.DispatchArgs(args, line, w)
+}
+
+// DispatchArgs runs an already tokenized command line. orig is what the user typed,
+// used only for the "did not understand" message; callers that have no raw line may
+// pass "" and the joined args are used instead.
+func (r *Registry) DispatchArgs(args []string, orig string, w StringWriter) error {
+	if len(args) == 0 {
+		r.dumpCommands(w)
+		return nil
+	}
+
+	c := r.Get(Name(args[0]))
+	if c == nil {
+		if orig == "" {
+			orig = strings.Join(args, " ")
+		}
+
+		if err := w.WriteLine(fmt.Sprintf("did not understand: %s", orig)); err != nil {
+			return err
+		}
+
+		r.dumpCommands(w)
+		return nil
+	}
+
+	if checkHelpArgs(args) {
+		// Through the registered help command, so -h and `help <name>` are one path.
+		return execCommand(r.Get(Help), []string{string(c.Name)}, w)
+	}
+
+	return execCommand(c, args[1:], w)
+}
+
+// dumpCommands writes the list of available commands to w.
+func (r *Registry) dumpCommands(w StringWriter) {
+	err := w.WriteLine("Available commands:")
+	if err != nil {
+		return
+	}
+
+	// In the order All gives, which is the display order.
+	cmds := make([]string, 0)
+	for _, l := range r.All() {
+		cmds = append(cmds, fmt.Sprintf("%s - %s", l.Name, l.ShortDescription))
+	}
+
+	_ = w.Write(strings.Join(cmds, "\n") + "\n\n")
+}
+
+// help writes the command list, or usage for the single command named in a.
+func (r *Registry) help(a []string, w StringWriter) error {
+	// Just typed help
+	if len(a) == 0 {
+		r.dumpCommands(w)
+		return nil
+	}
+
+	// We are printing a specific commands help text
+	cmd := r.Get(Name(a[0]))
+	if cmd == nil {
+		return w.WriteLine("Command not available " + a[0])
+	}
+
+	if err := w.WriteLine(fmt.Sprintf("%s - %s", cmd.Name, cmd.ShortDescription)); err != nil {
+		return err
+	}
+
+	if cmd.Help != "" {
+		if err := w.WriteLine(fmt.Sprintf("  %s", cmd.Help)); err != nil {
+			return err
+		}
+	}
+
+	if cmd.Flags != nil {
+		fs, _ := cmd.Flags()
+		if fs != nil {
+			fs.SetOutput(w.GetWriter())
+			fs.PrintDefaults()
+		}
+	}
+
+	return nil
+}

+ 97 - 0
commands/registry_test.go

@@ -0,0 +1,97 @@
+package commands
+
+import (
+	"bytes"
+	"flag"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func testRegistry() *Registry {
+	r := New()
+	r.Register(&Command{
+		Name:             "served",
+		ShortDescription: "a command this surface serves",
+		Callback: func(fs any, a []string, w StringWriter) error {
+			return w.WriteLine("read " + strings.Join(a, ","))
+		},
+	})
+	r.Register(&Command{
+		Name:             "withheld",
+		ShortDescription: "a command this surface withholds",
+		Callback: func(fs any, a []string, w StringWriter) error {
+			return w.WriteLine("wrote")
+		},
+	})
+	return r
+}
+
+// TestFlagUsageGoesToTheWriter pins that a parse failure's usage lands on the command's
+// writer.
+func TestFlagUsageGoesToTheWriter(t *testing.T) {
+	r := New()
+	r.Register(&Command{
+		Name:             "emit",
+		ShortDescription: "emits",
+		Flags: func() (*flag.FlagSet, any) {
+			fl := flag.NewFlagSet("", flag.ContinueOnError)
+			fl.Bool("json", false, "emit json")
+			return fl, nil
+		},
+		Callback: func(fs any, a []string, w StringWriter) error { return nil },
+	})
+
+	merged := &bytes.Buffer{}
+	require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, "", NewStringWriter(merged)))
+	assert.Contains(t, merged.String(), "flag provided but not defined")
+}
+
+// TestDispatchDropsUnparseableLine pins sshd's behaviour for a line shlex rejects: no
+// answer at all. An operator with an unbalanced quote gets silence, as they always have.
+func TestDispatchDropsUnparseableLine(t *testing.T) {
+	out := &bytes.Buffer{}
+	testRegistry().Dispatch(`served "unterminated`, NewStringWriter(out))
+	assert.Empty(t, out.String())
+}
+
+// TestAllSortsByName: registration order does not matter.
+func TestAllSortsByName(t *testing.T) {
+	r := New()
+	for _, n := range []Name{"zz", Version, "aa", ChangeRemote} {
+		r.Register(&Command{Name: n})
+	}
+
+	var got []Name
+	for _, c := range r.All() {
+		got = append(got, c.Name)
+	}
+	assert.Equal(t, []Name{"aa", ChangeRemote, Help, Version, "zz"}, got)
+}
+
+// TestHelpFlagAgreesWithHelpCommand pins that -h answers through the registered help
+// command. On a clone, help is bound to the source, so a command registered only on the
+// clone is known to neither -h nor `help <name>`, and the two never disagree.
+func TestHelpFlagAgreesWithHelpCommand(t *testing.T) {
+	r := testRegistry()
+	c := r.Clone()
+	c.Register(&Command{
+		Name:             Logout,
+		ShortDescription: "ends the session",
+		Callback:         func(fs any, a []string, w StringWriter) error { return nil },
+	})
+
+	flag := &bytes.Buffer{}
+	c.Dispatch("logout -h", NewStringWriter(flag))
+	cmd := &bytes.Buffer{}
+	c.Dispatch("help logout", NewStringWriter(cmd))
+	assert.Equal(t, cmd.String(), flag.String())
+	assert.Equal(t, "Command not available logout\n", flag.String())
+
+	// And a command both hold is described either way.
+	flag.Reset()
+	c.Dispatch("served -h", NewStringWriter(flag))
+	assert.Equal(t, "served - a command this surface serves\n", flag.String())
+}

+ 8 - 1
sshd/writer.go → commands/writer.go

@@ -1,7 +1,9 @@
-package sshd
+package commands
 
 import "io"
 
+// StringWriter is how a command talks back to whoever invoked it: one stream, which the
+// ssh surface points at its channel.
 type StringWriter interface {
 	WriteLine(string) error
 	Write(string) error
@@ -13,6 +15,11 @@ type stringWriter struct {
 	w io.Writer
 }
 
+// NewStringWriter returns a StringWriter that writes to w.
+func NewStringWriter(w io.Writer) StringWriter {
+	return &stringWriter{w: w}
+}
+
 func (w *stringWriter) WriteLine(s string) error {
 	return w.Write(s + "\n")
 }

+ 6 - 0
control.go

@@ -11,6 +11,7 @@ import (
 	"syscall"
 
 	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/header"
 	"github.com/slackhq/nebula/overlay"
 )
@@ -47,6 +48,7 @@ type Control struct {
 
 	f                      *Interface
 	l                      *slog.Logger
+	commands               *commands.Registry
 	ctx                    context.Context
 	cancel                 context.CancelFunc
 	sshStart               func()
@@ -372,6 +374,10 @@ func (c *Control) CloseAllTunnels(excludeLighthouses bool) (closed int) {
 	return
 }
 
+func (c *Control) Commands() *commands.Registry {
+	return c.commands
+}
+
 func (c *Control) Device() overlay.Device {
 	return c.f.inside
 }

+ 148 - 0
e2e/commands_output_test.go

@@ -0,0 +1,148 @@
+//go:build e2e_testing
+
+package e2e
+
+import (
+	"bytes"
+	"fmt"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/cert_test"
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/require"
+)
+
+// volatile lists everything in command output that legitimately differs between runs.
+// Each run mints fresh certificates and indexes, so these are replaced with a marker
+// before comparison. Keep the list short: anything added here is something the golden
+// stops guarding.
+var volatile = []struct {
+	re   *regexp.Regexp
+	with string
+}{
+	{regexp.MustCompile(`[0-9a-f]{40,}`), "<hex>"},                                                       // fingerprints, keys, signatures
+	{regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`), "<time>"}, // RFC 3339
+	{regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? [+-]\d{4} \w+`), "<time>"},        // time.Time.String()
+	{regexp.MustCompile(`(?s)(-----BEGIN [A-Z ]+-----\n).*?(\n-----END [A-Z ]+-----)`), "${1}<pem>${2}"}, // print-cert -raw
+	{regexp.MustCompile(`(-----BEGIN [A-Z ]+-----\\n).*?(\\n-----END [A-Z ]+-----)`), "${1}<pem>${2}"},   // the same, inside a JSON string
+	{regexp.MustCompile(`"(localIndex|remoteIndex|messageCounter)":\d+`), `"$1":<n>`},
+	{regexp.MustCompile(`(?m)^(\s*"?(?:LocalIndex|RemoteIndex|localIndex|remoteIndex|messageCounter)"?:?\s*)\d+`), "${1}<n>"}, // and the -pretty form
+}
+
+func normalise(s string) string {
+	for _, v := range volatile {
+		s = v.re.ReplaceAllString(s, v.with)
+	}
+	return s
+}
+
+// outputCase is one command line to run through the ssh dispatch path.
+type outputCase struct {
+	name string
+	args []string
+}
+
+// TestCommandOutput pins the bytes every command produces, in every format, through the
+// ssh dispatch path. It is the safety net for moving rendering out of the commands: a
+// change here is a change someone at an ssh prompt would see. Regenerate with:
+//
+//	go test -tags e2e_testing ./e2e -run TestCommandOutput -update
+func TestCommandOutput(t *testing.T) {
+	ca, _, caKey, _ := cert_test.NewTestCaCert(
+		cert.Version1, cert.Curve_CURVE25519,
+		time.Now(), time.Now().Add(10*time.Minute),
+		nil, nil, []string{},
+	)
+
+	control, _, _, _ := newSimpleServer(cert.Version1, ca, caKey, "output-golden", "10.222.0.1/24", nil)
+	control.Start()
+	t.Cleanup(func() { control.Stop() })
+
+	reg := control.Commands().Clone()
+
+	run := func(args ...string) string {
+		buf := &bytes.Buffer{}
+		_ = reg.DispatchArgs(args, strings.Join(args, " "), commands.NewStringWriter(buf))
+		return buf.String()
+	}
+
+	// Seed a pending tunnel so the listing and per-address commands have something to show.
+	seed := run("create-tunnel", "10.222.0.9")
+	require.Equal(t, "Created\n", seed, "seeding a tunnel should succeed before anything else is recorded")
+
+	cases := []outputCase{
+		{"version", nil},
+		{"device-info", nil},
+		{"list-hostmap", nil},
+		{"list-hostmap", []string{"-by-index"}},
+		{"list-pending-hostmap", nil},
+		{"list-pending-hostmap", []string{"-by-index"}},
+		{"list-lighthouse-addrmap", nil},
+		{"print-cert", nil},
+		{"print-cert", []string{"-raw"}},
+		{"print-cert", []string{"10.222.0.9"}},
+		{"print-cert", []string{"10.222.0.250"}},
+		{"print-cert", []string{"not-an-address"}},
+		{"print-tunnel", nil},
+		{"print-tunnel", []string{"10.222.0.9"}},
+		{"print-tunnel", []string{"10.222.0.250"}},
+		{"print-relays", nil},
+		{"query-lighthouse", nil},
+		{"query-lighthouse", []string{"10.222.0.9"}},
+		{"query-lighthouse", []string{"not-an-address"}},
+		// The test logger cannot change level or format, so these cases record the
+		// refusal each command prints on it, not a successful change.
+		{"log-level", nil},
+		{"log-level", []string{"debug"}},
+		{"log-level", []string{"bogus"}},
+		{"log-level", []string{"info"}},
+		{"log-format", nil},
+		{"log-format", []string{"json"}},
+		{"log-format", []string{"text"}},
+		{"change-remote", nil},
+		{"change-remote", []string{"10.222.0.9"}},
+		{"change-remote", []string{"-address", "10.94.0.10:4242", "10.222.0.9"}},
+		{"change-remote", []string{"-address", "10.94.0.10:4242", "10.222.0.250"}},
+		{"create-tunnel", nil},
+		{"create-tunnel", []string{"10.222.0.9"}},
+		// A fresh address, so this exercises the handshake start with a remote attached
+		// rather than the "already handshaking" path above.
+		{"create-tunnel", []string{"-address", "10.94.0.10:4242", "10.222.0.10"}},
+		{"create-tunnel", []string{"not-an-address"}},
+		{"close-tunnel", nil},
+		{"close-tunnel", []string{"10.222.0.250"}},
+		{"close-tunnel", []string{"not-an-address"}},
+		{"help", nil},
+		{"help", []string{"print-cert"}},
+		// reload is last so that whatever a reload does to the daemon cannot leak into an
+		// earlier case. In this harness it fails: the config was loaded from a string and
+		// has no path to re-read, which is what the recorded result says.
+		{"reload", nil},
+	}
+
+	got := &strings.Builder{}
+	for _, c := range cases {
+		for _, fmtFlags := range [][]string{nil, {"-json"}, {"-pretty"}} {
+			args := append([]string{c.name}, fmtFlags...)
+			args = append(args, c.args...)
+
+			fmt.Fprintf(got, "$ %s\n", strings.Join(args, " "))
+			out := normalise(run(args...))
+			got.WriteString(out)
+			// Output that does not end in a newline is recorded as such, the way diff marks
+			// it, so the golden sees that byte too and the next prompt still starts a line.
+			if out != "" && !strings.HasSuffix(out, "\n") {
+				got.WriteString("\\ No newline at end of output\n")
+			}
+			got.WriteString("\n")
+		}
+	}
+
+	test.Golden(t, filepath.Join("testdata", "commands_output.golden"), got.String())
+}

+ 543 - 0
e2e/testdata/commands_output.golden

@@ -0,0 +1,543 @@
+$ version
+e2e-test
+
+$ version -json
+e2e-test
+
+$ version -pretty
+e2e-test
+
+$ device-info
+name= cidr=[10.222.0.1/24]
+
+$ device-info -json
+{"name":"","cidr":["10.222.0.1/24"]}
+
+$ device-info -pretty
+{
+    "name": "",
+    "cidr": [
+        "10.222.0.1/24"
+    ]
+}
+
+$ list-hostmap
+
+$ list-hostmap -json
+[]
+
+$ list-hostmap -pretty
+[]
+
+$ list-hostmap -by-index
+
+$ list-hostmap -json -by-index
+[]
+
+$ list-hostmap -pretty -by-index
+[]
+
+$ list-pending-hostmap
+[10.222.0.9]: []
+
+$ list-pending-hostmap -json
+[{"vpnAddrs":["10.222.0.9"],"localIndex":<n>,"remoteIndex":<n>,"remoteAddrs":[],"cert":null,"messageCounter":<n>,"currentRemote":"","currentRelaysToMe":[],"currentRelaysThroughMe":[]}]
+
+$ list-pending-hostmap -pretty
+[
+    {
+        "vpnAddrs": [
+            "10.222.0.9"
+        ],
+        "localIndex": <n>,
+        "remoteIndex": <n>,
+        "remoteAddrs": [],
+        "cert": null,
+        "messageCounter": <n>,
+        "currentRemote": "",
+        "currentRelaysToMe": [],
+        "currentRelaysThroughMe": []
+    }
+]
+
+$ list-pending-hostmap -by-index
+
+$ list-pending-hostmap -json -by-index
+[]
+
+$ list-pending-hostmap -pretty -by-index
+[]
+
+$ list-lighthouse-addrmap
+10.222.0.9: {}
+
+$ list-lighthouse-addrmap -json
+[{"vpnAddr":"10.222.0.9","addrs":{}}]
+
+$ list-lighthouse-addrmap -pretty
+[
+    {
+        "vpnAddr": "10.222.0.9",
+        "addrs": {}
+    }
+]
+
+$ print-cert
+{
+	"details": {
+		"curve": "CURVE25519",
+		"groups": [],
+		"isCa": false,
+		"issuer": "<hex>",
+		"name": "output-golden",
+		"networks": [
+			"10.222.0.1/24"
+		],
+		"notAfter": "<time>",
+		"notBefore": "<time>",
+		"publicKey": "<hex>",
+		"unsafeNetworks": []
+	},
+	"fingerprint": "<hex>",
+	"signature": "<hex>",
+	"version": 1
+}
+
+$ print-cert -json
+{"details":{"curve":"CURVE25519","groups":[],"isCa":false,"issuer":"<hex>","name":"output-golden","networks":["10.222.0.1/24"],"notAfter":"<time>","notBefore":"<time>","publicKey":"<hex>","unsafeNetworks":[]},"fingerprint":"<hex>","signature":"<hex>","version":1}\ No newline at end of output
+
+$ print-cert -pretty
+{
+    "details": {
+        "curve": "CURVE25519",
+        "groups": [],
+        "isCa": false,
+        "issuer": "<hex>",
+        "name": "output-golden",
+        "networks": [
+            "10.222.0.1/24"
+        ],
+        "notAfter": "<time>",
+        "notBefore": "<time>",
+        "publicKey": "<hex>",
+        "unsafeNetworks": []
+    },
+    "fingerprint": "<hex>",
+    "signature": "<hex>",
+    "version": 1
+}\ No newline at end of output
+
+$ print-cert -raw
+-----BEGIN NEBULA CERTIFICATE-----
+<pem>
+-----END NEBULA CERTIFICATE-----
+
+$ print-cert -json -raw
+{"details":{"curve":"CURVE25519","groups":[],"isCa":false,"issuer":"<hex>","name":"output-golden","networks":["10.222.0.1/24"],"notAfter":"<time>","notBefore":"<time>","publicKey":"<hex>","unsafeNetworks":[]},"fingerprint":"<hex>","signature":"<hex>","version":1}\ No newline at end of output
+
+$ print-cert -pretty -raw
+{
+    "details": {
+        "curve": "CURVE25519",
+        "groups": [],
+        "isCa": false,
+        "issuer": "<hex>",
+        "name": "output-golden",
+        "networks": [
+            "10.222.0.1/24"
+        ],
+        "notAfter": "<time>",
+        "notBefore": "<time>",
+        "publicKey": "<hex>",
+        "unsafeNetworks": []
+    },
+    "fingerprint": "<hex>",
+    "signature": "<hex>",
+    "version": 1
+}\ No newline at end of output
+
+$ print-cert 10.222.0.9
+Could not find tunnel for vpn addr: 10.222.0.9
+
+$ print-cert -json 10.222.0.9
+Could not find tunnel for vpn addr: 10.222.0.9
+
+$ print-cert -pretty 10.222.0.9
+Could not find tunnel for vpn addr: 10.222.0.9
+
+$ print-cert 10.222.0.250
+Could not find tunnel for vpn addr: 10.222.0.250
+
+$ print-cert -json 10.222.0.250
+Could not find tunnel for vpn addr: 10.222.0.250
+
+$ print-cert -pretty 10.222.0.250
+Could not find tunnel for vpn addr: 10.222.0.250
+
+$ print-cert not-an-address
+The provided vpn addr could not be parsed: not-an-address
+
+$ print-cert -json not-an-address
+The provided vpn addr could not be parsed: not-an-address
+
+$ print-cert -pretty not-an-address
+The provided vpn addr could not be parsed: not-an-address
+
+$ print-tunnel
+No vpn address was provided
+
+$ print-tunnel -json
+flag provided but not defined: -json
+Usage:
+  -pretty
+    	pretty prints json
+
+$ print-tunnel -pretty
+No vpn address was provided
+
+$ print-tunnel 10.222.0.9
+Could not find tunnel for vpn addr: 10.222.0.9
+
+$ print-tunnel -json 10.222.0.9
+flag provided but not defined: -json
+Usage:
+  -pretty
+    	pretty prints json
+
+$ print-tunnel -pretty 10.222.0.9
+Could not find tunnel for vpn addr: 10.222.0.9
+
+$ print-tunnel 10.222.0.250
+Could not find tunnel for vpn addr: 10.222.0.250
+
+$ print-tunnel -json 10.222.0.250
+flag provided but not defined: -json
+Usage:
+  -pretty
+    	pretty prints json
+
+$ print-tunnel -pretty 10.222.0.250
+Could not find tunnel for vpn addr: 10.222.0.250
+
+$ print-relays
+{"Relays":null}
+
+$ print-relays -json
+flag provided but not defined: -json
+Usage:
+  -pretty
+    	pretty prints json
+
+$ print-relays -pretty
+{
+    "Relays": null
+}
+
+$ query-lighthouse
+No vpn address was provided
+
+$ query-lighthouse -json
+The provided vpn address could not be parsed: -json
+
+$ query-lighthouse -pretty
+The provided vpn address could not be parsed: -pretty
+
+$ query-lighthouse 10.222.0.9
+{}
+
+$ query-lighthouse -json 10.222.0.9
+The provided vpn address could not be parsed: -json
+
+$ query-lighthouse -pretty 10.222.0.9
+The provided vpn address could not be parsed: -pretty
+
+$ query-lighthouse not-an-address
+The provided vpn address could not be parsed: not-an-address
+
+$ query-lighthouse -json not-an-address
+The provided vpn address could not be parsed: -json
+
+$ query-lighthouse -pretty not-an-address
+The provided vpn address could not be parsed: -pretty
+
+$ log-level
+Log level is not reconfigurable on this logger
+
+$ log-level -json
+Log level is not reconfigurable on this logger
+
+$ log-level -pretty
+Log level is not reconfigurable on this logger
+
+$ log-level debug
+Log level is not reconfigurable on this logger
+
+$ log-level -json debug
+Log level is not reconfigurable on this logger
+
+$ log-level -pretty debug
+Log level is not reconfigurable on this logger
+
+$ log-level bogus
+Log level is not reconfigurable on this logger
+
+$ log-level -json bogus
+Log level is not reconfigurable on this logger
+
+$ log-level -pretty bogus
+Log level is not reconfigurable on this logger
+
+$ log-level info
+Log level is not reconfigurable on this logger
+
+$ log-level -json info
+Log level is not reconfigurable on this logger
+
+$ log-level -pretty info
+Log level is not reconfigurable on this logger
+
+$ log-format
+Log format is not reconfigurable on this logger
+
+$ log-format -json
+Log format is not reconfigurable on this logger
+
+$ log-format -pretty
+Log format is not reconfigurable on this logger
+
+$ log-format json
+Log format is not reconfigurable on this logger
+
+$ log-format -json json
+Log format is not reconfigurable on this logger
+
+$ log-format -pretty json
+Log format is not reconfigurable on this logger
+
+$ log-format text
+Log format is not reconfigurable on this logger
+
+$ log-format -json text
+Log format is not reconfigurable on this logger
+
+$ log-format -pretty text
+Log format is not reconfigurable on this logger
+
+$ change-remote
+No vpn address was provided
+
+$ change-remote -json
+flag provided but not defined: -json
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote -pretty
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote 10.222.0.9
+No address was provided
+
+$ change-remote -json 10.222.0.9
+flag provided but not defined: -json
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote -pretty 10.222.0.9
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote -address 10.94.0.10:4242 10.222.0.9
+Could not find tunnel for vpn address: 10.222.0.9
+
+$ change-remote -json -address 10.94.0.10:4242 10.222.0.9
+flag provided but not defined: -json
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote -pretty -address 10.94.0.10:4242 10.222.0.9
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote -address 10.94.0.10:4242 10.222.0.250
+Could not find tunnel for vpn address: 10.222.0.250
+
+$ change-remote -json -address 10.94.0.10:4242 10.222.0.250
+flag provided but not defined: -json
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ change-remote -pretty -address 10.94.0.10:4242 10.222.0.250
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	The new remote address, ip:port
+
+$ create-tunnel
+No vpn address was provided
+
+$ create-tunnel -json
+flag provided but not defined: -json
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel -pretty
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel 10.222.0.9
+Tunnel already handshaking
+
+$ create-tunnel -json 10.222.0.9
+flag provided but not defined: -json
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel -pretty 10.222.0.9
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel -address 10.94.0.10:4242 10.222.0.10
+Created
+
+$ create-tunnel -json -address 10.94.0.10:4242 10.222.0.10
+flag provided but not defined: -json
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel -pretty -address 10.94.0.10:4242 10.222.0.10
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel not-an-address
+The provided vpn address could not be parsed: not-an-address
+
+$ create-tunnel -json not-an-address
+flag provided but not defined: -json
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ create-tunnel -pretty not-an-address
+flag provided but not defined: -pretty
+Usage:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+
+$ close-tunnel
+No vpn address was provided
+
+$ close-tunnel -json
+flag provided but not defined: -json
+Usage:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+
+$ close-tunnel -pretty
+flag provided but not defined: -pretty
+Usage:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+
+$ close-tunnel 10.222.0.250
+Could not find tunnel for vpn address: 10.222.0.250
+
+$ close-tunnel -json 10.222.0.250
+flag provided but not defined: -json
+Usage:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+
+$ close-tunnel -pretty 10.222.0.250
+flag provided but not defined: -pretty
+Usage:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+
+$ close-tunnel not-an-address
+The provided vpn address could not be parsed: not-an-address
+
+$ close-tunnel -json not-an-address
+flag provided but not defined: -json
+Usage:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+
+$ close-tunnel -pretty not-an-address
+flag provided but not defined: -pretty
+Usage:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+
+$ help
+Available commands:
+change-remote - Changes the remote address used in the tunnel for the provided vpn addr
+close-tunnel - Closes a tunnel for the provided vpn addr
+create-tunnel - Creates a tunnel for the provided vpn address
+device-info - Prints information about the network device.
+help - prints available commands or help <command> for specific usage info
+list-hostmap - List all known previously connected hosts
+list-lighthouse-addrmap - List all lighthouse map entries
+list-pending-hostmap - List all handshaking hosts
+log-format - Gets or sets the current log format
+log-level - Gets or sets the current log level
+print-cert - Prints the current certificate being used or the certificate for the provided vpn addr
+print-relays - Prints json details about all relay info
+print-tunnel - Prints json details about a tunnel for the provided vpn addr
+query-lighthouse - Query the lighthouses for the provided vpn address
+reload - Reloads configuration from disk, same as sending HUP to the process
+version - Prints the currently running version of nebula
+
+
+$ help -json
+Command not available -json
+
+$ help -pretty
+Command not available -pretty
+
+$ help print-cert
+print-cert - Prints the current certificate being used or the certificate for the provided vpn addr
+  -json
+    	outputs as json
+  -pretty
+    	pretty prints json, assumes -json
+  -raw
+    	raw prints the PEM encoded certificate, not compatible with -json or -pretty
+
+$ help -json print-cert
+Command not available -json
+
+$ help -pretty print-cert
+Command not available -pretty
+
+$ reload
+Reloading config
+Config reload failed, the new config did not load: no config files found at 
+
+$ reload -json
+Reloading config
+Config reload failed, the new config did not load: no config files found at 
+
+$ reload -pretty
+Reloading config
+Config reload failed, the new config did not load: no config files found at 
+

+ 0 - 1
go.mod

@@ -7,7 +7,6 @@ require (
 	filippo.io/bigmod v0.1.0
 	github.com/DefinedNet/netlink v1.3.2-dn.1
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
-	github.com/armon/go-radix v1.0.0
 	github.com/flynn/noise v1.1.0
 	github.com/gaissmai/bart v0.29.0
 	github.com/gogo/protobuf v1.3.2

+ 0 - 2
go.sum

@@ -12,8 +12,6 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
-github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
-github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

+ 8 - 2
main.go

@@ -12,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/cpupick"
 	"github.com/slackhq/nebula/noiseutil"
@@ -71,7 +72,11 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 	}
 	l.Info("Firewall started", "firewallHashes", fw.GetRuleHashes())
 
-	ssh, err := sshd.NewSSHServer(ctx, l.With("subsystem", "sshd"))
+	// The command registry is shared by every surface that exposes these commands, so a
+	// command cannot reach one of them without reaching the others.
+	cmdRegistry := commands.New()
+
+	ssh, err := sshd.NewSSHServer(ctx, l.With("subsystem", "sshd"), cmdRegistry)
 	if err != nil {
 		return nil, util.ContextualizeIfNeeded("Error while creating SSH server", err)
 	}
@@ -343,13 +348,14 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
 		return nil, nil
 	}
 
-	attachCommands(l, c, ssh, ifce)
+	attachCommands(l, c, cmdRegistry, ifce)
 
 	networkChanges := udp.NewNetworkChangeMonitor(ctx, l, c)
 
 	return &Control{
 		state:                  StateReady,
 		f:                      ifce,
+		commands:               cmdRegistry,
 		l:                      l,
 		ctx:                    ctx,
 		cancel:                 cancel,

+ 0 - 743
ssh.go

@@ -1,59 +1,17 @@
 package nebula
 
 import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"flag"
 	"fmt"
 	"log/slog"
-	"maps"
 	"net"
-	"net/netip"
 	"os"
 	"slices"
-	"sort"
 	"strings"
 
 	"github.com/slackhq/nebula/config"
-	"github.com/slackhq/nebula/header"
-	"github.com/slackhq/nebula/logging"
 	"github.com/slackhq/nebula/sshd"
 )
 
-type sshListHostMapFlags struct {
-	Json    bool
-	Pretty  bool
-	ByIndex bool
-}
-
-type sshPrintCertFlags struct {
-	Json   bool
-	Pretty bool
-	Raw    bool
-}
-
-type sshPrintTunnelFlags struct {
-	Pretty bool
-}
-
-type sshChangeRemoteFlags struct {
-	Address string
-}
-
-type sshCloseTunnelFlags struct {
-	LocalOnly bool
-}
-
-type sshCreateTunnelFlags struct {
-	Address string
-}
-
-type sshDeviceInfoFlags struct {
-	Json   bool
-	Pretty bool
-}
-
 func wireSSHReload(l *slog.Logger, ssh *sshd.SSHServer, c *config.C) {
 	c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
 		if c.GetBool("sshd.enabled", false) {
@@ -227,704 +185,3 @@ func configSSH(l *slog.Logger, ssh *sshd.SSHServer, c *config.C, r *config.Reloa
 
 	return runner, nil
 }
-
-func attachCommands(l *slog.Logger, c *config.C, ssh *sshd.SSHServer, f *Interface) {
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "list-hostmap",
-		ShortDescription: "List all known previously connected hosts",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshListHostMapFlags{}
-			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
-			fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshListHostMap(f.hostMap, fs, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "list-pending-hostmap",
-		ShortDescription: "List all handshaking hosts",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshListHostMapFlags{}
-			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
-			fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshListHostMap(f.handshakeManager, fs, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "list-lighthouse-addrmap",
-		ShortDescription: "List all lighthouse map entries",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshListHostMapFlags{}
-			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshListLighthouseMap(f.lightHouse, fs, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "reload",
-		ShortDescription: "Reloads configuration from disk, same as sending HUP to the process",
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshReload(l, c, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "log-level",
-		ShortDescription: "Gets or sets the current log level",
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshLogLevel(l, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "log-format",
-		ShortDescription: "Gets or sets the current log format",
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshLogFormat(l, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "version",
-		ShortDescription: "Prints the currently running version of nebula",
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshVersion(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "device-info",
-		ShortDescription: "Prints information about the network device.",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshDeviceInfoFlags{}
-			fl.BoolVar(&s.Json, "json", false, "outputs as json with more information")
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshDeviceInfo(f, fs, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "print-cert",
-		ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshPrintCertFlags{}
-			fl.BoolVar(&s.Json, "json", false, "outputs as json")
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -json")
-			fl.BoolVar(&s.Raw, "raw", false, "raw prints the PEM encoded certificate, not compatible with -json or -pretty")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshPrintCert(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "print-tunnel",
-		ShortDescription: "Prints json details about a tunnel for the provided vpn addr",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshPrintTunnelFlags{}
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshPrintTunnel(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "print-relays",
-		ShortDescription: "Prints json details about all relay info",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshPrintTunnelFlags{}
-			fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshPrintRelays(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "change-remote",
-		ShortDescription: "Changes the remote address used in the tunnel for the provided vpn addr",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshChangeRemoteFlags{}
-			fl.StringVar(&s.Address, "address", "", "The new remote address, ip:port")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshChangeRemote(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "close-tunnel",
-		ShortDescription: "Closes a tunnel for the provided vpn addr",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshCloseTunnelFlags{}
-			fl.BoolVar(&s.LocalOnly, "local-only", false, "Disables notifying the remote that the tunnel is shutting down")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshCloseTunnel(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "create-tunnel",
-		ShortDescription: "Creates a tunnel for the provided vpn address",
-		Help:             "The lighthouses will be queried for real addresses but you can provide one as well.",
-		Flags: func() (*flag.FlagSet, any) {
-			fl := flag.NewFlagSet("", flag.ContinueOnError)
-			s := sshCreateTunnelFlags{}
-			fl.StringVar(&s.Address, "address", "", "Optionally provide a real remote address, ip:port ")
-			return fl, &s
-		},
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshCreateTunnel(f, fs, a, w)
-		},
-	})
-
-	ssh.RegisterCommand(&sshd.Command{
-		Name:             "query-lighthouse",
-		ShortDescription: "Query the lighthouses for the provided vpn address",
-		Help:             "This command is asynchronous. Only currently known udp addresses will be printed.",
-		Callback: func(fs any, a []string, w sshd.StringWriter) error {
-			return sshQueryLighthouse(f, fs, a, w)
-		},
-	})
-}
-
-func sshListHostMap(hl controlHostLister, a any, w sshd.StringWriter) error {
-	fs, ok := a.(*sshListHostMapFlags)
-	if !ok {
-		return nil
-	}
-
-	var hm []ControlHostInfo
-	if fs.ByIndex {
-		hm = listHostMapIndexes(hl)
-	} else {
-		hm = listHostMapHosts(hl)
-	}
-
-	sort.Slice(hm, func(i, j int) bool {
-		return hm[i].VpnAddrs[0].Compare(hm[j].VpnAddrs[0]) < 0
-	})
-
-	if fs.Json || fs.Pretty {
-		js := json.NewEncoder(w.GetWriter())
-		if fs.Pretty {
-			js.SetIndent("", "    ")
-		}
-
-		err := js.Encode(hm)
-		if err != nil {
-			return nil
-		}
-
-	} else {
-		for _, v := range hm {
-			err := w.WriteLine(fmt.Sprintf("%s: %s", v.VpnAddrs, v.RemoteAddrs))
-			if err != nil {
-				return err
-			}
-		}
-	}
-
-	return nil
-}
-
-func sshListLighthouseMap(lightHouse *LightHouse, a any, w sshd.StringWriter) error {
-	fs, ok := a.(*sshListHostMapFlags)
-	if !ok {
-		return nil
-	}
-
-	type lighthouseInfo struct {
-		VpnAddr string    `json:"vpnAddr"`
-		Addrs   *CacheMap `json:"addrs"`
-	}
-
-	lightHouse.RLock()
-	addrMap := make([]lighthouseInfo, len(lightHouse.addrMap))
-	x := 0
-	for k, v := range lightHouse.addrMap {
-		addrMap[x] = lighthouseInfo{
-			VpnAddr: k.String(),
-			Addrs:   v.CopyCache(),
-		}
-		x++
-	}
-	lightHouse.RUnlock()
-
-	sort.Slice(addrMap, func(i, j int) bool {
-		return strings.Compare(addrMap[i].VpnAddr, addrMap[j].VpnAddr) < 0
-	})
-
-	if fs.Json || fs.Pretty {
-		js := json.NewEncoder(w.GetWriter())
-		if fs.Pretty {
-			js.SetIndent("", "    ")
-		}
-
-		err := js.Encode(addrMap)
-		if err != nil {
-			return nil
-		}
-
-	} else {
-		for _, v := range addrMap {
-			b, err := json.Marshal(v.Addrs)
-			if err != nil {
-				return err
-			}
-			err = w.WriteLine(fmt.Sprintf("%s: %s", v.VpnAddr, string(b)))
-			if err != nil {
-				return err
-			}
-		}
-	}
-
-	return nil
-}
-
-func sshVersion(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	return w.WriteLine(fmt.Sprintf("%s", ifce.version))
-}
-
-func sshQueryLighthouse(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	if len(a) == 0 {
-		return w.WriteLine("No vpn address was provided")
-	}
-
-	vpnAddr, err := netip.ParseAddr(a[0])
-	if err != nil {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	if !vpnAddr.IsValid() {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	var cm *CacheMap
-	rl := ifce.lightHouse.Query(vpnAddr)
-	if rl != nil {
-		cm = rl.CopyCache()
-	}
-	return json.NewEncoder(w.GetWriter()).Encode(cm)
-}
-
-func sshCloseTunnel(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	flags, ok := fs.(*sshCloseTunnelFlags)
-	if !ok {
-		return nil
-	}
-
-	if len(a) == 0 {
-		return w.WriteLine("No vpn address was provided")
-	}
-
-	vpnAddr, err := netip.ParseAddr(a[0])
-	if err != nil {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	if !vpnAddr.IsValid() {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
-	if hostInfo == nil {
-		return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
-	}
-
-	if !flags.LocalOnly {
-		ifce.send(
-			header.CloseTunnel,
-			0,
-			hostInfo.ConnectionState,
-			hostInfo,
-			[]byte{},
-			make([]byte, 12, 12),
-			make([]byte, mtu),
-		)
-	}
-
-	ifce.closeTunnel(hostInfo)
-	return w.WriteLine("Closed")
-}
-
-func sshCreateTunnel(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	flags, ok := fs.(*sshCreateTunnelFlags)
-	if !ok {
-		return nil
-	}
-
-	if len(a) == 0 {
-		return w.WriteLine("No vpn address was provided")
-	}
-
-	vpnAddr, err := netip.ParseAddr(a[0])
-	if err != nil {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	if !vpnAddr.IsValid() {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
-	if hostInfo != nil {
-		return w.WriteLine(fmt.Sprintf("Tunnel already exists"))
-	}
-
-	hostInfo = ifce.handshakeManager.QueryVpnAddr(vpnAddr)
-	if hostInfo != nil {
-		return w.WriteLine(fmt.Sprintf("Tunnel already handshaking"))
-	}
-
-	var addr netip.AddrPort
-	if flags.Address != "" {
-		addr, err = netip.ParseAddrPort(flags.Address)
-		if err != nil {
-			return w.WriteLine("Address could not be parsed")
-		}
-	}
-
-	// Set the remote from the callback, which StartHandshake runs under the handshake manager lock
-	// before the hostinfo reaches the handshake loop or the static-host trigger, so the address is
-	// in the RemoteList before the first handshake is sent.
-	ifce.handshakeManager.StartHandshake(vpnAddr, func(hh *HandshakeHostInfo) {
-		if addr.IsValid() {
-			hh.hostinfo.SetRemote(addr)
-		}
-	})
-
-	return w.WriteLine("Created")
-}
-
-func sshChangeRemote(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	flags, ok := fs.(*sshChangeRemoteFlags)
-	if !ok {
-		return nil
-	}
-
-	if len(a) == 0 {
-		return w.WriteLine("No vpn address was provided")
-	}
-
-	if flags.Address == "" {
-		return w.WriteLine("No address was provided")
-	}
-
-	addr, err := netip.ParseAddrPort(flags.Address)
-	if err != nil {
-		return w.WriteLine("Address could not be parsed")
-	}
-
-	vpnAddr, err := netip.ParseAddr(a[0])
-	if err != nil {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	if !vpnAddr.IsValid() {
-		return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
-	}
-
-	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
-	if hostInfo == nil {
-		return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
-	}
-
-	hostInfo.SetRemote(addr)
-	return w.WriteLine("Changed")
-}
-
-func sshLogLevel(l *slog.Logger, fs any, a []string, w sshd.StringWriter) error {
-	ctrl, ok := l.Handler().(interface {
-		GetLevel() slog.Level
-		SetLevel(slog.Level)
-	})
-	if !ok {
-		return w.WriteLine("Log level is not reconfigurable on this logger")
-	}
-
-	if len(a) == 0 {
-		return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel())))
-	}
-
-	level, err := logging.ParseLevel(strings.ToLower(a[0]))
-	if err != nil {
-		return w.WriteLine(fmt.Sprintf("Unknown log level %s. Possible log levels: trace, debug, info, warn, error", a))
-	}
-
-	ctrl.SetLevel(level)
-	return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel())))
-}
-
-func sshLogFormat(l *slog.Logger, fs any, a []string, w sshd.StringWriter) error {
-	ctrl, ok := l.Handler().(interface {
-		GetFormat() string
-		SetFormat(string) error
-	})
-	if !ok {
-		return w.WriteLine("Log format is not reconfigurable on this logger")
-	}
-
-	if len(a) == 0 {
-		return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
-	}
-
-	if err := ctrl.SetFormat(strings.ToLower(a[0])); err != nil {
-		return err
-	}
-	return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
-}
-
-func sshPrintCert(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	args, ok := fs.(*sshPrintCertFlags)
-	if !ok {
-		return nil
-	}
-
-	cert := ifce.pki.getCertState().GetDefaultCertificate()
-	if len(a) > 0 {
-		vpnAddr, err := netip.ParseAddr(a[0])
-		if err != nil {
-			return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
-		}
-
-		if !vpnAddr.IsValid() {
-			return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
-		}
-
-		hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
-		if hostInfo == nil {
-			return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
-		}
-
-		cert = hostInfo.GetCert().Certificate
-	}
-
-	if args.Json || args.Pretty {
-		b, err := cert.MarshalJSON()
-		if err != nil {
-			return nil
-		}
-
-		if args.Pretty {
-			buf := new(bytes.Buffer)
-			err := json.Indent(buf, b, "", "    ")
-			b = buf.Bytes()
-			if err != nil {
-				return nil
-			}
-		}
-
-		return w.WriteBytes(b)
-	}
-
-	if args.Raw {
-		b, err := cert.MarshalPEM()
-		if err != nil {
-			return nil
-		}
-
-		return w.WriteBytes(b)
-	}
-
-	return w.WriteLine(cert.String())
-}
-
-func sshPrintRelays(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	args, ok := fs.(*sshPrintTunnelFlags)
-	if !ok {
-		w.WriteLine(fmt.Sprintf("sshPrintRelays failed to convert args type"))
-		return nil
-	}
-
-	relays := map[uint32]*HostInfo{}
-	ifce.hostMap.Lock()
-	maps.Copy(relays, ifce.hostMap.Relays)
-	ifce.hostMap.Unlock()
-
-	type RelayFor struct {
-		Error          error
-		Type           string
-		State          string
-		PeerAddr       netip.Addr
-		LocalIndex     uint32
-		RemoteIndex    uint32
-		RelayedThrough []netip.Addr
-	}
-
-	type RelayOutput struct {
-		NebulaAddr    netip.Addr
-		RelayForAddrs []RelayFor
-	}
-
-	type CmdOutput struct {
-		Relays []*RelayOutput
-	}
-
-	co := CmdOutput{}
-
-	enc := json.NewEncoder(w.GetWriter())
-
-	if args.Pretty {
-		enc.SetIndent("", "    ")
-	}
-
-	for k, v := range relays {
-		ro := RelayOutput{NebulaAddr: v.vpnAddrs[0]}
-		co.Relays = append(co.Relays, &ro)
-		relayHI := ifce.hostMap.QueryVpnAddr(v.vpnAddrs[0])
-		if relayHI == nil {
-			ro.RelayForAddrs = append(ro.RelayForAddrs, RelayFor{Error: errors.New("could not find hostinfo")})
-			continue
-		}
-		for _, vpnAddr := range relayHI.relayState.CopyRelayForIps() {
-			rf := RelayFor{Error: nil}
-			r, ok := relayHI.relayState.GetRelayForByAddr(vpnAddr)
-			if ok {
-				t := ""
-				switch r.Type {
-				case ForwardingType:
-					t = "forwarding"
-				case TerminalType:
-					t = "terminal"
-				default:
-					t = "unknown"
-				}
-
-				s := ""
-				switch r.State {
-				case Requested:
-					s = "requested"
-				case Established:
-					s = "established"
-				default:
-					s = "unknown"
-				}
-
-				rf.LocalIndex = r.LocalIndex
-				rf.RemoteIndex = r.RemoteIndex
-				rf.PeerAddr = r.PeerAddr
-				rf.Type = t
-				rf.State = s
-				if rf.LocalIndex != k {
-					rf.Error = fmt.Errorf("hostmap LocalIndex '%v' does not match RelayState LocalIndex", k)
-				}
-			}
-			relayedHI := ifce.hostMap.QueryVpnAddr(vpnAddr)
-			if relayedHI != nil {
-				rf.RelayedThrough = append(rf.RelayedThrough, relayedHI.relayState.CopyRelayIps()...)
-			}
-
-			ro.RelayForAddrs = append(ro.RelayForAddrs, rf)
-		}
-	}
-	err := enc.Encode(co)
-	if err != nil {
-		return err
-	}
-	return nil
-}
-
-func sshPrintTunnel(ifce *Interface, fs any, a []string, w sshd.StringWriter) error {
-	args, ok := fs.(*sshPrintTunnelFlags)
-	if !ok {
-		return nil
-	}
-
-	if len(a) == 0 {
-		return w.WriteLine("No vpn address was provided")
-	}
-
-	vpnAddr, err := netip.ParseAddr(a[0])
-	if err != nil {
-		return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
-	}
-
-	if !vpnAddr.IsValid() {
-		return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
-	}
-
-	hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
-	if hostInfo == nil {
-		return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
-	}
-
-	enc := json.NewEncoder(w.GetWriter())
-	if args.Pretty {
-		enc.SetIndent("", "    ")
-	}
-
-	return enc.Encode(copyHostInfo(hostInfo, ifce.hostMap.GetPreferredRanges()))
-}
-
-func sshDeviceInfo(ifce *Interface, fs any, w sshd.StringWriter) error {
-
-	data := struct {
-		Name string         `json:"name"`
-		Cidr []netip.Prefix `json:"cidr"`
-	}{
-		Name: ifce.inside.Name(),
-		Cidr: make([]netip.Prefix, len(ifce.inside.Networks())),
-	}
-
-	copy(data.Cidr, ifce.inside.Networks())
-
-	flags, ok := fs.(*sshDeviceInfoFlags)
-	if !ok {
-		return fmt.Errorf("internal error: expected flags to be sshDeviceInfoFlags but was %+v", fs)
-	}
-
-	if flags.Json || flags.Pretty {
-		js := json.NewEncoder(w.GetWriter())
-		if flags.Pretty {
-			js.SetIndent("", "    ")
-		}
-
-		return js.Encode(data)
-	} else {
-		return w.WriteLine(fmt.Sprintf("name=%v cidr=%v", data.Name, data.Cidr))
-	}
-}
-
-// sshReload reloads the config and writes the result to the client. It reloads even if the client has gone away
-func sshReload(l *slog.Logger, c *config.C, w sshd.StringWriter) error {
-	writeErr := w.WriteLine("Reloading config")
-	res := c.ReloadConfig()
-	res.Log(l)
-	if err := w.WriteLine(res.String()); writeErr == nil {
-		writeErr = err
-	}
-	return writeErr
-}

+ 57 - 0
ssh_commands_test.go

@@ -0,0 +1,57 @@
+package nebula
+
+import (
+	"bytes"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/slackhq/nebula/commands"
+	"github.com/slackhq/nebula/config"
+	"github.com/slackhq/nebula/test"
+)
+
+// TestSSHCommandSurface is a characterization test for the ssh command surface.
+// It pins the registered command set, their descriptions, help text and flag definitions
+// so that moving the registry to a transport neutral package is provably a relocation
+// rather than a rewrite. Regenerate with `go test . -run TestSSHCommandSurface -update`.
+func TestSSHCommandSurface(t *testing.T) {
+	got := renderCommandSurface(registeredCommands(t).All())
+	test.Golden(t, filepath.Join("testdata", "ssh_commands.golden"), got)
+}
+
+// registeredCommands returns a registry holding every command nebula serves. The
+// Interface is never dereferenced: attachCommands only closes over it.
+func registeredCommands(t *testing.T) *commands.Registry {
+	t.Helper()
+	l := test.NewLogger()
+	reg := commands.New()
+	attachCommands(l, config.NewC(l), reg, &Interface{})
+	return reg
+}
+
+func renderCommandSurface(cmds []*commands.Command) string {
+	lines := make([]string, 0, len(cmds))
+	for _, cmd := range cmds {
+		b := &bytes.Buffer{}
+		fmt.Fprintf(b, "%s\n", cmd.Name)
+		fmt.Fprintf(b, "  short: %s\n", cmd.ShortDescription)
+		fmt.Fprintf(b, "  help: %s\n", cmd.Help)
+
+		if cmd.Flags != nil {
+			fl, _ := cmd.Flags()
+			if fl != nil {
+				fb := &bytes.Buffer{}
+				fl.SetOutput(fb)
+				fl.PrintDefaults()
+				fmt.Fprintf(b, "  flags:\n%s", fb.String())
+			}
+		}
+
+		lines = append(lines, b.String())
+	}
+
+	// In the order All gives, so the golden pins display order too.
+	return strings.Join(lines, "")
+}

+ 0 - 161
sshd/command.go

@@ -1,161 +0,0 @@
-package sshd
-
-import (
-	"errors"
-	"flag"
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/armon/go-radix"
-)
-
-// CommandFlags is a function called before help or command execution to parse command line flags
-// It should return a flag.FlagSet instance and a pointer to the struct that will contain parsed flags
-type CommandFlags func() (*flag.FlagSet, any)
-
-// CommandCallback is the function called when your command should execute.
-// fs will be a a pointer to the struct provided by Command.Flags callback, if there was one. -h and -help are reserved
-// and handled automatically for you.
-// a will be any unconsumed arguments, if no Command.Flags was available this will be all the flags passed in.
-// w is the writer to use when sending messages back to the client.
-// If an error is returned by the callback it is logged locally, the callback should handle messaging errors to the user
-// where appropriate
-type CommandCallback func(fs any, a []string, w StringWriter) error
-
-type Command struct {
-	Name             string
-	ShortDescription string
-	Help             string
-	Flags            CommandFlags
-	Callback         CommandCallback
-}
-
-func execCommand(c *Command, args []string, w StringWriter) error {
-	var (
-		fl *flag.FlagSet
-		fs any
-	)
-
-	if c.Flags != nil {
-		fl, fs = c.Flags()
-		if fl != nil {
-			// SetOutput() here in case fl.Parse dumps usage.
-			fl.SetOutput(w.GetWriter())
-			err := fl.Parse(args)
-			if err != nil {
-				// fl.Parse has dumped error information to the user via the w writer.
-				return err
-			}
-			args = fl.Args()
-		}
-	}
-
-	return c.Callback(fs, args, w)
-}
-
-func dumpCommands(c *radix.Tree, w StringWriter) {
-	err := w.WriteLine("Available commands:")
-	if err != nil {
-		return
-	}
-
-	cmds := make([]string, 0)
-	for _, l := range allCommands(c) {
-		cmds = append(cmds, fmt.Sprintf("%s - %s", l.Name, l.ShortDescription))
-	}
-
-	sort.Strings(cmds)
-	_ = w.Write(strings.Join(cmds, "\n") + "\n\n")
-}
-
-func lookupCommand(c *radix.Tree, sCmd string) (*Command, error) {
-	cmd, ok := c.Get(sCmd)
-	if !ok {
-		return nil, nil
-	}
-
-	command, ok := cmd.(*Command)
-	if !ok {
-		return nil, errors.New("failed to cast command")
-	}
-
-	return command, nil
-}
-
-func matchCommand(c *radix.Tree, cmd string) []string {
-	cmds := make([]string, 0)
-	c.WalkPrefix(cmd, func(found string, v any) bool {
-		cmds = append(cmds, found)
-		return false
-	})
-	sort.Strings(cmds)
-	return cmds
-}
-
-func allCommands(c *radix.Tree) []*Command {
-	cmds := make([]*Command, 0)
-	c.WalkPrefix("", func(found string, v any) bool {
-		cmd, ok := v.(*Command)
-		if ok {
-			cmds = append(cmds, cmd)
-		}
-		return false
-	})
-	return cmds
-}
-
-func helpCallback(commands *radix.Tree, a []string, w StringWriter) (err error) {
-	// Just typed help
-	if len(a) == 0 {
-		dumpCommands(commands, w)
-		return nil
-	}
-
-	// We are printing a specific commands help text
-	cmd, err := lookupCommand(commands, a[0])
-	if err != nil {
-		return
-	}
-
-	if cmd != nil {
-		err = w.WriteLine(fmt.Sprintf("%s - %s", cmd.Name, cmd.ShortDescription))
-		if err != nil {
-			return err
-		}
-
-		if cmd.Help != "" {
-			err = w.WriteLine(fmt.Sprintf("  %s", cmd.Help))
-			if err != nil {
-				return err
-			}
-		}
-
-		if cmd.Flags != nil {
-			fs, _ := cmd.Flags()
-			if fs != nil {
-				fs.SetOutput(w.GetWriter())
-				fs.PrintDefaults()
-			}
-		}
-
-		return nil
-	}
-
-	err = w.WriteLine("Command not available " + a[0])
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func checkHelpArgs(args []string) bool {
-	for _, a := range args {
-		if a == "-h" || a == "-help" {
-			return true
-		}
-	}
-
-	return false
-}

+ 9 - 23
sshd/server.go

@@ -10,7 +10,7 @@ import (
 	"sync"
 	"sync/atomic"
 
-	"github.com/armon/go-radix"
+	"github.com/slackhq/nebula/commands"
 	"golang.org/x/crypto/ssh"
 )
 
@@ -29,10 +29,9 @@ type SSHServer struct {
 	trustedKeys map[string]map[string]bool
 	trustedCAs  []ssh.PublicKey
 
-	// List of available commands
-	helpCommand *Command
-	commands    *radix.Tree
-	listener    atomic.Pointer[net.Listener]
+	// Available commands.
+	commands *commands.Registry
+	listener atomic.Pointer[net.Listener]
 
 	// runs counts the Runs that are listening. A reload's Run can start before the one it replaced has returned
 	runs atomic.Int32
@@ -44,14 +43,14 @@ type SSHServer struct {
 	ctx context.Context
 }
 
-// NewSSHServer creates a new ssh server rigged with default commands and prepares to listen.
-// The ssh server's context is parented off the supplied ctx so cancelling it
-// (e.g. on Control.Stop) tears down active sessions and closes the listener.
-func NewSSHServer(ctx context.Context, l *slog.Logger) (*SSHServer, error) {
+// NewSSHServer creates a new ssh server serving the supplied command registry and
+// prepares to listen. The ssh server's context is parented off the supplied ctx so
+// cancelling it (e.g. on Control.Stop) tears down active sessions and closes the listener.
+func NewSSHServer(ctx context.Context, l *slog.Logger, reg *commands.Registry) (*SSHServer, error) {
 	s := &SSHServer{
 		trustedKeys: make(map[string]map[string]bool),
 		l:           l,
-		commands:    radix.New(),
+		commands:    reg,
 		ctx:         ctx,
 	}
 
@@ -97,14 +96,6 @@ func NewSSHServer(ctx context.Context, l *slog.Logger) (*SSHServer, error) {
 	s.authenticate = cc.Authenticate
 	s.config.Store(s.newConfig())
 
-	s.RegisterCommand(&Command{
-		Name:             "help",
-		ShortDescription: "prints available commands or help <command> for specific usage info",
-		Callback: func(a any, args []string, w StringWriter) error {
-			return helpCallback(s.commands, args, w)
-		},
-	})
-
 	return s, nil
 }
 
@@ -189,11 +180,6 @@ func (s *SSHServer) AddAuthorizedKey(user, pubKey string) error {
 	return nil
 }
 
-// RegisterCommand adds a command that can be run by a user, by default only `help` is available
-func (s *SSHServer) RegisterCommand(c *Command) {
-	s.commands.Insert(c.Name, c)
-}
-
 // Run begins listening and accepting connections. Each invocation derives a fresh per-Run context
 // from the constructor-supplied ctx so a Stop+Run sequence (used by config reload) starts clean
 // rather than carrying a permanently-cancelled context across runs.

+ 12 - 44
sshd/session.go

@@ -1,13 +1,10 @@
 package sshd
 
 import (
-	"fmt"
 	"log/slog"
-	"sort"
 	"strings"
 
-	"github.com/anmitsu/go-shlex"
-	"github.com/armon/go-radix"
+	"github.com/slackhq/nebula/commands"
 	"golang.org/x/crypto/ssh"
 	"golang.org/x/term"
 )
@@ -16,22 +13,22 @@ type session struct {
 	l        *slog.Logger
 	c        *ssh.ServerConn
 	term     *term.Terminal
-	commands *radix.Tree
+	commands *commands.Registry
 	cancel   func()
 }
 
-func NewSession(commands *radix.Tree, conn *ssh.ServerConn, chans <-chan ssh.NewChannel, cancel func(), l *slog.Logger) *session {
+func NewSession(reg *commands.Registry, conn *ssh.ServerConn, chans <-chan ssh.NewChannel, cancel func(), l *slog.Logger) *session {
 	s := &session{
-		commands: radix.NewFromMap(commands.ToMap()),
+		commands: reg.Clone(),
 		l:        l,
 		c:        conn,
 		cancel:   cancel,
 	}
 
-	s.commands.Insert("logout", &Command{
-		Name:             "logout",
+	s.commands.Register(&commands.Command{
+		Name:             commands.Logout,
 		ShortDescription: "Ends the current session",
-		Callback: func(a any, args []string, w StringWriter) error {
+		Callback: func(a any, args []string, w commands.StringWriter) error {
 			s.Close()
 			return nil
 		},
@@ -87,7 +84,7 @@ func (s *session) handleRequests(in <-chan *ssh.Request, channel ssh.Channel) {
 			}
 
 			req.Reply(true, nil)
-			s.dispatchCommand(payload.Value, &stringWriter{channel})
+			s.dispatchCommand(payload.Value, commands.NewStringWriter(channel))
 
 			status := struct{ Status uint32 }{uint32(0)}
 			channel.SendRequest("exit-status", false, ssh.Marshal(status))
@@ -111,12 +108,11 @@ func (s *session) createTerm(channel ssh.Channel) *term.Terminal {
 	term.AutoCompleteCallback = func(line string, pos int, key rune) (newLine string, newPos int, ok bool) {
 		// key 9 is tab
 		if key == 9 {
-			cmds := matchCommand(s.commands, line)
+			cmds := s.commands.Match(line)
 			if len(cmds) == 1 {
 				return cmds[0] + " ", len(cmds[0]) + 1, true
 			}
 
-			sort.Strings(cmds)
 			term.Write([]byte(strings.Join(cmds, "\n") + "\n\n"))
 		}
 
@@ -128,7 +124,7 @@ func (s *session) createTerm(channel ssh.Channel) *term.Terminal {
 }
 
 func (s *session) handleInput() {
-	w := &stringWriter{w: s.term}
+	w := commands.NewStringWriter(s.term)
 	for {
 		line, err := s.term.ReadLine()
 		if err != nil {
@@ -139,36 +135,8 @@ func (s *session) handleInput() {
 	}
 }
 
-func (s *session) dispatchCommand(line string, w StringWriter) {
-	args, err := shlex.Split(line, true)
-	if err != nil {
-		return
-	}
-
-	if len(args) == 0 {
-		dumpCommands(s.commands, w)
-		return
-	}
-
-	c, err := lookupCommand(s.commands, args[0])
-	if err != nil {
-		return
-	}
-
-	if c == nil {
-		err := w.WriteLine(fmt.Sprintf("did not understand: %s", line))
-		_ = err
-
-		dumpCommands(s.commands, w)
-		return
-	}
-
-	if checkHelpArgs(args) {
-		s.dispatchCommand(fmt.Sprintf("%s %s", "help", c.Name), w)
-		return
-	}
-
-	_ = execCommand(c, args[1:], w)
+func (s *session) dispatchCommand(line string, w commands.StringWriter) {
+	s.commands.Dispatch(line, w)
 }
 
 func (s *session) Close() {

+ 2 - 1
sshd_reload_test.go

@@ -13,6 +13,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/config"
 	"github.com/slackhq/nebula/sshd"
 	"github.com/slackhq/nebula/test"
@@ -44,7 +45,7 @@ func newSSHDReloadHarness(t *testing.T) *sshdReloadHarness {
 
 	h.c = config.NewC(l)
 	require.NoError(t, h.c.LoadString(h.config()))
-	h.server, err = sshd.NewSSHServer(t.Context(), l)
+	h.server, err = sshd.NewSSHServer(t.Context(), l, commands.New())
 	require.NoError(t, err)
 	wireSSHReload(l, h.server, h.c)
 	run, err := configSSH(l, h.server, h.c, nil)

+ 29 - 0
test/helpers.go

@@ -0,0 +1,29 @@
+package test
+
+import (
+	"flag"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// Update is the -update flag: Golden rewrites its file instead of comparing against it.
+var Update = flag.Bool("update", false, "rewrite golden files instead of comparing against them")
+
+// Golden compares got with the file at path, or rewrites the file when -update is set.
+func Golden(t *testing.T, path, got string) {
+	t.Helper()
+
+	if *Update {
+		require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
+		require.NoError(t, os.WriteFile(path, []byte(got), 0644))
+		return
+	}
+
+	want, err := os.ReadFile(path)
+	require.NoError(t, err, "missing golden file, run with -update")
+	assert.Equal(t, string(want), got)
+}

+ 94 - 0
testdata/ssh_commands.golden

@@ -0,0 +1,94 @@
+change-remote
+  short: Changes the remote address used in the tunnel for the provided vpn addr
+  help: 
+  flags:
+  -address string
+    	The new remote address, ip:port
+close-tunnel
+  short: Closes a tunnel for the provided vpn addr
+  help: 
+  flags:
+  -local-only
+    	Disables notifying the remote that the tunnel is shutting down
+create-tunnel
+  short: Creates a tunnel for the provided vpn address
+  help: The lighthouses will be queried for real addresses but you can provide one as well.
+  flags:
+  -address string
+    	Optionally provide a real remote address, ip:port 
+device-info
+  short: Prints information about the network device.
+  help: 
+  flags:
+  -json
+    	outputs as json with more information
+  -pretty
+    	pretty prints json, assumes -json
+help
+  short: prints available commands or help <command> for specific usage info
+  help: 
+list-hostmap
+  short: List all known previously connected hosts
+  help: 
+  flags:
+  -by-index
+    	gets all hosts in the hostmap from the index table
+  -json
+    	outputs as json with more information
+  -pretty
+    	pretty prints json, assumes -json
+list-lighthouse-addrmap
+  short: List all lighthouse map entries
+  help: 
+  flags:
+  -json
+    	outputs as json with more information
+  -pretty
+    	pretty prints json, assumes -json
+list-pending-hostmap
+  short: List all handshaking hosts
+  help: 
+  flags:
+  -by-index
+    	gets all hosts in the hostmap from the index table
+  -json
+    	outputs as json with more information
+  -pretty
+    	pretty prints json, assumes -json
+log-format
+  short: Gets or sets the current log format
+  help: 
+log-level
+  short: Gets or sets the current log level
+  help: 
+print-cert
+  short: Prints the current certificate being used or the certificate for the provided vpn addr
+  help: 
+  flags:
+  -json
+    	outputs as json
+  -pretty
+    	pretty prints json, assumes -json
+  -raw
+    	raw prints the PEM encoded certificate, not compatible with -json or -pretty
+print-relays
+  short: Prints json details about all relay info
+  help: 
+  flags:
+  -pretty
+    	pretty prints json
+print-tunnel
+  short: Prints json details about a tunnel for the provided vpn addr
+  help: 
+  flags:
+  -pretty
+    	pretty prints json
+query-lighthouse
+  short: Query the lighthouses for the provided vpn address
+  help: This command is asynchronous. Only currently known udp addresses will be printed.
+reload
+  short: Reloads configuration from disk, same as sending HUP to the process
+  help: 
+version
+  short: Prints the currently running version of nebula
+  help: