Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sensor_download): adds lock files to prevent collision when downloading similar sensors #569

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelogs/fragments/add-locking.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
minor_changes:
- sensor_download - adds the ability to lock files to prevent collision when downloading the sensor (https://github.com/CrowdStrike/ansible_collection_falcon/pull/569)

bugfixes:
- falcon_install - fix issue with temp directories being random or non-existent (https://github.com/CrowdStrike/ansible_collection_falcon/pull/569)
2 changes: 1 addition & 1 deletion changelogs/fragments/fix-truthy-564.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
bugfixes:
- falcon_configure - Fix truthy condition for falcon_cid and falcon_provisioning_token (https://github.com/CrowdStrike/ansible_collection_falcon/pull/565)
- falcon_configure - Fix truthy condition for falcon_cid and falcon_provisioning_token (https://github.com/CrowdStrike/ansible_collection_falcon/pull/565)
147 changes: 106 additions & 41 deletions plugins/modules/sensor_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
- crowdstrike.falcon.credentials
- crowdstrike.falcon.credentials.auth

notes:
- This module implements file locking to ensure safe concurrent downloads by preventing multiple
instances from accessing the same file simultaneously. As a result, a temporary 0-byte .lock
file will be created in the same directory as the downloaded file. If needed, this lock file
can be safely removed in a subsequent task after the download completes.

requirements:
- Sensor download [B(READ)] API scope

Expand Down Expand Up @@ -83,8 +89,12 @@
sample: /tmp/tmpzy7hn29t/falcon-sensor.deb
"""

import errno
import fcntl
import traceback
import os
import time
import random
from tempfile import mkdtemp

from ansible.module_utils.basic import AnsibleModule, missing_required_lib
Expand Down Expand Up @@ -127,6 +137,79 @@ def update_permissions(module, changed, path):
return module.set_fs_attributes_if_different(file_args, changed=changed)


def lock_file(file_path, exclusive=True, timeout=300, retry_interval=5):
"""Lock a file for reading or writing."""
lock_file_path = file_path + ".lock"
# Ignore the pylint warning here as a with block will close the file handle immediately
# and we need to keep it open to maintain the lock
lock_file_handle = open(lock_file_path, 'w', encoding='utf-8') # pylint: disable=consider-using-with
start_time = time.time()
# Implement a delay to prevent thundering herd
delay = random.random() # nosec

while True:
try:
if exclusive:
fcntl.flock(lock_file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
else:
fcntl.flock(lock_file_handle, fcntl.LOCK_SH | fcntl.LOCK_NB)
return lock_file_handle
except IOError as e:
if e.errno != errno.EAGAIN:
raise
if time.time() - start_time > timeout:
return None
time.sleep(delay + retry_interval)
delay = 0


def unlock_file(locked_file):
"""Unlock a file."""
fcntl.flock(locked_file, fcntl.LOCK_UN)
locked_file.close()


def check_destination_path(module, dest):
"""Check if the destination path is valid."""
if not os.path.isdir(dest):
module.fail_json(msg=f"Destination path does not exist or is not a directory: {dest}")

if not os.access(dest, os.W_OK):
module.fail_json(msg=f"Destination path is not writable: {dest}")


def handle_existing_file(module, result, path, sensor_hash):
"""Handle the case where the file already exists."""
# Compare sha256 hashes to see if any changes have been made
dest_hash = module.sha256(path)
if dest_hash == sensor_hash:
# File already exists and content is the same. Update permissions if needed.
msg = "File already exists and content is the same."

if update_permissions(module, result["changed"], path):
msg += " Permissions were updated."
result.update(changed=True)

module.exit_json(
msg=msg,
path=path,
**result,
)


def download_sensor_installer(module, result, falcon, sensor_hash, path):
"""Download the sensor installer."""
# Because this returns a binary, we need to handle errors differently
download = falcon.download_sensor_installer(id=sensor_hash)

if isinstance(download, dict):
# Error as download should not be a dict (from FalconPy)
module.fail_json(msg="Unable to download sensor installer", **result)

with open(path, "wb") as save_file:
save_file.write(download)


def main():
"""Entry point for module execution."""
module = AnsibleModule(
Expand All @@ -153,12 +236,7 @@ def main():
tmp_dir = True

# Make sure path exists and is a directory
if not os.path.isdir(dest):
module.fail_json(msg=f"Destination path does not exist or is not a directory: {dest}")

# Make sure path is writable
if not os.access(dest, os.W_OK):
module.fail_json(msg=f"Destination path is not writable: {dest}")
check_destination_path(module, dest)

falcon = authenticate(module, SensorDownload)

Expand All @@ -175,51 +253,38 @@ def main():
name = sensor_check["body"]["resources"][0]["name"]

path = os.path.join(dest, name)
lock = None

try:
lock = lock_file(path, timeout=300, retry_interval=5)
if not lock:
module.fail_json(msg=f"Unable to acquire lock for file: {path} after 5 minutes.", **result)

# Check if the file already exists
if not tmp_dir and os.path.isfile(path):
# Compare sha256 hashes to see if any changes have been made
dest_hash = module.sha256(path)
if dest_hash == sensor_hash:
# File already exists and content is the same. Update permissions if needed.
msg = "File already exists and content is the same."
# Check if the file already exists
if not tmp_dir and os.path.isfile(path):
handle_existing_file(module, result, path, sensor_hash)

if update_permissions(module, result["changed"], path):
msg += " Permissions were updated."
result.update(changed=True)
# If we get here, the file either doesn't exist or has changed
result.update(changed=True)

if module.check_mode:
module.exit_json(
msg=msg,
msg=f"File would have been downloaded: {path}",
path=path,
**result,
)

# If we get here, the file either doesn't exist or has changed
result.update(changed=True)

if module.check_mode:
module.exit_json(
msg=f"File would have been downloaded: {path}",
path=path,
**result,
)

# Download the sensor installer
# Because this returns a binary, we need to handle errors differently
download = falcon.download_sensor_installer(id=sensor_hash)

if isinstance(download, dict):
# Error as download should not be a dict (from FalconPy)
module.fail_json(msg="Unable to download sensor installer", **result)

with open(path, "wb") as save_file:
save_file.write(download)
# Download the sensor installer
download_sensor_installer(module, result, falcon, sensor_hash, path)

# Set permissions on the file
update_permissions(module, result["changed"], path)
# Set permissions on the file
update_permissions(module, result["changed"], path)

result.update(path=path)
module.exit_json(**result)
result.update(path=path)
module.exit_json(**result)
finally:
if lock:
unlock_file(lock)
else:
# Should be caught by handle_return_errors, but just in case.
module.fail_json(
Expand Down
6 changes: 3 additions & 3 deletions roles/falcon_install/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The following variables are currently supported:
- `falcon_allow_downgrade` - Whether or not to allow downgrading the sensor version (bool, default: ***false***)
- `falcon_gpg_key_check` - Whether or not to verify the Falcon sensor Linux based package (bool, default: ***true***)
- :warning: When `falcon_install_method` is set to **api**, this value will be fetched by the API unless specified.
- `falcon_install_tmp_dir` - Temporary Linux and MacOS installation directory for the Falson Sensor (string, default: ***/tmp***)
- `falcon_install_tmp_dir` - Temporary Linux and MacOS installation directory for the Falson Sensor (string, default: ***/tmp/falcon-sensor***)
- `falcon_retries` - Number of attempts to download the sensor (int, default: ***3***)
- `falcon_delay` - Number of seconds before trying another download attempt (int, default: ***3***)

Expand All @@ -44,7 +44,7 @@ The following variables are currently supported:
- **us-gov-1** -> api.laggar.gcw.crowdstrike.com
- **eu-1** -> api.eu-1.crowdstrike.com
- `falcon_api_enable_no_log` - Whether to enable or disable the logging of sensitive data being exposed in API calls (bool, default: ***true***)
- `falcon_api_sensor_download_path` - Local directory path to download the sensor to (string, default: ***null***)
- `falcon_api_sensor_download_path` - Local directory path to download the sensor to (string, default: ***/tmp/falcon-sensor***)
- `falcon_api_sensor_download_mode` - The file permissions to set on the downloaded sensor (string, default: ***null***)
- `falcon_api_sensor_download_owner` - The owner to set on the downloaded sensor (string, default: ***null***)
- `falcon_api_sensor_download_group` - The group to set on the downloaded sensor (string, default: ***null***)
Expand All @@ -70,7 +70,7 @@ The following variables are currently supported:
- `falcon_cid` - Specify CrowdStrike Customer ID with Checksum (string, default: ***null***)
- `falcon_windows_install_retries` - Number of times to retry sensor install on windows (int, default: ***10***)
- `falcon_windows_install_delay` - Number of seconds to wait to retry sensor install on windows in the event of a failure (int, default: ***120***)
- `falcon_windows_tmp_dir` - Temporary Windows installation directory for the Falson Sensor (string, default: ***%SYSTEMROOT%\\Temp***)
- `falcon_windows_tmp_dir` - Temporary Windows installation directory for the Falson Sensor (string, default: ***%SYSTEMROOT%\\Temp\\falcon-sensor***)
- `falcon_windows_install_args` - Additional Windows install arguments (string, default: ***/norestart***)
- `falcon_windows_uninstall_args` - Additional Windows uninstall arguments (string, default: ***/norestart***)
- `falcon_windows_become` - Whether to become a privileged user on Windows (bool, default: ***true***)
Expand Down
9 changes: 4 additions & 5 deletions roles/falcon_install/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ falcon_install_method: api
# The local directory path to download the sensor to.
# This is in relation to the localhost running the role.
#
# If path is not specified, a temporary directory will be created using the system's
# default temporary directory.
# By default, this will be the temp OS filesystem
#
falcon_api_sensor_download_path:
falcon_api_sensor_download_path: "/tmp/falcon-sensor"

# The name to save the sensor file as.
#
Expand Down Expand Up @@ -125,7 +124,7 @@ falcon_sensor_update_policy_name: ""
# Where should the sensor file be copied to on Linux and MacOS systems?
# By default, this will be the temp OS filesystem
#
falcon_install_tmp_dir: "/tmp"
falcon_install_tmp_dir: "/tmp/falcon-sensor"

# If the installation method is 'url', provide the url for the sensor to
# be downloaded from.
Expand Down Expand Up @@ -160,7 +159,7 @@ falcon_windows_install_delay: 120
#
# For Windows, this can be "%SYSTEMROOT%\\Temp"
#
falcon_windows_tmp_dir: "%SYSTEMROOT%\\Temp"
falcon_windows_tmp_dir: "%SYSTEMROOT%\\Temp\\falcon-sensor"

# Additional install arguments beyond the default required
#
Expand Down
24 changes: 17 additions & 7 deletions roles/falcon_install/tasks/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,12 @@
ansible.builtin.set_fact:
falcon_sensor_version: "+version:'{{ falcon_sensor_update_policy_package_version }}'"


- name: "CrowdStrike Falcon | Build API Sensor Query"
ansible.builtin.set_fact:
falcon_os_query: "os:'{{ falcon_target_os }}'+os_version:'{{ falcon_os_version }}'\
falcon_os_query:
"os:'{{ falcon_target_os }}'+os_version:'{{ falcon_os_version }}'\
{{ falcon_os_arch | default('') }}{{ falcon_sensor_version | default('') }}"


- name: CrowdStrike Falcon | Get list of filtered Falcon sensors
crowdstrike.falcon.sensor_download_info:
auth: "{{ falcon.auth }}"
Expand All @@ -60,6 +59,15 @@
msg: "No Falcon Sensor was found! If passing in falcon_sensor_version, ensure it is correct!"
when: falcon_api_installer_list.installers[0] is not defined

- name: CrowdStrike Falcon | Ensure download path exists (local)
ansible.builtin.file:
path: "{{ falcon_api_sensor_download_path }}"
state: directory
mode: "0755"
changed_when: false
delegate_to: localhost
run_once: true

- name: CrowdStrike Falcon | Download Falcon Sensor Installation Package (local)
crowdstrike.falcon.sensor_download:
auth: "{{ falcon.auth }}"
Expand All @@ -85,18 +93,20 @@
- name: CrowdStrike Falcon | Copy Sensor Installation Package to remote host (windows)
ansible.windows.win_copy:
src: "{{ falcon_sensor_download.path }}"
dest: "{{ falcon_install_win_temp_directory.path }}"
mode: 0640
dest: "{{ falcon_windows_tmp_dir_stat.stat.path }}"
changed_when: false
register: win_falcon_sensor_copied
when: ansible_os_family == "Windows"

- name: CrowdStrike Falcon | Remove Downloaded Sensor Installation Package (local)
- name: CrowdStrike Falcon | Remove Downloaded Sensor Installation directory (local)
ansible.builtin.file:
path: "{{ falcon_sensor_download.path }}"
path: "{{ item }}"
state: absent
changed_when: false
delegate_to: localhost
loop:
- "{{ falcon_sensor_download.path }}"
- "{{ falcon_sensor_download.path + '.lock' }}"
when: falcon_api_sensor_download_cleanup

- name: CrowdStrike Falcon | Set full file download path (non-windows)
Expand Down
17 changes: 2 additions & 15 deletions roles/falcon_install/tasks/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,10 @@
manager: auto
when: ansible_facts['distribution'] != "MacOSX"

- name: CrowdStrike Falcon | Gather tmp install directory objects
ansible.builtin.find:
paths: "{{ falcon_install_tmp_dir }}"
patterns: "ansible.*falcon"
file_type: directory
register: falcon_tmp_dir_objects
when: falcon_install_tmp_dir | length > 0
changed_when: no

- name: CrowdStrike Falcon | Remove tmp install directories
- name: CrowdStrike Falcon | Remove tmp install directory
ansible.builtin.file:
path: "{{ item.path }}"
path: "{{ falcon_install_tmp_dir }}"
state: absent
loop: "{{ falcon_tmp_dir_objects.files }}"
when:
- falcon_install_tmp_dir | length > 0
- falcon_tmp_dir_objects is defined and falcon_tmp_dir_objects.files | length > 0
changed_when: no

- name: CrowdStrike Falcon | Remove Falcon Sensor Package (local file)
Expand Down
21 changes: 13 additions & 8 deletions roles/falcon_install/tasks/preinstall.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,28 +80,33 @@
falcon_sensor_update_policy_platform: "{{ ansible_facts['os_family'] }}"
when: ansible_facts['os_family'] == "Windows"

- name: CrowdStrike Falcon | Verify Temporary Install Directory Exists (non-Windows)
ansible.builtin.tempfile:
- name: CrowdStrike Falcon | Ensure Temporary Install Directory Exists (non-Windows)
ansible.builtin.file:
path: "{{ falcon_install_tmp_dir }}"
state: directory
suffix: falcon
mode: '0755'
when:
- ansible_facts['system'] == "Linux" or ansible_facts['system'] == "Darwin"
- falcon_install_tmp_dir is defined
register: falcon_install_temp_directory
changed_when: no

- name: CrowdStrike Falcon | Verify Temporary Install Directory Exists (Windows)
ansible.windows.win_tempfile:
- name: CrowdStrike Falcon | Ensure Temporary Install Directory Exists (Windows)
ansible.windows.win_file:
path: "{{ falcon_windows_tmp_dir }}"
state: directory
suffix: falcon
when:
- ansible_facts['os_family'] == "Windows"
- falcon_windows_tmp_dir is defined
register: falcon_install_win_temp_directory
changed_when: no

- name: CrowdStrike Falcon | Validate Temporary install directory (Windows)
ansible.windows.win_stat:
path: "{{ falcon_windows_tmp_dir }}"
when:
- ansible_facts['os_family'] == "Windows"
register: falcon_windows_tmp_dir_stat
failed_when: false

- name: CrowdStrike Falcon | Verify Falcon is not already installed (macOS)
ansible.builtin.stat:
path: "{{ falcon_path }}"
Expand Down
Loading