diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..79b69df3 --- /dev/null +++ b/.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 diff --git a/commands.go b/commands.go new file mode 100644 index 00000000..2c859e97 --- /dev/null +++ b/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 +} diff --git a/commands/command.go b/commands/command.go new file mode 100644 index 00000000..3ff820d4 --- /dev/null +++ b/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 +} diff --git a/commands/names.go b/commands/names.go new file mode 100644 index 00000000..78bf3bc3 --- /dev/null +++ b/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" +) diff --git a/commands/registry.go b/commands/registry.go new file mode 100644 index 00000000..aeeefebe --- /dev/null +++ b/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 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 ` 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 +} diff --git a/commands/registry_test.go b/commands/registry_test.go new file mode 100644 index 00000000..ca788253 --- /dev/null +++ b/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 `, 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()) +} diff --git a/sshd/writer.go b/commands/writer.go similarity index 64% rename from sshd/writer.go rename to commands/writer.go index 8354c094..5ea2d804 100644 --- a/sshd/writer.go +++ b/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") } diff --git a/control.go b/control.go index 41bc4f97..57cca794 100644 --- a/control.go +++ b/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 } diff --git a/e2e/commands_output_test.go b/e2e/commands_output_test.go new file mode 100644 index 00000000..331f4188 --- /dev/null +++ b/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,}`), ""}, // fingerprints, keys, signatures + {regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`), "