Cisco ASA Multicontext Firewall Backup

Auf der Cisco ASA Firewall wird ein Backup-Benutzer benötigt, der minimale Privilegien in den Kontexten admin und system benötigt. Der öffentliche SSH Authentisierungsschlüssel ist jener vom Benutzer auf dem Backup-Server unter welchem das Script aufgerufen wird.

changeto context admin
privilege cmd level 6 command changeto

enable password ********** level 6
username backup privilege 6
username backup attributes
  ssh authentication publickey ThEeNcRyPtEdSsHpUbLiCkEy==
 
changeto system
privilege cmd level 6 command backup
privilege cmd level 6 mode exec command copy
privilege show level 6 mode exec command tech-support

Leider unterstützt Cisco ASA keine Authentisierung mittels öffentlichem Schlüssel für scp-Befehle von der Firewall auf einen Backup-Server. Deshalb muss das Passwort auf der Kommandozeile im Klartext angegeben werden. Welch eine Schande für eine Firma, welche Sicherheitsgeräte verkauft.

Untenstehend das Expect-Skript, welches via SSH auf die Cisco ASA Firewall verbindet, in den privilegierten Modus wechselt, Tech-Support Informationen holt und ein Backup macht. Wegen eines Bugs auf der ASA Firewalls, muss dieses zuerst auf die Flash-Disk geschrieben werden und dann mittels copy-Befehl über scp auf den Backup-Server kopiert werden. Expect verwendet die Tcl-Syntax.

#!/usr/bin/expect -f
# ============================================================================
# Backup of Cisco ASA firewalls with multiple contexts.
# ============================================================================

exp_internal 0
log_user 1


# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
set ASA_USER        "backup"
set ASA_ENABLE      6
set BACKUP_USER     "ciscobackup"
set BACKUP_HOST     "123.123.123.123"
set PASSWORD_FILE   "$::env(HOME)/.backuppw"


# ----------------------------------------------------------------------------
# abort
# ----------------------------------------------------------------------------
# Prints the string given as argument out to stderr and exits.
#
proc abort {message } {
  puts stderr "ABORT: ${message}\n"
  exit 1
}


# ----------------------------------------------------------------------------
# sanitize_hostname
# ----------------------------------------------------------------------------
# Removes every non-allowed character from string. Keep shell argument safe.
#
proc sanitize_hostname {string1} {
  set string2 ""
  regsub -all -- {[^A-Za-z0-9\.\-]+} $string1 {} string2
  return $string2
}


# ----------------------------------------------------------------------------
# sanitize_dir
# ----------------------------------------------------------------------------
# Removes every non-allowed character from string. Keep shell argument safe.
#
proc sanitize_dir {string1} {
  set string2 ""
  regsub -all -- {[^A-Za-z0-9_/\.\-]+} $string1 {} string2
  return $string2
}


# ----------------------------------------------------------------------------
# get_retention_suffix
# ----------------------------------------------------------------------------
# Define retention filename suffix for simple retention algorithm:
# Daily up to 7 per week
# Monthly up to 12 per year on 1st day of month.
# Yearly up to forever on 1st of January
# Run script daily after midnight.
#
proc get_retention_suffix {} {
  set year    [timestamp -format %Y]
  set month   [timestamp -format %m]
  set day     [timestamp -format %d]
  set weekday [timestamp -format %w]

  if {$day == 1 && $month == 1} {
    set suffix "yearly_${year}"
  } elseif {$day == 1} {
    set suffix "monthly_${month}"
  } else {
    set suffix "daily_${weekday}"
  }
  return $suffix
}


# ----------------------------------------------------------------------------
# read_password
# ----------------------------------------------------------------------------
# Reads passwords from file defined in $PASSWORD_FILE given as argument. Trims
# any trailing newlines and spaces.
#
proc read_password {pwfile} {
  set password ""
  if {![file exist $pwfile]} {
    abort "Password file $pwfile does not exist!"
  } else {  
    set fp [open $pwfile r]
    set data [read $fp]
    close $fp
    regsub {[\n\r\t ]+$} $data {} password
  }
  return $password
} 


