Update a file through a Python script

Summary

In this project, I developed a command-line tool to manage an allow list of IP addresses, which is utilized to control access to restricted content within an organization. Written in Python, this tool is designed to offer a seamless way to add or remove IP addresses from the allow list, while also ensuring that the list remains up-to-date.

Key features

  1. File Handling: Upon being called from the command line the tool checks whether the allow list file allow_list.txt exists and reads its content if available.
  2. Adding IP addresses: Upon typing a simple command flag (--add) users can add one or more IP addresses to the allow list.
  3. Removing IP addresses: Conversely, users can remove one or more IP addresses using the command flag (--remove), revoking access from spceific IPs as needed.
  4. Ease of use: The tool is built to be used from the command line, making it accessible for administrators and engineers who may need to modify the allow list.
  5. Automated management: By automating the process of updating the allow list, this tool significantly reduces manual effort, eliminates human error, and ensures that the access controls are always current.

Technologies used

The script is written in Python and makes use of the following libraries:
  • os: this library provides a way of interfacing with the underlying operating system. In the code, it is used to check whether the allow list file exists.
  • argparse: this library is used to parse command-line arguments. It makes it easy to write user-friendly command-line interfaces, allowing the script to define what arguments it requires and how these arguments should be handled.
  • re: this module provides regular expression matching operations, which the script uses to validate the IP passed as parameters according to IPv4 and IPv6 formats.

Steps

After importing the necessary libraries, the code defines the function update_allow_list(), responsible for the heavy lifting in the script by first creating the list – if none exists – and then updating it by adding or removing the IPs as commanded, after a simple input validation.

Step 1

Naturaly, the code starts by importing aforementioned libraries:

import os
import argparse
import re

Step 2

A helper function is_valid_ip() is defined and implemented, in order to check whether the IPs passed in as parameters conform to IPv4 or IPv6 format. If so, it returs True; if not, it returns False, and prints an error message to the console.

def is_valid_ip(ip_address):
  # Pattern for IPv4
  pattern_ipv4 = r'^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$'
  # Pattern for IPv6
  pattern_ipv6 = r'^((([0-9A-Fa-f]{1,4}:){1,6}:)|(([0-9A-Fa-f]{1,4}:){7}))([0-9A-Fa-f]{1,4})$'
    
  if bool(re.match(pattern_ipv4, ip_address) or re.match(pattern_ipv6, ip_address)):
     return True
  else:
     print("{} is not a valid IP address.".format(ip_address))
     return False

Step 3

The update_allow_list() function begins by initializing an empty list of IP addresses – which will be populated with the IP addresses already in the allow list (in the built-in file path allow_list.txt), and eventually have its contents appended or removed as directed by the user.

The os.path module as the code calls its function exists() which returns True if the parameter refers to an existing path or an open file descriptor, and returns False for broken symbolic links.

Upon checking the existence of the file, the code opens it in read mode, assiging the returning object to the file variable. Inside the block, the files contents are read and split into lines with ip_address = file.read().splitlines().

By preceding the open() function with the keyword with, the code ensures that the file is properly closed once its suite finishes execution, even if an exception is raised within the block. This helps prevent file leaks, and makes the code cleaner and more robust.

def update_allow_list(add_list=[], remove_list=[]):
    ip_addresses = []
    file_path = "allow_list.txt"

    # Check if the file exists, and if it does, read the contents
    if os.path.exists(file_path):
        with open(file_path, "r") as file:
            ip_addresses = file.read().splitlines()

Step 4

The code then iterates through the remove_list argument – iteration which will only be executed if a remove list is present. Each IP in the remove list, upon having its format positively checked, is then removed from the ip_addresses list variable.

# Remove IPs from the list
for ip in remove_list:
    if is_valid_ip(ip):
        if ip in ip_addresses:
            ip_addresses.remove(ip)
            print("IP {} successfully removed from {}".format(ip, file_path))
        else:
            print("IP {} not found in {}".format(ip, file_path))

Step 5

The code also iterates through the add_list argument, in the same mode as it does the remove_list. Upon input validation, the IP is appended to the ip_addresses variable, or a message is displayed if it had already been previously added.

# Add IPs to the list
for ip in add_list:
    if is_valid_ip(ip):
        if ip not in ip_addresses:
            ip_addresses.append(ip)
            print("IP {} sucessfully added to {}".format(ip, file_path))
        else:
            print("IP {} previously added to {}".format(ip, file_path))

Step 6

The str.join() function is then called to concatenate all the IPs in the ip_addresses list, separated by lines. The buil-in open() function is then called once more, in write mode, and saves the updated allow list – concluding the execution of the update_allow_list() function.

# Write the updated list to the file (this 
# will create the file if it doesn't exist)
updated_ip_addresses = "\n".join(ip_addresses)
with open(file_path, "w") as file:
    file.write(updated_ip_addresses)

Step 7

Since the script is run directly, and not imported as a module, this behaviour is enforced by if __name__ == "__main__". Inside the block:

  • an argument parser object is created in order to hold the information necessary to parse the command line into Python data types
  • the add_argument() method defines the two optional arguments, for add and remove, respectively
  • the nargs='+' parameter allows for one or more values for each argument (which will only be checked if the add or remove tag is used)
  • the default=[] sets the value to an empty list if no argument is provided.
  • parser.parse_args() parses the arguments provided on the command line and returns them as a namespace object, containing the values for each argument as defined – meaning: it will have two attributes: args.add and args.remove, each containing the list of IP addresses passed for the corresponding command-line option
  • after the parser attributes have been extracted, update_allow_list(args.add, args.remove) is then called, initiating the updating procedure.

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Update the allow list of IP addresses.')
    parser.add_argument('-a', '--add', nargs='+', default=[], help='List of IP addresses to add.')
    parser.add_argument('-r', '--remove', nargs='+', default=[], help='List of IP addresses to remove.')

    args = parser.parse_args()

    update_allow_list(args.add, args.remove)

Challenges & Solutions

Despite being a somewhat simple script – and maybe because of it – the major challenge was to try to keep the code simple, easily readabla and maintainable, and, at the same time have a minimal amount of validation and responsiveness to the user. The solution was to try and find a balance between simplicity and robustness, where the input is validated, on IP at a time and the code is responsive to the user if an IP to be removed is not in list or an IP to be added is already there.

Code samples & Demo

This is the final version of the script:

Lessons learned

This script project underscored, for me, the utility of the language's straightforward syntax and accessible libraries. The availability of tools like os, for file existence checking, and argparse, for command-line parsing, allowed for the implementation of a practical and functional script.

Conclusion

Overall, we can see Python's efficiency for handling file operations and custom user inputs without unnecessary complexity. Its simplicity, coupled with essential standard libraries, streamlined the development of the script – showcasing Python's role as a versatile and approachable programming language.