chef/cookbooks/cpe_anyconnect/resources/cpe_anyconnect.rb (375 lines of code) (raw):

# # Cookbook:: cpe_anyconnect # Resources:: cpe_anyconnect # # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2 # # Copyright:: (c) 2021-present, Uber Technologies, Inc. # All rights reserved. # # This source code is licensed under the Apache 2.0 license found in the # LICENSE file in the root directory of this source tree. # unified_mode true resource_name :cpe_anyconnect provides :cpe_anyconnect, :os => ['darwin', 'windows'] default_action :manage action :manage do manage if manage? install if install? uninstall if !install? && uninstall? end action_class do # rubocop:disable Metrics/BlockLength def install? node['cpe_anyconnect']['install'] end def manage? node['cpe_anyconnect']['manage'] end def uninstall? node['cpe_anyconnect']['uninstall'] end def install debian_install if debian? macos_install if macos? windows_install if windows? end def debian_install # TODO: need to write return end def macos_install # Create the cache directories and stage necessary files create_anyconnect_cache sync_anyconnect_cache # cpe_remote_pkg doesn't support ChoiceChanges.xml which is needed to not install specific parts of this package # Download the anyconnect pkg download_package(pkg) # Install the pacakge with the ChoiceChangesXML cc_xml_path = ::File.join(anyconnect_root_cache_path, 'pkg', 'ChoiceChanges.xml') allow_downgrade = pkg['allow_downgrade'] if allow_downgrade if node.os_at_least?('12.0') && node.sext_profile_removal_contains_extension?( 'com.cisco.anyconnect.macos.acsockext', 'DE8Y96K9QP', node['cpe_anyconnect']['profile_identifier'] ) execute '/opt/cisco/anyconnect/bin/anyconnect_uninstall.sh' do not_if { node.macos_package_installed?(pkg['receipt'], pkg['version']) } not_if { anyconnect_vpn_connected? } only_if { ::File.exist?('/opt/cisco/anyconnect/bin/anyconnect_uninstall.sh') } end execute '/opt/cisco/anyconnect/bin/dart_uninstall.sh' do not_if { node.macos_package_installed?(pkg['dart_receipt'], pkg['version']) } not_if { anyconnect_vpn_connected? } only_if { ::File.exist?('/opt/cisco/anyconnect/bin/dart_uninstall.sh') } end else Chef::Log.warn('cpe_anyconnect - AnyConnect package has logic to fail if attempting to downgrade - you must '\ 'manually uninstall the application first if you are not passing a system extension profile!') Chef::Log.warn('cpe_anyconnect - forcing downgrade to false') allow_downgrade = false end end execute "/usr/sbin/installer -applyChoiceChangesXML #{cc_xml_path} -pkg #{pkg_path(pkg)} -target /" do # functionally equivalent to allow_downgrade false on cpe_remote_pkg if allow_downgrade not_if { node.macos_package_installed?(pkg['receipt'], pkg['version']) } else not_if { node.macos_min_package_installed?(pkg['receipt'], pkg['version']) } end not_if { anyconnect_vpn_connected? } notifies :create, 'file[trigger_gui]', :immediately end # We only want the UI to trigger upon the first install and upgrades. # In testing, their own postinstall script logic is very unreliable. # Touch the gui_keepalive path and then restart the agent will trigger the UI gui_la_label = node['cpe_anyconnect']['la_gui_identifier'] file 'trigger_gui' do action :nothing only_if { ::File.exist?("/Library/LaunchAgents/#{gui_la_label}.plist") } path '/opt/cisco/anyconnect/gui_keepalive' notifies :restart, "launchd[#{gui_la_label}]", :immediately end launchd gui_la_label do type 'agent' action :nothing end if node['cpe_anyconnect']['umbrella_diagnostic_link'] umbrella_diagnostic_link end end def windows_install # Create the cache directories and stage necessary files create_anyconnect_cache sync_anyconnect_cache # Add precheck / remediation before running through install. windows_error_prevention # Download and Install all modules node['cpe_anyconnect']['modules'].each do |pkg| # Download the anyconnect msi download_package(pkg) # Set default installer arguments pkg['install_args'].nil? ? install_args = '/norestart /passive /qn' : install_args = "/norestart /passive /qn #{pkg['install_args']}" # Install the pacakge windows_package "Install #{pkg['display_name']}" do source pkg_path(pkg) options install_args checksum pkg['checksum'] not_if { anyconnect_vpn_connected? } not_if do # Don't try to install if package and version are already installed (node['packages'].key?(pkg['display_name']) && node['packages'][pkg['display_name']]['version'].eql?(pkg['version'])) end end end end def manage debian_manage if debian? macos_manage if macos? windows_manage if windows? end def debian_manage # TODO: need to write return end def macos_manage # If the Anyconnect App goes missing, either by accident or abuse, trigger re-install ac_receipt = pkg['receipt'] orgid = node['cpe_anyconnect']['organization_id'] unless ::Dir.exist?(node['cpe_anyconnect']['app_path']) execute "/usr/sbin/pkgutil --forget #{ac_receipt}" do not_if { shell_out("/usr/sbin/pkgutil --pkg-info #{ac_receipt}").error? } end end # We only want the UI to trigger upon the first install and upgrades. # In testing, their own postinstall script logic is very unreliable. # Touch the gui_keepalive path and then restart the agent will trigger the UI gui_la_label = node['cpe_anyconnect']['la_gui_identifier'] file 'trigger_gui' do action :nothing only_if { ::File.exist?("/Library/LaunchAgents/#{gui_la_label}.plist") } path '/opt/cisco/anyconnect/gui_keepalive' notifies :restart, "launchd[#{gui_la_label}]", :immediately end launchd gui_la_label do type 'agent' action :nothing end # Ensure the anyconnect VPN Agent daemon is enabled launchd 'com.cisco.anyconnect.vpnagentd-manage' do action :enable label 'com.cisco.anyconnect.vpnagentd' only_if { ::File.exist?('/Library/LaunchDaemons/com.cisco.anyconnect.vpnagentd.plist') } type 'daemon' notifies :create, 'file[trigger_gui]', :immediately end # Disable VPN and trigger a re-enroll by deleting the data folder if the nslookup fails # Backup logs before directory deletion option. unless orgid.nil? ruby_block 'backup beacon logs' do block do Chef::Log.info( 'Backup beacon /opt/cisco/anyconnect/umbrella/data/beacon-logs/ ' \ '- trigger re-enroll of anyconnect client', ) require 'fileutils' FileUtils.cp_r( '/opt/cisco/anyconnect/umbrella/data/beacon-logs', '/var/log', ) end action :nothing only_if { node['cpe_anyconnect']['backup_logs'] } end directory '/opt/cisco/anyconnect/umbrella/data' do action :nothing notifies :disable, 'launchd[com.cisco.anyconnect.vpnagentd-manage]', :before notifies :enable, 'launchd[com.cisco.anyconnect.vpnagentd-manage]', :immediately recursive true end ruby_block 'Delete /opt/cisco/anyconnect/umbrella/data - trigger re-enroll of anyconnect client' do block do Chef::Log.info('Delete /opt/cisco/anyconnect/umbrella/data - trigger re-enroll of anyconnect client') end notifies :run, 'ruby_block[backup beacon logs]', :immediately notifies :delete, 'directory[/opt/cisco/anyconnect/umbrella/data]', :immediately not_if { nslookup(orgid) } only_if { node.macos_min_package_installed?(ac_receipt, '4.9.06037') } only_if { node.daemon_running?('com.cisco.anyconnect.vpnagentd') } # must be loaded to return nslookup data only_if { Time.now.to_i - Time.at(node.macos_boottime).to_i >= 300 } # takes a bit to fully load on boot only_if { Time.now.to_i - Time.at(node.macos_waketime).to_i >= 300 } # takes a bit to fully load upon wake # anyconnect must be on for a few to fully activate nslookup, otherwise this could loop infinitely only_if { node.macos_process_uptime('vpnagentd') >= 300 } end end end def windows_manage if windows_vpnagent_service_status.nil? if node['packages'].include?('Cisco AnyConnect Secure Mobility Client') Chef::Log.warn('Anyconnect is installed but [vpnagent] service has been removed') end elsif windows_vpnagent_service_status.include?('Running') Chef::Log.info('Anyconnect service [vpnagent] is running') elsif windows_vpnagent_service_status.include?('Stopped') Chef::Log.info('Anyconnect service [vpnagent] is stopped') end cisco_install_path = ::File.join(ENV['ProgramFiles(x86)'], 'Cisco/Cisco AnyConnect Secure Mobility Client') app_link = ::File.join(cisco_install_path, 'vpnui.exe') if node['cpe_anyconnect']['desktop_shortcut'] # Create Icon for Cisco AnyConnect Secure Mobility Client windows_shortcut desktop_link do iconlocation ::File.join(cisco_install_path, 'res/GUI.ico') description 'Cisco AnyConnect Secure Mobility Client' target app_link only_if { ::File.exist?(app_link) } not_if { ::File.exist?(desktop_link) } end else # Remove Icon for Cisco AnyConnect Secure Mobility Client remove_desktop_link end end def uninstall debian_uninstall if debian? macos_uninstall if macos? windows_uninstall if windows? end def debian_uninstall # TODO: need to write return end def macos_uninstall execute '/opt/cisco/anyconnect/bin/anyconnect_uninstall.sh' do only_if { ::File.exist?('/opt/cisco/anyconnect/bin/anyconnect_uninstall.sh') } end execute '/opt/cisco/anyconnect/bin/dart_uninstall.sh' do only_if { ::File.exist?('/opt/cisco/anyconnect/bin/dart_uninstall.sh') } end end def windows_uninstall # Ensure the cache directory exists so we can download packages needed to uninstall create_anyconnect_cache # Move core and dart modules to the end of array so these are uninstalled last modules = node['cpe_anyconnect']['modules'].dup core = modules.index { |k| k['name'].eql?('core') } dart = modules.index { |k| k['name'].eql?('dart') } modules[core], modules[modules.count - 2] = modules[modules.count - 2], modules[core] unless core.nil? modules[dart], modules[modules.count - 1] = modules[modules.count - 1], modules[dart] unless dart.nil? modules.each do |pkg| # Download the anyconnect msi cpe_remote_file app_name do file_name pkg_filename(pkg) checksum pkg['checksum'] path pkg_path(pkg) only_if { node['packages'].key?(pkg['display_name']) } end # We need to download each uninstaller because Chef does not properly uninstall using display_name # Only download the uninstaller if module is installed windows_package "Uninstall #{pkg['display_name']}" do source pkg_path(pkg) checksum pkg['checksum'] action :remove options '/qn /norestart' only_if { node['packages'].key?(pkg['display_name']) } end end # Remove AppData Files directory ::File.join(ENV['PROGRAMDATA'], 'Cisco/Cisco AnyConnect Secure Mobility Client') do action :delete recursive true ignore_failure true end # Remove Desktop Link remove_desktop_link end def pkg node['cpe_anyconnect']['pkg'].to_hash end def cache_path pkg['cache_path'] end def app_name pkg['app_name'] end def anyconnect_root_cache_path ::File.join(cache_path, app_name) end def create_anyconnect_cache # Create cache path directory anyconnect_root_cache_path do group node['root_group'] owner root_owner recursive true mode '0755' end end def sync_anyconnect_cache # Sync the entire anyconnect folder to handle any files an admin would need remote_directory anyconnect_root_cache_path do group node['root_group'] owner root_owner mode '0755' source 'anyconnect' end end def download_package(pkg) cpe_remote_file app_name do file_name pkg_filename(pkg) checksum pkg['checksum'] path pkg_path(pkg) end end def pkg_path(pkg) ::File.join(anyconnect_root_cache_path, pkg_filename(pkg)) end def pkg_filename(pkg) # Since Windows and MacOS naming is different, we need to return different filepaths # depending on if the package is a module or a macos package pkg['app_name'].nil? ? "#{app_name}-#{pkg['name']}-#{pkg['version']}.msi" : "#{app_name}-#{pkg['version']}.pkg" end def bfe_service_group # Required to find which svchost group the bfe service is assigned to. # Either LocalServiceNoNetwork or LoclalServiceNoNetworkFirewall return nil unless windows? cmd = "(Get-WMIObject Win32_Service -Filter \"Name='BFE'\")" group = powershell_out("(#{cmd}.PathName).split(' ')[2]").stdout.to_s.chomp group.empty? ? nil : group end def bfe_registry return nil unless windows? # Grabs registry value of svchost group and adds BFE to the list base = 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Svchost' cmd = "(Get-ItemProperty -Path 'registry::#{base}')" values = powershell_out("#{cmd}.#{bfe_service_group}").stdout.to_s.chomp.split(' ') values.push('BFE') unless values.include?('BFE') return values unless values.nil? || values.empty? 'BFE' end def anyconnect_vpn_connected? return false unless windows? || macos? client = if windows? ::File.join(ENV['ProgramFiles(x86)'], 'Cisco/Cisco AnyConnect Secure Mobility Client/vpncli.exe') elsif macos? '/opt/cisco/anyconnect/bin/vpn' end return false unless ::File.exist?(client) if windows? exe = 'Cisco/Cisco AnyConnect Secure Mobility Client/vpncli.exe' result = powershell_out("(& (Join-path -Path ${ENV:ProgramFiles(x86)} -ChildPath '#{exe}') state)").stdout.to_s elsif macos? result = `#{client} state` end result&.include?('state: Connected') end def desktop_link # set default location if ENV['PUBLIC'] is not assigned public = ENV['PUBLIC'] || 'C:/Users/Public' ::File.join(public, 'Desktop', 'Cisco Anyconnect Secure Mobility Client.lnk') end def remove_desktop_link file desktop_link do action :delete end end def umbrella_diagnostic_link umbrella_diagnostic_path = '/opt/cisco/anyconnect/bin/UmbrellaDiagnostic.app' link node['cpe_anyconnect']['umbrella_diagnostic_link'] do to umbrella_diagnostic_path only_if { ::File.exist?(umbrella_diagnostic_path) } end end def windows_error_prevention # Resolves 1603 error when installing on Windows devices (known issue) return if bfe_service_group.nil? registry_key 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Svchost' do values [{ 'name' => bfe_service_group, 'type' => :multi_string, 'data' => bfe_registry }] action :create end windows_service 'BFE' do action :start end end def windows_vpnagent_service_status return nil unless windows? status = powershell_out('(Get-Service vpnagent).status').stdout.to_s.chomp status.empty? ? nil : status end def nslookup(orgid) count = 0 json_path = ::File.join(anyconnect_root_cache_path, 'cpe_anyconnect.json') guard_successful = true unless node.nslookup_txt_records('debug.opendns.com')['orgid'] == orgid if ::File.exists?(json_path) count = node.parse_json(json_path)['nslookup_failures'] + 1 else count = 1 end end if count > node['cpe_anyconnect']['nslookup_failure_count_threshold'] count = 0 guard_successful = false end write_json(count, json_path) return guard_successful end def write_json(count, json_path) if count == 0 file json_path do action :delete end else file json_path do action :create content Chef::JSONCompat.to_json_pretty({ 'nslookup_failures' => count }) end end end end