# ----------------------------------------------------------------------------
# connect_firewall
# ----------------------------------------------------------------------------
# Connects via SSH to firewall. Accept new host key if necessary. Then enable
# privileged exec mode.
#
proc connect_firewall {asa_user asa_host asa_enable password} {
  global prompt
  global spawn_id

  # Connect via SSH to firewall. Accept new host key if necessary.
  send_user -- "Connecting to ${asa_user}@${asa_host} ...\n"
  spawn ssh "${asa_user}@${asa_host}"
  expect {
    -re {The authenticity of host .* can.t be established.} {exp_continue}
    -re {RSA key fingerprint is} {exp_continue}
    -re {Are you sure you want to continue connecting \(yes/no\)\?} { send -- "yes\r"; exp_continue }
    -re {Warning: Permanently added .* to the list of known hosts.} { exp_continue }
    -re {User .* logged in to} { exp_continue }
    -re $prompt {}
    timeout { abort "Timeout at ssh login" }
    default { abort "Unknown condition occured at ssh login." }
  }

  # Enable privileged exec mode.
  send_user -- "Enable privileged exec mode ...\n"
  send -- "enable ${asa_enable}\r"
  expect {
    "Password: " {}
    "No password set for privilege level" { exit 1 }
    default { abort "Unknown condition occured at enable." }
  }
  send -- "${password}\r"
  expect {
    default { abort "Unknown condition occured at enable password." }
    timeout { abort "Timeout occured at enable password." }
    "Invalid password" { abort "Invalid password" }
    "Access denied"    { abort "Access denied" }
    -re $prompt {}
  }
  expect -re {.*} # Flush buffer
  return
}


# ----------------------------------------------------------------------------
# get_version
# ----------------------------------------------------------------------------
# Returns the ASA software version as floating point decimal number.
# Cisco ASA version numbering is major.minor, which means that 9.12 is 
# greather than 9.3 for example. Convert to decimal for mathematical 
# comparison. 9.12 becomes 9.012 and 9.3 becomes 9.003 for example.
# A thousand minor versions should be enough. :-)
#
proc get_version {} {
  global prompt
  global spawn_id
  set version_major ""
  set version_minor "" 
  expect -re {.*} # Flush buffer
  send_user -- "Checking for ASA version ..."
  send -- "show version | include ^Cisco.*Appliance.*Version\r"
  expect {
    default { abort "Unknown condition occured at show version" }
    timeout { abort "Timeout occured at show version." }
    -re {Cisco.*Appliance Software Version ([0-9]+)\.([0-9]+)} {
           set version_major $expect_out(1,string)
           set version_minor $expect_out(2,string)
         } 
    -re $prompt {}
  }
  set version_float [expr { $version_major + $version_minor/1000.0}]
  expect -re {.*} # Flush buffer
  return $version_float
}


# ----------------------------------------------------------------------------
# get_context_mode
# ----------------------------------------------------------------------------
# Queries context mode of the ASA firewall. Change to system context if ASA
# has multiple contexts. Returns context mode (single, multiple).
#
proc get_context_mode {} {
  global prompt
  global spawn_id
  set context_mode ""
  send_user -- "Checking context mode ...\n"
  send -- "show mode\r"
  expect {
    -re {context mode: single}   {set context_mode "single"}
    -re {context mode: multiple} {set context_mode "multiple"}
    -re $prompt {}
    timeout { abort "Timeout at 'show mode'" }
    default { abort "Unknown condition occured at 'show mode'" }
  }

  if {$context_mode eq "multiple"} {
    send_user -- "Multiple contexts: Changing to system context ...\n"
    send -- "changeto system\r"
    expect {
      -re $prompt {}
      default { abort "Unknown condition occured at 'changeto system'" }
    }
  }
  expect -re {.*} # Flush buffer
  return $context_mode
}


