// 2018 Rackspace, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"): you may // not use this file except in compliance with the License. You may obtain // a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. // Package shutdown contains code for prepping netdevices for shutdown package shutdown import ( "context" "errors" "fmt" "regexp" "strconv" "strings" "time" "github.rackspace.com/gorax/goscp" "github.com/gofrs/uuid" "github.rackspace.com/gorax/clients/sshAPI" asaapi "github.rackspace.com/gorax/asaapi/pkg/asa" "github.rackspace.com/gorax/passkey" "github.rackspace.com/gorax/asaapi/pkg/asaModels" "github.rackspace.com/gorax/clients/coreAPI" "github.rackspace.com/gorax/clients/fenetsec" ) const ( asaPublicInterface = "outside" datapipeSKU = "110339" defaultInterfaceASA = "GigabitEthernet0/0" f5TouchCmd = "run util bash -c \"touch /service/mcpd/forceload\"" platformADX = "adx" platformASA = "asa" platformF5 = "f5" platformFirepower = "firepower" sshapiAuthDatapipe = "dp" sshapiAuthRackspace = "rackspace" typeDatapipe = "datapipe" typeRackspace = "rackspace" ) type ( // Client type represents client references to third-party resources required // for the methods within this package. Client struct { ID int Opts Options auth []string coreapi *coreAPI.Client creds []credentials device *coreAPI.DeviceInfo deviceType string fe *fenetsec.Client httpCreds credentials interfacesASA []*asaModels.InterfaceConfig passkey *passkey.Client platform string rawConfig string response Response updatedConfig string } // Options defines the input payload from the remote client Options struct { ID int `json:"device"` FullConfig bool `json:"full_config"` LiveConfig bool `json:"live_config"` // pull config from device rather than torq MgmtInfo *IPInfo `json:"mgmt_info,omitempty"` MgmtIP string `json:"mgmt_ip"` PublicInfo *IPInfo `json:"public_info,omitempty"` SkipBackup bool `json:"skip_backup"` SkipRevert bool `json:"skip_revert"` TftpPath string `json:"tftp_path"` UpdateDevice bool `json:"update_device"` VerifyOnly bool `json:"verify_only"` VlanMap map[string]string `json:"vlan_map,omitempty"` } credentials struct { Username string Password string } // InfoItem contains info about the item =p InfoItem struct { Old string `json:"old"` New string `json:"new"` } // IPInfo has info about the IP =p IPInfo struct { IP InfoItem `json:"ip"` Netmask InfoItem `json:"netmask"` Gateway InfoItem `json:"gateway"` StandbyIP InfoItem `json:"standby_ip"` } platformDefaultValues struct { CopyCommand string GetCommand string Prompt string RemoteFilename string RunningConfig string SaveCommand string VerifyCommand string } // Response is the response struct for the Run method Response struct { Result string `json:"result"` Config struct { Data string `json:"data"` Name string `json:"name"` } `json:"config"` Diff string `json:"diff"` UpdatedDevice bool `json:"device_updated"` UsedPeerConfig bool `json:"user_peer_config"` } ) var ( asaDefaultInterface = map[string]string{ "5505": "Vlan2", "5508": "GigabitEthernet1/1", "5510": "Ethernet0/0", "5515": "GigabitEthernet0/0", "5520": "GigabitEthernet0/0", "5525": "GigabitEthernet0/0", "5540": "GigabitEthernet0/0", "5545": "GigabitEthernet0/0", "5550": "GigabitEthernet0/0", "5555": "GigabitEthernet0/0", "5580": "TenGigabitEthernet7/0", "5585": "GigabitEthernet0/0", } platformDefaults = map[string]platformDefaultValues{ platformADX: { GetCommand: "show startup-config", Prompt: "#", RemoteFilename: "startconfig", RunningConfig: "running-config", SaveCommand: "wr mem", VerifyCommand: "more startup-config", }, platformASA: { CopyCommand: "copy /noconfirm flash:dcmig-startup startup-config", GetCommand: "more system:running-config", Prompt: "# ", RemoteFilename: "dcmig-startup", RunningConfig: "running-config", SaveCommand: "wr mem", VerifyCommand: "show startup-config", }, platformF5: { GetCommand: "run util bash -c \"cat /config/bigip_base.conf\"", Prompt: "(tmos)# ", RemoteFilename: "/config/bigip_base.conf", RunningConfig: "bigip_base.conf", SaveCommand: "save sys config", VerifyCommand: "run util bash -c \"cat /config/bigip_base.conf\"", }, platformFirepower: { CopyCommand: "copy /noconfirm flash:dcmig-startup startup-config", GetCommand: "more system:running-config", Prompt: "# ", RemoteFilename: "dcmig-startup", RunningConfig: "running-config", SaveCommand: "wr mem", VerifyCommand: "show startup-config", }, } ) // New creates a new client func New(ctx context.Context, device *coreAPI.DeviceInfo, platform string, opts *Options) *Client { c := Client{ ID: opts.ID, Opts: *opts, auth: []string{sshapiAuthRackspace}, coreapi: coreAPI.FromContext(ctx), device: device, deviceType: typeRackspace, fe: fenetsec.FromContext(ctx), passkey: passkey.FromContext(ctx), platform: platform, response: Response{}, } for _, sku := range c.device.Skus { if sku == datapipeSKU { c.deviceType = typeDatapipe c.auth = []string{sshapiAuthDatapipe, sshapiAuthRackspace} c.Opts.LiveConfig = true // crappy hack for firepower devices running asa code if c.platform == platformFirepower { c.platform = platformASA } } } // required for the asa lib c.httpCreds = credentials{ Username: c.passkey.Service.TacacsLogin.Credential.Username, Password: c.passkey.Service.TacacsLogin.Credential.Password, } // required for goscp c.creds = []credentials{ { Username: c.passkey.Service.TacacsLogin.Credential.Username, Password: c.passkey.Service.TacacsLogin.Credential.Password, }, } if c.deviceType == typeDatapipe { c.creds = []credentials{ { Username: c.passkey.Service.DPTacacsLogin.Credential.Username, Password: c.passkey.Service.DPTacacsLogin.Credential.Password, }, { Username: c.passkey.Service.TacacsLogin.Credential.Username, Password: c.passkey.Service.TacacsLogin.Credential.Password, }, } } // fudge the drac IP of the device if needed if opts.MgmtIP != "" { c.device.DracIP = opts.MgmtIP } return &c } // Run runs the runner func (c *Client) Run() (*Response, error) { var err error if err = c.getFWInterfaces(); err != nil { return nil, err } if err = c.backupConfig(); err != nil { return nil, err } if err = c.getConfig(); err != nil { return nil, err } if c.Opts.VerifyOnly { if err = c.verifyConfig(); err != nil { c.revertConfig() } return &c.response, err } c.modifyConfig() if c.Opts.UpdateDevice { if err = c.updateConfig(); err != nil { c.revertConfig() return &c.response, err } if err = c.verifyConfig(); err != nil { c.revertConfig() return &c.response, err } } return &c.response, nil } // only supported option is to backup config to torq for rackspace devices // will silently skip step for non-rackspace devices func (c *Client) backupConfig() error { if c.Opts.SkipBackup { return nil } switch c.deviceType { case typeRackspace: resp, err := c.fe.DeviceBackup(c.ID) if err != nil { return err } for k, v := range resp.Details { if k == "finalize" || k == "ha_status" { for _, val := range v { if val == "ok" { return nil } } } } return errors.New("'ok' not found in finalize or ha_status step") } return nil } func (c *Client) ciscoFirewall() bool { return c.platform == platformASA || c.platform == platformFirepower } func (c *Client) getConfig() error { if c.Opts.LiveConfig { var err error c.rawConfig, err = c.sshapiCmd(platformDefaults[c.platform].GetCommand) if err != nil { return errors.New("getConfig(): " + err.Error()) } if c.rawConfig == "" { return errors.New("getConfig(): empty response") } return nil } // get config from last Torq backup var err error deviceID := c.ID if c.ciscoFirewall() { deviceID, err = c.isPrimaryFW() if err != nil { return err } } c.rawConfig, err = c.fe.DeviceFileRaw(deviceID, platformDefaults[c.platform].RunningConfig) if err == nil && c.rawConfig == "" { err = errors.New("feGetConfig(): empty config file returned") } if err != nil { return err } // flip failover lan unit since we're using peer config if c.ciscoFirewall() && deviceID != c.ID { c.response.UsedPeerConfig = true re := regexp.MustCompile(`failover lan unit (\S+)(\b)`) m := re.FindStringSubmatch(c.rawConfig) if len(m) > 0 { repl := "secondary" if m[1] == repl { repl = "primary" } c.rawConfig = re.ReplaceAllString(c.rawConfig, fmt.Sprintf("failover lan unit %s${2}", repl)) } } return nil } // pull the running config of the ASA interfaces; need it for: // - isPrimaryFW() to match up active IP in torq with the right HA device ID // - 5505 config modification func (c *Client) getFWInterfaces() (err error) { if !c.ciscoFirewall() { return } // iterate through sshapi auth options u, _ := uuid.NewV4() sshapi, err := sshAPI.NewSSHAPIClient(c.coreapi, c.device, c.platform, true, u) if err != nil { return err } authErrors := "" for _, auth := range c.auth { sshapi.UseAuth(auth) a, err := asaapi.NewOptASA( c.ID, c.httpCreds.Username, c.httpCreds.Password, asaapi.WithCoreAPI(c.coreapi), asaapi.WithDeviceInfo(c.device), asaapi.WithSSHAPI(sshapi), ) if err != nil { continue } if err := a.GetInterfaces(); err != nil { // if cmd failed, attempt next set of creds authErrors += fmt.Sprintf("auth=%s, err=%s\n", auth, err) continue } c.interfacesASA = a.Config.Interfaces return nil } return fmt.Errorf("getFWInterfaces(): failed all attempts to get ASA interfaces. errors: %s", authErrors) } // for HA ASA firewalls, this will determine which device ID has the primary outside IP assigned to it func (c *Client) isPrimaryFW() (devID int, err error) { devID = c.ID if c.device.FailoverDevice == nil || c.device.FailoverDevice != nil && c.device.FailoverDevice.FailoverID == "" { return } // try to match outside interface by nameif for _, i := range c.interfacesASA { if i == nil || i.Nameif == nil || i.IPAddress == nil { continue } if strings.EqualFold(*i.Nameif, asaPublicInterface) { if *i.IPAddress == c.device.PrimaryIP { return } if i.Standby != nil && *i.Standby == c.device.FailoverDevice.PrimaryIP { return strconv.Atoi(c.device.FailoverDevice.FailoverID) } } } // fallback to match outside interface by name defaultInt := defaultInterfaceASA for k, v := range asaDefaultInterface { if strings.Contains(c.device.PlatformModel, k) { defaultInt = v break } } for _, i := range c.interfacesASA { if i == nil || i.Nameif == nil || i.IPAddress == nil { continue } if i.Name == defaultInt { if *i.IPAddress == c.device.PrimaryIP { return } if i.Standby != nil && *i.Standby == c.device.FailoverDevice.PrimaryIP { return strconv.Atoi(c.device.FailoverDevice.FailoverID) } } } return 0, errors.New("isPrimaryFW(): unable to find default/outside interface matching primary IP") } func (c *Client) modifyConfig() { switch c.platform { case platformADX: m := adx{Opts: c.Opts, RawConfig: c.rawConfig} c.updatedConfig = m.modify() case platformASA: m := asa{Interfaces: c.interfacesASA, Model: c.device.PlatformModel, Opts: c.Opts, RawConfig: c.rawConfig} c.updatedConfig = m.modify() case platformF5: m := f5{Opts: c.Opts, RawConfig: c.rawConfig} c.updatedConfig = m.modify() case platformFirepower: m := asa{Interfaces: c.interfacesASA, Model: c.device.PlatformModel, Opts: c.Opts, RawConfig: c.rawConfig} c.updatedConfig = m.modify() } if c.Opts.FullConfig { c.response.Config.Name = platformDefaults[c.platform].RemoteFilename c.response.Config.Data = c.updatedConfig } if c.updatedConfig == c.rawConfig { c.response.Result = "no changes required" return } // diff configs updatedList := strings.Split(c.updatedConfig, "\n") for i, ln := range strings.Split(c.rawConfig, "\n") { if ln != updatedList[i] { c.response.Diff += fmt.Sprintf("line %d:\n- %s\n+ %s\n", i+1, ln, updatedList[i]) } } } func (c *Client) postUpload() error { switch c.platform { case platformADX: // the adx requires time to save startup to disk; running 'more startup' too quickly won't show changes time.Sleep(5 * time.Second) case platformASA, platformFirepower: // save uploaded file to startup-config _, err := c.sshapiCmd(platformDefaults[c.platform].CopyCommand) if err != nil { return fmt.Errorf("postUpload(): %s", err) } case platformF5: // forces mcpd service to reload the text config and rebuild the binary config _, err := c.sshapiCmd(f5TouchCmd) if err != nil { return fmt.Errorf("postUpload(): %s", err) } } return nil } func (c *Client) revertConfig() { if !c.Opts.SkipRevert { _, _ = c.sshapiCmd(platformDefaults[c.platform].SaveCommand) } } func (c *Client) scpAddress() string { if c.usePublic() { return c.device.PrimaryIP } return c.device.DracIP } func (c *Client) sshapiCmd(cmdString string) (string, error) { u, _ := uuid.NewV4() sshapi, err := sshAPI.NewSSHAPIClient(c.coreapi, c.device, c.platform, c.usePublic(), u) if err != nil { return "", err } authErrors := "" for _, a := range c.auth { sshapi.UseAuth(a) cmd, err := sshapi.PostCommand( cmdString, platformDefaults[c.platform].Prompt, 60, ) if err != nil { authErrors += fmt.Sprintf("sshapi device=%d, auth=%s, err=%s\n", c.ID, a, err) continue } if cmd.Response == nil || *cmd.Response == "" { authErrors += fmt.Sprintf("sshapi device=%d, auth=%s, err=%s\n", c.ID, a, "empty response") continue } // strip command from response re := regexp.MustCompile(fmt.Sprintf(`^%s(?:\n|\r\n)`, cmdString)) *cmd.Response = re.ReplaceAllString(*cmd.Response, "") // strip trailing prompt re = regexp.MustCompile(fmt.Sprintf(`\n.*?%s$`, platformDefaults[c.platform].Prompt)) *cmd.Response = re.ReplaceAllString(*cmd.Response, "\n") return *cmd.Response, nil } return "", errors.New(authErrors) } func (c *Client) updateConfig() error { if err := c.uploadConfig(); err != nil { return err } if err := c.postUpload(); err != nil { return err } c.response.UpdatedDevice = true c.response.Result = "config modified" return nil } func (c *Client) uploadConfig() error { authErrors := "" for _, cred := range c.creds { u, _ := uuid.NewV4() scp := goscp.SCPRequest{ UserInfo: goscp.UserInfo{ Username: cred.Username, UUID: u.String(), }, SaveAs: platformDefaults[c.platform].RemoteFilename, Content: c.updatedConfig, Host: c.scpAddress(), Port: 22, Username: cred.Username, Password: cred.Password, } _, err := scp.ScpPush() if err == nil { authErrors = "" break } authErrors += fmt.Sprintf("username=%s, host=%s, err=%s\n", cred.Username, c.scpAddress(), err) } if authErrors != "" { return errors.New("uploadConfig(): " + authErrors) } return nil } // use drac IP for rackspace adx/f5; public for everything else func (c *Client) usePublic() bool { if c.deviceType != typeRackspace { return true } return c.ciscoFirewall() } func (c *Client) verifyConfig() error { startupConfig, err := c.sshapiCmd(platformDefaults[c.platform].VerifyCommand) if err != nil { return errors.New("verifyConfig(): " + err.Error()) } switch c.platform { case platformADX: m := adx{ Opts: c.Opts, RawConfig: c.rawConfig, StartupConfig: startupConfig, } return m.verify() case platformASA: m := asa{ Interfaces: c.interfacesASA, Model: c.device.PlatformModel, Opts: c.Opts, RawConfig: c.rawConfig, StartupConfig: startupConfig, } return m.verify() case platformF5: m := f5{ Opts: c.Opts, RawConfig: c.rawConfig, StartupConfig: startupConfig, } return m.verify() case platformFirepower: m := asa{ Interfaces: c.interfacesASA, Model: c.device.PlatformModel, Opts: c.Opts, RawConfig: c.rawConfig, StartupConfig: startupConfig, } return m.verify() } return nil }