Report ssh command failures to the surface (#1959)
smoke-extra / freebsd-amd64 (push) Failing after 14s
smoke-extra / linux-amd64-ipv6disable (push) Failing after 13s
smoke-extra / netbsd-amd64 (push) Failing after 14s
smoke-extra / openbsd-amd64 (push) Failing after 15s
smoke-extra / linux-386 (push) Failing after 14s
smoke / Run multi node smoke test (push) Failing after 1m46s
Build and test / Static checks (push) Successful in 2m38s
Build and test / Test linux (push) Failing after 1m12s
Build and test / Test linux-pkcs11 (push) Failing after 2m6s
Build and test / Test linux-boringcrypto (push) Failing after 3m0s
Build and test / Test linux-fips140 (push) Failing after 2m56s
Build and test / Cross-build linux-arm (push) Successful in 3m10s
Build and test / Cross-build linux-mips (push) Successful in 3m55s
Build and test / Cross-build linux-other (push) Successful in 3m15s
Build and test / Cross-build windows (push) Successful in 1m5s
Build and test / Cross-build freebsd (push) Successful in 1m37s
Build and test / Cross-build netbsd (push) Successful in 1m36s
Build and test / Cross-build openbsd (push) Successful in 1m35s
Build and test / Cross-build mobile (push) Successful in 3m32s
smoke-extra / Run windows smoke test (push) Canceled after 0s
smoke / Run self traffic smoke test on macOS (push) Canceled after 0s
Build and test / Test macos (push) Canceled after 0s
Build and test / Test windows (push) Canceled after 0s
Build and test / CI status (push) Canceled after 0s

Report ssh command failures to the surface (#1959)

The ssh debug commands told the user when something went wrong but
never told the surface that ran them, so `ssh host cmd` always exited 0
and a few failures were not reported at all. A failed command now
returns its error, a failure the user has already heard about is a
commands.Failure, and the ssh exec path exits 1 for either. The command
bodies also lose their ssh prefixes and the duplication they carried
over from ssh.go; both goldens pin that nothing else changed.

- Dispatch returns the callback's error, or shlex's; the ssh session
  logs it at debug, and not at all for a Failure the user already saw
- commands.Fail writes a message and returns it as a Failure in one step;
  an unknown command, `help` for a command that is not there, a failed
  reload and an unbalanced quote are failures
- ssh exec exit-status is 1 for a command, flag or failure error, 0
  otherwise; the e2e golden records the exit status for every case
- Clone rebinds help to the copy, so an ssh session's help lists logout
  and `help logout` and `logout -h` answer; sshd.LogoutCommand is the
  session's logout, exported for tests
- "did not understand" names the first word only, the one looked up;
  DispatchArgs takes just the tokens
- log-format writes its refusal to the user, as log-level does
- print-relays emits the relay error as a string and omits the key when
  there is none, where it printed {} or "Error":null
- list-hostmap and list-lighthouse-addrmap -json return the encode
  error; print-cert reports a certificate that will not marshal
- a flags type that is not the one registered is an internal error, not
  a silent success
- command functions and flag types are <name>Command and <name>Flags;
  parseVpnAddr and listHostMapFlagSet replace the repeated parse block
  and flag set; close-tunnel sends through sendCloseTunnel

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Matt RichardsonandClaude Fable 5.1 authored and GitHub committed 2026-10-19 10:48:26 -04:00
1 parent b426843f88
commit 8a4bd8b10f
11 files changed
+644 -249

No files matched your search

+144 -190
View File
@@ -3,7 +3,6 @@ package nebula
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -14,42 +13,51 @@ import (
"github.com/slackhq/nebula/commands" "github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/logging" "github.com/slackhq/nebula/logging"
) )
// The commands nebula serves. attachCommands builds the one registry every surface // The commands nebula serves. attachCommands builds the one registry every surface
// reads. See the commands package for what a Command is. // reads. See the commands package for what a Command is.
type sshListHostMapFlags struct { type listHostMapFlags struct {
Json bool Json bool
Pretty bool Pretty bool
ByIndex bool ByIndex bool
} }
type sshPrintCertFlags struct { // listHostMapFlagSet is the flag set list-hostmap and list-pending-hostmap share.
func listHostMapFlagSet() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError)
s := listHostMapFlags{}
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
}
type printCertFlags struct {
Json bool Json bool
Pretty bool Pretty bool
Raw bool Raw bool
} }
type sshPrintTunnelFlags struct { type printTunnelFlags struct {
Pretty bool Pretty bool
} }
type sshChangeRemoteFlags struct { type changeRemoteFlags struct {
Address string Address string
} }
type sshCloseTunnelFlags struct { type closeTunnelFlags struct {
LocalOnly bool LocalOnly bool
} }
type sshCreateTunnelFlags struct { type createTunnelFlags struct {
Address string Address string
} }
type sshDeviceInfoFlags struct { type deviceInfoFlags struct {
Json bool Json bool
Pretty bool Pretty bool
} }
@@ -58,32 +66,18 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
reg.Register(&commands.Command{ reg.Register(&commands.Command{
Name: commands.ListHostmap, Name: commands.ListHostmap,
ShortDescription: "List all known previously connected hosts", ShortDescription: "List all known previously connected hosts",
Flags: func() (*flag.FlagSet, any) { Flags: listHostMapFlagSet,
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 { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshListHostMap(f.hostMap, fs, w) return listHostMapCommand(f.hostMap, fs, w)
}, },
}) })
reg.Register(&commands.Command{ reg.Register(&commands.Command{
Name: commands.ListPendingHostmap, Name: commands.ListPendingHostmap,
ShortDescription: "List all handshaking hosts", ShortDescription: "List all handshaking hosts",
Flags: func() (*flag.FlagSet, any) { Flags: listHostMapFlagSet,
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 { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshListHostMap(f.handshakeManager, fs, w) return listHostMapCommand(f.handshakeManager, fs, w)
}, },
}) })
@@ -92,13 +86,13 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "List all lighthouse map entries", ShortDescription: "List all lighthouse map entries",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshListHostMapFlags{} s := listHostMapFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json with more information") 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.Pretty, "pretty", false, "pretty prints json, assumes -json")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshListLighthouseMap(f.lightHouse, fs, w) return listLighthouseAddrmapCommand(f.lightHouse, fs, w)
}, },
}) })
@@ -106,7 +100,7 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
Name: commands.Reload, Name: commands.Reload,
ShortDescription: "Reloads configuration from disk, same as sending HUP to the process", ShortDescription: "Reloads configuration from disk, same as sending HUP to the process",
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshReload(l, c, w) return reloadCommand(l, c, w)
}, },
}) })
@@ -114,7 +108,7 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
Name: commands.LogLevel, Name: commands.LogLevel,
ShortDescription: "Gets or sets the current log level", ShortDescription: "Gets or sets the current log level",
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshLogLevel(l, fs, a, w) return logLevelCommand(l, fs, a, w)
}, },
}) })
@@ -122,7 +116,7 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
Name: commands.LogFormat, Name: commands.LogFormat,
ShortDescription: "Gets or sets the current log format", ShortDescription: "Gets or sets the current log format",
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshLogFormat(l, fs, a, w) return logFormatCommand(l, fs, a, w)
}, },
}) })
@@ -130,7 +124,7 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
Name: commands.Version, Name: commands.Version,
ShortDescription: "Prints the currently running version of nebula", ShortDescription: "Prints the currently running version of nebula",
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshVersion(f, fs, a, w) return versionCommand(f, fs, a, w)
}, },
}) })
@@ -139,13 +133,13 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Prints information about the network device.", ShortDescription: "Prints information about the network device.",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshDeviceInfoFlags{} s := deviceInfoFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json with more information") 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.Pretty, "pretty", false, "pretty prints json, assumes -json")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshDeviceInfo(f, fs, w) return deviceInfoCommand(f, fs, w)
}, },
}) })
@@ -154,14 +148,14 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr", ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshPrintCertFlags{} s := printCertFlags{}
fl.BoolVar(&s.Json, "json", false, "outputs as json") fl.BoolVar(&s.Json, "json", false, "outputs as json")
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json, assumes -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") fl.BoolVar(&s.Raw, "raw", false, "raw prints the PEM encoded certificate, not compatible with -json or -pretty")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshPrintCert(f, fs, a, w) return printCertCommand(f, fs, a, w)
}, },
}) })
@@ -170,12 +164,12 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Prints json details about a tunnel for the provided vpn addr", ShortDescription: "Prints json details about a tunnel for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshPrintTunnelFlags{} s := printTunnelFlags{}
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json") fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshPrintTunnel(f, fs, a, w) return printTunnelCommand(f, fs, a, w)
}, },
}) })
@@ -184,12 +178,12 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Prints json details about all relay info", ShortDescription: "Prints json details about all relay info",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshPrintTunnelFlags{} s := printTunnelFlags{}
fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json") fl.BoolVar(&s.Pretty, "pretty", false, "pretty prints json")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshPrintRelays(f, fs, a, w) return printRelaysCommand(f, fs, a, w)
}, },
}) })
@@ -198,12 +192,12 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Changes the remote address used in the tunnel for the provided vpn addr", ShortDescription: "Changes the remote address used in the tunnel for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshChangeRemoteFlags{} s := changeRemoteFlags{}
fl.StringVar(&s.Address, "address", "", "The new remote address, ip:port") fl.StringVar(&s.Address, "address", "", "The new remote address, ip:port")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshChangeRemote(f, fs, a, w) return changeRemoteCommand(f, fs, a, w)
}, },
}) })
@@ -212,12 +206,12 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Closes a tunnel for the provided vpn addr", ShortDescription: "Closes a tunnel for the provided vpn addr",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshCloseTunnelFlags{} s := closeTunnelFlags{}
fl.BoolVar(&s.LocalOnly, "local-only", false, "Disables notifying the remote that the tunnel is shutting down") fl.BoolVar(&s.LocalOnly, "local-only", false, "Disables notifying the remote that the tunnel is shutting down")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshCloseTunnel(f, fs, a, w) return closeTunnelCommand(f, fs, a, w)
}, },
}) })
@@ -227,12 +221,12 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
Help: "The lighthouses will be queried for real addresses but you can provide one as well.", Help: "The lighthouses will be queried for real addresses but you can provide one as well.",
Flags: func() (*flag.FlagSet, any) { Flags: func() (*flag.FlagSet, any) {
fl := flag.NewFlagSet("", flag.ContinueOnError) fl := flag.NewFlagSet("", flag.ContinueOnError)
s := sshCreateTunnelFlags{} s := createTunnelFlags{}
fl.StringVar(&s.Address, "address", "", "Optionally provide a real remote address, ip:port ") fl.StringVar(&s.Address, "address", "", "Optionally provide a real remote address, ip:port ")
return fl, &s return fl, &s
}, },
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshCreateTunnel(f, fs, a, w) return createTunnelCommand(f, fs, a, w)
}, },
}) })
@@ -241,15 +235,15 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
ShortDescription: "Query the lighthouses for the provided vpn address", ShortDescription: "Query the lighthouses for the provided vpn address",
Help: "This command is asynchronous. Only currently known udp addresses will be printed.", Help: "This command is asynchronous. Only currently known udp addresses will be printed.",
Callback: func(fs any, a []string, w commands.StringWriter) error { Callback: func(fs any, a []string, w commands.StringWriter) error {
return sshQueryLighthouse(f, fs, a, w) return queryLighthouseCommand(f, fs, a, w)
}, },
}) })
} }
func sshListHostMap(hl controlHostLister, a any, w commands.StringWriter) error { func listHostMapCommand(hl controlHostLister, a any, w commands.StringWriter) error {
fs, ok := a.(*sshListHostMapFlags) fs, ok := a.(*listHostMapFlags)
if !ok { if !ok {
return nil return fmt.Errorf("internal error: expected *listHostMapFlags but was %T", a)
} }
var hm []ControlHostInfo var hm []ControlHostInfo
@@ -269,9 +263,8 @@ func sshListHostMap(hl controlHostLister, a any, w commands.StringWriter) error
js.SetIndent("", " ") js.SetIndent("", " ")
} }
err := js.Encode(hm) if err := js.Encode(hm); err != nil {
if err != nil { return err
return nil
} }
} else { } else {
@@ -286,10 +279,10 @@ func sshListHostMap(hl controlHostLister, a any, w commands.StringWriter) error
return nil return nil
} }
func sshListLighthouseMap(lightHouse *LightHouse, a any, w commands.StringWriter) error { func listLighthouseAddrmapCommand(lightHouse *LightHouse, a any, w commands.StringWriter) error {
fs, ok := a.(*sshListHostMapFlags) fs, ok := a.(*listHostMapFlags)
if !ok { if !ok {
return nil return fmt.Errorf("internal error: expected *listHostMapFlags but was %T", a)
} }
type lighthouseInfo struct { type lighthouseInfo struct {
@@ -319,9 +312,8 @@ func sshListLighthouseMap(lightHouse *LightHouse, a any, w commands.StringWriter
js.SetIndent("", " ") js.SetIndent("", " ")
} }
err := js.Encode(addrMap) if err := js.Encode(addrMap); err != nil {
if err != nil { return err
return nil
} }
} else { } else {
@@ -340,22 +332,30 @@ func sshListLighthouseMap(lightHouse *LightHouse, a any, w commands.StringWriter
return nil return nil
} }
func sshVersion(ifce *Interface, fs any, a []string, w commands.StringWriter) error { // parseVpnAddr parses the vpn address a command takes as its first operand, failing to w
return w.WriteLine(fmt.Sprintf("%s", ifce.version)) // when there is none or it does not parse. noun is how the command's messages name it,
} // "vpn address" or "vpn addr", which the output golden pins per command.
func parseVpnAddr(a []string, noun string, w commands.StringWriter) (netip.Addr, error) {
func sshQueryLighthouse(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
if len(a) == 0 { if len(a) == 0 {
return w.WriteLine("No vpn address was provided") return netip.Addr{}, commands.Fail(w, "No vpn address was provided")
} }
vpnAddr, err := netip.ParseAddr(a[0]) vpnAddr, err := netip.ParseAddr(a[0])
if err != nil { if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0])) return netip.Addr{}, commands.Fail(w, fmt.Sprintf("The provided %s could not be parsed: %s", noun, a[0]))
} }
if !vpnAddr.IsValid() { return vpnAddr, nil
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0])) }
func versionCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
return w.WriteLine(ifce.version)
}
func queryLighthouseCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
vpnAddr, err := parseVpnAddr(a, "vpn address", w)
if err != nil {
return err
} }
var cm *CacheMap var cm *CacheMap
@@ -366,80 +366,56 @@ func sshQueryLighthouse(ifce *Interface, fs any, a []string, w commands.StringWr
return json.NewEncoder(w.GetWriter()).Encode(cm) return json.NewEncoder(w.GetWriter()).Encode(cm)
} }
func sshCloseTunnel(ifce *Interface, fs any, a []string, w commands.StringWriter) error { func closeTunnelCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
flags, ok := fs.(*sshCloseTunnelFlags) flags, ok := fs.(*closeTunnelFlags)
if !ok { if !ok {
return nil return fmt.Errorf("internal error: expected *closeTunnelFlags but was %T", fs)
} }
if len(a) == 0 { vpnAddr, err := parseVpnAddr(a, "vpn address", w)
return w.WriteLine("No vpn address was provided")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil { if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0])) return err
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
} }
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr) hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil { if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0])) return commands.Fail(w, fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
} }
if !flags.LocalOnly { if !flags.LocalOnly {
ifce.send( ifce.sendCloseTunnel(hostInfo)
header.CloseTunnel,
0,
hostInfo.ConnectionState,
hostInfo,
[]byte{},
make([]byte, 12, 12),
make([]byte, mtu),
)
} }
ifce.closeTunnel(hostInfo) ifce.closeTunnel(hostInfo)
return w.WriteLine("Closed") return w.WriteLine("Closed")
} }
func sshCreateTunnel(ifce *Interface, fs any, a []string, w commands.StringWriter) error { func createTunnelCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
flags, ok := fs.(*sshCreateTunnelFlags) flags, ok := fs.(*createTunnelFlags)
if !ok { if !ok {
return nil return fmt.Errorf("internal error: expected *createTunnelFlags but was %T", fs)
} }
if len(a) == 0 { vpnAddr, err := parseVpnAddr(a, "vpn address", w)
return w.WriteLine("No vpn address was provided")
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil { if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0])) return err
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn address could not be parsed: %s", a[0]))
} }
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr) hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo != nil { if hostInfo != nil {
return w.WriteLine(fmt.Sprintf("Tunnel already exists")) return commands.Fail(w, "Tunnel already exists")
} }
hostInfo = ifce.handshakeManager.QueryVpnAddr(vpnAddr) hostInfo = ifce.handshakeManager.QueryVpnAddr(vpnAddr)
if hostInfo != nil { if hostInfo != nil {
return w.WriteLine(fmt.Sprintf("Tunnel already handshaking")) return commands.Fail(w, "Tunnel already handshaking")
} }
var addr netip.AddrPort var addr netip.AddrPort
if flags.Address != "" { if flags.Address != "" {
addr, err = netip.ParseAddrPort(flags.Address) addr, err = netip.ParseAddrPort(flags.Address)
if err != nil { if err != nil {
return w.WriteLine("Address could not be parsed") return commands.Fail(w, "Address could not be parsed")
} }
} }
@@ -455,50 +431,42 @@ func sshCreateTunnel(ifce *Interface, fs any, a []string, w commands.StringWrite
return w.WriteLine("Created") return w.WriteLine("Created")
} }
func sshChangeRemote(ifce *Interface, fs any, a []string, w commands.StringWriter) error { func changeRemoteCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
flags, ok := fs.(*sshChangeRemoteFlags) flags, ok := fs.(*changeRemoteFlags)
if !ok { if !ok {
return nil return fmt.Errorf("internal error: expected *changeRemoteFlags but was %T", fs)
} }
if len(a) == 0 { vpnAddr, err := parseVpnAddr(a, "vpn address", w)
return w.WriteLine("No vpn address was provided") if err != nil {
return err
} }
if flags.Address == "" { if flags.Address == "" {
return w.WriteLine("No address was provided") return commands.Fail(w, "No address was provided")
} }
addr, err := netip.ParseAddrPort(flags.Address) addr, err := netip.ParseAddrPort(flags.Address)
if err != nil { if err != nil {
return w.WriteLine("Address could not be parsed") return commands.Fail(w, "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) hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil { if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0])) return commands.Fail(w, fmt.Sprintf("Could not find tunnel for vpn address: %v", a[0]))
} }
hostInfo.SetRemote(addr) hostInfo.SetRemote(addr)
return w.WriteLine("Changed") return w.WriteLine("Changed")
} }
func sshLogLevel(l *slog.Logger, fs any, a []string, w commands.StringWriter) error { func logLevelCommand(l *slog.Logger, fs any, a []string, w commands.StringWriter) error {
ctrl, ok := l.Handler().(interface { ctrl, ok := l.Handler().(interface {
GetLevel() slog.Level GetLevel() slog.Level
SetLevel(slog.Level) SetLevel(slog.Level)
}) })
if !ok { if !ok {
return w.WriteLine("Log level is not reconfigurable on this logger") return commands.Fail(w, "Log level is not reconfigurable on this logger")
} }
if len(a) == 0 { if len(a) == 0 {
@@ -507,20 +475,20 @@ func sshLogLevel(l *slog.Logger, fs any, a []string, w commands.StringWriter) er
level, err := logging.ParseLevel(strings.ToLower(a[0])) level, err := logging.ParseLevel(strings.ToLower(a[0]))
if err != nil { if err != nil {
return w.WriteLine(fmt.Sprintf("Unknown log level %s. Possible log levels: trace, debug, info, warn, error", a)) return commands.Fail(w, fmt.Sprintf("Unknown log level %s. Possible log levels: trace, debug, info, warn, error", a))
} }
ctrl.SetLevel(level) ctrl.SetLevel(level)
return w.WriteLine(fmt.Sprintf("Log level is: %s", logging.LevelName(ctrl.GetLevel()))) 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 { func logFormatCommand(l *slog.Logger, fs any, a []string, w commands.StringWriter) error {
ctrl, ok := l.Handler().(interface { ctrl, ok := l.Handler().(interface {
GetFormat() string GetFormat() string
SetFormat(string) error SetFormat(string) error
}) })
if !ok { if !ok {
return w.WriteLine("Log format is not reconfigurable on this logger") return commands.Fail(w, "Log format is not reconfigurable on this logger")
} }
if len(a) == 0 { if len(a) == 0 {
@@ -528,31 +496,27 @@ func sshLogFormat(l *slog.Logger, fs any, a []string, w commands.StringWriter) e
} }
if err := ctrl.SetFormat(strings.ToLower(a[0])); err != nil { if err := ctrl.SetFormat(strings.ToLower(a[0])); err != nil {
return err return commands.Fail(w, fmt.Sprintf("Unable to set log format: %s", err))
} }
return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat())) return w.WriteLine(fmt.Sprintf("Log format is: %s", ctrl.GetFormat()))
} }
func sshPrintCert(ifce *Interface, fs any, a []string, w commands.StringWriter) error { func printCertCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
args, ok := fs.(*sshPrintCertFlags) args, ok := fs.(*printCertFlags)
if !ok { if !ok {
return nil return fmt.Errorf("internal error: expected *printCertFlags but was %T", fs)
} }
cert := ifce.pki.getCertState().GetDefaultCertificate() cert := ifce.pki.getCertState().GetDefaultCertificate()
if len(a) > 0 { if len(a) > 0 {
vpnAddr, err := netip.ParseAddr(a[0]) vpnAddr, err := parseVpnAddr(a, "vpn addr", w)
if err != nil { if err != nil {
return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0])) return err
}
if !vpnAddr.IsValid() {
return w.WriteLine(fmt.Sprintf("The provided vpn addr could not be parsed: %s", a[0]))
} }
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr) hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil { if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0])) return commands.Fail(w, fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
} }
cert = hostInfo.GetCert().Certificate cert = hostInfo.GetCert().Certificate
@@ -561,16 +525,15 @@ func sshPrintCert(ifce *Interface, fs any, a []string, w commands.StringWriter)
if args.Json || args.Pretty { if args.Json || args.Pretty {
b, err := cert.MarshalJSON() b, err := cert.MarshalJSON()
if err != nil { if err != nil {
return nil return commands.Fail(w, fmt.Sprintf("Unable to marshal the certificate: %s", err))
} }
if args.Pretty { if args.Pretty {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
err := json.Indent(buf, b, "", " ") if err := json.Indent(buf, b, "", " "); err != nil {
b = buf.Bytes() return commands.Fail(w, fmt.Sprintf("Unable to marshal the certificate: %s", err))
if err != nil {
return nil
} }
b = buf.Bytes()
} }
return w.WriteBytes(b) return w.WriteBytes(b)
@@ -579,7 +542,7 @@ func sshPrintCert(ifce *Interface, fs any, a []string, w commands.StringWriter)
if args.Raw { if args.Raw {
b, err := cert.MarshalPEM() b, err := cert.MarshalPEM()
if err != nil { if err != nil {
return nil return commands.Fail(w, fmt.Sprintf("Unable to marshal the certificate: %s", err))
} }
return w.WriteBytes(b) return w.WriteBytes(b)
@@ -588,20 +551,18 @@ func sshPrintCert(ifce *Interface, fs any, a []string, w commands.StringWriter)
return w.WriteLine(cert.String()) return w.WriteLine(cert.String())
} }
func sshPrintRelays(ifce *Interface, fs any, a []string, w commands.StringWriter) error { func printRelaysCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
args, ok := fs.(*sshPrintTunnelFlags) args, ok := fs.(*printTunnelFlags)
if !ok { if !ok {
w.WriteLine(fmt.Sprintf("sshPrintRelays failed to convert args type")) return fmt.Errorf("internal error: expected *printTunnelFlags but was %T", fs)
return nil
} }
relays := map[uint32]*HostInfo{} ifce.hostMap.RLock()
ifce.hostMap.Lock() relays := maps.Clone(ifce.hostMap.Relays)
maps.Copy(relays, ifce.hostMap.Relays) ifce.hostMap.RUnlock()
ifce.hostMap.Unlock()
type RelayFor struct { type RelayFor struct {
Error error Error string `json:",omitempty"`
Type string Type string
State string State string
PeerAddr netip.Addr PeerAddr netip.Addr
@@ -632,11 +593,11 @@ func sshPrintRelays(ifce *Interface, fs any, a []string, w commands.StringWriter
co.Relays = append(co.Relays, &ro) co.Relays = append(co.Relays, &ro)
relayHI := ifce.hostMap.QueryVpnAddr(v.vpnAddrs[0]) relayHI := ifce.hostMap.QueryVpnAddr(v.vpnAddrs[0])
if relayHI == nil { if relayHI == nil {
ro.RelayForAddrs = append(ro.RelayForAddrs, RelayFor{Error: errors.New("could not find hostinfo")}) ro.RelayForAddrs = append(ro.RelayForAddrs, RelayFor{Error: "could not find hostinfo"})
continue continue
} }
for _, vpnAddr := range relayHI.relayState.CopyRelayForIps() { for _, vpnAddr := range relayHI.relayState.CopyRelayForIps() {
rf := RelayFor{Error: nil} rf := RelayFor{}
r, ok := relayHI.relayState.GetRelayForByAddr(vpnAddr) r, ok := relayHI.relayState.GetRelayForByAddr(vpnAddr)
if ok { if ok {
t := "" t := ""
@@ -665,7 +626,7 @@ func sshPrintRelays(ifce *Interface, fs any, a []string, w commands.StringWriter
rf.Type = t rf.Type = t
rf.State = s rf.State = s
if rf.LocalIndex != k { if rf.LocalIndex != k {
rf.Error = fmt.Errorf("hostmap LocalIndex '%v' does not match RelayState LocalIndex", k) rf.Error = fmt.Sprintf("hostmap LocalIndex '%v' does not match RelayState LocalIndex", k)
} }
} }
relayedHI := ifce.hostMap.QueryVpnAddr(vpnAddr) relayedHI := ifce.hostMap.QueryVpnAddr(vpnAddr)
@@ -676,35 +637,23 @@ func sshPrintRelays(ifce *Interface, fs any, a []string, w commands.StringWriter
ro.RelayForAddrs = append(ro.RelayForAddrs, rf) ro.RelayForAddrs = append(ro.RelayForAddrs, rf)
} }
} }
err := enc.Encode(co) return enc.Encode(co)
}
func printTunnelCommand(ifce *Interface, fs any, a []string, w commands.StringWriter) error {
args, ok := fs.(*printTunnelFlags)
if !ok {
return fmt.Errorf("internal error: expected *printTunnelFlags but was %T", fs)
}
vpnAddr, err := parseVpnAddr(a, "vpn addr", w)
if err != nil { if err != nil {
return err 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) hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil { if hostInfo == nil {
return w.WriteLine(fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0])) return commands.Fail(w, fmt.Sprintf("Could not find tunnel for vpn addr: %v", a[0]))
} }
enc := json.NewEncoder(w.GetWriter()) enc := json.NewEncoder(w.GetWriter())
@@ -715,7 +664,11 @@ func sshPrintTunnel(ifce *Interface, fs any, a []string, w commands.StringWriter
return enc.Encode(copyHostInfo(hostInfo, ifce.hostMap.GetPreferredRanges())) return enc.Encode(copyHostInfo(hostInfo, ifce.hostMap.GetPreferredRanges()))
} }
func sshDeviceInfo(ifce *Interface, fs any, w commands.StringWriter) error { func deviceInfoCommand(ifce *Interface, fs any, w commands.StringWriter) error {
flags, ok := fs.(*deviceInfoFlags)
if !ok {
return fmt.Errorf("internal error: expected flags to be deviceInfoFlags but was %+v", fs)
}
data := struct { data := struct {
Name string `json:"name"` Name string `json:"name"`
@@ -727,11 +680,6 @@ func sshDeviceInfo(ifce *Interface, fs any, w commands.StringWriter) error {
copy(data.Cidr, 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 { if flags.Json || flags.Pretty {
js := json.NewEncoder(w.GetWriter()) js := json.NewEncoder(w.GetWriter())
if flags.Pretty { if flags.Pretty {
@@ -744,13 +692,19 @@ func sshDeviceInfo(ifce *Interface, fs any, w commands.StringWriter) error {
} }
} }
// sshReload reloads the config and writes the result to the client. It reloads even if the client has gone away // reloadCommand 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 { func reloadCommand(l *slog.Logger, c *config.C, w commands.StringWriter) error {
writeErr := w.WriteLine("Reloading config") writeErr := w.WriteLine("Reloading config")
res := c.ReloadConfig() res := c.ReloadConfig()
res.Log(l) res.Log(l)
if err := w.WriteLine(res.String()); writeErr == nil { if err := w.WriteLine(res.String()); writeErr == nil {
writeErr = err writeErr = err
} }
if writeErr != nil {
return writeErr return writeErr
} }
if res.Err != nil || res.Failed {
return &commands.Failure{Msg: res.String()}
}
return nil
}
+26 -3
View File
@@ -9,12 +9,14 @@ import (
type CommandFlags func() (*flag.FlagSet, any) type CommandFlags func() (*flag.FlagSet, any)
// CommandCallback is called when your command should execute. // 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 // fs will be a pointer to the struct provided by Command.Flags callback, if there was one. -h and -help are reserved
// and handled automatically for you. // 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. // 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. // 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 // An error returned by the callback is handed back to whoever dispatched the command, which reports a failure: exit
// where appropriate // status 1 over ssh. The error is not shown to the user. For a failure the user caused or can act on, write the
// message and return it as a *Failure, which Fail does in one step; the surface then reports the failure without
// logging what the user was already told. Any other error is logged by the surface as well.
type CommandCallback func(fs any, a []string, w StringWriter) error type CommandCallback func(fs any, a []string, w StringWriter) error
type Command struct { type Command struct {
@@ -26,6 +28,27 @@ type Command struct {
Callback CommandCallback Callback CommandCallback
} }
// Failure is the error a command returns for a failure it has already explained to the user:
// the message it wrote is the whole story. A surface reports it as a failure and does not
// log it.
type Failure struct {
Msg string
}
func (f *Failure) Error() string {
return f.Msg
}
// Fail writes msg to w and returns it as a *Failure, so a command reports a failure to the
// user and to its surface in one line. If the write itself fails, that error is returned
// instead: the user was not told anything.
func Fail(w StringWriter, msg string) error {
if err := w.WriteLine(msg); err != nil {
return err
}
return &Failure{Msg: msg}
}
func execCommand(c *Command, args []string, w StringWriter) error { func execCommand(c *Command, args []string, w StringWriter) error {
var ( var (
fl *flag.FlagSet fl *flag.FlagSet
+23 -21
View File
@@ -38,7 +38,8 @@ func (r *Registry) registerHelp() {
}) })
} }
// Register adds a command that can be run by a user. // Register adds a command that can be run by a user. A command registered under a name
// already held replaces it; see Clone for what that means for Help.
func (r *Registry) Register(c *Command) { func (r *Registry) Register(c *Command) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -77,29 +78,32 @@ func (r *Registry) All() []*Command {
return cmds return cmds
} }
// Clone returns a copy holding the same commands. // Clone returns a copy holding the same commands, with help bound to the copy: a
// command registered on the clone alone is listed and described there, and one the
// clone withholds is not. That rebinding replaces whatever was registered under Help on
// the source, built-in or not.
func (r *Registry) Clone() *Registry { func (r *Registry) Clone() *Registry {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() c := &Registry{commands: maps.Clone(r.commands)}
return &Registry{commands: maps.Clone(r.commands)} r.mu.RUnlock()
c.registerHelp()
return c
} }
// Dispatch parses a command line and runs it, reporting problems to w. // Dispatch parses a command line and runs it, reporting problems to w. The error is for
func (r *Registry) Dispatch(line string, w StringWriter) { // the caller to log, not to show; see CommandCallback.
// A line shlex cannot split, an unbalanced quote, gets no answer. sshd was silent func (r *Registry) Dispatch(line string, w StringWriter) error {
// here too; this is a relocation, so it stays silent. // A line shlex cannot split, an unbalanced quote.
args, err := shlex.Split(line, true) args, err := shlex.Split(line, true)
if err != nil { if err != nil {
return return Fail(w, "could not parse the command line: "+err.Error())
} }
_ = r.DispatchArgs(args, line, w) return r.DispatchArgs(args, w)
} }
// DispatchArgs runs an already tokenized command line. orig is what the user typed, // DispatchArgs runs an already tokenized command line. The error is as for Dispatch.
// used only for the "did not understand" message; callers that have no raw line may func (r *Registry) DispatchArgs(args []string, w StringWriter) error {
// pass "" and the joined args are used instead.
func (r *Registry) DispatchArgs(args []string, orig string, w StringWriter) error {
if len(args) == 0 { if len(args) == 0 {
r.dumpCommands(w) r.dumpCommands(w)
return nil return nil
@@ -107,16 +111,14 @@ func (r *Registry) DispatchArgs(args []string, orig string, w StringWriter) erro
c := r.Get(Name(args[0])) c := r.Get(Name(args[0]))
if c == nil { if c == nil {
if orig == "" { // Only the first word was looked up, so only it is named.
orig = strings.Join(args, " ") msg := fmt.Sprintf("did not understand: %s", args[0])
} if err := w.WriteLine(msg); err != nil {
if err := w.WriteLine(fmt.Sprintf("did not understand: %s", orig)); err != nil {
return err return err
} }
r.dumpCommands(w) r.dumpCommands(w)
return nil return &Failure{Msg: msg}
} }
if checkHelpArgs(args) { if checkHelpArgs(args) {
@@ -154,7 +156,7 @@ func (r *Registry) help(a []string, w StringWriter) error {
// We are printing a specific commands help text // We are printing a specific commands help text
cmd := r.Get(Name(a[0])) cmd := r.Get(Name(a[0]))
if cmd == nil { if cmd == nil {
return w.WriteLine("Command not available " + a[0]) return Fail(w, "Command not available "+a[0])
} }
if err := w.WriteLine(fmt.Sprintf("%s - %s", cmd.Name, cmd.ShortDescription)); err != nil { if err := w.WriteLine(fmt.Sprintf("%s - %s", cmd.Name, cmd.ShortDescription)); err != nil {
+88 -9
View File
@@ -2,6 +2,7 @@ package commands
import ( import (
"bytes" "bytes"
"errors"
"flag" "flag"
"strings" "strings"
"testing" "testing"
@@ -45,18 +46,41 @@ func TestFlagUsageGoesToTheWriter(t *testing.T) {
}) })
merged := &bytes.Buffer{} merged := &bytes.Buffer{}
require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, "", NewStringWriter(merged))) require.Error(t, r.DispatchArgs([]string{"emit", "-nope"}, NewStringWriter(merged)))
assert.Contains(t, merged.String(), "flag provided but not defined") assert.Contains(t, merged.String(), "flag provided but not defined")
} }
// TestDispatchDropsUnparseableLine pins sshd's behaviour for a line shlex rejects: no // TestDispatchReportsUnparseableLine: a line shlex rejects, an unbalanced quote, is a
// answer at all. An operator with an unbalanced quote gets silence, as they always have. // failure the user is told about.
func TestDispatchDropsUnparseableLine(t *testing.T) { func TestDispatchReportsUnparseableLine(t *testing.T) {
out := &bytes.Buffer{} out := &bytes.Buffer{}
testRegistry().Dispatch(`served "unterminated`, NewStringWriter(out)) err := testRegistry().Dispatch(`served "unterminated`, NewStringWriter(out))
assert.Empty(t, out.String())
var f *Failure
require.ErrorAs(t, err, &f)
assert.Equal(t, f.Msg+"\n", out.String())
assert.Contains(t, out.String(), "could not parse the command line: ")
} }
// TestFailTellsTheUserAndTheSurface: Fail writes the message and returns it as a Failure;
// when the write fails, the user heard nothing, so the write error is what comes back.
func TestFailTellsTheUserAndTheSurface(t *testing.T) {
out := &bytes.Buffer{}
err := Fail(NewStringWriter(out), "no such tunnel")
var f *Failure
require.ErrorAs(t, err, &f)
assert.Equal(t, "no such tunnel", f.Msg)
assert.Equal(t, "no such tunnel\n", out.String())
err = Fail(NewStringWriter(failingWriter{}), "no such tunnel")
require.EqualError(t, err, "gone")
assert.NotErrorAs(t, err, &f)
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("gone") }
// TestAllSortsByName: registration order does not matter. // TestAllSortsByName: registration order does not matter.
func TestAllSortsByName(t *testing.T) { func TestAllSortsByName(t *testing.T) {
r := New() r := New()
@@ -72,8 +96,8 @@ func TestAllSortsByName(t *testing.T) {
} }
// TestHelpFlagAgreesWithHelpCommand pins that -h answers through the registered help // 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 // command, and that help on a clone describes the clone: a command registered only there
// clone is known to neither -h nor `help <name>`, and the two never disagree. // is known to both -h and `help <name>`, and one withheld from it is known to neither.
func TestHelpFlagAgreesWithHelpCommand(t *testing.T) { func TestHelpFlagAgreesWithHelpCommand(t *testing.T) {
r := testRegistry() r := testRegistry()
c := r.Clone() c := r.Clone()
@@ -88,10 +112,65 @@ func TestHelpFlagAgreesWithHelpCommand(t *testing.T) {
cmd := &bytes.Buffer{} cmd := &bytes.Buffer{}
c.Dispatch("help logout", NewStringWriter(cmd)) c.Dispatch("help logout", NewStringWriter(cmd))
assert.Equal(t, cmd.String(), flag.String()) assert.Equal(t, cmd.String(), flag.String())
assert.Equal(t, "Command not available logout\n", flag.String()) assert.Equal(t, "logout - ends the session\n", flag.String())
// The source never learns of it.
src := &bytes.Buffer{}
r.Dispatch("help logout", NewStringWriter(src))
assert.Equal(t, "Command not available logout\n", src.String())
// And a command both hold is described either way. // And a command both hold is described either way.
flag.Reset() flag.Reset()
c.Dispatch("served -h", NewStringWriter(flag)) c.Dispatch("served -h", NewStringWriter(flag))
assert.Equal(t, "served - a command this surface serves\n", flag.String()) assert.Equal(t, "served - a command this surface serves\n", flag.String())
} }
// TestCloneHelpListsTheClone: the command list help prints is the clone's, so a clone
// that withholds a command does not name it, and the source still does.
func TestCloneHelpListsTheClone(t *testing.T) {
r := testRegistry()
c := New()
c.Register(r.Get("served"))
c = c.Clone()
out := &bytes.Buffer{}
c.Dispatch("help", NewStringWriter(out))
assert.Equal(t, "Available commands:\n"+
"help - prints available commands or help <command> for specific usage info\n"+
"served - a command this surface serves\n\n", out.String())
out.Reset()
c.Dispatch("help withheld", NewStringWriter(out))
assert.Equal(t, "Command not available withheld\n", out.String())
out.Reset()
r.Dispatch("help withheld", NewStringWriter(out))
assert.Equal(t, "withheld - a command this surface withholds\n", out.String())
}
// TestDispatchReturnsTheCommandsError: what the callback returns reaches the caller, so
// a surface can log it.
func TestDispatchReturnsTheCommandsError(t *testing.T) {
r := New()
r.Register(&Command{
Name: "fails",
Callback: func(fs any, a []string, w StringWriter) error {
return errors.New("boom")
},
})
out := &bytes.Buffer{}
require.EqualError(t, r.Dispatch("fails", NewStringWriter(out)), "boom")
assert.Empty(t, out.String(), "a command's own error is not shown; the command speaks for itself")
}
// TestUnknownCommandNamesTheWord: only the first word is looked up, so only it is
// reported, followed by the command list, and the line is a failure to the surface.
func TestUnknownCommandNamesTheWord(t *testing.T) {
out := &bytes.Buffer{}
err := testRegistry().Dispatch(`nope "with args"`, NewStringWriter(out))
var f *Failure
require.ErrorAs(t, err, &f)
assert.Equal(t, "did not understand: nope", f.Msg)
assert.True(t, strings.HasPrefix(out.String(), "did not understand: nope\nAvailable commands:\n"), out.String())
}
+67
View File
@@ -0,0 +1,67 @@
package nebula
import (
"bytes"
"io"
"net/netip"
"testing"
"github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/logging"
"github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLogFormatRefusalIsShown: a format the logger does not know is reported to the user
// as a failure, rather than returned bare, which over ssh was silence.
func TestLogFormatRefusalIsShown(t *testing.T) {
l := logging.NewLogger(io.Discard)
out := &bytes.Buffer{}
w := commands.NewStringWriter(out)
var f *commands.Failure
require.ErrorAs(t, logFormatCommand(l, nil, []string{"bogus"}, w), &f)
assert.Equal(t, "Unable to set log format: unknown log format `bogus`. possible formats: [text json]\n", out.String())
out.Reset()
require.NoError(t, logFormatCommand(l, nil, []string{"JSON"}, w))
assert.Equal(t, "Log format is: json\n", out.String())
}
// TestPrintRelaysReportsErrorsAsText: the Error field was an error value, which encodes
// as {} and lost the message. It is the message now, and absent when there is none.
func TestPrintRelaysReportsErrorsAsText(t *testing.T) {
l := test.NewLogger()
hm := newHostMap(l)
relay := netip.MustParseAddr("10.0.0.1")
peer := netip.MustParseAddr("10.0.0.2")
// A relay host that is in Relays but not in Hosts, so its hostinfo cannot be found.
hm.Relays[1] = &HostInfo{vpnAddrs: []netip.Addr{relay}}
ifce := &Interface{hostMap: hm}
out := &bytes.Buffer{}
require.NoError(t, printRelaysCommand(ifce, &printTunnelFlags{}, nil, commands.NewStringWriter(out)))
assert.JSONEq(t, `{"Relays":[{"NebulaAddr":"10.0.0.1","RelayForAddrs":[{"Error":"could not find hostinfo","Type":"","State":"","PeerAddr":"","LocalIndex":0,"RemoteIndex":0,"RelayedThrough":null}]}]}`, out.String())
// The same relay host, found, relaying for a peer whose index matches: no Error key.
hi := hm.Relays[1]
r := &Relay{Type: ForwardingType, State: Established, PeerAddr: peer, LocalIndex: 1, RemoteIndex: 2}
hi.relayState = RelayState{
relayForByAddr: map[netip.Addr]*Relay{peer: r},
relayForByIdx: map[uint32]*Relay{r.LocalIndex: r},
}
hm.Hosts[relay] = hi
out.Reset()
require.NoError(t, printRelaysCommand(ifce, &printTunnelFlags{}, nil, commands.NewStringWriter(out)))
assert.JSONEq(t, `{"Relays":[{"NebulaAddr":"10.0.0.1","RelayForAddrs":[{"Type":"forwarding","State":"established","PeerAddr":"10.0.0.2","LocalIndex":1,"RemoteIndex":2,"RelayedThrough":null}]}]}`, out.String())
// And an index mismatch names it.
hm.Relays[7] = hi
delete(hm.Relays, 1)
out.Reset()
require.NoError(t, printRelaysCommand(ifce, &printTunnelFlags{}, nil, commands.NewStringWriter(out)))
assert.Contains(t, out.String(), `"Error":"hostmap LocalIndex '7' does not match RelayState LocalIndex"`)
}
+3
View File
@@ -374,6 +374,9 @@ func (c *Control) CloseAllTunnels(excludeLighthouses bool) (closed int) {
return return
} }
// Commands returns the registry the daemon's surfaces serve from. It is the live
// registry, not a copy: a command registered on it is served to every ssh session that
// starts afterwards. Clone it to run commands without that reach.
func (c *Control) Commands() *commands.Registry { func (c *Control) Commands() *commands.Registry {
return c.commands return c.commands
} }
+21 -5
View File
@@ -14,6 +14,7 @@ import (
"github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/cert_test" "github.com/slackhq/nebula/cert_test"
"github.com/slackhq/nebula/commands" "github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/sshd"
"github.com/slackhq/nebula/test" "github.com/slackhq/nebula/test"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -64,16 +65,22 @@ func TestCommandOutput(t *testing.T) {
control.Start() control.Start()
t.Cleanup(func() { control.Stop() }) t.Cleanup(func() { control.Stop() })
// As an ssh session does: a clone of the daemon's registry with logout added, which
// help on the clone lists and describes. There is no session to end here.
reg := control.Commands().Clone() reg := control.Commands().Clone()
reg.Register(sshd.LogoutCommand(func() {}))
run := func(args ...string) string { // run dispatches as the ssh exec path does and returns the output and the error, which
// over ssh is exit status 1.
run := func(args ...string) (string, error) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
_ = reg.DispatchArgs(args, strings.Join(args, " "), commands.NewStringWriter(buf)) err := reg.DispatchArgs(args, commands.NewStringWriter(buf))
return buf.String() return buf.String(), err
} }
// Seed a pending tunnel so the listing and per-address commands have something to show. // Seed a pending tunnel so the listing and per-address commands have something to show.
seed := run("create-tunnel", "10.222.0.9") seed, err := run("create-tunnel", "10.222.0.9")
require.NoError(t, err)
require.Equal(t, "Created\n", seed, "seeding a tunnel should succeed before anything else is recorded") require.Equal(t, "Created\n", seed, "seeding a tunnel should succeed before anything else is recorded")
cases := []outputCase{ cases := []outputCase{
@@ -120,6 +127,9 @@ func TestCommandOutput(t *testing.T) {
{"close-tunnel", []string{"not-an-address"}}, {"close-tunnel", []string{"not-an-address"}},
{"help", nil}, {"help", nil},
{"help", []string{"print-cert"}}, {"help", []string{"print-cert"}},
{"help", []string{"logout"}},
{"logout", []string{"-h"}},
{"not-a-command", []string{"10.222.0.9"}},
// reload is last so that whatever a reload does to the daemon cannot leak into an // 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 // 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. // has no path to re-read, which is what the recorded result says.
@@ -133,13 +143,19 @@ func TestCommandOutput(t *testing.T) {
args = append(args, c.args...) args = append(args, c.args...)
fmt.Fprintf(got, "$ %s\n", strings.Join(args, " ")) fmt.Fprintf(got, "$ %s\n", strings.Join(args, " "))
out := normalise(run(args...)) out, err := run(args...)
out = normalise(out)
got.WriteString(out) got.WriteString(out)
// Output that does not end in a newline is recorded as such, the way diff marks // 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. // it, so the golden sees that byte too and the next prompt still starts a line.
if out != "" && !strings.HasSuffix(out, "\n") { if out != "" && !strings.HasSuffix(out, "\n") {
got.WriteString("\\ No newline at end of output\n") got.WriteString("\\ No newline at end of output\n")
} }
// A failed command is recorded as the exit status an ssh caller sees, so the
// golden pins which outcomes count as failures.
if err != nil {
got.WriteString("exit status 1\n")
}
got.WriteString("\n") got.WriteString("\n")
} }
} }
+177
View File
@@ -158,66 +158,84 @@ $ print-cert -pretty -raw
$ print-cert 10.222.0.9 $ print-cert 10.222.0.9
Could not find tunnel for vpn addr: 10.222.0.9 Could not find tunnel for vpn addr: 10.222.0.9
exit status 1
$ print-cert -json 10.222.0.9 $ print-cert -json 10.222.0.9
Could not find tunnel for vpn addr: 10.222.0.9 Could not find tunnel for vpn addr: 10.222.0.9
exit status 1
$ print-cert -pretty 10.222.0.9 $ print-cert -pretty 10.222.0.9
Could not find tunnel for vpn addr: 10.222.0.9 Could not find tunnel for vpn addr: 10.222.0.9
exit status 1
$ print-cert 10.222.0.250 $ print-cert 10.222.0.250
Could not find tunnel for vpn addr: 10.222.0.250 Could not find tunnel for vpn addr: 10.222.0.250
exit status 1
$ print-cert -json 10.222.0.250 $ print-cert -json 10.222.0.250
Could not find tunnel for vpn addr: 10.222.0.250 Could not find tunnel for vpn addr: 10.222.0.250
exit status 1
$ print-cert -pretty 10.222.0.250 $ print-cert -pretty 10.222.0.250
Could not find tunnel for vpn addr: 10.222.0.250 Could not find tunnel for vpn addr: 10.222.0.250
exit status 1
$ print-cert not-an-address $ print-cert not-an-address
The provided vpn addr could not be parsed: not-an-address The provided vpn addr could not be parsed: not-an-address
exit status 1
$ print-cert -json not-an-address $ print-cert -json not-an-address
The provided vpn addr could not be parsed: not-an-address The provided vpn addr could not be parsed: not-an-address
exit status 1
$ print-cert -pretty not-an-address $ print-cert -pretty not-an-address
The provided vpn addr could not be parsed: not-an-address The provided vpn addr could not be parsed: not-an-address
exit status 1
$ print-tunnel $ print-tunnel
No vpn address was provided No vpn address was provided
exit status 1
$ print-tunnel -json $ print-tunnel -json
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-pretty -pretty
pretty prints json pretty prints json
exit status 1
$ print-tunnel -pretty $ print-tunnel -pretty
No vpn address was provided No vpn address was provided
exit status 1
$ print-tunnel 10.222.0.9 $ print-tunnel 10.222.0.9
Could not find tunnel for vpn addr: 10.222.0.9 Could not find tunnel for vpn addr: 10.222.0.9
exit status 1
$ print-tunnel -json 10.222.0.9 $ print-tunnel -json 10.222.0.9
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-pretty -pretty
pretty prints json pretty prints json
exit status 1
$ print-tunnel -pretty 10.222.0.9 $ print-tunnel -pretty 10.222.0.9
Could not find tunnel for vpn addr: 10.222.0.9 Could not find tunnel for vpn addr: 10.222.0.9
exit status 1
$ print-tunnel 10.222.0.250 $ print-tunnel 10.222.0.250
Could not find tunnel for vpn addr: 10.222.0.250 Could not find tunnel for vpn addr: 10.222.0.250
exit status 1
$ print-tunnel -json 10.222.0.250 $ print-tunnel -json 10.222.0.250
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-pretty -pretty
pretty prints json pretty prints json
exit status 1
$ print-tunnel -pretty 10.222.0.250 $ print-tunnel -pretty 10.222.0.250
Could not find tunnel for vpn addr: 10.222.0.250 Could not find tunnel for vpn addr: 10.222.0.250
exit status 1
$ print-relays $ print-relays
{"Relays":null} {"Relays":null}
@@ -227,6 +245,7 @@ flag provided but not defined: -json
Usage: Usage:
-pretty -pretty
pretty prints json pretty prints json
exit status 1
$ print-relays -pretty $ print-relays -pretty
{ {
@@ -235,183 +254,230 @@ $ print-relays -pretty
$ query-lighthouse $ query-lighthouse
No vpn address was provided No vpn address was provided
exit status 1
$ query-lighthouse -json $ query-lighthouse -json
The provided vpn address could not be parsed: -json The provided vpn address could not be parsed: -json
exit status 1
$ query-lighthouse -pretty $ query-lighthouse -pretty
The provided vpn address could not be parsed: -pretty The provided vpn address could not be parsed: -pretty
exit status 1
$ query-lighthouse 10.222.0.9 $ query-lighthouse 10.222.0.9
{} {}
$ query-lighthouse -json 10.222.0.9 $ query-lighthouse -json 10.222.0.9
The provided vpn address could not be parsed: -json The provided vpn address could not be parsed: -json
exit status 1
$ query-lighthouse -pretty 10.222.0.9 $ query-lighthouse -pretty 10.222.0.9
The provided vpn address could not be parsed: -pretty The provided vpn address could not be parsed: -pretty
exit status 1
$ query-lighthouse not-an-address $ query-lighthouse not-an-address
The provided vpn address could not be parsed: not-an-address The provided vpn address could not be parsed: not-an-address
exit status 1
$ query-lighthouse -json not-an-address $ query-lighthouse -json not-an-address
The provided vpn address could not be parsed: -json The provided vpn address could not be parsed: -json
exit status 1
$ query-lighthouse -pretty not-an-address $ query-lighthouse -pretty not-an-address
The provided vpn address could not be parsed: -pretty The provided vpn address could not be parsed: -pretty
exit status 1
$ log-level $ log-level
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -json $ log-level -json
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -pretty $ log-level -pretty
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level debug $ log-level debug
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -json debug $ log-level -json debug
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -pretty debug $ log-level -pretty debug
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level bogus $ log-level bogus
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -json bogus $ log-level -json bogus
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -pretty bogus $ log-level -pretty bogus
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level info $ log-level info
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -json info $ log-level -json info
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-level -pretty info $ log-level -pretty info
Log level is not reconfigurable on this logger Log level is not reconfigurable on this logger
exit status 1
$ log-format $ log-format
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format -json $ log-format -json
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format -pretty $ log-format -pretty
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format json $ log-format json
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format -json json $ log-format -json json
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format -pretty json $ log-format -pretty json
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format text $ log-format text
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format -json text $ log-format -json text
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ log-format -pretty text $ log-format -pretty text
Log format is not reconfigurable on this logger Log format is not reconfigurable on this logger
exit status 1
$ change-remote $ change-remote
No vpn address was provided No vpn address was provided
exit status 1
$ change-remote -json $ change-remote -json
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote -pretty $ change-remote -pretty
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote 10.222.0.9 $ change-remote 10.222.0.9
No address was provided No address was provided
exit status 1
$ change-remote -json 10.222.0.9 $ change-remote -json 10.222.0.9
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote -pretty 10.222.0.9 $ change-remote -pretty 10.222.0.9
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote -address 10.94.0.10:4242 10.222.0.9 $ change-remote -address 10.94.0.10:4242 10.222.0.9
Could not find tunnel for vpn address: 10.222.0.9 Could not find tunnel for vpn address: 10.222.0.9
exit status 1
$ change-remote -json -address 10.94.0.10:4242 10.222.0.9 $ change-remote -json -address 10.94.0.10:4242 10.222.0.9
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote -pretty -address 10.94.0.10:4242 10.222.0.9 $ change-remote -pretty -address 10.94.0.10:4242 10.222.0.9
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote -address 10.94.0.10:4242 10.222.0.250 $ change-remote -address 10.94.0.10:4242 10.222.0.250
Could not find tunnel for vpn address: 10.222.0.250 Could not find tunnel for vpn address: 10.222.0.250
exit status 1
$ change-remote -json -address 10.94.0.10:4242 10.222.0.250 $ change-remote -json -address 10.94.0.10:4242 10.222.0.250
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ change-remote -pretty -address 10.94.0.10:4242 10.222.0.250 $ change-remote -pretty -address 10.94.0.10:4242 10.222.0.250
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
The new remote address, ip:port The new remote address, ip:port
exit status 1
$ create-tunnel $ create-tunnel
No vpn address was provided No vpn address was provided
exit status 1
$ create-tunnel -json $ create-tunnel -json
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel -pretty $ create-tunnel -pretty
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel 10.222.0.9 $ create-tunnel 10.222.0.9
Tunnel already handshaking Tunnel already handshaking
exit status 1
$ create-tunnel -json 10.222.0.9 $ create-tunnel -json 10.222.0.9
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel -pretty 10.222.0.9 $ create-tunnel -pretty 10.222.0.9
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel -address 10.94.0.10:4242 10.222.0.10 $ create-tunnel -address 10.94.0.10:4242 10.222.0.10
Created Created
@@ -421,72 +487,86 @@ flag provided but not defined: -json
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel -pretty -address 10.94.0.10:4242 10.222.0.10 $ create-tunnel -pretty -address 10.94.0.10:4242 10.222.0.10
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel not-an-address $ create-tunnel not-an-address
The provided vpn address could not be parsed: not-an-address The provided vpn address could not be parsed: not-an-address
exit status 1
$ create-tunnel -json not-an-address $ create-tunnel -json not-an-address
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ create-tunnel -pretty not-an-address $ create-tunnel -pretty not-an-address
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-address string -address string
Optionally provide a real remote address, ip:port Optionally provide a real remote address, ip:port
exit status 1
$ close-tunnel $ close-tunnel
No vpn address was provided No vpn address was provided
exit status 1
$ close-tunnel -json $ close-tunnel -json
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-local-only -local-only
Disables notifying the remote that the tunnel is shutting down Disables notifying the remote that the tunnel is shutting down
exit status 1
$ close-tunnel -pretty $ close-tunnel -pretty
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-local-only -local-only
Disables notifying the remote that the tunnel is shutting down Disables notifying the remote that the tunnel is shutting down
exit status 1
$ close-tunnel 10.222.0.250 $ close-tunnel 10.222.0.250
Could not find tunnel for vpn address: 10.222.0.250 Could not find tunnel for vpn address: 10.222.0.250
exit status 1
$ close-tunnel -json 10.222.0.250 $ close-tunnel -json 10.222.0.250
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-local-only -local-only
Disables notifying the remote that the tunnel is shutting down Disables notifying the remote that the tunnel is shutting down
exit status 1
$ close-tunnel -pretty 10.222.0.250 $ close-tunnel -pretty 10.222.0.250
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-local-only -local-only
Disables notifying the remote that the tunnel is shutting down Disables notifying the remote that the tunnel is shutting down
exit status 1
$ close-tunnel not-an-address $ close-tunnel not-an-address
The provided vpn address could not be parsed: not-an-address The provided vpn address could not be parsed: not-an-address
exit status 1
$ close-tunnel -json not-an-address $ close-tunnel -json not-an-address
flag provided but not defined: -json flag provided but not defined: -json
Usage: Usage:
-local-only -local-only
Disables notifying the remote that the tunnel is shutting down Disables notifying the remote that the tunnel is shutting down
exit status 1
$ close-tunnel -pretty not-an-address $ close-tunnel -pretty not-an-address
flag provided but not defined: -pretty flag provided but not defined: -pretty
Usage: Usage:
-local-only -local-only
Disables notifying the remote that the tunnel is shutting down Disables notifying the remote that the tunnel is shutting down
exit status 1
$ help $ help
Available commands: Available commands:
@@ -500,6 +580,7 @@ list-lighthouse-addrmap - List all lighthouse map entries
list-pending-hostmap - List all handshaking hosts list-pending-hostmap - List all handshaking hosts
log-format - Gets or sets the current log format log-format - Gets or sets the current log format
log-level - Gets or sets the current log level log-level - Gets or sets the current log level
logout - Ends the current session
print-cert - Prints the current certificate being used or the certificate for the provided vpn addr 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-relays - Prints json details about all relay info
print-tunnel - Prints json details about a tunnel for the provided vpn addr print-tunnel - Prints json details about a tunnel for the provided vpn addr
@@ -510,9 +591,11 @@ version - Prints the currently running version of nebula
$ help -json $ help -json
Command not available -json Command not available -json
exit status 1
$ help -pretty $ help -pretty
Command not available -pretty Command not available -pretty
exit status 1
$ help print-cert $ help print-cert
print-cert - Prints the current certificate being used or the certificate for the provided vpn addr print-cert - Prints the current certificate being used or the certificate for the provided vpn addr
@@ -525,19 +608,113 @@ print-cert - Prints the current certificate being used or the certificate for th
$ help -json print-cert $ help -json print-cert
Command not available -json Command not available -json
exit status 1
$ help -pretty print-cert $ help -pretty print-cert
Command not available -pretty Command not available -pretty
exit status 1
$ help logout
logout - Ends the current session
$ help -json logout
Command not available -json
exit status 1
$ help -pretty logout
Command not available -pretty
exit status 1
$ logout -h
logout - Ends the current session
$ logout -json -h
logout - Ends the current session
$ logout -pretty -h
logout - Ends the current session
$ not-a-command 10.222.0.9
did not understand: not-a-command
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
logout - Ends the current session
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
exit status 1
$ not-a-command -json 10.222.0.9
did not understand: not-a-command
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
logout - Ends the current session
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
exit status 1
$ not-a-command -pretty 10.222.0.9
did not understand: not-a-command
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
logout - Ends the current session
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
exit status 1
$ reload $ reload
Reloading config Reloading config
Config reload failed, the new config did not load: no config files found at Config reload failed, the new config did not load: no config files found at
exit status 1
$ reload -json $ reload -json
Reloading config Reloading config
Config reload failed, the new config did not load: no config files found at Config reload failed, the new config did not load: no config files found at
exit status 1
$ reload -pretty $ reload -pretty
Reloading config Reloading config
Config reload failed, the new config did not load: no config files found at Config reload failed, the new config did not load: no config files found at
exit status 1
+20 -9
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/config" "github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/test" "github.com/slackhq/nebula/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -26,7 +27,7 @@ func (w *lineWriter) Write(s string) error { return w.WriteLine(s) }
func (w *lineWriter) WriteBytes(b []byte) error { return w.WriteLine(string(b)) } func (w *lineWriter) WriteBytes(b []byte) error { return w.WriteLine(string(b)) }
func (w *lineWriter) GetWriter() io.Writer { return io.Discard } func (w *lineWriter) GetWriter() io.Writer { return io.Discard }
func TestSSHReloadReportsTheResult(t *testing.T) { func TestReloadCommandReportsTheResult(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
write := func(body string) { write := func(body string) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte(body), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte(body), 0o600))
@@ -43,32 +44,42 @@ func TestSSHReloadReportsTheResult(t *testing.T) {
} }
}) })
reload := func() []string { // reload returns what the client was told. A reload that failed is a commands.Failure
// to the surface, exit 1 over ssh, with the result line as its message; one that
// merely needs a restart is not.
reload := func(failed bool) []string {
w := &lineWriter{} w := &lineWriter{}
require.NoError(t, sshReload(l, c, w)) err := reloadCommand(l, c, w)
if failed {
var f *commands.Failure
require.ErrorAs(t, err, &f)
assert.Equal(t, w.lines[len(w.lines)-1], f.Msg)
} else {
require.NoError(t, err)
}
return w.lines return w.lines
} }
write("a: 2\nb: 1") write("a: 2\nb: 1")
assert.Equal(t, []string{"Reloading config", "Config reload complete"}, reload()) assert.Equal(t, []string{"Reloading config", "Config reload complete"}, reload(false))
fail = true fail = true
write("a: 3\nb: 1") write("a: 3\nb: 1")
assert.Equal(t, []string{"Reloading config", "Config reload completed with errors, review the logs"}, reload()) assert.Equal(t, []string{"Reloading config", "Config reload completed with errors, review the logs"}, reload(true))
fail = false fail = false
write("a: 3\nb: 2") write("a: 3\nb: 2")
assert.Equal(t, []string{"Reloading config", "Config reload was completed but requires a restart to fully apply, keys [b]"}, reload()) assert.Equal(t, []string{"Reloading config", "Config reload was completed but requires a restart to fully apply, keys [b]"}, reload(false))
write("a: [") write("a: [")
lines := reload() lines := reload(true)
require.Len(t, lines, 2) require.Len(t, lines, 2)
assert.Contains(t, lines[1], "Config reload failed, the new config did not load") assert.Contains(t, lines[1], "Config reload failed, the new config did not load")
assert.Contains(t, lines[1], dir, "names the config that did not load") assert.Contains(t, lines[1], dir, "names the config that did not load")
} }
// A client that went away still gets the reload it asked for // A client that went away still gets the reload it asked for
func TestSSHReloadReloadsWhenTheClientIsGone(t *testing.T) { func TestReloadCommandReloadsWhenTheClientIsGone(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte("a: 1"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte("a: 1"), 0o600))
l := test.NewLogger() l := test.NewLogger()
@@ -78,6 +89,6 @@ func TestSSHReloadReloadsWhenTheClientIsGone(t *testing.T) {
c.RegisterReloadCallback(func(*config.C, *config.Reload) { reloaded = true }) c.RegisterReloadCallback(func(*config.C, *config.Reload) { reloaded = true })
gone := errors.New("client is gone") gone := errors.New("client is gone")
require.ErrorIs(t, sshReload(l, c, &lineWriter{err: gone}), gone) require.ErrorIs(t, reloadCommand(l, c, &lineWriter{err: gone}), gone)
assert.True(t, reloaded) assert.True(t, reloaded)
} }
+34 -11
View File
@@ -1,6 +1,7 @@
package sshd package sshd
import ( import (
"errors"
"log/slog" "log/slog"
"strings" "strings"
@@ -25,19 +26,25 @@ func NewSession(reg *commands.Registry, conn *ssh.ServerConn, chans <-chan ssh.N
cancel: cancel, cancel: cancel,
} }
s.commands.Register(&commands.Command{ s.commands.Register(LogoutCommand(s.Close))
Name: commands.Logout,
ShortDescription: "Ends the current session",
Callback: func(a any, args []string, w commands.StringWriter) error {
s.Close()
return nil
},
})
go s.handleChannels(chans) go s.handleChannels(chans)
return s return s
} }
// LogoutCommand is the logout command a session registers on its clone of the registry,
// with end as what it does. Exported so a test can serve what a session serves.
func LogoutCommand(end func()) *commands.Command {
return &commands.Command{
Name: commands.Logout,
ShortDescription: "Ends the current session",
Callback: func(fs any, a []string, w commands.StringWriter) error {
end()
return nil
},
}
}
func (s *session) handleChannels(chans <-chan ssh.NewChannel) { func (s *session) handleChannels(chans <-chan ssh.NewChannel) {
defer s.Close() defer s.Close()
for newChannel := range chans { for newChannel := range chans {
@@ -84,9 +91,13 @@ func (s *session) handleRequests(in <-chan *ssh.Request, channel ssh.Channel) {
} }
req.Reply(true, nil) req.Reply(true, nil)
s.dispatchCommand(payload.Value, commands.NewStringWriter(channel)) err := s.dispatchCommand(payload.Value, commands.NewStringWriter(channel))
// A command that failed exits 1, so `ssh host cmd` callers can tell.
status := struct{ Status uint32 }{uint32(0)} status := struct{ Status uint32 }{uint32(0)}
if err != nil {
status.Status = 1
}
channel.SendRequest("exit-status", false, ssh.Marshal(status)) channel.SendRequest("exit-status", false, ssh.Marshal(status))
channel.Close() channel.Close()
return return
@@ -135,8 +146,20 @@ func (s *session) handleInput() {
} }
} }
func (s *session) dispatchCommand(line string, w commands.StringWriter) { // dispatchCommand runs line and returns the command's error, which it has logged unless
s.commands.Dispatch(line, w) // the command already told the user everything: the log line is for the operator reading
// the daemon's log, who learns nothing from a failure the user caused.
func (s *session) dispatchCommand(line string, w commands.StringWriter) error {
err := s.commands.Dispatch(line, w)
if err == nil {
return nil
}
var f *commands.Failure
if !errors.As(err, &f) {
s.l.Debug("Command returned an error", "line", line, "error", err)
}
return err
} }
func (s *session) Close() { func (s *session) Close() {
+41 -1
View File
@@ -6,10 +6,12 @@ import (
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -24,6 +26,7 @@ import (
type sshdReloadHarness struct { type sshdReloadHarness struct {
c *config.C c *config.C
reg *commands.Registry
server *sshd.SSHServer server *sshd.SSHServer
keyFile string keyFile string
signer ssh.Signer signer ssh.Signer
@@ -45,7 +48,8 @@ func newSSHDReloadHarness(t *testing.T) *sshdReloadHarness {
h.c = config.NewC(l) h.c = config.NewC(l)
require.NoError(t, h.c.LoadString(h.config())) require.NoError(t, h.c.LoadString(h.config()))
h.server, err = sshd.NewSSHServer(t.Context(), l, commands.New()) h.reg = commands.New()
h.server, err = sshd.NewSSHServer(t.Context(), l, h.reg)
require.NoError(t, err) require.NoError(t, err)
wireSSHReload(l, h.server, h.c) wireSSHReload(l, h.server, h.c)
run, err := configSSH(l, h.server, h.c, nil) run, err := configSSH(l, h.server, h.c, nil)
@@ -239,3 +243,39 @@ func TestSSHDReload_SkippedEntriesFail(t *testing.T) {
}) })
} }
} }
// TestSSHExecExitStatusReflectsTheCommand: an exec request exits 1 when the command
// returned an error and 0 otherwise, with the command's output either way.
func TestSSHExecExitStatusReflectsTheCommand(t *testing.T) {
h := newSSHDReloadHarness(t)
h.reg.Register(&commands.Command{
Name: "fails",
Callback: func(fs any, a []string, w commands.StringWriter) error {
_ = w.WriteLine("nope")
return errors.New("failed")
},
})
client, _ := h.connect(t)
sess, err := client.NewSession()
require.NoError(t, err)
out, err := sess.CombinedOutput("fails")
var exit *ssh.ExitError
require.ErrorAs(t, err, &exit)
assert.Equal(t, 1, exit.ExitStatus())
assert.Equal(t, "nope\n", string(out))
sess, err = client.NewSession()
require.NoError(t, err)
out, err = sess.CombinedOutput("help fails")
require.NoError(t, err)
assert.Equal(t, "fails - \n", string(out))
// A word that is not a command is a failure too, with the command list as before.
sess, err = client.NewSession()
require.NoError(t, err)
out, err = sess.CombinedOutput("nope")
require.ErrorAs(t, err, &exit)
assert.Equal(t, 1, exit.ExitStatus())
assert.True(t, strings.HasPrefix(string(out), "did not understand: nope\nAvailable commands:\n"), string(out))
}