# ----------------------------------------------------------------------------
# get_contexts
# ----------------------------------------------------------------------------
# Show contexts, parse output, append context names into list contexts.
# Returns list of context names, starting with system.
#
proc get_contexts {} {
  global prompt
  global spawn_id
  set contexts [list system]
  send -- "show context\r"
  expect {
    default { abort "Unknown condition occured at 'show context'" }
    timeout { abort "Timeout condition occured at 'show context'" }
    -re $prompt {}
  }
  foreach line [split $expect_out(buffer) "\n"] {
    send_user -- "Line: '$line'"
    if {[regexp {^[ \*]([A-Za-z0-9\-]+)} $line match0 match1]} {
      lappend contexts $match1
    }
  }
  expect -re {.*} # Flush buffer
  return $contexts
}


# ----------------------------------------------------------------------------
# get_interface_hack
# ----------------------------------------------------------------------------
# When accessing ASA through a VPN tunnel and doing a copy command, the ASA
# uses the public interface IP as source address. The copy command then fails
# because traffic is not encrypted through the VPN tunnel. Appending the 
# option ";int=inside" is an undocumented hack to use the inside IP address
# instead. Works only for the copy command, not for the backup command.
#
proc get_interface_hack {} {
  global prompt
  global spawn_id
  set interface_hack ""
  send -- "show interface | include inside\r"
  expect {
    default { abort "Unknown condition occured at 'show mode'" }
    timeout { abort "Timeout at 'show mode'" }
    -re {Interface.*inside.*is up} {set interface_hack ";int=inside"; exp_continue}
    -re {ERROR: % Invalid input detected at} {exp_continue}
    -re $prompt {}
  }
  send_user -- "Interface hack: '$interface_hack'\n"
  expect -re {.*} # Flush buffer
  return $interface_hack
}


# ----------------------------------------------------------------------------
# copy_file
# ----------------------------------------------------------------------------
# Does what you expect. Copies a file from source to destination. Applies the
# inside interface hack for source NAT over VPN.
#
proc copy_file {source destination} {
  global prompt
  global spawn_id
  global interface_hack
  expect -re {.*} # Flush buffer
  send -- "copy /noconfirm ${source} ${destination}${interface_hack}\r"
  expect {
    default { abort "Unknown condition occured at copy ${source}." }
    timeout { abort "Timeout occured at copy ${source}." }
    -re {!+} {exp_continue}
    -re {INFO: No digital signature found} {exp_continue}
    -re {bytes copied in .* secs} {exp_continue}
    -re {No such file or directory} { abort "No such file or directory" }
    -re $prompt {}
  }
  expect -re {.*} # Flush buffer
  return
}


# ----------------------------------------------------------------------------
# delete_file
# ----------------------------------------------------------------------------
proc delete_file {filename} {
  global prompt
  global spawn_id
  expect -re {.*} # Flush buffer
  send -- "delete /noconfirm ${filename}\r"
  expect {
    default { abort "Unknown condition occured at delete ${filename}." }
    timeout { abort "Timeout occured at delete ${filename}." }
    -re {No such file or directory} { abort "No such file or directory" }
    -re $prompt {}
  }
  expect -re {.*} # Flush buffer
  return
}


# ----------------------------------------------------------------------------
# copy_tech_support
# ----------------------------------------------------------------------------
# Sending tech-support directly to scp path fails. Copy first to flash disk,
# then copy, then delete.
#
proc copy_tech_support {backup_url suffix } {
  global prompt
  global spawn_id
  expect -re {.*} # Flush buffer
  send_user -- "Collecting tech-support ...\n"
  send -- "show tech-support file flash:/tmp_tech-support.txt\r"
  expect {
    default { abort "Unknown condition occured at show tech-support" }
    timeout { abort "Timeout occured at show tech-support" }
    -re {INFO: No digital signature found} {exp_continue}
    -re $prompt {}
  }

  copy_file "flash:/tmp_tech-support.txt " "${backup_url}/tech-support_${suffix}.txt"
  delete_file "flash:/tmp_tech-support.txt"
  expect -re {.*} # Flush buffer
  return
}


