diff --git a/.gitignore b/.gitignore index 634069a..37bd2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.vscode outputs* dist -cmdo \ No newline at end of file +cmdo +private \ No newline at end of file diff --git a/README.md b/README.md index 8bf5525..29764f0 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,101 @@ output` directory. `cmdo -o stdout -a clab-scrapli-srlinux -u admin -p admin -k nokia_srlinux -c "show version :: show system aaa"` -### Running commands in bulk +## Inventory As indicated in the quickstart, `commando` can run commands against many devices as opposed to the _singe-device_ operation. -For the _bulk_ mode the devices are expressed in the sort-of inventory. The inventory file schema is simple, the network devices are defined under `.devices` element with each device identified by ``: +For the _bulk_ mode the devices are expressed in the inventory file. The inventory file schema is simple, it consists of the following top-level elements: + +```yaml +credentials: # container for credentials +transports: # optional container for transport options +devices: # here the devices connection details are +``` + +### Credentials +Commando let's you define many credential parameters which you can later associate with any of the devices. For example, a credential config for access switches might differ from the core routers. + +```yaml +credentials: + # this is a named credential config that you can refer to in the devices settings + switches: + username: admin + password: admin + routers: + username: ops + password: secret123 + +devices: + sw1: + address: some.host.com + # credentials info from credentials containers named 'switches' will be used + credentials: switches + rtr1: + address: some.host2.com + # credentials info from credentials containers named 'routers' will be used + credentials: routers +``` + +If you create a credential named `default`, then you can omit specifying credentials in the device configuration, this will be applied by default: + +```yaml +credentials: + default: + username: admin + password: admin + +devices: + sw1: # sw1 will use `default` credentials configuration + address: some.host.com +``` + +Here is a full list of credentials configuration options: + +```yaml +credentials: + : + username: + password: + secondary-password: + private-key: # takes a path to the private key +``` + +### Transports +Different transports can be defined in the inventory and mapped to the devices to support flexible connectivity options. + +Transports are defined in the top level of the inventory: + +```yaml +transports: + myssh: + port: 5622 + +devices: + sw1: # sw1 will use port 5622 for SSH connection + address: some.host.com + transport: myssh +``` + +If the transport is not defined for a given device, the default transport options are assumed: + +* port 22 +* no strict host key checking +* transport type - standard +* ssh config file is not used + +Here is a full list of transport configuration options: + +```yaml +credentials: + : + port: # ssh port number to use + strict-key: # true or false; sets host key checking + transport-type: # `standard` or system. standard transport uses Go SSH client, `system` transport uses system's default SSH client (i.e. OpenSSH) + ssh-config-file: # takes a path to ssh config file. Can only be used if transport is set to `system` +``` + +### Devices +The network devices are defined under `.devices` element with each device identified by a ``: ```yaml devices: @@ -55,21 +146,21 @@ devices: : ``` -Each device holds a number of options that define the device platform, auth parameters, and the commands to send: +Each device holds a number of options that define the device platform, its address, and the commands to send: ```yaml devices: : - # platform is one of arista_eos, cisco_iosxe, cisco_nxos, cisco_iosxr, - # juniper_junos, nokia_sros, nokia_sros_classic, nokia_srlinux - platform: string - address: string - username: string - password: string - send-commands: - - cmd1 - - cmd2 - - cmdN + # platform is one of arista_eos, cisco_iosxe, cisco_nxos, cisco_iosxr, + # juniper_junos, nokia_sros, nokia_sros_classic, nokia_srlinux + platform: string + address: string + credentials: string # optional reference to the defined credentials + transport: string # optional reference to the defined transport options + send-commands: + - cmd1 + - cmd2 + - cmdN ``` `send-commands` list holds a list of commands which will be send towards a device. Check out the attached [example inventory](inventory.yml) file to a reference. diff --git a/commando/cmdo.go b/commando/cmdo.go index e5d7414..fc30c8c 100644 --- a/commando/cmdo.go +++ b/commando/cmdo.go @@ -39,36 +39,60 @@ var ( errNoUsernameDefined = errors.New("username was not provided. Use --username | -u to set it") errNoPasswordDefined = errors.New("password was not provided. Use --passoword | -p to set it") errNoCommandsDefined = errors.New("commands were not provided. Use --commands | -c to set a `::` delimited list of commands to run") + + errInvalidCredentialsName = errors.New("invalid credentials name provided for host") + errInvalidTransportsName = errors.New("invalid transport name provided for host") + + errInvalidTransport = errors.New("invalid transport name provided in inventory. Transport should be one of: [standard, system]") ) const ( fileOutput = "file" stdoutOutput = "stdout" + defaultName = "default" ) type inventory struct { - Devices map[string]*device `yaml:"devices,omitempty"` + Credentials map[string]*credentials `yaml:"credentials,omitempty"` + Transports map[string]*transports `yaml:"transports,omitempty"` + Devices map[string]*device `yaml:"devices,omitempty"` } type device struct { Platform string `yaml:"platform,omitempty"` Address string `yaml:"address,omitempty"` - Username string `yaml:"username,omitempty"` - Password string `yaml:"password,omitempty"` + Credentials string `yaml:"credentials,omitempty"` + Transport string `yaml:"transport,omitempty"` SendCommands []string `yaml:"send-commands,omitempty"` } +type credentials struct { + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + SecondaryPassword string `yaml:"secondary-password,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` +} + +type transports struct { + Port int `yaml:"port,omitempty"` + StrictKey bool `yaml:"strict-key,omitempty"` + SSHConfigFile string `yaml:"ssh-config-file,omitempty"` + TransportType string `yaml:"transport,omitempty"` +} + type appCfg struct { - inventory string // path to inventory file - output string // output mode - timestamp bool // append timestamp to output dir - outDir string // output directory path - devFilter string // pattern - platform string // platform name - address string // device address - username string // ssh username - password string // ssh password - commands string // commands to send + inventory string // path to inventory file + credentials map[string]*credentials // credentials loaded from inventory + transports map[string]*transports // transports loaded from inventory + output string // output mode + timestamp bool // append timestamp to output dir + outDir string // output directory path + devFilter string // pattern + platform string // platform name + address string // device address + username string // ssh username + password string // ssh password + commands string // commands to send } // run runs the commando. @@ -112,6 +136,110 @@ func (app *appCfg) run() error { return nil } +func (app *appCfg) validTransport(t string) bool { + switch t { + case transport.SystemTransportName: + return true + case transport.StandardTransportName: + return true + default: + return false + } +} + +func (app *appCfg) loadCredentials(o []base.Option, c string) ([]base.Option, error) { + creds, ok := app.credentials[c] + if !ok { + return o, errInvalidCredentialsName + } + + if creds.Username != "" { + o = append(o, base.WithAuthUsername(creds.Username)) + } + + if creds.Password != "" { + o = append(o, base.WithAuthPassword(creds.Password)) + } + + if creds.SecondaryPassword != "" { + o = append(o, base.WithAuthSecondary(creds.SecondaryPassword)) + } + + if creds.PrivateKey != "" { + o = append(o, base.WithAuthPrivateKey(creds.PrivateKey)) + } + + return o, nil +} + +func (app *appCfg) loadTransport(o []base.Option, t string) ([]base.Option, error) { + // default to strict key false and standard transport, so load those into options first + o = append(o, base.WithTransportType(transport.StandardTransportName), base.WithAuthStrictKey(false)) + + transp, ok := app.transports[t] + if !ok { + if t == defaultName { + // default can not exist in the inventory, we already set the default settings above + return o, nil + } + + return o, errInvalidTransportsName + } + + if transp.Port != 0 { + o = append(o, base.WithPort(transp.Port)) + } + + if transp.StrictKey { + o = append(o, base.WithAuthStrictKey(transp.StrictKey)) + } + + if transp.SSHConfigFile != "" { + o = append(o, base.WithSSHConfigFile(transp.SSHConfigFile)) + } + + if transp.TransportType != "" { + if !app.validTransport(transp.TransportType) { + return nil, errInvalidTransport + } + + o = append(o, base.WithTransportType(transp.TransportType)) + } + + return o, nil +} + +// loadOptions loads options from the provided inventory. +func (app *appCfg) loadOptions(d *device) ([]base.Option, error) { + var o []base.Option + + var err error + + c := defaultName + + if d.Credentials != "" { + c = d.Credentials + } + + o, err = app.loadCredentials(o, c) + if err != nil { + return o, err + } + + t := defaultName + + if d.Transport != "" { + t = d.Transport + } + + o, err = app.loadTransport(o, t) + if err != nil { + return o, err + } + + return o, err +} + func (app *appCfg) runCommands( name string, d *device, @@ -120,40 +248,46 @@ func (app *appCfg) runCommands( var err error + o, err := app.loadOptions(d) + if err != nil { + log.Errorf("failed to load credentials or transport options for %s; error: %+v\n", name, err) + return + } + switch d.Platform { case "nokia_srlinux": driver, err = srlinux.NewSRLinuxDriver( d.Address, - base.WithAuthStrictKey(false), - base.WithAuthUsername(d.Username), - base.WithAuthPassword(d.Password), - base.WithTransportType(transport.StandardTransportName), + o..., ) default: driver, err = core.NewCoreDriver( d.Address, d.Platform, - base.WithAuthStrictKey(false), - base.WithAuthUsername(d.Username), - base.WithAuthPassword(d.Password), - base.WithTransportType(transport.StandardTransportName), + o..., ) } if err != nil { log.Errorf("failed to create driver for device %s; error: %+v\n", err, name) + rCh <- nil + return } err = driver.Open() if err != nil { log.Errorf("failed to open connection to device %s; error: %+v\n", err, name) + rCh <- nil + return } r, err := driver.SendCommands(d.SendCommands) if err != nil { log.Errorf("failed to send commands to device %s; error: %+v\n", err, name) + rCh <- nil + return } @@ -168,7 +302,7 @@ func (app *appCfg) outputResult( r *base.MultiResponse) { defer wg.Done() - if err := rw.WriteResponse(r, name, d, app); err != nil { + if err := rw.WriteResponse(r, name, d); err != nil { log.Errorf("error while writing the response: %v", err) } } @@ -205,6 +339,9 @@ func (app *appCfg) loadInventoryFromYAML(i *inventory) error { return errNoDevices } + app.credentials = i.Credentials + app.transports = i.Transports + return nil } @@ -232,8 +369,6 @@ func (app *appCfg) loadInventoryFromFlags(i *inventory) error { i.Devices[app.address] = &device{ Platform: app.platform, Address: app.address, - Username: app.username, - Password: app.password, SendCommands: cmds, } diff --git a/commando/respwriter.go b/commando/respwriter.go index 247fb90..7f96b4c 100644 --- a/commando/respwriter.go +++ b/commando/respwriter.go @@ -12,8 +12,12 @@ import ( "github.com/scrapli/scrapligo/driver/base" ) +const ( + filePermissions = 0755 +) + type responseWriter interface { - WriteResponse(r *base.MultiResponse, name string, d *device, appCfg *appCfg) error + WriteResponse(r *base.MultiResponse, name string, d *device) error } func (app *appCfg) newResponseWriter(f string) responseWriter { @@ -39,7 +43,14 @@ func (app *appCfg) newResponseWriter(f string) responseWriter { // consoleWriter writes the scrapli responses to the console. type consoleWriter struct{} -func (w *consoleWriter) WriteResponse(r *base.MultiResponse, name string, d *device, appCfg *appCfg) error { +func (w *consoleWriter) writeFailure(name string) error { + c := color.New(color.FgRed) + c.Fprintf(os.Stderr, "\n**************************\n%s failed\n**************************\n", name) + + return nil +} + +func (w *consoleWriter) writeSuccess(r *base.MultiResponse, name string, d *device) error { c := color.New(color.FgGreen) c.Fprintf(os.Stderr, "\n**************************\n%s\n**************************\n", name) @@ -57,14 +68,22 @@ func (w *consoleWriter) WriteResponse(r *base.MultiResponse, name string, d *dev return nil } +func (w *consoleWriter) WriteResponse(r *base.MultiResponse, name string, d *device) error { + if r == nil { + return w.writeFailure(name) + } + + return w.writeSuccess(r, name, d) +} + // fileWriter writes the scrapli responses to the files on disk. type fileWriter struct { dir string // output dir name } -func (w *fileWriter) WriteResponse(r *base.MultiResponse, name string, d *device, appCfg *appCfg) error { +func (w *fileWriter) WriteResponse(r *base.MultiResponse, name string, d *device) error { outDir := path.Join(w.dir, name) - if err := os.MkdirAll(outDir, 0755); err != nil { + if err := os.MkdirAll(outDir, filePermissions); err != nil { return err } @@ -72,7 +91,7 @@ func (w *fileWriter) WriteResponse(r *base.MultiResponse, name string, d *device c := sanitizeCmd(cmd) rb := []byte(r.Responses[idx].Result) - if err := ioutil.WriteFile(path.Join(outDir, c), rb, 0755); err != nil { //nolint:gosec + if err := ioutil.WriteFile(path.Join(outDir, c), rb, filePermissions); err != nil { return err } } diff --git a/go.mod b/go.mod index 635ea13..489cb32 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.16 require ( github.com/fatih/color v1.12.0 - github.com/scrapli/scrapligo v0.0.0-20210602192414-6dd77b8af3c2 + github.com/scrapli/scrapligo v0.0.0-20210704164516-6c3b4e74cfad github.com/sirupsen/logrus v1.8.1 github.com/srl-labs/srlinux-scrapli v0.0.0-20210601201111-9eed8d440381 github.com/urfave/cli/v2 v2.3.0 diff --git a/go.sum b/go.sum index 5da9754..9e24427 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,12 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/scrapli/scrapligo v0.0.0-20210601185115-acff0f312680/go.mod h1:itZE+qsyMvCMnxccsxMX4ATFqVLDIG/Mw4po4msqwpo= github.com/scrapli/scrapligo v0.0.0-20210602192414-6dd77b8af3c2 h1:tUOvIzMIJa9pbqKWTWx2OJi8JxF5AwgUGGWkMkSughM= github.com/scrapli/scrapligo v0.0.0-20210602192414-6dd77b8af3c2/go.mod h1:itZE+qsyMvCMnxccsxMX4ATFqVLDIG/Mw4po4msqwpo= +github.com/scrapli/scrapligo v0.0.0-20210704164516-6c3b4e74cfad h1:eb+6BSk5gTJ3rsQ1CfZGfMsoxvo7ZYwKlCTszU1CtJs= +github.com/scrapli/scrapligo v0.0.0-20210704164516-6c3b4e74cfad/go.mod h1:+csimZHh80jQXjdDdHmAIKCwiXPZvXQ7ZgKEQWmFpK8= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirikothe/gotextfsm v1.0.0 h1:4kKwbUziG9G+31PfLY+vI3FzYK/kcByh4ndT3NyPMkc= +github.com/sirikothe/gotextfsm v1.0.0/go.mod h1:CJYqpTg9u5VPCoD0VEl9E68prCIiWQD8m457k098DdQ= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/srl-labs/srlinux-scrapli v0.0.0-20210601201111-9eed8d440381 h1:CMi5adZCXpbLtn1KLCj2sXtOcCFvmUzPzTR6Stf3DVU= diff --git a/inventory.yml b/inventory.yml index d808611..c1d3658 100644 --- a/inventory.yml +++ b/inventory.yml @@ -1,25 +1,42 @@ +credentials: + default: + username: admin + password: admin + eos: + username: commando + password: commando + secondary-password: supercommando + +transports: + default: {} + # default has the following default settings + # port: 22 + # strict-key: false + # transport-type: standard + # + # optional settings + # ssh-config-file: /your/ssh/config/file + eos: + transport-type: system + devices: sros: platform: nokia_sros address: clab-scrapli-sros - username: admin - password: admin send-commands: - show version - show router interface eos: platform: arista_eos address: clab-scrapli-ceos - username: admin - password: admin + credentials: eos + transport: eos send-commands: - show version - show uptime srlinux: platform: nokia_srlinux address: clab-scrapli-srlinux - username: admin - password: admin send-commands: - show version - show network-instance interfaces