# ----------------------------------------------------------------------------
# run_backup
# ----------------------------------------------------------------------------
# First run a backup to flash disk and then copy it via scp due to Cisco bug
# CSCvh02142. Backup of WebVPN data fails in multiple contexts.
#
proc run_backup {context_mode password backup_url suffix {context ""}} {
  global prompt
  global spawn_id
  expect -re {.*} # Flush buffer
  if {$context_mode eq "single"} {
    set backup_cmd "backup /noconfirm passphrase ${password} location flash:/backup.tar.gz\r"
    set backup_url_file "${backup_url}/backup_${suffix}.tar.gz"
  } else {
    set backup_cmd "backup /noconfirm context ${context} passphrase ${password} location flash:/backup.tar.gz\r"
    set backup_url_file "${backup_url}/backup_${context}_${suffix}.tar.gz"
  }
  send -- $backup_cmd
  expect {
    default { abort "Unknown condition occured at backup." }
    timeout { abort "Timeout occured at backup." }
    -re {Invalid input detected at} {abort "Backup command invalid" }
    -re {Warning: This device is part of a failover set up.} {exp_continue}
    -re {Begin backup} {exp_continue}
    -re {Backing up.*Done} {exp_continue}
    -re {Compressing the backup directory.*Done} {exp_continue}
    -re {Copying Backup.*Done} {exp_continue}
    -re {Cleaning up.*Done} {exp_continue}
    -re {Backup finished} {exp_continue}
    -re $prompt {}
  }

  copy_file "flash:/backup.tar.gz" "${backup_url_file}"
  delete_file "flash:/backup.tar.gz"
  expect -re {.*} # Flush buffer
  return
}


# ----------------------------------------------------------------------------
# Main 
# ----------------------------------------------------------------------------

if {$argc != 2} {
  abort "Usage: ${argv0} <hostname> <destdir>"
}

set asa_host [sanitize_hostname [lindex $argv 0]]
set dest_dir [sanitize_dir [lindex $argv 1]]
set password [read_password $PASSWORD_FILE]
set backup_url "scp://${BACKUP_USER}:${password}@${BACKUP_HOST}/${dest_dir}"
set prompt {[a-z0-9\-/]*[>\#] $}
set suffix [get_retention_suffix]

# Create destination directory if it not exists.
if {[file exist $dest_dir] && ! [file isdirectory $dest_dir]} {
  abort "Destination ${dest_dir} exists, but it is not a directory!"
} else {
  file mkdir $dest_dir
}

send_user -- "\n\n"
send_user -- "------------------------------------------------------------\n"
send_user -- "Cisco ASA to back up : ${asa_host}\n"
send_user -- "Destination directory: ${dest_dir}\n"
send_user -- "------------------------------------------------------------\n"

connect_firewall $ASA_USER $asa_host $ASA_ENABLE $password

set context_mode [get_context_mode]
set interface_hack [get_interface_hack]
set version [get_version]

copy_tech_support $backup_url $suffix

if {$version >= 9.003} {
  # The backup command was added with ASA version 9.3(2).
  # It contains the running-config, startup-config, certificates and if there
  # was no bug with multiple contexts, also webvpn data such as anyconnect
  # packages.
  if {$context_mode eq "single"} {
    send_user -- "Single context: Backing up ...\n"
    run_backup $context_mode $password $backup_url $suffix
  } else {
    send_user -- "Multiple contexts: Getting context names ...\n"
    set contexts [get_contexts]

    # Iterate through list of contexts and do backup to scp destination.
    foreach context $contexts {
      send_user -- "Backing up context $context ...\n"
      run_backup $context_mode $password $backup_url $suffix $context
    }
  }
} else {
  # Legacy ASA version. No backup command. Copy running and startup config.
  # No export of keys and certificates in pkcs#12 format.
  copy_file "running-config" "${backup_url}/running-config_${suffix}.cfg"
  copy_file "startup-config" "${backup_url}/startup-config_${suffix}.cfg"
}

send -- "exit\r"
send_user -- "Finished backup.\n"