diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36e61be..beb70cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +fail_fast: true repos: - repo: https://github.com/PyCQA/flake8 @@ -8,7 +9,7 @@ repos: additional_dependencies: - flake8-docstrings - flake8-sfs - args: [--max-line-length=120, --extend-ignore=SFS3 D107 SFS301 D100 D104 D401 SFS101] + args: [--max-line-length=120, --extend-ignore=SFS3 D107 SFS301 D100 D104 D401 SFS101 SFS201] - repo: https://github.com/PyCQA/isort @@ -21,8 +22,8 @@ repos: repo: local hooks: - - id: build_docs - name: build_html + id: docs + name: docs entry: /bin/bash gen_docs.sh language: system pass_filenames: false diff --git a/LICENSE b/LICENSE index b1a7ef4..cb10c62 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Vignesh Sivanandha Rao +Copyright (c) 2021 Vignesh Rao Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index af5b21c..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -global-exclude .env -global-exclude *.json -global-exclude .DS_Store -include vpn/* -recursive-include vpn * diff --git a/README.md b/README.md index 408c666..37ea2e6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ [![GitHub](https://img.shields.io/github/license/thevickypedia/vpn-server)][LICENSE] [![GitHub repo size](https://img.shields.io/github/repo-size/thevickypedia/vpn-server)][API_REPO] [![GitHub code size](https://img.shields.io/github/languages/code-size/thevickypedia/vpn-server)][API_REPO] -[![LOC](https://img.shields.io/tokei/lines/github/thevickypedia/vpn-server)][API_REPO] ###### Deployments [![pages-build-deployment](https://github.com/thevickypedia/vpn-server/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/thevickypedia/vpn-server/actions/workflows/pages/pages-build-deployment) @@ -29,7 +28,7 @@ - Create an AWS EC2 instance using a pre-built OpenVPN AMI. - Create a security group with the necessary ports allowed. - Configure the vpn server using SSH. -- Download the [OpenVPN client](https://openvpn.net/vpn-client/) and connect using public IP of the ec2 instance. +- Download the [OpenVPN client](https://openvpn.net/vpn-client/) and connect using the public DNS of the ec2 instance. - All set! Now the internet traffic will be routed through the VPN. Verify it using an [IP Lookup](https://whatismyipaddress.com/) > To take it a step further, if you have a registered domain in AWS, > vpn-server can be accessed with an alias record in route53 pointing to the public IP of the ec2 instance. @@ -37,25 +36,24 @@ - This module can also be used to clean up all the AWS resources spun up for creating a vpn server. ### ENV Variables -Environment variables are loaded from `.env` file if present. +Environment variables are loaded from any `env` file if present.
More on Environment variables +- **VPN_USERNAME** - Username to access `OpenVPN Connect` client. +- **VPN_PASSWORD** - Password to access `OpenVPN Connect` client. +- **VPN_PORT** - Port number for web interfaces. + - **IMAGE_ID** - AMI ID to be used. Defaults to a pre-built AMI from SSM parameter for [OpenVPN Access Server AMI Alias][AMI_ALIAS]. - **INSTANCE_TYPE** - Instance type to use for the VPN server. Defaults to `t2.nano`, use `t2.micro` if under [free-tier](https://aws.amazon.com/free). -- **VPN_USERNAME** - Username to access `OpenVPN Connect` client. Defaults to log in profile or `openvpn` -- **VPN_PASSWORD** - Password to access `OpenVPN Connect` client. Defaults to `awsVPN2021` -- **DOMAIN** - Domain name for the hosted zone. -- **RECORD_NAME** - Alias record name using which the VPN server has to be accessed. - -**To get notification about login information:**
-- **GMAIL_USER** - Username of the gmail account. -- **GMAIL_PASS** - Password of the gmail account. -- **RECIPIENT** - Email address to which the notification has to be sent. -- **PHONE** - Phone number to which the notification has to be sent (Works only for `US` based cellular) - -*Optionally `env vars` for AWS config (`AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_REGION_NAME`) can be setup.* +- **KEY_PAIR** - Name of the key pair file to connect to ec2. +- **SECURITY_GROUP** - Name of the security group. +- **VPN_INFO** - Name of the JSON file to dump the server information. +- **HOSTED_ZONE** - Domain name for the hosted zone. +- **SUBDOMAIN** - Alias record name using which the VPN server has to be accessed. + +*Optionally `env vars` for AWS config (`AWS_PROFILE_NAME`, `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_REGION_NAME`) can be setup.*
### Install @@ -65,6 +63,10 @@ python -m pip install vpn-server ### Usage ```python +import os + +os.environ['env_file'] = 'custom' # to load a custom .env file + import vpn # Instantiates the object, takes the same args as env vars. @@ -72,48 +74,32 @@ vpn_server = vpn.VPNServer() # Defaults to console logging, but supports custom vpn_server.create_vpn_server() # Create a VPN Server, login information will be saved to a JSON file. -# Re-configure an existing VPN Server (not required, unless the configuration steps have been interrupted) -# vpn_server.reconfigure_vpn() - # Test an existing VPN Server (not required, as a test is run right after creation anyway) # vpn_server.test_vpn() vpn_server.delete_vpn_server() # Deletes the VPN Server removing the AWS resources acquired during creation. ``` +
+
-Manual Configuration - -*Following are the prompts and response required to configure the VPN server.* - -- Are you sure you want to continue connecting (yes/no)? `yes` -1. Please enter 'yes' to indicate your agreement [no]: `yes` -2. Will this be the primary Access Server node? Default: `yes` -3. Please specify the network interface and IP address to be used by the Admin Web UI: `Default: all interfaces: 0.0.0.0` -4. Please specify the port number for the Admin Web UI. Default: `943` -5. Please specify the TCP port number for the OpenVPN Daemon. Default: `443` -6. Should client traffic be routed by default through the VPN? `yes` -7. Should client DNS traffic be routed by default through the VPN? Default: `No` -8. Use local authentication via internal DB? Default: `yes` -9. Should private subnets be accessible to clients by default? Default: `yes` -10. Do you wish to login to the Admin UI as "openvpn"? Default: `yes` -11. Specify the username for an existing user or for the new user account: `{USERNAME}` -12. Type the password for the 'vicky' account: `{PASSWORD}` -13. Confirm the password for the 'vicky' account: `{PASSWORD}` -14. Please specify your Activation key (or leave blank to specify later): `{ENTER/RETURN}` - -- Download the `OpenVPN` application and get connected to the VPN server. +Limitations +Currently `expose` cannot handle, tunneling multiple port numbers without modifying the following env vars in the `.env` file. +```shell +KEY_PAIR # SSH connection to AWS ec2 +KEY_FILE # Private key filename for self signed SSL +CERT_FILE # Public certificate filename for self signed SSL +SERVER_INFO # Filename to dump JSON data with server configuration information +SECURITY_GROUP # Ingress and egress firewall rules to control traffic allowed via VPC +```
-### AWS Resources Used -- EC2 - - Instance - _To redirect traffic through the instance's IP_ - - SecurityGroup - _To allow traffic over specific TCP ports_ - - Systems Manager - _To access [OpenVPN SSM parameter store][AMI_ALIAS] to retrieve the AMI ID_ - - Route53 [Optional] - _To access VPN server using an `A` record in `Route 53`_ -- VPC [Default] -- Subnet [Default] +## Coding Standards +Docstring format: [`Google`](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings)
+Styling conventions: [`PEP 8`](https://www.python.org/dev/peps/pep-0008/)
+Clean code with pre-commit hooks: [`flake8`](https://flake8.pycqa.org/en/latest/) and +[`isort`](https://pycqa.github.io/isort/) ### [Release Notes](https://github.com/thevickypedia/vpn-server/blob/main/release_notes.rst) **Requirement** @@ -148,7 +134,7 @@ pre-commit run --all-files ## License & copyright -© Vignesh Sivanandha Rao +© Vignesh Rao Licensed under the [MIT License][LICENSE] diff --git a/doc_generator/Makefile b/doc_gen/Makefile similarity index 100% rename from doc_generator/Makefile rename to doc_gen/Makefile diff --git a/doc_generator/conf.py b/doc_gen/conf.py similarity index 97% rename from doc_generator/conf.py rename to doc_gen/conf.py index d5eabc0..df86e51 100644 --- a/doc_generator/conf.py +++ b/doc_gen/conf.py @@ -18,8 +18,8 @@ # -- Project information ----------------------------------------------------- project = 'VPN Server' -copyright = '2021, Vignesh Sivanandha Rao' -author = 'Vignesh Sivanandha Rao' +copyright = '2021, Vignesh Rao' +author = 'Vignesh Rao' # -- General configuration --------------------------------------------------- diff --git a/doc_gen/index.rst b/doc_gen/index.rst new file mode 100644 index 0000000..a71f421 --- /dev/null +++ b/doc_gen/index.rst @@ -0,0 +1,101 @@ +.. VPN Server documentation master file, created by + sphinx-quickstart on Tue Sep 14 23:25:43 2021. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to VPN Server's documentation! +====================================== + +.. toctree:: + :maxdepth: 2 + :caption: Read Me: + + README + +VPN Server +========== + +.. automodule:: vpn.main + :members: + :private-members: + :undoc-members: + +Configuration +============= + +.. autoclass:: vpn.models.config.ConfigurationSettings(pydantic.BaseModel) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +==== + +.. autoclass:: vpn.models.config.AMIBase(pydantic.BaseModel) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +==== + +.. autoclass:: vpn.models.config.EnvConfig(pydantic.BaseSettings) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +==== + +.. autoclass:: vpn.models.config.Settings(pydantic.BaseModel) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +Exceptions +========== + +.. automodule:: vpn.models.exceptions + :members: + :private-members: + :undoc-members: + +ImageFactory +============ + +.. automodule:: vpn.models.image_factory + :members: + :private-members: + :undoc-members: + +LOGGER +====== + +.. automodule:: vpn.models.logger + :members: + :private-members: + :undoc-members: + +Route53 +======= + +.. automodule:: vpn.models.route53 + :members: + :private-members: + :undoc-members: + +SSH Configuration +================= + +.. automodule:: vpn.models.server + :members: + :private-members: + :undoc-members: + +Utilities +========= + +.. automodule:: vpn.models.util + :members: + :private-members: + :undoc-members: + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/doc_generator/make.bat b/doc_gen/make.bat similarity index 100% rename from doc_generator/make.bat rename to doc_gen/make.bat diff --git a/doc_generator/index.rst b/doc_generator/index.rst deleted file mode 100644 index fdc2b1b..0000000 --- a/doc_generator/index.rst +++ /dev/null @@ -1,61 +0,0 @@ -.. VPN Server documentation master file, created by - sphinx-quickstart on Tue Sep 14 23:25:43 2021. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to VPN Server's documentation! -====================================== - -.. toctree:: - :maxdepth: 2 - :caption: Read Me: - - README - -VPN Server -========== - -.. automodule:: vpn.controller - :members: - :private-members: - :undoc-members: - :exclude-members: PEM_FILE, INFO_FILE - -VPN Server - SSH Configuration -============================== - -.. automodule:: vpn.server - :members: - :private-members: - :undoc-members: - -VPN Server - SSH Prompt and Response -==================================== - -.. automodule:: vpn.config - :members: - :private-members: - :undoc-members: - -VPN Server - Models -=================== - -.. automodule:: vpn.models - :members: - :private-members: - :undoc-members: - -VPN Server - AWS Defaults -========================= - -.. automodule:: vpn.defaults - :members: - :undoc-members: - :exclude-members: AMI_NAME, IMAGE_MAP - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/README.html b/docs/README.html index 2fbae18..f79fb9a 100644 --- a/docs/README.html +++ b/docs/README.html @@ -51,8 +51,7 @@

Platform Supported

GitHub GitHub repo size -GitHub code size -LOC

+GitHub code size

Deployments

@@ -78,7 +77,7 @@

How it worksOpenVPN client and connect using public IP of the ec2 instance.

+
  • Download the OpenVPN client and connect using the public DNS of the ec2 instance.

  • All set! Now the internet traffic will be routed through the VPN. Verify it using an IP Lookup

  • @@ -92,24 +91,21 @@

    How it works

    ENV Variables

    -

    Environment variables are loaded from .env file if present.

    +

    Environment variables are loaded from any env file if present.

    More on Environment variables
      +
    • VPN_USERNAME - Username to access OpenVPN Connect client.

    • +
    • VPN_PASSWORD - Password to access OpenVPN Connect client.

    • +
    • VPN_PORT - Port number for web interfaces.

    • IMAGE_ID - AMI ID to be used. Defaults to a pre-built AMI from SSM parameter for OpenVPN Access Server AMI Alias.

    • INSTANCE_TYPE - Instance type to use for the VPN server. Defaults to t2.nano, use t2.micro if under free-tier.

    • -
    • VPN_USERNAME - Username to access OpenVPN Connect client. Defaults to log in profile or openvpn

    • -
    • VPN_PASSWORD - Password to access OpenVPN Connect client. Defaults to awsVPN2021

    • -
    • DOMAIN - Domain name for the hosted zone.

    • -
    • RECORD_NAME - Alias record name using which the VPN server has to be accessed.

    • +
    • KEY_PAIR - Name of the key pair file to connect to ec2.

    • +
    • SECURITY_GROUP - Name of the security group.

    • +
    • VPN_INFO - Name of the JSON file to dump the server information.

    • +
    • HOSTED_ZONE - Domain name for the hosted zone.

    • +
    • SUBDOMAIN - Alias record name using which the VPN server has to be accessed.

    -

    To get notification about login information:

    -
      -
    • GMAIL_USER - Username of the gmail account.

    • -
    • GMAIL_PASS - Password of the gmail account.

    • -
    • RECIPIENT - Email address to which the notification has to be sent.

    • -
    • PHONE - Phone number to which the notification has to be sent (Works only for US based cellular)

    • -
    -

    Optionally env vars for AWS config (AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_REGION_NAME) can be setup.

    +

    Optionally env vars for AWS config (AWS_PROFILE_NAME, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_REGION_NAME) can be setup.

    Install

    @@ -119,64 +115,41 @@

    Install

    Usage

    -
    import vpn
    +
    import os
    +
    +os.environ['env_file'] = 'custom'  # to load a custom .env file
    +
    +import vpn
     
     # Instantiates the object, takes the same args as env vars.
     vpn_server = vpn.VPNServer()  # Defaults to console logging, but supports custom logger.
     
     vpn_server.create_vpn_server()  # Create a VPN Server, login information will be saved to a JSON file.
     
    -# Re-configure an existing VPN Server (not required, unless the configuration steps have been interrupted)
    -# vpn_server.reconfigure_vpn()
    -
     # Test an existing VPN Server (not required, as a test is run right after creation anyway)
     # vpn_server.test_vpn()
     
     vpn_server.delete_vpn_server()  # Deletes the VPN Server removing the AWS resources acquired during creation.
     
    -
    -Manual Configuration

    Following are the prompts and response required to configure the VPN server.

    -
      -
    • Are you sure you want to continue connecting (yes/no)? yes

    • -
    -
      -
    1. Please enter ‘yes’ to indicate your agreement [no]: yes

    2. -
    3. Will this be the primary Access Server node? Default: yes

    4. -
    5. Please specify the network interface and IP address to be used by the Admin Web UI: Default: all interfaces: 0.0.0.0

    6. -
    7. Please specify the port number for the Admin Web UI. Default: 943

    8. -
    9. Please specify the TCP port number for the OpenVPN Daemon. Default: 443

    10. -
    11. Should client traffic be routed by default through the VPN? yes

    12. -
    13. Should client DNS traffic be routed by default through the VPN? Default: No

    14. -
    15. Use local authentication via internal DB? Default: yes

    16. -
    17. Should private subnets be accessible to clients by default? Default: yes

    18. -
    19. Do you wish to login to the Admin UI as “openvpn”? Default: yes

    20. -
    21. Specify the username for an existing user or for the new user account: {USERNAME}

    22. -
    23. Type the password for the ‘vicky’ account: {PASSWORD}

    24. -
    25. Confirm the password for the ‘vicky’ account: {PASSWORD}

    26. -
    27. Please specify your Activation key (or leave blank to specify later): {ENTER/RETURN}

    28. -
    -
      -
    • Download the OpenVPN application and get connected to the VPN server.

    • -
    +
    +Limitations

    Currently expose cannot handle, tunneling multiple port numbers without modifying the following env vars in the .env file.

    +
    KEY_PAIR        # SSH connection to AWS ec2
    +KEY_FILE        # Private key filename for self signed SSL
    +CERT_FILE       # Public certificate filename for self signed SSL
    +SERVER_INFO     # Filename to dump JSON data with server configuration information
    +SECURITY_GROUP  # Ingress and egress firewall rules to control traffic allowed via VPC
    +
    +

    -
    -

    AWS Resources Used

    - -
    +
    +

    Coding Standards

    +

    Docstring format: Google
    +Styling conventions: PEP 8
    +Clean code with pre-commit hooks: flake8 and +isort

    -

    Release Notes

    +

    Release Notes

    Requirement

    python -m pip install gitverse
     
    @@ -187,7 +160,7 @@

    -

    Linting

    +

    Linting

    PreCommit will ensure linting, and the doc creation are run on every commit.

    Requirement

    pip install sphinx==5.1.1 pre-commit recommonmark
    @@ -199,14 +172,15 @@ 

    Linting -

    Links

    +

    Links

    Repository

    Runbook

    Package

    +
    @@ -229,10 +203,12 @@

    Table of Contents

  • ENV Variables
  • Install
  • Usage
  • -
  • AWS Resources Used
  • +
  • Coding Standards +
  • License & copyright
  • @@ -282,7 +258,7 @@

    Navigation

    diff --git a/docs/_sources/README.md.txt b/docs/_sources/README.md.txt index 408c666..37ea2e6 100644 --- a/docs/_sources/README.md.txt +++ b/docs/_sources/README.md.txt @@ -7,7 +7,6 @@ [![GitHub](https://img.shields.io/github/license/thevickypedia/vpn-server)][LICENSE] [![GitHub repo size](https://img.shields.io/github/repo-size/thevickypedia/vpn-server)][API_REPO] [![GitHub code size](https://img.shields.io/github/languages/code-size/thevickypedia/vpn-server)][API_REPO] -[![LOC](https://img.shields.io/tokei/lines/github/thevickypedia/vpn-server)][API_REPO] ###### Deployments [![pages-build-deployment](https://github.com/thevickypedia/vpn-server/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/thevickypedia/vpn-server/actions/workflows/pages/pages-build-deployment) @@ -29,7 +28,7 @@ - Create an AWS EC2 instance using a pre-built OpenVPN AMI. - Create a security group with the necessary ports allowed. - Configure the vpn server using SSH. -- Download the [OpenVPN client](https://openvpn.net/vpn-client/) and connect using public IP of the ec2 instance. +- Download the [OpenVPN client](https://openvpn.net/vpn-client/) and connect using the public DNS of the ec2 instance. - All set! Now the internet traffic will be routed through the VPN. Verify it using an [IP Lookup](https://whatismyipaddress.com/) > To take it a step further, if you have a registered domain in AWS, > vpn-server can be accessed with an alias record in route53 pointing to the public IP of the ec2 instance. @@ -37,25 +36,24 @@ - This module can also be used to clean up all the AWS resources spun up for creating a vpn server. ### ENV Variables -Environment variables are loaded from `.env` file if present. +Environment variables are loaded from any `env` file if present.
    More on Environment variables +- **VPN_USERNAME** - Username to access `OpenVPN Connect` client. +- **VPN_PASSWORD** - Password to access `OpenVPN Connect` client. +- **VPN_PORT** - Port number for web interfaces. + - **IMAGE_ID** - AMI ID to be used. Defaults to a pre-built AMI from SSM parameter for [OpenVPN Access Server AMI Alias][AMI_ALIAS]. - **INSTANCE_TYPE** - Instance type to use for the VPN server. Defaults to `t2.nano`, use `t2.micro` if under [free-tier](https://aws.amazon.com/free). -- **VPN_USERNAME** - Username to access `OpenVPN Connect` client. Defaults to log in profile or `openvpn` -- **VPN_PASSWORD** - Password to access `OpenVPN Connect` client. Defaults to `awsVPN2021` -- **DOMAIN** - Domain name for the hosted zone. -- **RECORD_NAME** - Alias record name using which the VPN server has to be accessed. - -**To get notification about login information:**
    -- **GMAIL_USER** - Username of the gmail account. -- **GMAIL_PASS** - Password of the gmail account. -- **RECIPIENT** - Email address to which the notification has to be sent. -- **PHONE** - Phone number to which the notification has to be sent (Works only for `US` based cellular) - -*Optionally `env vars` for AWS config (`AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_REGION_NAME`) can be setup.* +- **KEY_PAIR** - Name of the key pair file to connect to ec2. +- **SECURITY_GROUP** - Name of the security group. +- **VPN_INFO** - Name of the JSON file to dump the server information. +- **HOSTED_ZONE** - Domain name for the hosted zone. +- **SUBDOMAIN** - Alias record name using which the VPN server has to be accessed. + +*Optionally `env vars` for AWS config (`AWS_PROFILE_NAME`, `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_REGION_NAME`) can be setup.*
    ### Install @@ -65,6 +63,10 @@ python -m pip install vpn-server ### Usage ```python +import os + +os.environ['env_file'] = 'custom' # to load a custom .env file + import vpn # Instantiates the object, takes the same args as env vars. @@ -72,48 +74,32 @@ vpn_server = vpn.VPNServer() # Defaults to console logging, but supports custom vpn_server.create_vpn_server() # Create a VPN Server, login information will be saved to a JSON file. -# Re-configure an existing VPN Server (not required, unless the configuration steps have been interrupted) -# vpn_server.reconfigure_vpn() - # Test an existing VPN Server (not required, as a test is run right after creation anyway) # vpn_server.test_vpn() vpn_server.delete_vpn_server() # Deletes the VPN Server removing the AWS resources acquired during creation. ``` +
    +
    -Manual Configuration - -*Following are the prompts and response required to configure the VPN server.* - -- Are you sure you want to continue connecting (yes/no)? `yes` -1. Please enter 'yes' to indicate your agreement [no]: `yes` -2. Will this be the primary Access Server node? Default: `yes` -3. Please specify the network interface and IP address to be used by the Admin Web UI: `Default: all interfaces: 0.0.0.0` -4. Please specify the port number for the Admin Web UI. Default: `943` -5. Please specify the TCP port number for the OpenVPN Daemon. Default: `443` -6. Should client traffic be routed by default through the VPN? `yes` -7. Should client DNS traffic be routed by default through the VPN? Default: `No` -8. Use local authentication via internal DB? Default: `yes` -9. Should private subnets be accessible to clients by default? Default: `yes` -10. Do you wish to login to the Admin UI as "openvpn"? Default: `yes` -11. Specify the username for an existing user or for the new user account: `{USERNAME}` -12. Type the password for the 'vicky' account: `{PASSWORD}` -13. Confirm the password for the 'vicky' account: `{PASSWORD}` -14. Please specify your Activation key (or leave blank to specify later): `{ENTER/RETURN}` - -- Download the `OpenVPN` application and get connected to the VPN server. +Limitations +Currently `expose` cannot handle, tunneling multiple port numbers without modifying the following env vars in the `.env` file. +```shell +KEY_PAIR # SSH connection to AWS ec2 +KEY_FILE # Private key filename for self signed SSL +CERT_FILE # Public certificate filename for self signed SSL +SERVER_INFO # Filename to dump JSON data with server configuration information +SECURITY_GROUP # Ingress and egress firewall rules to control traffic allowed via VPC +```
    -### AWS Resources Used -- EC2 - - Instance - _To redirect traffic through the instance's IP_ - - SecurityGroup - _To allow traffic over specific TCP ports_ - - Systems Manager - _To access [OpenVPN SSM parameter store][AMI_ALIAS] to retrieve the AMI ID_ - - Route53 [Optional] - _To access VPN server using an `A` record in `Route 53`_ -- VPC [Default] -- Subnet [Default] +## Coding Standards +Docstring format: [`Google`](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings)
    +Styling conventions: [`PEP 8`](https://www.python.org/dev/peps/pep-0008/)
    +Clean code with pre-commit hooks: [`flake8`](https://flake8.pycqa.org/en/latest/) and +[`isort`](https://pycqa.github.io/isort/) ### [Release Notes](https://github.com/thevickypedia/vpn-server/blob/main/release_notes.rst) **Requirement** @@ -148,7 +134,7 @@ pre-commit run --all-files ## License & copyright -© Vignesh Sivanandha Rao +© Vignesh Rao Licensed under the [MIT License][LICENSE] diff --git a/docs/_sources/index.rst.txt b/docs/_sources/index.rst.txt index fdc2b1b..a71f421 100644 --- a/docs/_sources/index.rst.txt +++ b/docs/_sources/index.rst.txt @@ -15,43 +15,83 @@ Welcome to VPN Server's documentation! VPN Server ========== -.. automodule:: vpn.controller +.. automodule:: vpn.main :members: :private-members: :undoc-members: - :exclude-members: PEM_FILE, INFO_FILE -VPN Server - SSH Configuration -============================== +Configuration +============= -.. automodule:: vpn.server +.. autoclass:: vpn.models.config.ConfigurationSettings(pydantic.BaseModel) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +==== + +.. autoclass:: vpn.models.config.AMIBase(pydantic.BaseModel) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +==== + +.. autoclass:: vpn.models.config.EnvConfig(pydantic.BaseSettings) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +==== + +.. autoclass:: vpn.models.config.Settings(pydantic.BaseModel) + :members: + :exclude-members: _abc_impl, model_config, model_fields + +Exceptions +========== + +.. automodule:: vpn.models.exceptions + :members: + :private-members: + :undoc-members: + +ImageFactory +============ + +.. automodule:: vpn.models.image_factory :members: :private-members: :undoc-members: -VPN Server - SSH Prompt and Response -==================================== +LOGGER +====== -.. automodule:: vpn.config +.. automodule:: vpn.models.logger :members: :private-members: :undoc-members: -VPN Server - Models -=================== +Route53 +======= -.. automodule:: vpn.models +.. automodule:: vpn.models.route53 :members: :private-members: :undoc-members: -VPN Server - AWS Defaults -========================= +SSH Configuration +================= -.. automodule:: vpn.defaults +.. automodule:: vpn.models.server :members: + :private-members: + :undoc-members: + +Utilities +========= + +.. automodule:: vpn.models.util + :members: + :private-members: :undoc-members: - :exclude-members: AMI_NAME, IMAGE_MAP Indices and tables ================== diff --git a/docs/genindex.html b/docs/genindex.html index d3cf3dd..5ea884a 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -46,57 +46,44 @@

    Index

    | C | D | E - | F | G | I | M + | N | R | S | T | V - | W

    _

    @@ -104,13 +91,17 @@

    _

    A

    @@ -118,7 +109,13 @@

    A

    C

    +
    @@ -126,7 +123,11 @@

    C

    D

    +
    @@ -134,15 +135,11 @@

    D

    E

    -
    - -

    F

    -
    @@ -150,7 +147,17 @@

    F

    G

    +
    @@ -158,7 +165,7 @@

    G

    I

    @@ -166,32 +173,48 @@

    I

    M

    +

    N

    + + +
    +

    R

    @@ -199,13 +222,11 @@

    R

    S

    @@ -213,7 +234,11 @@

    S

    T

    +
    @@ -221,54 +246,62 @@

    T

    V

    -
    + +
  • + vpn.models.util -

    W

    - -
    @@ -309,7 +342,7 @@

    Navigation

    diff --git a/docs/index.html b/docs/index.html index b71e5ed..f9cdc2d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -55,21 +55,18 @@

    Welcome to VPN Server’s documentation!ENV Variables

  • Install
  • Usage
  • -
  • AWS Resources Used
  • -
  • Release Notes
  • -
  • Linting
  • -
  • Links
  • +
  • Coding Standards
  • License & copyright
  • -
    -

    VPN Server

    +
    +

    VPN Server

    -
    -class vpn.controller.VPNServer(aws_access_key: str = None, aws_secret_key: str = None, image_id: str = None, aws_region_name: str = None, domain: str = None, record_name: str = None, vpn_username: str = None, vpn_password: str = None, gmail_user: str = None, gmail_pass: str = None, phone: str = None, recipient: str = None, instance_type: str = None, logger: Logger = None)
    +
    +class vpn.main.VPNServer(logger: Logger = None)

    Initiates VPNServer object to spin up an EC2 instance with a pre-configured AMI which serves as a VPN server.

    >>> VPNServer
     
    @@ -77,33 +74,12 @@

    Welcome to VPN Server’s documentation!boto3 module.

    Parameters:
    -
      -
    • aws_access_key – Access token for AWS account.

    • -
    • aws_secret_key – Secret ID for AWS account.

    • -
    • aws_region_name – Region where the instance should live. Defaults to AWS profile default.

    • -
    • image_id – AMI ID using which the instance should be created.

    • -
    • domain – Domain name for the hosted zone.

    • -
    • record_name – Record using which the VPN server has to be accessed.

    • -
    • vpn_username – Username to access VPN client.

    • -
    • vpn_password – Password to access VPN client.

    • -
    • gmail_user – Gmail username or email address.

    • -
    • gmail_pass – Gmail password.

    • -
    • phone – Phone number to which an SMS notification has to be sent.

    • -
    • recipient – Email address to which an email notification has to be sent.

    • -
    • logger – Bring your own logger.

    • -
    +

    logger – Bring your own logger.

    -
    -

    See also

    -
      -
    • If no values (for aws authentication) are passed during object initialization, script checks for env vars.

    • -
    • If the environment variables are null, gets the default credentials from ~/.aws/credentials.

    • -
    -
    -
    -_authorize_security_group(security_group_id: str) bool
    +
    +_authorize_security_group(security_group_id: str) bool

    Authorizes the security group for certain ingress list.

    Parameters:
    @@ -116,7 +92,7 @@

    Welcome to VPN Server’s documentation!
  • TCP 22 — SSH access.

  • TCP 443 — Web interface access and OpenVPN TCP connections.

  • -
  • TCP 943 — Web interface access (This can be dynamic, but the same should be used to configure the VPN.)

  • +
  • TCP 943 — Web interface access (can be dynamic)

  • TCP 945 — Cluster control channel.

  • UDP 1194 — OpenVPN UDP connections.

  • @@ -132,43 +108,40 @@

    Welcome to VPN Server’s documentation! -
    -_configure_vpn(data: dict) bool
    -

    Frames a dictionary of anticipated prompts and responses to initiate interactive SSH commands.

    +
    +_configure_vpn(public_dns: str, public_ip: str) None
    +

    Configures the ec2 instance to take traffic from localhost and initiates tunneling.

    Parameters:
    -

    data – A dictionary with key, value pairs with instance information in it.

    -
    -
    Returns:
    -

    A boolean flag to indicate whether the interactive ssh session succeeded.

    -
    -
    Return type:
    -

    bool

    +
      +
    • public_dns – Public DNS name of the ec2 that was created.

    • +
    • public_ip – IP address of the ec2 instance.

    • +

    -
    -_create_ec2_instance() Optional[Tuple[str, str]]
    -

    Creates an EC2 instance with the pre-configured AMI id.

    +
    +_create_ec2_instance() Optional[Tuple[str, str]]
    +

    Creates an EC2 instance with a pre-configured AMI id.

    Returns:
    -

    A tuple of Instance ID and Security Group ID.

    +

    Instance ID, SecurityGroup ID if successful.

    Return type:
    -

    tuple

    +

    Union[Tuple[str, str], None]

    -
    -_create_key_pair() bool
    +
    +_create_key_pair() bool

    Creates a KeyPair of type RSA stored as a PEM file to use with OpenSSH.

    Returns:
    -

    Flag to indicate the calling function whether a KeyPair was created.

    +

    Boolean flag to indicate the calling function if a KeyPair was created.

    Return type:

    bool

    @@ -177,26 +150,30 @@

    Welcome to VPN Server’s documentation! -
    -_create_security_group() Optional[str]
    -

    Calls the class method _get_vpc_id and uses the VPC ID to create a SecurityGroup for the instance.

    +
    +_create_security_group() Optional[str]
    +

    Gets VPC id and creates a security group for the ec2 instance.

    +
    +

    Warning

    +

    Deletes and re-creates the SG, in case an SG exists with the same name already.

    +
    Returns:

    SecurityGroup ID

    Return type:
    -

    str or None

    +

    Union[str, None]

    -
    -_delete_key_pair() bool
    -

    Deletes the KeyPair.

    +
    +_delete_key_pair() bool
    +

    Deletes the KeyPair created to access the ec2 instance.

    Returns:
    -

    Flag to indicate the calling function whether the KeyPair was deleted.

    +

    Boolean flag to indicate the calling function if the KeyPair was deleted successfully.

    Return type:

    bool

    @@ -205,15 +182,15 @@

    Welcome to VPN Server’s documentation! -
    -_delete_security_group(security_group_id: str) bool
    +
    +_delete_security_group(security_group_id: str) bool

    Deletes the security group.

    Parameters:

    security_group_id – Takes the SecurityGroup ID as an argument.

    Returns:
    -

    Flag to indicate the calling function whether the SecurityGroup was deleted.

    +

    Boolean flag to indicate the calling function whether the SecurityGroup was deleted.

    Return type:

    bool

    @@ -222,79 +199,19 @@

    Welcome to VPN Server’s documentation! -
    -_get_hosted_zone_id_by_name(domain: str) Optional[str]
    -

    Get hosted zone id using the domain name.

    -
    -
    Parameters:
    -

    domain – Domain name to add the A record.

    -
    -
    Returns:
    -

    Hosted zone ID.

    -
    -
    Return type:
    -

    str

    -
    -
    -

    - -
    -
    -_get_image_id_by_name() str
    -

    Looks for AMI ID in the default image map. Fetches AMI ID from public images if not present.

    -
    -
    Returns:
    -

    AMI ID.

    -
    -
    Return type:
    -

    str

    -
    -
    -
    - -
    -
    -_get_image_id_from_ssm() str
    -

    Gets the AMI ID from SSM parameter store, using the AMI alias provided in the marketplace configuration page.

    -
    -
    Returns:
    -

    AMI ID.

    -
    -
    Return type:
    -

    str

    -
    -
    -
    - -
    -
    -_get_vpc_id() Optional[str]
    -

    Gets the default VPC id.

    -
    -
    Returns:
    -

    Default VPC id.

    -
    -
    Return type:
    -

    str or None

    -
    -
    -
    - -
    -
    -_hosted_zone_record(instance_ip: Union[IPv4Address, str], action: str, record_name: Optional[str] = None, domain: Optional[str] = None) Optional[bool]
    -

    Add or remove A record in hosted zone.

    +
    +_disassociate_security_group(security_group_id: str, instance: object = None, instance_id: str = None) bool
    +

    Disassociates an SG from the ec2 instance by assigning it to the default security group.

    Parameters:
      -
    • instance_ip – Public IP of the ec2 instance.

    • -
    • action – Argument to ADD|DELETE|UPSERT dns record.

    • -
    • record_name – Name of the DNS record.

    • -
    • domain – Domain of the hosted zone where an alias record has been made.

    • +
    • security_group_id – Security group ID

    • +
    • instance – Instance object.

    • +
    • instance_id – Instance ID if object is unavailable.

    Returns:
    -

    Boolean flag to indicate whether the A name record was added.

    +

    Boolean flag to indicate the calling function whether the disassociation was successful.

    Return type:

    bool

    @@ -303,69 +220,43 @@

    Welcome to VPN Server’s documentation! -
    -_instance_info(instance_id: str) Optional[Tuple[str, str, str]]
    -

    Makes a describe_instance_status API call to get the status of the instance that was created.

    +
    +_get_vpc_id() Optional[str]
    +

    Fetches the default VPC id.

    -
    Parameters:
    -

    instance_id – Takes the instance ID as an argument.

    -
    -
    Returns:
    -

    A tuple object of Public DNS Name and Public IP Address.

    +
    Returns:
    +

    Default VPC id.

    -
    Return type:
    -

    tuple or None

    +
    Return type:
    +

    Union[str, None]

    -
    -_notification_response(response: Response) None
    -

    Logs the response after sending notifications.

    +
    +_init(start: Union[bool, int]) None
    +

    Initializer function.

    Parameters:
    -

    response – Takes the response dictionary to log the success/failure message.

    +

    start – Boolean flag to indicate if its startup or shutdown.

    -
    -_notify(message: str, attachment: Optional[str] = None) None
    -

    Send login details via SMS and Email if the following env vars are present.

    -

    gmail_user, gmail_pass and phone [or] recipient

    +
    +_terminate_ec2_instance(instance_id: str = None, instance: object = None) ServiceResource
    +

    Terminates the requested instance.

    Parameters:
      -
    • message – Login information that has to be sent as a message/email.

    • -
    • attachment – Name of the log file in case of a failure.

    • +
    • instance_id – Takes instance ID as an argument.

    • +
    • instance – Takes the instance object as an optional argument.

    -
    -
    - -
    -
    -_sleeper(sleep_time: int) None
    -

    Sleeps for a particular duration.

    -
    -
    Parameters:
    -

    sleep_time – Takes the time script has to sleep, as an argument.

    -
    -
    -
    - -
    -
    -_terminate_ec2_instance(instance_id: str) bool
    -

    Terminates the requested instance.

    -
    -
    Parameters:
    -

    instance_id – Takes instance ID as an argument. Defaults to the instance that was created previously.

    -
    Returns:
    -

    Flag to indicate the calling function whether the instance was terminated.

    +

    Boolean flag to indicate the calling function whether the instance was terminated.

    Return type:

    bool

    @@ -374,12 +265,15 @@

    Welcome to VPN Server’s documentation! -
    -_tester(data: Dict) bool
    +
    +_tester(data: Dict, timeout: int = 3) bool

    Tests GET and SSH connections on the existing server.

    Parameters:
    -

    data – Takes the instance information in a dictionary format as an argument.

    +
      +
    • data – Takes the instance information in a dictionary format as an argument.

    • +
    • timeout – Timeout to make the test call.

    • +
    @@ -405,8 +299,8 @@

    Welcome to VPN Server’s documentation! -
    -create_vpn_server() None
    +
    +create_vpn_server() None

    Calls the class methods _create_ec2_instance and _instance_info to configure the VPN server.

    See also

    @@ -420,66 +314,292 @@

    Welcome to VPN Server’s documentation! -
    -delete_vpn_server(partial: Optional[bool] = False, instance_id: Optional[str] = None, security_group_id: Optional[str] = None, domain: Optional[str] = None, record_name: Optional[str] = None, instance_ip: Optional[Union[IPv4Address, str]] = None) None
    -

    Disables VPN server by terminating the EC2 instance, KeyPair, and the SecurityGroup created.

    +
    +delete_vpn_server(instance_id: str = None, security_group_id: str = None, public_ip: str = None) None
    +

    Disables tunnelling by removing all AWS resources acquired.

    Parameters:
      -
    • partial – Flag to indicate whether the SecurityGroup has to be removed.

    • instance_id – Instance that has to be terminated.

    • security_group_id – Security group that has to be removed.

    • -
    • domain – Domain of the hosted zone where an alias record has been made.

    • -
    • record_name – Record name for the alias.

    • -
    • instance_ip – Value of the record.

    • +
    • public_ip – Public IP address to delete the A record from route53.

    +
    +

    See also

    +

    Doesn’t require any argument, as long as the JSON dump is neither removed nor modified by hand.

    +
    +

    References

    + +

    + +
    +
    +test_vpn() None
    +

    Tests the GET and SSH connections to an existing VPN server.

    +
    +
    +

    +
    +

    Configuration

    +
    +
    +class vpn.models.config.ConfigurationSettings(pydantic.BaseModel)
    +

    OpenVPN’s configuration settings, for SSH interaction.

    +
    >>> ConfigurationSettings
    +
    +
    +

    Create a new model by parsing and validating input data from keyword arguments.

    +

    Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

    +

    __init__ uses __pydantic_self__ instead of the more common self for the first arg to +allow self as a field name.

    +
    + +
    +
    +
    +class vpn.models.config.AMIBase(pydantic.BaseModel)
    +

    Default values to fetch AMI image ID.

    +
    >>> AMIBase
    +
    +
    +

    Create a new model by parsing and validating input data from keyword arguments.

    +

    Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

    +

    __init__ uses __pydantic_self__ instead of the more common self for the first arg to +allow self as a field name.

    -
    -instantiate_aws()
    -

    Create a boto3 session and load all boto3 clients.

    +
    +model_post_init(__context: Any) None
    +

    This function is meant to behave like a BaseModel method to initialise private attributes.

    +

    It takes context as an argument since that’s what pydantic-core passes when calling it.

    +
    +
    Parameters:
    +
      +
    • self – The BaseModel instance.

    • +
    • __context – The context.

    • +
    +
    +
    +
    + +
    + +
    +
    +
    +class vpn.models.config.EnvConfig(pydantic.BaseSettings)
    +

    Env configuration.

    +
    >>> EnvConfig
    +
    +
    +

    References

    +

    https://docs.pydantic.dev/2.3/migration/#required-optional-and-nullable-fields

    +

    Create a new model by parsing and validating input data from keyword arguments.

    +

    Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

    +

    __init__ uses __pydantic_self__ instead of the more common self for the first arg to +allow self as a field name.

    +
    +
    +class Config
    +

    Extra config for .env file and extra.

    -
    -reconfigure_vpn() None
    -

    Runs the configuration on an existing VPN server.

    +
    +classmethod validate_instance_type(v: str) str
    +

    Validate instance type to make sure it is not a nano.

    -
    -test_vpn() None
    -

    Tests the GET and SSH connections to an existing VPN server.

    +
    +classmethod validate_vpn_password(v: str) str
    +

    Validates vpn_password as per the required regex.

    +
    +
    +
    +
    +
    +class vpn.models.config.Settings(pydantic.BaseModel)
    +

    Wrapper for configuration settings.

    +
    >>> Settings
    +
    +
    +

    Create a new model by parsing and validating input data from keyword arguments.

    +

    Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

    +

    __init__ uses __pydantic_self__ instead of the more common self for the first arg to +allow self as a field name.

    +
    + +
    +
    +

    Exceptions

    +
    +
    +exception vpn.models.exceptions.AWSResourceError(status_code: int, error_msg: str)
    +

    Custom resource error for AWS resources.

    +
    + +
    +
    +exception vpn.models.exceptions.NotImplementedWarning
    +

    Custom implementation warning.

    +
    + +
    +
    +

    ImageFactory

    +
    +
    +class vpn.models.image_factory.ImageFactory(session: Session, logger: Logger)
    +

    Handles retrieving AMI ID from multiple sources.

    +
    >>> ImageFactory
    +
    +
    +

    Instantiates the ImageFactory object.

    +
    +
    Parameters:
    +
      +
    • session – boto3 session instantiated in the origin class.

    • +
    • logger – Custom logger.

    • +
    +
    +
    +
    +
    +get_ami_id_name() str
    +

    Retrieve AMI ID using Ami Name.

    +
    + +
    +
    +get_ami_id_product_code() str
    +

    Retrieve AMI ID using Product Code.

    +
    + +
    +
    +get_ami_id_ssm() str
    +

    Retrieve AMI ID using Ami Alias.

    +
    + +
    +
    +get_image_id() str
    +

    Tries to get image id from multiple sources, sequentially.

    +
    +

    See also

    +

    Executes in sequence as fastest first. +- Alias on SSM points to a single parameter that possibly contains a single AMI ID as its value. +- Lookup AMI with image name is specific and possibly points to a single AMI ID. +- Lookup AMI with product code will possibly return many images, so grabs the most recently created one.

    +
    +
    +
    Returns:
    +

    AMI image ID.

    +
    +
    Return type:
    +

    str

    +
    +
    Raises:
    +
    +
    +
    +
    + +
    + +
    +
    +vpn.models.image_factory.deprecation_warning(image_id: str, deprecation_time: str) None
    +

    Raises a deprecation warning if the chosen AMI is nearing (value is set in config) its DeprecationTime.

    +
    +
    +

    LOGGER

    +

    Loads a default logger with StreamHandler set to DEBUG mode.

    +
    >>> LOGGER
    +
    +
    +
    +
    +

    Route53

    -
    -vpn.controller.validate_response(response: Dict) bool
    -

    Validates response from AWS.

    +
    +vpn.models.route53.change_record_set(client: client, source: str, destination: str, logger: Logger, zone_id: str, action: str) Optional[Dict]
    +

    Changes a record set within an existing hosted zone.

    Parameters:
    -

    response – Takes response from boto3 calls.

    +
      +
    • client – Pre-instantiated boto3 client.

    • +
    • source – Source DNS name.

    • +
    • destination – Destination hostname or IP address.

    • +
    • logger – Custom logger.

    • +
    • zone_id – Hosted zone ID.

    • +
    • action – Action to perform. Example: UPSERT or DELETE

    • +
    Returns:
    -

    Returns True if the HTTPStatusCode is 200.

    +

    ChangeSet response from AWS.

    Return type:
    -

    bool

    +

    Union[Dict, None]

    +
    +
    +
    + +
    +
    +vpn.models.route53.get_zone_id(client: client, logger: Logger, dns: str, init: bool = False) Optional[str]
    +

    Gets the zone ID of a DNS name registered in route53.

    +
    +
    Parameters:
    +
      +
    • client – Pre-instantiated boto3 client.

    • +
    • logger – Custom logger.

    • +
    • dns – Hosted zone name.

    • +
    • init – Boolean flag to raise an error in case of missing zone ID.

    • +
    +
    +
    Returns:
    +

    Returns the zone ID.

    +
    +
    Return type:
    +

    Union[str, None]

    +
    +
    Raises:
    +
    -
    -

    VPN Server - SSH Configuration

    +
    +

    SSH Configuration

    -
    -class vpn.server.Server(hostname: str, pem_file: str, username: str)
    +
    +class vpn.models.server.Server(hostname: str, username: str, logger: Logger)

    Initiates Server object to create an SSH session to configure the server.

    >>> Server
     
    @@ -487,25 +607,36 @@

    Welcome to VPN Server’s documentation!***.pem file.

    Parameters:
    -
      -
    • hostname – Hostname of the server.

    • -
    • username – Username to log in to the server.

    • -
    • pem_file – PEM filename to authenticate the login.

    • -
    +

    hostname – Hostname of the server.

    -
    -run_interactive_ssh(logger: Logger, log_file: Optional[str] = None, prompts_and_response: Optional[Dict] = None, display: Optional[bool] = True, timeout: Optional[int] = 30) bool
    +
    +add_formatter() None
    +

    Re-add any formatters that were removed during instantiation.

    +
    + +
    +
    +remove_formatter() None
    +

    Remove any logging formatters to allow room for OpenVPN configuration interaction.

    +
    + +
    +
    +restart_service() None
    +

    Restarts the openvpn service.

    +
    + +
    +
    +run_interactive_ssh(display: bool = True, timeout: int = 30) None

    Runs interactive ssh commands to configure the VPN server.

    Parameters:
      -
    • prompts_and_response – Prompts and their responses.

    • -
    • logger – Logging module.

    • display – Boolean flag whether to display interaction data on screen.

    • -
    • timeout – Default session timeout.

    • -
    • log_file – To write clean console output to the log file.

    • +
    • timeout – Default interaction session timeout.

    Returns:
    @@ -517,97 +648,53 @@

    Welcome to VPN Server’s documentation! -

    VPN Server - SSH Prompt and Response

    -
    -
    -class vpn.config.SSHConfig(vpn_username: AnyStr, vpn_password: AnyStr)
    -

    Initiates SSHConfig object to isolate the configuration dictionary.

    -
    >>> SSHConfig
    -
    -
    -

    Instantiates object and stores port, username and password as members.

    +
    +
    +test_service(timeout: int, display: bool) bool
    +

    Check status of the service running on remote server.

    Parameters:
      -
    • vpn_username – Username for authentication.

    • -
    • vpn_password – Password for authentication.

    • +
    • timeout – Default interaction session timeout.

    • +
    • display – Boolean flag whether to display interaction data on screen.

    +
    Returns:
    +

    Returns a boolean flag if test was successful.

    +
    +
    Return type:
    +

    bool

    +
    -
    -
    -get_config() Dict[AnyStr, Union[Tuple, List]]
    -

    Returns the dictionary for ssh config.

    -
    -

    VPN Server - Models

    -
    -
    -class vpn.models.Settings
    -

    Initiate Settings object to access env vars acros modules.

    -
    >>> Settings
    -
    -
    -

    Instantiate the class, load all env variables and perform custom validations.

    -
    - -
    -
    -vpn.models.ec2_instance_types(region_name: str)
    -

    Yield all available EC2 instance types in a particular region.

    -
    - +
    +

    Utilities

    -
    -vpn.models.flush_screen() None
    -

    Flushes the screen output.

    -
    -

    See also

    -

    Writes new set of empty strings for the size of the terminal if ran using one.

    -
    +
    +vpn.models.util.available_instance_types() Generator[str]
    +

    Get all available EC2 instance types looping through describe instances API call.

    +
    +
    Yields:
    +

    Generator[str] – Instance type.

    +
    +
    -
    -vpn.models.write_screen(text: Any) None
    -

    Write text on screen that can be cleared later.

    +
    +vpn.models.util.available_regions() Generator[str]
    +

    Get all available regions with describe regions API call.

    -
    Parameters:
    -

    text – Text to be written.

    +
    Yields:
    +

    Generator[str] – Region name.

    -
    -
    -

    VPN Server - AWS Defaults

    -
    -
    -class vpn.defaults.AWSDefaults
    -

    Default values for missing AWS configuration.

    -
    >>> AWSDefaults
    -
    -
    -
    -
    -AMI_ALIAS: str = '/aws/service/marketplace/prod-qqrkogtl46mpu/2.8.5'
    -
    - -
    -
    -AMI_SOURCE: Url = 'https://aws.amazon.com/marketplace/server/configuration?productId=fe8020db-5343-4c43-9e65-5ed4a825c931'
    -
    - -
    -

    Indices and tables

    @@ -629,11 +716,14 @@

    Indices and tablesTable of Contents

    @@ -681,7 +771,7 @@

    Navigation

    diff --git a/docs/objects.inv b/docs/objects.inv index 97bf329..5e6e356 100644 Binary files a/docs/objects.inv and b/docs/objects.inv differ diff --git a/docs/py-modindex.html b/docs/py-modindex.html index 32d7167..604e37c 100644 --- a/docs/py-modindex.html +++ b/docs/py-modindex.html @@ -60,27 +60,37 @@

    Python Module Index

        - vpn.config + vpn.main     - vpn.controller + vpn.models.exceptions     - vpn.defaults + vpn.models.image_factory     - vpn.models + vpn.models.logger     - vpn.server + vpn.models.route53 + + + +     + vpn.models.server + + + +     + vpn.models.util @@ -119,7 +129,7 @@

    Navigation

    diff --git a/docs/search.html b/docs/search.html index 148a8c4..eb2ef9c 100644 --- a/docs/search.html +++ b/docs/search.html @@ -98,7 +98,7 @@

    Navigation

    diff --git a/docs/searchindex.js b/docs/searchindex.js index e37f351..f4688e5 100644 --- a/docs/searchindex.js +++ b/docs/searchindex.js @@ -1 +1 @@ -Search.setIndex({"docnames": ["README", "index"], "filenames": ["README.md", "index.rst"], "titles": ["Platform Supported", "Welcome to VPN Server\u2019s documentation!"], "terms": {"you": 0, "need": 0, "don": 0, "t": 0, "want": 0, "pai": 0, "openvpn": [0, 1], "i": [0, 1], "solut": 0, "configur": 0, "manual": [0, 1], "can": [0, 1], "lengthi": 0, "process": 0, "onc": 0, "keep": 0, "instanc": [0, 1], "up": [0, 1], "all": [0, 1], "time": [0, 1], "cost": 0, "scale": 0, "down": [0, 1], "demand": 0, "make": [0, 1], "an": [0, 1], "absolut": 0, "nightmar": 0, "thi": [0, 1], "modul": [0, 1], "allow": 0, "creat": [0, 1], "your": [0, 1], "own": [0, 1], "under": 0, "2": [0, 1], "minut": 0, "The": 0, "fulli": 0, "autom": 0, "run": [0, 1], "ec2": [0, 1], "pre": [0, 1], "built": 0, "ami": [0, 1], "secur": [0, 1], "group": [0, 1], "necessari": 0, "port": [0, 1], "ssh": 0, "download": 0, "client": [0, 1], "connect": [0, 1], "public": [0, 1], "ip": [0, 1], "set": [0, 1], "now": 0, "internet": 0, "traffic": 0, "rout": 0, "through": 0, "verifi": 0, "lookup": 0, "To": [0, 1], "take": [0, 1], "step": 0, "further": 0, "have": 0, "regist": 0, "domain": [0, 1], "access": [0, 1], "alia": [0, 1], "record": [0, 1], "route53": 0, "point": 0, "abov": 0, "ar": [0, 1], "perform": [0, 1], "automat": 0, "when": [0, 1], "new": [0, 1], "also": [0, 1], "clean": [0, 1], "spun": 0, "environ": [0, 1], "load": [0, 1], "from": [0, 1], "file": [0, 1], "present": [0, 1], "more": 0, "imag": [0, 1], "_": 0, "id": [0, 1], "default": 0, "ssm": [0, 1], "paramet": [0, 1], "type": [0, 1], "t2": 0, "nano": 0, "micro": 0, "free": 0, "tier": 0, "usernam": [0, 1], "log": [0, 1], "profil": [0, 1], "password": [0, 1], "awsvpn2021": 0, "name": [0, 1], "host": [0, 1], "zone": [0, 1], "which": [0, 1], "ha": [0, 1], "get": [0, 1], "notif": [0, 1], "about": 0, "login": [0, 1], "inform": [0, 1], "gmail": [0, 1], "user": [0, 1], "account": [0, 1], "pass": [0, 1], "recipi": [0, 1], "email": [0, 1], "address": [0, 1], "sent": [0, 1], "phone": [0, 1], "number": [0, 1], "onli": 0, "u": 0, "base": 0, "cellular": 0, "option": [0, 1], "var": [0, 1], "config": [0, 1], "aws_access_kei": [0, 1], "aws_secret_kei": [0, 1], "aws_region_nam": [0, 1], "setup": 0, "python": 0, "m": 0, "pip": 0, "import": 0, "instanti": [0, 1], "object": [0, 1], "same": [0, 1], "arg": 0, "vpn_server": 0, "vpnserver": [0, 1], "consol": [0, 1], "custom": [0, 1], "logger": [0, 1], "create_vpn_serv": [0, 1], "save": 0, "json": 0, "re": 0, "exist": [0, 1], "requir": 0, "unless": 0, "been": [0, 1], "interrupt": 0, "reconfigure_vpn": [0, 1], "test": [0, 1], "right": 0, "after": [0, 1], "creation": 0, "anywai": 0, "test_vpn": [0, 1], "delete_vpn_serv": [0, 1], "delet": [0, 1], "remov": [0, 1], "acquir": 0, "dure": [0, 1], "follow": [0, 1], "prompt": 0, "respons": 0, "sure": 0, "continu": 0, "ye": 0, "pleas": 0, "enter": 0, "indic": 0, "agreement": 0, "Will": 0, "primari": 0, "node": 0, "specifi": 0, "network": 0, "interfac": [0, 1], "admin": 0, "web": [0, 1], "ui": 0, "0": 0, "943": [0, 1], "tcp": [0, 1], "daemon": 0, "443": [0, 1], "should": [0, 1], "dn": [0, 1], "No": 0, "local": 0, "authent": [0, 1], "via": [0, 1], "intern": 0, "db": 0, "privat": 0, "subnet": 0, "do": 0, "wish": 0, "vicki": 0, "confirm": 0, "activ": 0, "kei": [0, 1], "leav": 0, "blank": 0, "later": [0, 1], "return": [0, 1], "applic": 0, "redirect": 0, "": 0, "securitygroup": [0, 1], "over": 0, "specif": 0, "system": 0, "manag": 0, "store": [0, 1], "retriev": 0, "A": [0, 1], "53": 0, "vpc": [0, 1], "gitvers": 0, "revers": 0, "f": 0, "release_not": 0, "rst": 0, "precommit": 0, "ensur": 0, "doc": 0, "everi": 0, "commit": 0, "sphinx": 0, "5": [0, 1], "1": 0, "recommonmark": 0, "repositori": 0, "runbook": 0, "packag": 0, "vignesh": 0, "sivanandha": 0, "rao": 0, "mit": 0, "platform": 1, "support": 1, "repo": 1, "stat": 1, "deploy": 1, "how": 1, "work": 1, "env": 1, "variabl": 1, "instal": 1, "usag": 1, "resourc": 1, "us": 1, "releas": 1, "note": 1, "lint": 1, "link": 1, "licens": 1, "copyright": 1, "class": 1, "control": 1, "str": 1, "none": 1, "image_id": 1, "record_nam": 1, "vpn_usernam": 1, "vpn_password": 1, "gmail_us": 1, "gmail_pass": 1, "instance_typ": 1, "initi": 1, "spin": 1, "serv": 1, "assign": 1, "pem": 1, "boto3": 1, "token": 1, "secret": 1, "region": 1, "where": 1, "live": 1, "sm": 1, "bring": 1, "If": 1, "valu": 1, "script": 1, "check": 1, "null": 1, "credenti": 1, "_authorize_security_group": 1, "security_group_id": 1, "bool": 1, "author": 1, "certain": 1, "ingress": 1, "list": 1, "argument": 1, "firewal": 1, "open": 1, "22": 1, "dynam": 1, "945": 1, "cluster": 1, "channel": 1, "udp": 1, "1194": 1, "flag": 1, "call": 1, "function": 1, "whether": 1, "wa": 1, "_configure_vpn": 1, "data": 1, "dict": 1, "frame": 1, "dictionari": 1, "anticip": 1, "interact": 1, "command": 1, "pair": 1, "boolean": 1, "session": 1, "succeed": 1, "_create_ec2_inst": 1, "tupl": 1, "_create_key_pair": 1, "keypair": 1, "rsa": 1, "openssh": 1, "_create_security_group": 1, "method": 1, "_get_vpc_id": 1, "_delete_key_pair": 1, "_delete_security_group": 1, "_get_hosted_zone_id_by_nam": 1, "add": 1, "_get_image_id_by_nam": 1, "look": 1, "map": 1, "fetch": 1, "_get_image_id_from_ssm": 1, "provid": 1, "marketplac": 1, "page": 1, "_hosted_zone_record": 1, "instance_ip": 1, "union": 1, "ipv4address": 1, "action": 1, "upsert": 1, "made": 1, "ad": 1, "_instance_info": 1, "instance_id": 1, "describe_instance_statu": 1, "api": 1, "statu": 1, "_notification_respons": 1, "send": 1, "success": 1, "failur": 1, "messag": 1, "_notifi": 1, "attach": 1, "detail": 1, "case": 1, "_sleeper": 1, "sleep_tim": 1, "int": 1, "sleep": 1, "particular": 1, "durat": 1, "_terminate_ec2_inst": 1, "termin": 1, "request": 1, "previous": 1, "_tester": 1, "format": 1, "startup": 1, "info": 1, "alreadi": 1, "updat": 1, "vm": 1, "true": 1, "reachabl": 1, "origin": 1, "succe": 1, "fals": 1, "fail": 1, "unabl": 1, "befor": 1, "tear": 1, "notifi": 1, "retri": 1, "anoth": 1, "start": 1, "regardless": 1, "partial": 1, "disabl": 1, "instantiate_aw": 1, "validate_respons": 1, "valid": 1, "httpstatuscod": 1, "200": 1, "hostnam": 1, "pem_fil": 1, "rsakei": 1, "gener": 1, "filenam": 1, "run_interactive_ssh": 1, "log_fil": 1, "prompts_and_respons": 1, "displai": 1, "timeout": 1, "30": 1, "screen": 1, "write": 1, "output": 1, "complet": 1, "successfulli": 1, "sshconfig": 1, "anystr": 1, "isol": 1, "member": 1, "get_config": 1, "acro": 1, "ec2_instance_typ": 1, "region_nam": 1, "yield": 1, "avail": 1, "flush_screen": 1, "flush": 1, "empti": 1, "string": 1, "size": 1, "ran": 1, "one": 1, "write_screen": 1, "text": 1, "ani": 1, "clear": 1, "written": 1, "awsdefault": 1, "miss": 1, "ami_alia": 1, "servic": 1, "prod": 1, "qqrkogtl46mpu": 1, "8": 1, "ami_sourc": 1, "url": 1, "http": 1, "amazon": 1, "com": 1, "productid": 1, "fe8020db": 1, "5343": 1, "4c43": 1, "9e65": 1, "5ed4a825c931": 1, "index": 1, "search": 1}, "objects": {"vpn": [[1, 0, 0, "-", "config"], [1, 0, 0, "-", "controller"], [1, 0, 0, "-", "defaults"], [1, 0, 0, "-", "models"], [1, 0, 0, "-", "server"]], "vpn.config": [[1, 1, 1, "", "SSHConfig"]], "vpn.config.SSHConfig": [[1, 2, 1, "", "get_config"]], "vpn.controller": [[1, 1, 1, "", "VPNServer"], [1, 3, 1, "", "validate_response"]], "vpn.controller.VPNServer": [[1, 2, 1, "", "_authorize_security_group"], [1, 2, 1, "", "_configure_vpn"], [1, 2, 1, "", "_create_ec2_instance"], [1, 2, 1, "", "_create_key_pair"], [1, 2, 1, "", "_create_security_group"], [1, 2, 1, "", "_delete_key_pair"], [1, 2, 1, "", "_delete_security_group"], [1, 2, 1, "", "_get_hosted_zone_id_by_name"], [1, 2, 1, "", "_get_image_id_by_name"], [1, 2, 1, "", "_get_image_id_from_ssm"], [1, 2, 1, "", "_get_vpc_id"], [1, 2, 1, "", "_hosted_zone_record"], [1, 2, 1, "", "_instance_info"], [1, 2, 1, "", "_notification_response"], [1, 2, 1, "", "_notify"], [1, 2, 1, "", "_sleeper"], [1, 2, 1, "", "_terminate_ec2_instance"], [1, 2, 1, "", "_tester"], [1, 2, 1, "", "create_vpn_server"], [1, 2, 1, "", "delete_vpn_server"], [1, 2, 1, "", "instantiate_aws"], [1, 2, 1, "", "reconfigure_vpn"], [1, 2, 1, "", "test_vpn"]], "vpn.defaults": [[1, 1, 1, "", "AWSDefaults"]], "vpn.defaults.AWSDefaults": [[1, 4, 1, "", "AMI_ALIAS"], [1, 4, 1, "", "AMI_SOURCE"]], "vpn.models": [[1, 1, 1, "", "Settings"], [1, 3, 1, "", "ec2_instance_types"], [1, 3, 1, "", "flush_screen"], [1, 3, 1, "", "write_screen"]], "vpn.server": [[1, 1, 1, "", "Server"]], "vpn.server.Server": [[1, 2, 1, "", "run_interactive_ssh"]]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:method", "3": "py:function", "4": "py:attribute"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "method", "Python method"], "3": ["py", "function", "Python function"], "4": ["py", "attribute", "Python attribute"]}, "titleterms": {"platform": 0, "support": 0, "repo": 0, "stat": 0, "deploy": 0, "vpn": [0, 1], "server": [0, 1], "how": 0, "work": 0, "env": 0, "variabl": 0, "instal": 0, "usag": 0, "aw": [0, 1], "resourc": 0, "us": 0, "releas": 0, "note": 0, "lint": 0, "link": 0, "licens": 0, "copyright": 0, "welcom": 1, "": 1, "document": 1, "read": 1, "me": 1, "ssh": 1, "configur": 1, "prompt": 1, "respons": 1, "model": 1, "default": 1, "indic": 1, "tabl": 1}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 6, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 56}}) \ No newline at end of file +Search.setIndex({"docnames": ["README", "index"], "filenames": ["README.md", "index.rst"], "titles": ["Platform Supported", "Welcome to VPN Server\u2019s documentation!"], "terms": {"you": 0, "need": 0, "don": 0, "t": [0, 1], "want": 0, "pai": 0, "openvpn": [0, 1], "i": [0, 1], "solut": 0, "configur": 0, "manual": [0, 1], "can": [0, 1], "lengthi": 0, "process": 0, "onc": 0, "keep": 0, "instanc": [0, 1], "up": [0, 1], "all": [0, 1], "time": 0, "cost": 0, "scale": 0, "down": [0, 1], "demand": 0, "make": [0, 1], "an": [0, 1], "absolut": 0, "nightmar": 0, "thi": [0, 1], "modul": [0, 1], "allow": [0, 1], "creat": [0, 1], "your": [0, 1], "own": [0, 1], "under": 0, "2": [0, 1], "minut": 0, "The": [0, 1], "fulli": 0, "autom": 0, "run": [0, 1], "us": [0, 1], "aw": [0, 1], "ec2": [0, 1], "pre": [0, 1], "built": 0, "ami": [0, 1], "secur": [0, 1], "group": [0, 1], "necessari": 0, "port": [0, 1], "ssh": 0, "download": 0, "client": [0, 1], "connect": [0, 1], "public": [0, 1], "dn": [0, 1], "set": [0, 1], "now": 0, "internet": 0, "traffic": [0, 1], "rout": 0, "through": [0, 1], "verifi": 0, "ip": [0, 1], "lookup": [0, 1], "To": 0, "take": [0, 1], "step": 0, "further": 0, "have": 0, "regist": [0, 1], "domain": 0, "access": [0, 1], "alia": [0, 1], "record": [0, 1], "route53": 0, "point": [0, 1], "abov": 0, "ar": [0, 1], "perform": [0, 1], "automat": 0, "when": [0, 1], "new": [0, 1], "also": [0, 1], "clean": 0, "resourc": [0, 1], "spun": 0, "environ": 0, "load": [0, 1], "from": [0, 1], "ani": [0, 1], "file": [0, 1], "present": [0, 1], "more": [0, 1], "_": 0, "usernam": [0, 1], "password": 0, "number": 0, "web": [0, 1], "interfac": [0, 1], "imag": [0, 1], "id": [0, 1], "default": [0, 1], "ssm": [0, 1], "paramet": [0, 1], "type": [0, 1], "t2": 0, "nano": [0, 1], "micro": 0, "free": 0, "tier": 0, "kei": [0, 1], "pair": [0, 1], "name": [0, 1], "info": [0, 1], "json": [0, 1], "dump": [0, 1], "inform": [0, 1], "host": [0, 1], "zone": [0, 1], "subdomain": 0, "which": [0, 1], "ha": [0, 1], "option": [0, 1], "var": 0, "config": [0, 1], "aws_profile_nam": 0, "aws_access_kei": 0, "aws_secret_kei": 0, "aws_region_nam": 0, "setup": 0, "python": 0, "m": 0, "pip": 0, "import": 0, "o": 0, "env_fil": 0, "custom": [0, 1], "instanti": [0, 1], "object": [0, 1], "same": [0, 1], "arg": [0, 1], "vpn_server": 0, "vpnserver": [0, 1], "consol": 0, "log": [0, 1], "logger": 0, "create_vpn_serv": [0, 1], "login": 0, "save": 0, "test": [0, 1], "exist": [0, 1], "requir": [0, 1], "right": 0, "after": 0, "creation": 0, "anywai": 0, "test_vpn": [0, 1], "delete_vpn_serv": [0, 1], "delet": [0, 1], "remov": [0, 1], "acquir": [0, 1], "dure": [0, 1], "limit": 0, "current": 0, "expos": 0, "cannot": [0, 1], "handl": [0, 1], "tunnel": [0, 1], "multipl": [0, 1], "without": 0, "modifi": [0, 1], "follow": 0, "key_pair": 0, "key_fil": 0, "privat": [0, 1], "filenam": 0, "self": [0, 1], "sign": 0, "ssl": 0, "cert_fil": 0, "certif": 0, "server_info": 0, "data": [0, 1], "security_group": 0, "ingress": [0, 1], "egress": 0, "firewal": [0, 1], "rule": 0, "control": [0, 1], "via": 0, "vpc": [0, 1], "docstr": 0, "format": [0, 1], "googl": 0, "style": 0, "convent": 0, "pep": 0, "8": 0, "commit": 0, "hook": 0, "flake8": 0, "isort": 0, "gitvers": 0, "revers": 0, "f": 0, "release_not": 0, "rst": 0, "precommit": 0, "ensur": 0, "doc": [0, 1], "everi": 0, "sphinx": 0, "5": 0, "1": 0, "recommonmark": 0, "repositori": 0, "runbook": 0, "packag": 0, "vignesh": 0, "rao": 0, "mit": 0, "platform": 1, "support": 1, "repo": 1, "stat": 1, "deploy": 1, "how": 1, "work": 1, "env": 1, "variabl": 1, "instal": 1, "usag": 1, "code": 1, "standard": 1, "licens": 1, "copyright": 1, "class": 1, "main": 1, "none": 1, "initi": 1, "spin": 1, "serv": 1, "assign": 1, "pem": 1, "boto3": 1, "bring": 1, "_authorize_security_group": 1, "security_group_id": 1, "str": 1, "bool": 1, "author": 1, "certain": 1, "list": 1, "securitygroup": 1, "argument": 1, "open": 1, "tcp": 1, "22": 1, "443": 1, "943": 1, "dynam": 1, "945": 1, "cluster": 1, "channel": 1, "udp": 1, "1194": 1, "return": 1, "flag": 1, "call": 1, "function": 1, "whether": 1, "wa": 1, "_configure_vpn": 1, "public_dn": 1, "public_ip": 1, "localhost": 1, "address": 1, "_create_ec2_inst": 1, "tupl": 1, "success": 1, "union": 1, "_create_key_pair": 1, "keypair": 1, "rsa": 1, "store": 1, "openssh": 1, "boolean": 1, "_create_security_group": 1, "get": 1, "re": 1, "sg": 1, "case": 1, "alreadi": 1, "_delete_key_pair": 1, "successfulli": 1, "_delete_security_group": 1, "_disassociate_security_group": 1, "instance_id": 1, "disassoci": 1, "unavail": 1, "_get_vpc_id": 1, "fetch": 1, "_init": 1, "start": 1, "int": 1, "its": 1, "startup": 1, "shutdown": 1, "_terminate_ec2_inst": 1, "serviceresourc": 1, "termin": 1, "request": 1, "_tester": 1, "dict": 1, "timeout": 1, "3": 1, "dictionari": 1, "made": 1, "updat": 1, "vm": 1, "true": 1, "reachabl": 1, "origin": 1, "succe": 1, "fals": 1, "fail": 1, "unabl": 1, "method": 1, "_instance_info": 1, "check": 1, "befor": 1, "If": 1, "tear": 1, "notifi": 1, "user": 1, "detail": 1, "add": 1, "valu": 1, "retri": 1, "anoth": 1, "sent": 1, "regardless": 1, "disabl": 1, "A": 1, "doesn": 1, "long": 1, "neither": 1, "nor": 1, "hand": 1, "refer": 1, "http": 1, "amazonaw": 1, "com": 1, "v1": 1, "api": 1, "latest": 1, "servic": 1, "wait_until_termin": 1, "html": 1, "model": 1, "configurationset": 1, "pydant": 1, "basemodel": 1, "interact": 1, "pars": 1, "valid": 1, "input": 1, "keyword": 1, "rais": 1, "validationerror": 1, "pydantic_cor": 1, "form": 1, "__init__": 1, "__pydantic_self__": 1, "instead": 1, "common": 1, "first": 1, "field": 1, "amibas": 1, "model_post_init": 1, "__context": 1, "meant": 1, "behav": 1, "like": 1, "initialis": 1, "attribut": 1, "It": 1, "context": 1, "sinc": 1, "what": 1, "core": 1, "pass": 1, "envconfig": 1, "baseset": 1, "dev": 1, "migrat": 1, "nullabl": 1, "extra": 1, "classmethod": 1, "validate_instance_typ": 1, "v": 1, "sure": 1, "validate_vpn_password": 1, "vpn_password": 1, "per": 1, "regex": 1, "wrapper": 1, "awsresourceerror": 1, "status_cod": 1, "error_msg": 1, "error": 1, "notimplementedwarn": 1, "implement": 1, "warn": 1, "image_factori": 1, "session": 1, "retriev": 1, "sourc": 1, "get_ami_id_nam": 1, "get_ami_id_product_cod": 1, "product": 1, "get_ami_id_ssm": 1, "get_image_id": 1, "tri": 1, "sequenti": 1, "execut": 1, "sequenc": 1, "fastest": 1, "singl": 1, "possibli": 1, "contain": 1, "specif": 1, "mani": 1, "so": 1, "grab": 1, "most": 1, "recent": 1, "one": 1, "deprecation_warn": 1, "image_id": 1, "deprecation_tim": 1, "deprec": 1, "chosen": 1, "nearing": 1, "deprecationtim": 1, "streamhandl": 1, "debug": 1, "mode": 1, "change_record_set": 1, "destin": 1, "zone_id": 1, "action": 1, "chang": 1, "within": 1, "hostnam": 1, "exampl": 1, "upsert": 1, "changeset": 1, "respons": 1, "get_zone_id": 1, "init": 1, "miss": 1, "rsakei": 1, "gener": 1, "add_formatt": 1, "formatt": 1, "were": 1, "remove_formatt": 1, "room": 1, "restart_servic": 1, "restart": 1, "run_interactive_ssh": 1, "displai": 1, "30": 1, "command": 1, "screen": 1, "complet": 1, "test_servic": 1, "statu": 1, "remot": 1, "available_instance_typ": 1, "avail": 1, "loop": 1, "describ": 1, "yield": 1, "available_region": 1, "region": 1, "index": 1, "search": 1, "page": 1}, "objects": {"vpn": [[1, 0, 0, "-", "main"]], "vpn.main": [[1, 1, 1, "", "VPNServer"]], "vpn.main.VPNServer": [[1, 2, 1, "", "_authorize_security_group"], [1, 2, 1, "", "_configure_vpn"], [1, 2, 1, "", "_create_ec2_instance"], [1, 2, 1, "", "_create_key_pair"], [1, 2, 1, "", "_create_security_group"], [1, 2, 1, "", "_delete_key_pair"], [1, 2, 1, "", "_delete_security_group"], [1, 2, 1, "", "_disassociate_security_group"], [1, 2, 1, "", "_get_vpc_id"], [1, 2, 1, "", "_init"], [1, 2, 1, "", "_terminate_ec2_instance"], [1, 2, 1, "", "_tester"], [1, 2, 1, "", "create_vpn_server"], [1, 2, 1, "", "delete_vpn_server"], [1, 2, 1, "", "test_vpn"]], "vpn.models.config": [[1, 1, 1, "", "AMIBase"], [1, 1, 1, "", "ConfigurationSettings"], [1, 1, 1, "", "EnvConfig"], [1, 1, 1, "", "Settings"]], "vpn.models.config.AMIBase": [[1, 2, 1, "", "model_post_init"]], "vpn.models.config.EnvConfig": [[1, 1, 1, "", "Config"], [1, 2, 1, "", "validate_instance_type"], [1, 2, 1, "", "validate_vpn_password"]], "vpn.models": [[1, 0, 0, "-", "exceptions"], [1, 0, 0, "-", "image_factory"], [1, 0, 0, "-", "logger"], [1, 0, 0, "-", "route53"], [1, 0, 0, "-", "server"], [1, 0, 0, "-", "util"]], "vpn.models.exceptions": [[1, 3, 1, "", "AWSResourceError"], [1, 3, 1, "", "NotImplementedWarning"]], "vpn.models.image_factory": [[1, 1, 1, "", "ImageFactory"], [1, 4, 1, "", "deprecation_warning"]], "vpn.models.image_factory.ImageFactory": [[1, 2, 1, "", "get_ami_id_name"], [1, 2, 1, "", "get_ami_id_product_code"], [1, 2, 1, "", "get_ami_id_ssm"], [1, 2, 1, "", "get_image_id"]], "vpn.models.route53": [[1, 4, 1, "", "change_record_set"], [1, 4, 1, "", "get_zone_id"]], "vpn.models.server": [[1, 1, 1, "", "Server"]], "vpn.models.server.Server": [[1, 2, 1, "", "add_formatter"], [1, 2, 1, "", "remove_formatter"], [1, 2, 1, "", "restart_service"], [1, 2, 1, "", "run_interactive_ssh"], [1, 2, 1, "", "test_service"]], "vpn.models.util": [[1, 4, 1, "", "available_instance_types"], [1, 4, 1, "", "available_regions"]]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:method", "3": "py:exception", "4": "py:function"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "method", "Python method"], "3": ["py", "exception", "Python exception"], "4": ["py", "function", "Python function"]}, "titleterms": {"platform": 0, "support": 0, "repo": 0, "stat": 0, "deploy": 0, "vpn": [0, 1], "server": [0, 1], "how": 0, "work": 0, "env": 0, "variabl": 0, "instal": 0, "usag": 0, "code": 0, "standard": 0, "releas": 0, "note": 0, "lint": 0, "link": 0, "licens": 0, "copyright": 0, "welcom": 1, "": 1, "document": 1, "read": 1, "me": 1, "configur": 1, "except": 1, "imagefactori": 1, "logger": 1, "route53": 1, "ssh": 1, "util": 1, "indic": 1, "tabl": 1}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 6, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 56}}) \ No newline at end of file diff --git a/gen_docs.sh b/gen_docs.sh index 223e90d..fd85cc8 100644 --- a/gen_docs.sh +++ b/gen_docs.sh @@ -5,6 +5,6 @@ set -e gitverse-release reverse -f release_notes.rst -t 'Release Notes' rm -rf docs mkdir docs -mkdir -p doc_generator/_static -cp README.md doc_generator && cd doc_generator && make clean html && mv _build/html/* ../docs && rm README.md +mkdir -p doc_gen/_static +cp README.md doc_gen && cd doc_gen && make clean html && mv _build/html/* ../docs && rm README.md touch ../docs/.nojekyll diff --git a/pyproject.toml b/pyproject.toml index 5f50f0c..a570ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,14 @@ name = "vpn-server" dynamic = ["version", "dependencies"] description = "Create an on demand VPN Server running with OpenVPN using AWS EC2" readme = "README.md" -authors = [{ name = "Vignesh Sivanandha Rao", email = "svignesh1793@gmail.com" }] +authors = [{ name = "Vignesh Rao", email = "svignesh1793@gmail.com" }] license = { file = "LICENSE" } classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Information Technology", - "Operating System :: MacOS :: MacOS X", + "Operating System :: OS Independent", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3 :: Only", "Topic :: System :: Networking :: Firewalls" ] keywords = ["openvpn-server", "vpn-server", "aws-ec2"] diff --git a/release_notes.rst b/release_notes.rst index ec16edd..fb9dd3a 100644 --- a/release_notes.rst +++ b/release_notes.rst @@ -1,10 +1,25 @@ Release Notes ============= +0.1.3 (09/24/2023) +------------------ +- Includes bug fixes and upgrades to latest OpenVPN Access Server version +- Removes notification features and redundancies +- Uses pydantic for validations +- Cuts run time in half + 0.9.1 (08/30/2023) ------------------ - Includes some minor modifications in type hinting and build process +0.9.1a (08/30/2023) +------------------- +- Set return type to `None` from `NoReturn` +- Add dependencies to requirements.txt +- Use gitverse for generating release notes +- Upgrade to latest flake8 and isort +- Set to beta version + 0.9 (04/03/2023) ---------------- - Upgrade `gmail-connector` and references diff --git a/vpn/__init__.py b/vpn/__init__.py index 354538c..684db2a 100644 --- a/vpn/__init__.py +++ b/vpn/__init__.py @@ -1,6 +1,6 @@ """Place holder for package.""" -from vpn.controller import (INFO_FILE, PEM_FILE, VPNServer, # noqa: F401 - settings) +from vpn.main import VPNServer # noqa: F401 +from vpn.models import util # noqa: F401 -version = "0.9.1" +version = "0.1.3" diff --git a/vpn/config.py b/vpn/config.py deleted file mode 100644 index b5fb1c8..0000000 --- a/vpn/config.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import AnyStr, Dict, List, Tuple, Union - - -class SSHConfig: - """Initiates ``SSHConfig`` object to isolate the configuration dictionary. - - >>> SSHConfig - - """ - - def __init__(self, vpn_username: AnyStr, vpn_password: AnyStr): - """Instantiates object and stores port, username and password as members. - - Args: - vpn_username: Username for authentication. - vpn_password: Password for authentication. - """ - self.vpn_username = vpn_username - self.vpn_password = vpn_password - - def get_config(self) -> Dict[AnyStr, Union[Tuple, List]]: - """Returns the dictionary for ssh config.""" - return { - "1|Please enter 'yes' to indicate your agreement \\[no\\]: ": ("yes", 10), - "2|> Press ENTER for default \\[yes\\]: ": ("yes", 2), - "3|> Press Enter for default \\[1\\]: ": ("1", 2), - "4|> Press ENTER for default \\[943\\]: ": ("943", 2), - "5|> Press ENTER for default \\[443\\]: ": ("443", 2), - "6|> Press ENTER for default \\[no\\]: ": ("yes", 2), - "7|> Press ENTER for default \\[no\\]: ": ("yes", 2), - "8|> Press ENTER for default \\[yes\\]: ": ("yes", 2), - "9|> Press ENTER for EC2 default \\[yes\\]: ": ("yes", 2), - "10|> Press ENTER for default \\[yes\\]: ": ("no", 2), - "11|> Specify the username for an existing user or for the new user account: ": [self.vpn_username, 2], - f"12|Type the password for the '{self.vpn_username}' account:": [self.vpn_password, 2], - f"13|Confirm the password for the '{self.vpn_username}' account:": [self.vpn_password, 2], - "14|> Please specify your Activation key \\(or leave blank to specify later\\): ": ("\n", 2) - } diff --git a/vpn/controller.py b/vpn/controller.py deleted file mode 100644 index 1a5673c..0000000 --- a/vpn/controller.py +++ /dev/null @@ -1,835 +0,0 @@ -import json -import logging -import operator -import os -import string -import time -from datetime import datetime -from ipaddress import IPv4Address -from threading import Thread -from typing import Dict, Optional, Tuple, Union - -import boto3 -import dotenv -import gmailconnector as gc -import requests -import urllib3 -from botocore.exceptions import ClientError -from urllib3.exceptions import InsecureRequestWarning - -from .config import SSHConfig -from .defaults import AWSDefaults -from .models import Settings, flush_screen, write_screen -from .server import Server - -urllib3.disable_warnings(InsecureRequestWarning) # Disable warnings for self-signed certificates - -if os.path.isfile('.env'): - dotenv.load_dotenv(dotenv_path='.env', verbose=False) -settings = Settings() - -PEM_FILE = os.path.join(os.getcwd(), 'OpenVPN.pem') -INFO_FILE = os.path.join(os.getcwd(), 'vpn_info.json') - - -def validate_response(response: Dict) -> bool: - """Validates response from AWS. - - Args: - response: Takes response from boto3 calls. - - Returns: - bool: - Returns ``True`` if the ``HTTPStatusCode`` is 200. - """ - if response.get('ResponseMetadata', {}).get('HTTPStatusCode', 400) == 200: - return True - - -class VPNServer: - """Initiates ``VPNServer`` object to spin up an EC2 instance with a pre-configured AMI which serves as a VPN server. - - >>> VPNServer - - """ - - def __init__(self, aws_access_key: str = None, aws_secret_key: str = None, - image_id: str = None, aws_region_name: str = None, domain: str = None, record_name: str = None, - vpn_username: str = None, vpn_password: str = None, gmail_user: str = None, gmail_pass: str = None, - phone: str = None, recipient: str = None, instance_type: str = None, logger: logging.Logger = None): - """Assigns a name to the PEM file, initiates the logger, client and resource for EC2 using ``boto3`` module. - - Args: - aws_access_key: Access token for AWS account. - aws_secret_key: Secret ID for AWS account. - aws_region_name: Region where the instance should live. Defaults to AWS profile default. - image_id: AMI ID using which the instance should be created. - domain: Domain name for the hosted zone. - record_name: Record using which the VPN server has to be accessed. - vpn_username: Username to access VPN client. - vpn_password: Password to access VPN client. - gmail_user: Gmail username or email address. - gmail_pass: Gmail password. - phone: Phone number to which an SMS notification has to be sent. - recipient: Email address to which an email notification has to be sent. - logger: Bring your own logger. - - See Also: - - If no values (for aws authentication) are passed during object initialization, script checks for env vars. - - If the environment variables are ``null``, gets the default credentials from ``~/.aws/credentials``. - """ - # Check is custom directory exists, raise an error otherwise - if os.path.isdir(os.path.dirname(PEM_FILE)): - self.PEM_FILE = PEM_FILE - self.PEM_IDENTIFIER = os.path.basename(PEM_FILE).rstrip('.pem') - else: - raise NotADirectoryError(f"{os.path.dirname(PEM_FILE)!r} does not exist!") - if os.path.isdir(os.path.dirname(INFO_FILE)): - self.INFO_FILE = INFO_FILE - self.INFO_IDENTIFIER = os.path.basename(INFO_FILE) - else: - raise NotADirectoryError(f"{os.path.dirname(INFO_FILE)!r} does not exist!") - - # AWS region setup - self.region = aws_region_name or settings.aws_region_name - - # AWS user inputs - self.aws_access_key = aws_access_key - self.aws_secret_key = aws_secret_key - self.image_id = image_id or settings.image_id - self.domain = domain or settings.domain - self.record_name = record_name or settings.record_name - self.instance_type = instance_type or settings.instance_type - - # Login credentials setup - self.vpn_username = vpn_username or settings.vpn_username - self.vpn_password = vpn_password or settings.vpn_password - - # Log config - if logger: - self.logger = logger - else: - self.logger = logging.getLogger(__name__) - log_handler = logging.StreamHandler() - log_handler.setFormatter(fmt=logging.Formatter( - fmt='%(asctime)s - %(levelname)s - [%(module)s:%(lineno)d] - %(funcName)s - %(message)s', - datefmt='%b-%d-%Y %I:%M:%S %p' - )) - self.logger.addHandler(hdlr=log_handler) - self.logger.setLevel(level=logging.DEBUG) - for handler in self.logger.handlers: - if isinstance(handler, logging.FileHandler): - self.log_file = handler.baseFilename - break - else: - self.log_file = None - - # Notification information - self.gmail_user = gmail_user or settings.gmail_user - self.gmail_pass = gmail_pass or settings.gmail_pass - self.recipient = recipient or settings.recipient - self.phone = phone or settings.phone - - self.configuration = SSHConfig(vpn_username=self.vpn_username, vpn_password=self.vpn_password) - self.instantiate_aws() - - # noinspection PyAttributeOutsideInit - def instantiate_aws(self): - """Create a boto3 session and load all boto3 clients.""" - session = boto3.Session(aws_access_key_id=self.aws_access_key or settings.aws_access_key, - aws_secret_access_key=self.aws_secret_key or settings.aws_secret_key, - region_name=self.region) - self.ssm_client = session.client(service_name='ssm') - self.ec2_client = session.client(service_name='ec2') - self.ec2_resource = session.resource(service_name='ec2') - self.route53_client = session.client(service_name='route53') - - def _get_image_id_by_name(self) -> str: - """Looks for AMI ID in the default image map. Fetches AMI ID from public images if not present. - - Returns: - str: - AMI ID. - """ - try: - images = self.ec2_client.describe_images(Filters=[ - { - 'Name': 'name', - 'Values': [AWSDefaults.AMI_NAME] - }, - ]) - except ClientError as error: - self.logger.error(f'API call to retrieve AMI ID has failed.\n{error}') - raise - - if image_id := (images.get('Images') or [{}])[0].get('ImageId'): - self.logger.info("Retrieved AMI using image name.") - return image_id - - def _get_image_id_from_ssm(self) -> str: - """Gets the AMI ID from SSM parameter store, using the AMI alias provided in the marketplace configuration page. - - Returns: - str: - AMI ID. - """ - try: - response = self.ssm_client.get_parameters( - Names=[ - AWSDefaults.AMI_ALIAS - ], - WithDecryption=True - ) - except ClientError as error: - self.logger.error(f'API call to retrieve AMI ID has failed.\n{error}') - raise - - if validate_response(response=response): - if params := response.get('Parameters'): - params.sort(key=operator.itemgetter('LastModifiedDate')) # Sort by last modified date - return params[-1].get('Value') # Get the most recent AMI ID - - def _sleeper(self, sleep_time: int) -> None: - """Sleeps for a particular duration. - - Args: - sleep_time: Takes the time script has to sleep, as an argument. - """ - if self.log_file: - self.logger.info(f'Waiting for {sleep_time} seconds.') - time.sleep(sleep_time) - else: - time.sleep(1) - for i in range(sleep_time): - write_screen(f'\rRemaining: {sleep_time - i:0{len(str(sleep_time))}}s') - time.sleep(1) - flush_screen() - - def _create_key_pair(self) -> bool: - """Creates a ``KeyPair`` of type ``RSA`` stored as a ``PEM`` file to use with ``OpenSSH``. - - Returns: - bool: - Flag to indicate the calling function whether a ``KeyPair`` was created. - """ - try: - response = self.ec2_client.create_key_pair( - KeyName=self.PEM_IDENTIFIER, - KeyType='rsa' - ) - except ClientError as error: - error = str(error) - if '(InvalidKeyPair.Duplicate)' in error and self.PEM_IDENTIFIER in error: - self.logger.warning(f'Found an existing KeyPair named: {self.PEM_IDENTIFIER!r}. Re-creating it.') - self._delete_key_pair() - self._create_key_pair() - return True - self.logger.error(f'API call to create key pair has failed.\n{error!r}') - return False - - if validate_response(response=response): - with open(self.PEM_FILE, 'w') as file: - file.write(response.get('KeyMaterial')) - self.logger.info(f'Created a key pair named: {self.PEM_IDENTIFIER!r} and stored as {self.PEM_FILE}') - return True - else: - self.logger.error(f'Unable to create a key pair: {self.PEM_IDENTIFIER!r}') - - def _get_vpc_id(self) -> Union[str, None]: - """Gets the default VPC id. - - Returns: - str or None: - Default VPC id. - """ - try: - response = self.ec2_client.describe_vpcs() - except ClientError as error: - self.logger.error(f'API call to get VPC id has failed.\n{error}') - return - - if validate_response(response=response): - if vpc_id := response.get('Vpcs', [{}])[0].get('VpcId', ''): - self.logger.info(f'Got the default VPC: {vpc_id}') - return vpc_id - self.logger.error('Unable to get VPC ID') - - def _authorize_security_group(self, security_group_id: str) -> bool: - """Authorizes the security group for certain ingress list. - - Args: - security_group_id: Takes the SecurityGroup ID as an argument. - - See Also: - `Firewall configuration ports to be open: `__ - - - TCP 22 — SSH access. - - TCP 443 — Web interface access and OpenVPN TCP connections. - - TCP 943 — Web interface access (This can be dynamic, but the same should be used to configure the VPN.) - - TCP 945 — Cluster control channel. - - UDP 1194 — OpenVPN UDP connections. - - Returns: - bool: - Flag to indicate the calling function whether the security group was authorized. - """ - try: - response = self.ec2_client.authorize_security_group_ingress( - GroupId=security_group_id, - IpPermissions=[ - {'IpProtocol': 'tcp', - 'FromPort': 22, - 'ToPort': 22, - 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, - {'IpProtocol': 'tcp', - 'FromPort': 443, - 'ToPort': 443, - 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, - {'IpProtocol': 'tcp', - 'FromPort': 943, - 'ToPort': 943, - 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, - {'IpProtocol': 'tcp', - 'FromPort': 945, - 'ToPort': 945, - 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, - {'IpProtocol': 'udp', - 'FromPort': 1194, - 'ToPort': 1194, - 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]} - ]) - except ClientError as error: - error = str(error) - if '(InvalidPermission.Duplicate)' in error: - self.logger.warning(f'Identified same permissions in an existing SecurityGroup: {security_group_id}') - return True - self.logger.error(f'API call to authorize the security group {security_group_id} has failed.\n{error}') - return False - if validate_response(response=response): - self.logger.info(f'Ingress Successfully Set for SecurityGroup {security_group_id}') - for sg_rule in response['SecurityGroupRules']: - log = 'Allowed protocol: ' + sg_rule['IpProtocol'] + ' ' - if sg_rule['FromPort'] == sg_rule['ToPort']: - log += 'on port: ' + str(sg_rule['ToPort']) + ' ' - else: - log += 'from port: ' f"{sg_rule['FromPort']} to port: {sg_rule['ToPort']}" + ' ' - self.logger.info(log + 'with CIDR ' + sg_rule['CidrIpv4']) - return True - else: - self.logger.info(f'Failed to set Ingress: {response}') - - def _create_security_group(self) -> Union[str, None]: - """Calls the class method ``_get_vpc_id`` and uses the VPC ID to create a ``SecurityGroup`` for the instance. - - Returns: - str or None: - SecurityGroup ID - """ - if not (vpc_id := self._get_vpc_id()): - return - - try: - response = self.ec2_client.create_security_group( - GroupName='OpenVPN Access Server', - Description='Security Group to allow certain port ranges for VPN server.', - VpcId=vpc_id - ) - except ClientError as error: - error = str(error) - if '(InvalidGroup.Duplicate)' in error and 'OpenVPN Access Server' in error: - self.logger.warning('Found an existing SecurityGroup named: OpenVPN Access Server. Reusing it.') - response = self.ec2_client.describe_security_groups( - Filters=[ - dict(Name='group-name', Values=['OpenVPN Access Server']) - ] - ) - group_id = response['SecurityGroups'][0]['GroupId'] - return group_id - self.logger.error(f'API call to create security group has failed.\n{error}') - return - - if validate_response(response=response): - security_group_id = response['GroupId'] - self.logger.info(f'Security Group Created {security_group_id} in VPC {vpc_id}') - return security_group_id - else: - self.logger.error('Failed to created the SecurityGroup') - - def _create_ec2_instance(self) -> Union[Tuple[str, str], None]: - """Creates an EC2 instance with the pre-configured AMI id. - - Returns: - tuple: - A tuple of Instance ID and Security Group ID. - """ - self.image_id = self.image_id or self._get_image_id_from_ssm() or self._get_image_id_by_name() - if not self.image_id: - raise LookupError( - f'Failed to retrieve AMI ID. Get AMI ID from {AWSDefaults.AMI_SOURCE} and set one manually for ' - f'{self.region!r}.' - ) - - if not self._create_key_pair(): - return - - if not (security_group_id := self._create_security_group()): - self._delete_key_pair() - return - - if not self._authorize_security_group(security_group_id=security_group_id): - self._delete_key_pair() - self._delete_security_group(security_group_id=security_group_id) - return - - try: - response = self.ec2_client.run_instances( - InstanceType=self.instance_type, - MaxCount=1, - MinCount=1, - ImageId=self.image_id, - KeyName=self.PEM_IDENTIFIER, - SecurityGroupIds=[security_group_id] - ) - except ClientError as error: - self._delete_key_pair() - self._delete_security_group(security_group_id=security_group_id) - self.logger.error(f'API call to create instance has failed.\n{error}') - return - - if validate_response(response=response): - instance_id = response.get('Instances')[0].get('InstanceId') - self.logger.info(f'Created the EC2 instance: {instance_id}') - return instance_id, security_group_id - else: - self._delete_key_pair() - self._delete_security_group(security_group_id=security_group_id) - self.logger.error('Failed to create an EC2 instance.') - - def _delete_key_pair(self) -> bool: - """Deletes the ``KeyPair``. - - Returns: - bool: - Flag to indicate the calling function whether the KeyPair was deleted. - """ - try: - response = self.ec2_client.delete_key_pair( - KeyName=self.PEM_IDENTIFIER - ) - except ClientError as error: - self.logger.error(f'API call to delete the key {self.PEM_IDENTIFIER!r} has failed.\n{error}') - return False - - if validate_response(response=response): - if os.path.exists(self.PEM_FILE): - self.logger.info(f'{self.PEM_IDENTIFIER!r} has been deleted from KeyPairs.') - os.chmod(self.PEM_FILE, int('700', base=8) or 0o700) - os.remove(self.PEM_FILE) - return True - else: - self.logger.error(f'Failed to delete the key: {self.PEM_IDENTIFIER!r}') - - def _delete_security_group(self, security_group_id: str) -> bool: - """Deletes the security group. - - Args: - security_group_id: Takes the SecurityGroup ID as an argument. - - Returns: - bool: - Flag to indicate the calling function whether the SecurityGroup was deleted. - """ - try: - response = self.ec2_client.delete_security_group( - GroupId=security_group_id - ) - except ClientError as error: - self.logger.error(f'API call to delete the Security Group {security_group_id} has failed.\n{error}') - if '(InvalidGroup.NotFound)' in str(error): - return True - return False - - if validate_response(response=response): - self.logger.info(f'{security_group_id} has been deleted from Security Groups.') - return True - else: - self.logger.error(f'Failed to delete the SecurityGroup: {security_group_id}') - - def _terminate_ec2_instance(self, instance_id: str) -> bool: - """Terminates the requested instance. - - Args: - instance_id: Takes instance ID as an argument. Defaults to the instance that was created previously. - - Returns: - bool: - Flag to indicate the calling function whether the instance was terminated. - """ - try: - response = self.ec2_client.terminate_instances( - InstanceIds=[instance_id] - ) - except ClientError as error: - self.logger.error(f'API call to terminate the instance has failed.\n{error}') - return False - - if validate_response(response=response): - self.logger.info(f'InstanceId {instance_id} has been set to terminate.') - return True - else: - self.logger.error(f'Failed to terminate the InstanceId: {instance_id}') - - def _instance_info(self, instance_id: str) -> Union[Tuple[str, str, str], None]: - """Makes a ``describe_instance_status`` API call to get the status of the instance that was created. - - Args: - instance_id: Takes the instance ID as an argument. - - Returns: - tuple or None: - A tuple object of Public DNS Name and Public IP Address. - """ - self.logger.info('Waiting for the instance to go live.') - self._sleeper(sleep_time=25) - while True: - self._sleeper(sleep_time=3) - try: - response = self.ec2_client.describe_instance_status( - InstanceIds=[instance_id] - ) - except ClientError as error: - self.logger.error(f'API call to describe instance has failed.\n{error}') - return - - if not validate_response(response=response): - continue - if status := response.get('InstanceStatuses'): - if status[0].get('InstanceState').get('Name') == 'running': - instance_info = self.ec2_resource.Instance(instance_id) - return (instance_info.public_dns_name, - instance_info.public_ip_address, - instance_info.private_ip_address) - - def _tester(self, data: Dict) -> bool: - """Tests ``GET`` and ``SSH`` connections on the existing server. - - Args: - data: Takes the instance information in a dictionary format as an argument. - - See Also: - - Called when a startup request is made but info file and pem file are present already. - - Called when a manual test request is made. - - Testing SSH connection will also run updates on the VM. - - Returns: - bool: - - ``True`` if the existing connection is reachable and ``ssh`` to the origin succeeds. - - ``False`` if the connection fails or unable to ``ssh`` to the origin. - """ - self.logger.info(f"Testing GET connection to https://{data.get('public_ip')}:943") - try: - url_check = requests.get(url=f"https://{data.get('public_ip')}:943", verify=False, timeout=5) - except requests.RequestException as error: - self.logger.error(error) - self.logger.error('Unable to connect the VPN server.') - return False - - test_ssh = Server(username='openvpnas', hostname=data.get('public_dns'), pem_file=self.PEM_FILE) - self.logger.info(f"Testing SSH connection to {data.get('public_dns')}") - if url_check.ok and test_ssh.run_interactive_ssh(logger=self.logger, display=False, - timeout=5, log_file=self.log_file): - self.logger.info(f"Connection to https://{data.get('public_ip')}:943 and " - f"SSH to {data.get('public_dns')} was successful.") - return True - else: - self.logger.error('Unable to establish SSH connection with the VPN server. ' - 'Please check the logs for more information.') - return False - - def reconfigure_vpn(self) -> None: - """Runs the configuration on an existing VPN server.""" - if os.path.isfile(self.INFO_FILE) and os.path.isfile(self.PEM_FILE): - with open(self.INFO_FILE) as file: - data_exist = json.load(file) - self._configure_vpn(data=data_exist) - self._tester(data=data_exist) - else: - self.logger.error(f'Input file: {self.INFO_IDENTIFIER} is missing. CANNOT proceed.') - - def test_vpn(self) -> None: - """Tests the ``GET`` and ``SSH`` connections to an existing VPN server.""" - if os.path.isfile(self.INFO_FILE) and os.path.isfile(self.PEM_FILE): - with open(self.INFO_FILE) as file: - data_exist = json.load(file) - self._tester(data=data_exist) - else: - self.logger.error(f'Input file: {self.INFO_IDENTIFIER} is missing. CANNOT proceed.') - - def _get_hosted_zone_id_by_name(self, domain: str) -> Union[str, None]: - """Get hosted zone id using the domain name. - - Args: - domain: Domain name to add the A record. - - Returns: - str: - Hosted zone ID. - """ - try: - zones = self.route53_client.list_hosted_zones_by_name(DNSName=domain) - except ClientError as error: - self.logger.error(f"API call to get hosted zone has failed.\n{error}") - return - - if not zones or len(zones['HostedZones']) == 0: - self.logger.info(f"Could not find hosted zone for the domain: {domain!r}") - return - - zone_id = zones['HostedZones'][0]['Id'] - return zone_id.split('/')[-1] - - def _hosted_zone_record(self, instance_ip: Union[IPv4Address, str], action: str, record_name: Optional[str] = None, - domain: Optional[str] = None) -> Union[bool, None]: - """Add or remove A record in hosted zone. - - Args: - instance_ip: Public IP of the ec2 instance. - action: Argument to ADD|DELETE|UPSERT dns record. - record_name: Name of the DNS record. - domain: Domain of the hosted zone where an alias record has been made. - - Returns: - bool: - Boolean flag to indicate whether the A name record was added. - """ - domain = domain or self.domain - record_name = record_name or self.record_name - if not domain or not record_name: - self.logger.warning('ENV vars are not configured for hosted zone.') - return - - if not (hosted_zone_id := self._get_hosted_zone_id_by_name(domain=domain)): - return - - try: - response = self.route53_client.change_resource_record_sets( - HostedZoneId=hosted_zone_id, - ChangeBatch={ - 'Comment': 'OpenVPN server', - 'Changes': [ - { - 'Action': action, - 'ResourceRecordSet': { - 'Name': record_name, - 'Type': 'A', - 'TTL': 300, - 'ResourceRecords': [ - { - 'Value': instance_ip - }, - ], - } - }, - ] - } - ) - except ClientError as error: - self.logger.error(f"API call to add A record has failed.\n{error}") - return - - if validate_response(response=response): - self.logger.info(f"{string.capwords(action)}ed {record_name} -> {instance_ip} in the hosted zone: " - f"{'.'.join(record_name.split('.')[-2:])}") - return True - else: - self.logger.error(f"Failed to add A record: {record_name!r}") - - def create_vpn_server(self) -> None: - """Calls the class methods ``_create_ec2_instance`` and ``_instance_info`` to configure the VPN server. - - See Also: - - Checks if info and pem files are present, before spinning up a new instance. - - If present, checks the connection to the existing origin and tears down the instance if connection fails. - - If connects, notifies user with details and adds key-value pair ``Retry: True`` to info file. - - If another request is sent to start the vpn, creates a new instance regardless of existing info. - """ - if os.path.isfile(self.INFO_FILE) and os.path.isfile(self.PEM_FILE): - with open(self.INFO_FILE) as file: - data_exist = json.load(file) - - self.logger.warning(f"Found an existing VPN Server running at {data_exist.get('SERVER')}") - if self._tester(data=data_exist): - if data_exist.get('RETRY'): - self.logger.warning('Received a second request to spin up a new VPN Server. Proceeding this time.') - else: - data_exist.update({'RETRY': True}) - self._notify(message=f"CURRENTLY SERVING: {data_exist.get('SERVER').lstrip('https://')}\n\n" - f"Username: {data_exist.get('USERNAME')}\n" - f"Password: {data_exist.get('PASSWORD')}") - with open(self.INFO_FILE, 'w') as file: - json.dump(data_exist, file, indent=2) - return - else: - self.logger.error('Existing server is not responding. Creating a new one.') - self.delete_vpn_server(partial=True) # Keeps the security group for re-use - - if not (instance_basic := self._create_ec2_instance()): - return - instance_id, security_group_id = instance_basic - - if not (instance := self._instance_info(instance_id=instance_id)): - return - public_dns, public_ip, private_ip = instance - - instance_info = { - 'region_name': self.region, - 'instance_id': instance_id, - 'public_dns': public_dns, - 'public_ip': public_ip, - 'private_ip': private_ip, - 'security_group_id': security_group_id - } - - with open(self.INFO_FILE, 'w') as file: - json.dump(instance_info, file, indent=2) - - self.logger.info(f'Restricting wide open permissions on {self.PEM_FILE!r}') - os.chmod(self.PEM_FILE, int('400', base=8) or 0o400) - - self.logger.info('Waiting for SSH origin to be active.') - self._sleeper(sleep_time=15) - - if not self._configure_vpn(data=instance_info): - self.logger.warning('Unknown error occurred during configuration. Testing connecting to server.') - - if not self._tester(data=instance_info): - if self.log_file: - self._notify(message='Failed to configure VPN server. Please check the logs for more information.', - attachment=self.log_file) - return - - if self._hosted_zone_record(instance_ip=public_ip, action='UPSERT'): - instance_info['domain'] = self.domain - instance_info['record_name'] = self.record_name - msg = f"SERVER: {public_ip}:943\n" \ - f"Alias: https://{self.record_name}\n\n" \ - f"Username: {self.vpn_username}\n" \ - f"Password: {self.vpn_password}" - else: - msg = f"SERVER: {public_ip}:943\n\n" \ - f"Username: {self.vpn_username}\n" \ - f"Password: {self.vpn_password}" - - self.logger.info('VPN server has been configured successfully. ' - f'Details have been stored in {self.INFO_IDENTIFIER}.') - instance_info.update({'SERVER': f"https://{public_ip}:943", - 'USERNAME': self.vpn_username, - 'PASSWORD': self.vpn_password}) - with open(self.INFO_FILE, 'w') as file: - json.dump(instance_info, file, indent=2) - - self._notify(message=msg) - - def _configure_vpn(self, data: dict) -> bool: - """Frames a dictionary of anticipated prompts and responses to initiate interactive SSH commands. - - Args: - data: A dictionary with key, value pairs with instance information in it. - - Returns: - bool: - A boolean flag to indicate whether the interactive ssh session succeeded. - """ - self.logger.info('Configuring VPN server.') - ssh_configuration = Server(hostname=data.get('public_dns'), - username='root', - pem_file=self.PEM_FILE) - return ssh_configuration.run_interactive_ssh(logger=self.logger, log_file=self.log_file, - prompts_and_response=self.configuration.get_config()) - - def _notify(self, message: str, attachment: Optional[str] = None) -> None: - """Send login details via SMS and Email if the following env vars are present. - - ``gmail_user``, ``gmail_pass`` and ``phone [or] recipient`` - - Args: - message: Login information that has to be sent as a message/email. - attachment: Name of the log file in case of a failure. - """ - subject = f"VPN Server::{datetime.now().strftime('%B %d, %Y %I:%M %p')}" - if self.recipient: - email_response = gc.SendEmail( - gmail_user=self.gmail_user, gmail_pass=self.gmail_pass - ).send_email( - recipient=self.recipient, subject=subject, body=message, sender='VPNServer', attachment=attachment - ) - self._notification_response(response=email_response) - else: - self.logger.warning('ENV vars are not configured for an email notification.') - - if self.phone: - sms_response = gc.SendSMS( - gmail_user=self.gmail_user, gmail_pass=self.gmail_pass - ).send_sms(phone=self.phone, subject=subject, message=message) - self._notification_response(response=sms_response) - else: - self.logger.warning('ENV vars are not configured for an SMS notification.') - - def _notification_response(self, response: gc.Response) -> None: - """Logs the response after sending notifications. - - Args: - response: Takes the response dictionary to log the success/failure message. - """ - if response.ok: - self.logger.info(response.body) - else: - self.logger.error(response.json()) - - def delete_vpn_server(self, partial: Optional[bool] = False, instance_id: Optional[str] = None, - security_group_id: Optional[str] = None, - domain: Optional[str] = None, record_name: Optional[str] = None, - instance_ip: Optional[Union[IPv4Address, str]] = None) -> None: - """Disables VPN server by terminating the ``EC2`` instance, ``KeyPair``, and the ``SecurityGroup`` created. - - Args: - partial: Flag to indicate whether the ``SecurityGroup`` has to be removed. - instance_id: Instance that has to be terminated. - security_group_id: Security group that has to be removed. - domain: Domain of the hosted zone where an alias record has been made. - record_name: Record name for the alias. - instance_ip: Value of the record. - """ - if not os.path.exists(self.INFO_FILE) and (not instance_id or not security_group_id): - self.logger.error("CANNOT proceed without input file or 'instance_id' and 'security_group_id' as params.") - return - - if os.path.isfile(self.INFO_FILE): - with open(self.INFO_FILE, 'r') as file: - data = json.load(file) - else: - data = {} - - if data.get('region_name') != self.region: - self.logger.warning( - f"VPNServer was instantiated with {self.region!r} but existing server is on {data.get('region_name')!r}" - ) - self.region = data.get('region_name') - self.instantiate_aws() # Re-instantiate VPNObject to update boto3 session and clients with region name - - if self._delete_key_pair() and self._terminate_ec2_instance(instance_id=instance_id or data.get('instance_id')): - Thread(target=self._hosted_zone_record, - kwargs={'record_name': record_name or data.get('record_name'), - 'domain': domain or data.get('domain'), - 'instance_ip': instance_ip or data.get('public_ip'), 'action': 'DELETE'}).start() - if partial: - os.remove(self.INFO_FILE) - return - self.logger.info('Waiting for dependents to release before deleting SecurityGroup.') - self._sleeper(sleep_time=90) - while True: - if self._delete_security_group(security_group_id=security_group_id or data.get('security_group_id')): - break - else: - self._sleeper(sleep_time=20) - os.remove(self.INFO_FILE) if os.path.isfile(self.INFO_FILE) else None diff --git a/vpn/defaults.py b/vpn/defaults.py deleted file mode 100644 index 52a68d4..0000000 --- a/vpn/defaults.py +++ /dev/null @@ -1,14 +0,0 @@ -from urllib3.util.url import Url as HttpUrl - - -class AWSDefaults: - """Default values for missing AWS configuration. - - >>> AWSDefaults - - """ - - AMI_SOURCE: 'HttpUrl' = 'https://aws.amazon.com/marketplace/server/configuration?' \ - 'productId=fe8020db-5343-4c43-9e65-5ed4a825c931' - AMI_NAME: str = 'OpenVPN Access Server Community Image-fe8020db-5343-4c43-9e65-5ed4a825c931-ami-06585f7cf2fb8855c.4' - AMI_ALIAS: str = '/aws/service/marketplace/prod-qqrkogtl46mpu/2.8.5' diff --git a/vpn/main.py b/vpn/main.py new file mode 100644 index 0000000..11329c0 --- /dev/null +++ b/vpn/main.py @@ -0,0 +1,565 @@ +import json +import logging +import os +import time +import warnings +from typing import Dict, Tuple, Union + +import boto3 +import inflect +import requests +import urllib3 +from boto3.resources.base import ServiceResource +from botocore.exceptions import ClientError, WaiterError +from urllib3.exceptions import InsecureRequestWarning + +from vpn.models.config import env, settings +from vpn.models.exceptions import NotImplementedWarning +from vpn.models.image_factory import ImageFactory +from vpn.models.logger import LOGGER +from vpn.models.route53 import change_record_set, get_zone_id +from vpn.models.server import Server + + +class VPNServer: + """Initiates ``VPNServer`` object to spin up an EC2 instance with a pre-configured AMI which serves as a VPN server. + + >>> VPNServer + + """ + + def __init__(self, logger: logging.Logger = None): + """Assigns a name to the PEM file, initiates the logger, client and resource for EC2 using ``boto3`` module. + + Args: + logger: Bring your own logger. + """ + self.logger = logger or LOGGER + self.session = boto3.Session(region_name=env.aws_region_name, + profile_name=env.aws_profile_name, + aws_access_key_id=env.aws_access_key, + aws_secret_access_key=env.aws_secret_key) + self.logger.info("Session instantiated for region: '%s' with '%s' instance", + self.session.region_name, env.instance_type) + self.ec2_resource = self.session.resource(service_name='ec2') + self.route53_client = self.session.client(service_name='route53') + + self.image_id = None + self.zone_id = None + + def _init(self, start: Union[bool, int]) -> None: + """Initializer function. + + Args: + start: Boolean flag to indicate if its startup or shutdown. + """ + if start: # Not required during shutdown, since image_id is only used to create an ec2 instance + variable = "created in" # var for logging if entrypoint is present + if env.image_id: + self.image_id = env.image_id + else: + self.image_id = ImageFactory(self.session, self.logger).get_image_id() + else: + variable = "removed from" # var for logging if entrypoint is present + if env.hosted_zone: + self.zone_id = get_zone_id(client=self.route53_client, logger=self.logger, dns=env.hosted_zone, init=True) + if settings.entrypoint: + self.logger.info("Entrypoint: '%s' will be %s the hosted zone [%s] '%s'", + settings.entrypoint, variable, self.zone_id, env.hosted_zone) + + def _create_key_pair(self) -> bool: + """Creates a ``KeyPair`` of type ``RSA`` stored as a ``PEM`` file to use with ``OpenSSH``. + + Returns: + bool: + Boolean flag to indicate the calling function if a ``KeyPair`` was created. + """ + try: + key_pair = self.ec2_resource.create_key_pair( + KeyName=env.key_pair, + KeyType='rsa' + ) + except ClientError as error: + error = str(error) + if '(InvalidKeyPair.Duplicate)' in error: + self.logger.warning('Found an existing KeyPair named: %s. Re-creating it.', + env.key_pair) + self._delete_key_pair() + return self._create_key_pair() + self.logger.warning('API call to create key pair has failed.') + self.logger.error(error) + return False + + with open(settings.key_pair_file, 'w') as file: + file.write(key_pair.key_material) + file.flush() + self.logger.info('Stored KeyPair as %s', settings.key_pair_file) + return True + + def _get_vpc_id(self) -> Union[str, None]: + """Fetches the default VPC id. + + Returns: + Union[str, None]: + Default VPC id. + """ + try: + vpcs = list(self.ec2_resource.vpcs.all()) + except ClientError as error: + self.logger.warning('API call to get VPC ID has failed.') + self.logger.error(error) + return + default_vpc = None + for vpc in vpcs: + if vpc.is_default: + default_vpc = vpc + break + if default_vpc: + self.logger.info('Got the default VPC: %s', default_vpc.id) + return default_vpc.id + else: + self.logger.error('Unable to get the default VPC ID') + + def _authorize_security_group(self, security_group_id: str) -> bool: + """Authorizes the security group for certain ingress list. + + Args: + security_group_id: Takes the SecurityGroup ID as an argument. + + See Also: + `Firewall configuration ports to be open: `__ + + - TCP 22 — SSH access. + - TCP 443 — Web interface access and OpenVPN TCP connections. + - TCP 943 — Web interface access (can be dynamic) + - TCP 945 — Cluster control channel. + - UDP 1194 — OpenVPN UDP connections. + + Returns: + bool: + Flag to indicate the calling function whether the security group was authorized. + """ + try: + security_group = self.ec2_resource.SecurityGroup(security_group_id) + security_group.authorize_ingress( + IpPermissions=[ + {'IpProtocol': 'tcp', + 'FromPort': 22, + 'ToPort': 22, + 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, # todo: restrict to current IP and instance IP address + {'IpProtocol': 'tcp', + 'FromPort': 443, + 'ToPort': 443, + 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, + {'IpProtocol': 'tcp', + 'FromPort': env.vpn_port, + 'ToPort': env.vpn_port, + 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, + {'IpProtocol': 'tcp', + 'FromPort': 945, + 'ToPort': 945, + 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}, + {'IpProtocol': 'udp', + 'FromPort': 1194, + 'ToPort': 1194, + 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]} + ]) + except ClientError as error: + error = str(error) + if '(InvalidPermission.Duplicate)' in error: + self.logger.warning('Identified same permissions in an existing SecurityGroup: %s', + security_group_id) + return True + self.logger.error('API call to authorize the security group %s has failed.', security_group_id) + self.logger.error(error) + return False + for sg_rule in security_group.ip_permissions: + log = 'Allowed protocol: ' + sg_rule['IpProtocol'] + ' ' + if sg_rule['FromPort'] == sg_rule['ToPort']: + log += 'on port: ' + str(sg_rule['ToPort']) + ' ' + else: + log += 'from port: ' f"{sg_rule['FromPort']} to port: {sg_rule['ToPort']}" + ' ' + for ip_range in sg_rule['IpRanges']: + self.logger.info(log + 'with CIDR ' + ip_range['CidrIp']) + return True + + def _create_security_group(self) -> Union[str, None]: + """Gets VPC id and creates a security group for the ec2 instance. + + Warnings: + Deletes and re-creates the SG, in case an SG exists with the same name already. + + Returns: + Union[str, None]: + SecurityGroup ID + """ + if not (vpc_id := self._get_vpc_id()): + return + + try: + security_group = self.ec2_resource.create_security_group( + GroupName=env.security_group, + Description='Security Group to allow certain port ranges for exposing localhost to public internet.', + VpcId=vpc_id + ) + except ClientError as error: + error = str(error) + if '(InvalidGroup.Duplicate)' in error and env.security_group in error: + security_groups = list(self.ec2_resource.security_groups.all()) + for security_group in security_groups: + if security_group.group_name == env.security_group: + self.logger.info("Re-using existing SecurityGroup '%s'", security_group.group_id) + return security_group.group_id + raise RuntimeError('Duplicate raised, but no such SG found.') + self.logger.warning('API call to create security group has failed.') + self.logger.error(error) + return + + security_group_id = security_group.id + self.logger.info('Security Group created %s in VPC %s', security_group_id, vpc_id) + return security_group_id + + def _create_ec2_instance(self) -> Union[Tuple[str, str], None]: + """Creates an EC2 instance with a pre-configured AMI id. + + Returns: + Union[Tuple[str, str], None]: + Instance ID, SecurityGroup ID if successful. + """ + if not (security_group_id := self._create_security_group()): + self._delete_key_pair() + return + if not self._create_key_pair(): + return + try: + # Use the EC2 resource to launch an EC2 instance + instances = self.ec2_resource.create_instances( + ImageId=self.image_id, + MinCount=1, + MaxCount=1, + InstanceType=env.instance_type, + KeyName=env.key_pair, + SecurityGroupIds=[security_group_id] + ) + instance = instances[0] # Get the first (and only) instance + except ClientError as error: + self._delete_key_pair() + self._delete_security_group(security_group_id=security_group_id) + self.logger.warning('API call to create instance has failed.') + self.logger.error(error) + return None + + instance_id = instance.id + self.logger.info('Created the EC2 instance: %s', instance_id) + return instance_id, security_group_id + + def _delete_key_pair(self) -> bool: + """Deletes the ``KeyPair`` created to access the ec2 instance. + + Returns: + bool: + Boolean flag to indicate the calling function if the KeyPair was deleted successfully. + """ + try: + key_pair = self.ec2_resource.KeyPair(env.key_pair) + key_pair.delete() + except ClientError as error: + self.logger.warning("API call to delete the key '%s' has failed.", env.key_pair) + self.logger.error(error) + return False + + self.logger.info('%s has been deleted from KeyPairs.', env.key_pair) + + # Delete the associated .pem file if it exists + if os.path.exists(settings.key_pair_file): + os.chmod(settings.key_pair_file, int('700', base=8) or 0o700) + os.remove(settings.key_pair_file) + self.logger.info(f'Removed {settings.key_pair_file}.') + return True + + def _disassociate_security_group(self, + security_group_id: str, + instance: object = None, + instance_id: str = None) -> bool: + """Disassociates an SG from the ec2 instance by assigning it to the default security group. + + Args: + security_group_id: Security group ID + instance: Instance object. + instance_id: Instance ID if object is unavailable. + + Returns: + bool: + Boolean flag to indicate the calling function whether the disassociation was successful. + """ + try: + if not instance: + instance = self.ec2_resource.Instance(instance_id) + if security_groups := list(self.ec2_resource.security_groups.filter(GroupNames=['default'])): + default_sg = security_groups[0] + instance.modify_attribute(Groups=[default_sg.id]) + instance.modify_attribute(Groups=[group_id['GroupId'] for group_id in instance.security_groups + if group_id['GroupId'] != security_group_id]) + self.logger.info("Security group %s has been disassociated from instance %s.", + security_group_id, instance.id) + return True + else: + self.logger.info("Unable to get default SG to replace association") + except ClientError as error: + self.logger.info(error) + + def _delete_security_group(self, security_group_id: str) -> bool: + """Deletes the security group. + + Args: + security_group_id: Takes the SecurityGroup ID as an argument. + + Returns: + bool: + Boolean flag to indicate the calling function whether the SecurityGroup was deleted. + """ + try: + security_group = self.ec2_resource.SecurityGroup(security_group_id) + security_group.delete() + except ClientError as error: + self.logger.warning('API call to delete the Security Group %s has failed.', security_group_id) + self.logger.error(error) + if '(InvalidGroup.NotFound)' in str(error): + return True + return False + self.logger.info('%s has been deleted from Security Groups.', security_group_id) + return True + + def _terminate_ec2_instance(self, + instance_id: str = None, + instance: object = None) -> ServiceResource or None: + """Terminates the requested instance. + + Args: + instance_id: Takes instance ID as an argument. + instance: Takes the instance object as an optional argument. + + Returns: + bool: + Boolean flag to indicate the calling function whether the instance was terminated. + """ + try: + if not instance: + instance = self.ec2_resource.Instance(instance_id) + if not instance_id: + instance_id = instance.id + instance.terminate() + except ClientError as error: + self.logger.warning('API call to terminate the instance has failed.') + self.logger.error(error) + return + self.logger.info('InstanceId %s has been set to terminate.', instance_id) + return instance + + def _tester(self, data: Dict, timeout: int = 3) -> bool: + """Tests ``GET`` and ``SSH`` connections on the existing server. + + Args: + data: Takes the instance information in a dictionary format as an argument. + timeout: Timeout to make the test call. + + See Also: + - Called when a startup request is made but info file and pem file are present already. + - Called when a manual test request is made. + - Testing SSH connection will also run updates on the VM. + + Returns: + bool: + - ``True`` if the existing connection is reachable and ``ssh`` to the origin succeeds. + - ``False`` if the connection fails or unable to ``ssh`` to the origin. + """ + urllib3.disable_warnings(InsecureRequestWarning) # Disable warnings for self-signed certificates + self.logger.info(f"Testing GET connection to https://{data.get('public_ip')}:{env.vpn_port}") + try: + url_check = requests.get(url=f"https://{data.get('public_ip')}:{env.vpn_port}", + verify=False, timeout=timeout) + self.logger.debug(url_check) + except requests.RequestException as error: + self.logger.error(error) + self.logger.error('Unable to connect the VPN server.') + return False + + self.logger.info(f"Testing SSH connection to {data.get('public_dns')}") + test_ssh = Server(username=env.vpn_username, hostname=data.get('public_dns'), logger=self.logger) + if url_check.ok and test_ssh.test_service(display=False, timeout=5): + self.logger.info(f"Connection to https://{data.get('public_ip')}:{env.vpn_port} and " + f"SSH to {data.get('public_dns')} was successful.") + return True + else: + self.logger.error('Unable to establish SSH connection with the VPN server. ' + 'Please check the logs for more information.') + return False + + def test_vpn(self) -> None: + """Tests the ``GET`` and ``SSH`` connections to an existing VPN server.""" + if os.path.isfile(env.vpn_info) and os.path.isfile(settings.key_pair_file): + with open(env.vpn_info) as file: + data_exist = json.load(file) + self._tester(data=data_exist) + else: + self.logger.error(f'Input file: {env.vpn_info} is missing. CANNOT proceed.') + + def create_vpn_server(self) -> None: + """Calls the class methods ``_create_ec2_instance`` and ``_instance_info`` to configure the VPN server. + + See Also: + - Checks if info and pem files are present, before spinning up a new instance. + - If present, checks the connection to the existing origin and tears down the instance if connection fails. + - If connects, notifies user with details and adds key-value pair ``Retry: True`` to info file. + - If another request is sent to start the vpn, creates a new instance regardless of existing info. + """ + if os.path.isfile(env.vpn_info) and os.path.isfile(settings.key_pair_file): + self.logger.warning('Received request to start VM, but looks like a session is up and running already.') + self.logger.warning('Initiating re-configuration.') + with open(env.vpn_info) as file: + data = json.load(file) + env.image_id = 'ami-0000000000' # placeholder value since this won't be used in re-configuration + self._init(True) + if not self._tester(data): + self._configure_vpn(data['public_dns'], data['public_ip']) + return + self._init(True) + if ec2_info := self._create_ec2_instance(): + instance_id, security_group_id = ec2_info + else: + return + + instance = self.ec2_resource.Instance(instance_id) + self.logger.info("Waiting for instance to enter 'running' state") + try: + instance.wait_until_running( + Filters=[{"Name": "instance-state-name", "Values": ["running"]}] + ) + except WaiterError as error: + self.logger.error(error) + warnings.warn( + "Failed on waiting for instance to enter 'running' state, please raise an issue at:\n" + "https://github.com/thevickypedia/expose/issues", + NotImplementedWarning + ) + self._delete_key_pair() + self._disassociate_security_group(instance=instance, security_group_id=security_group_id) + self._terminate_ec2_instance(instance=instance) + self._delete_security_group(security_group_id) + return + instance.reload() + self.logger.info("Finished re-loading instance '%s'", instance_id) + + if not self._authorize_security_group(security_group_id): + self._delete_key_pair() + sg_association = self._disassociate_security_group(instance=instance, security_group_id=security_group_id) + self._terminate_ec2_instance(instance=instance) + if not sg_association: + try: + instance.wait_until_terminated( + Filters=[{"Name": "instance-state-name", "Values": ["terminated"]}] + ) + except WaiterError as error: + self.logger.error(error) + warnings.warn( + "Failed on waiting for instance to enter 'running' state, please raise an issue at:\n" + "https://github.com/thevickypedia/expose/issues", + NotImplementedWarning + ) + self._delete_security_group(security_group_id) + return + + instance_info = { + 'port': env.vpn_port, + 'instance_id': instance_id, + 'public_dns': instance.public_dns_name, + 'public_ip': instance.public_ip_address, + 'security_group_id': security_group_id, + 'ssh_endpoint': f'ssh -i {settings.key_pair_file} openvpnas@{instance.public_dns_name}' + } + + os.chmod(settings.key_pair_file, int('400', base=8) or 0o400) + + with open(env.vpn_info, 'w') as file: + json.dump(instance_info, file, indent=2) + file.flush() + + self._configure_vpn(instance.public_dns_name, instance.public_ip_address) + + if not self._tester(data=instance_info): + self.logger.error('Failed to configure VPN server. Please check the logs for more information.') + return + + self.logger.info('VPN server has been configured successfully. Details have been stored in %s.', + env.vpn_info) + + def _configure_vpn(self, public_dns: str, public_ip: str) -> None: + """Configures the ec2 instance to take traffic from localhost and initiates tunneling. + + Args: + public_dns: Public DNS name of the ec2 that was created. + public_ip: IP address of the ec2 instance. + """ + self.logger.info('Connecting to server via SSH') + + # Max of 10 iterations with 5 second interval between each iteration with default timeout + for i in range(10): + try: + server = Server(hostname=public_dns, username='openvpnas', logger=self.logger) + self.logger.info("Connection established on %s attempt", inflect.engine().ordinal(i + 1)) + break + except Exception as error: + self.logger.error(error) + time.sleep(5) + else: + self.delete_vpn_server() + raise TimeoutError( + "Unable to connect SSH server, please call the 'start' function once again if instance looks healthy" + ) + server.run_interactive_ssh() + change_record_set(source=settings.entrypoint, destination=public_ip, logger=self.logger, + client=self.route53_client, zone_id=self.zone_id, action='UPSERT') + + def delete_vpn_server(self, instance_id: str = None, security_group_id: str = None, public_ip: str = None) -> None: + """Disables tunnelling by removing all AWS resources acquired. + + Args: + instance_id: Instance that has to be terminated. + security_group_id: Security group that has to be removed. + public_ip: Public IP address to delete the A record from route53. + + See Also: + Doesn't require any argument, as long as the JSON dump is neither removed nor modified by hand. + + References: + - | https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/instance/ + | wait_until_terminated.html + """ + try: + with open(env.vpn_info) as file: + data = json.load(file) + except FileNotFoundError: + assert instance_id and security_group_id, \ + (f"\n\nInput file: {env.vpn_info!r} is missing. " + "Arguments 'instance_id' and 'security_group_id' are required to proceed.") + data = {} + self._init(False) + security_group_id = security_group_id or data.get('security_group_id') + instance_id = instance_id or data.get('instance_id') + public_ip = public_ip or data.get('public_ip') + + self._delete_key_pair() + sg_association = self._disassociate_security_group(instance_id=instance_id, security_group_id=security_group_id) + instance = self._terminate_ec2_instance(instance_id=instance_id) + if env.hosted_zone and env.subdomain and public_ip: + change_record_set(source=settings.entrypoint, destination=public_ip, logger=self.logger, + client=self.route53_client, zone_id=self.zone_id, action='DELETE') + if not sg_association and instance: + try: + instance.wait_until_terminated( + Filters=[{"Name": "instance-state-name", "Values": ["terminated"]}] + ) + except WaiterError as error: + self.logger.error(error) + self._delete_security_group(security_group_id) + os.remove(env.vpn_info) if os.path.isfile(env.vpn_info) else None diff --git a/vpn/models.py b/vpn/models.py deleted file mode 100644 index fbae455..0000000 --- a/vpn/models.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import sys -from typing import Any - -import boto3 - - -def ec2_instance_types(region_name: str): - """Yield all available EC2 instance types in a particular region.""" - ec2 = boto3.client('ec2', region_name=region_name) - describe_args = {} - while True: - describe_result = ec2.describe_instance_types(**describe_args) - yield from [i['InstanceType'] for i in describe_result['InstanceTypes']] - if 'NextToken' not in describe_result: - break - describe_args['NextToken'] = describe_result['NextToken'] - - -class Settings: - """Initiate ``Settings`` object to access env vars acros modules. - - >>> Settings - - """ - - def __init__(self): - """Instantiate the class, load all env variables and perform custom validations.""" - self.aws_access_key: str = os.environ.get('AWS_ACCESS_KEY', os.environ.get('aws_access_key')) - self.aws_secret_key: str = os.environ.get('AWS_SECRET_KEY', os.environ.get('aws_secret_key')) - self.aws_region_name: str = os.environ.get('AWS_REGION_NAME', os.environ.get('aws_region_name')) - self.image_id: str = os.environ.get('IMAGE_ID', os.environ.get('image_id')) - self.domain: str = os.environ.get('DOMAIN', os.environ.get('domain')) - self.record_name: str = os.environ.get('RECORD_NAME', os.environ.get('record_name')) - self.vpn_username: str = os.environ.get('VPN_USERNAME', os.environ.get('vpn_username', - os.environ.get('USER', 'openvpn'))) - self.vpn_password: str = os.environ.get('VPN_PASSWORD', os.environ.get('vpn_password', 'awsVPN2021')) - self.gmail_user: str = os.environ.get('GMAIL_USER', os.environ.get('gmail_user')) - self.gmail_pass: str = os.environ.get('GMAIL_PASS', os.environ.get('gmail_pass')) - self.phone: str = os.environ.get('PHONE', os.environ.get('phone')) - self.recipient: str = os.environ.get('RECIPIENT', os.environ.get('recipient')) - self.instance_type: str = os.environ.get('INSTANCE_TYPE', os.environ.get('instance_type')) - - test_client = boto3.client('ec2') - self.available_regions = [region['RegionName'] for region in test_client.describe_regions()['Regions']] - if self.aws_region_name and self.aws_region_name.lower() in self.available_regions: - self.aws_region_name = self.aws_region_name.lower() - elif self.aws_region_name: - raise ValueError( - f'Incorrect region name. {self.aws_region_name!r} does not exist.' - ) - else: - self.aws_region_name = test_client.meta.region_name - - if self.instance_type and self.instance_type in list(ec2_instance_types(region_name=self.aws_region_name)): - self.instance_type = self.instance_type - elif self.instance_type: - raise ValueError( - f'Incorrect instance type. {self.instance_type!r} does not exist.' - ) - else: - self.instance_type = "t2.nano" - - -def write_screen(text: Any) -> None: - """Write text on screen that can be cleared later. - - Args: - text: Text to be written. - """ - sys.stdout.write(f"\r{text}") - - -def flush_screen() -> None: - """Flushes the screen output. - - See Also: - Writes new set of empty strings for the size of the terminal if ran using one. - """ - if sys.stdin.isatty(): - sys.stdout.write(f"\r{' '.join(['' for _ in range(os.get_terminal_size().columns)])}") - else: - sys.stdout.write("\r") diff --git a/vpn/models/config.py b/vpn/models/config.py new file mode 100644 index 0000000..52e5ab3 --- /dev/null +++ b/vpn/models/config.py @@ -0,0 +1,178 @@ +import os +import re +from typing import List, Union + +from pydantic import BaseModel, Field, FilePath, HttpUrl, field_validator +from pydantic_settings import BaseSettings + + +class ConfigurationSettings(BaseModel): + """OpenVPN's configuration settings, for SSH interaction. + + >>> ConfigurationSettings + + """ + + request: str + response: Union[str, int] + timeout: int + critical: bool + + +class AMIBase(BaseModel): + """Default values to fetch AMI image ID. + + >>> AMIBase + + """ + + _BASE_URL: str = 'https://aws.amazon.com/marketplace/server/configuration?productId={productId}' + _BASE_SSM: str = '/aws/service/marketplace/prod-{path}' + _PRODUCT_ID: str = 'fe8020db-5343-4c43-9e65-5ed4a825c931' + + PRODUCT_PAGE: HttpUrl = _BASE_URL.format(productId=_PRODUCT_ID) + NAME: str = f'OpenVPN Access Server QA Image-{_PRODUCT_ID}' + ALIAS: str = _BASE_SSM.format(path='qqrkogtl46mpu/2.11.3') + PRODUCT_CODE: str = 'f2ew2wrz425a1jagnifd02u5t' + + +ami_base = AMIBase() + + +# noinspection PyMethodParameters +class EnvConfig(BaseSettings): + """Env configuration. + + >>> EnvConfig + + References: + https://docs.pydantic.dev/2.3/migration/#required-optional-and-nullable-fields + """ + + vpn_username: str = Field(..., min_length=4, max_length=30) + vpn_password: str = Field(..., min_length=8, max_length=60) + vpn_port: int = 943 + + aws_profile_name: Union[str, None] = None + aws_access_key: Union[str, None] = None + aws_secret_key: Union[str, None] = None + + image_id: Union[str, None] = Field(None, pattern="^ami-.*") + instance_type: str = "t2.micro" + aws_region_name: str = "us-east-2" + + key_pair: str = "OpenVPN" + security_group: str = "OpenVPN Access Server" + vpn_info: str = Field("vpn_info.json", pattern=r".+\.json$") + + hosted_zone: Union[str, None] = None + subdomain: Union[str, None] = None + + class Config: + """Extra config for .env file and extra.""" + + extra = "allow" + env_file = os.environ.get('env_file', os.environ.get('ENV_FILE', '.env')) + + @field_validator('vpn_password', mode='before', check_fields=True) + def validate_vpn_password(cls, v: str) -> str: + """Validates vpn_password as per the required regex.""" + if re.match(pattern=r"^(?=.*\d)(?=.*[A-Z])(?=.*[!@#$%&'()*+,-/[\]^_`{|}~<>]).+$", string=v): + return v + raise ValueError( + r"Password must contain a digit, an Uppercase letter, and a symbol from !@#$%&'()*+,-/[\]^_`{|}~<>" + ) + + @field_validator('instance_type', mode='before', check_fields=True) + def validate_instance_type(cls, v: str) -> str: + """Validate instance type to make sure it is not a nano.""" + if re.match(pattern=r".+\.nano$", string=v): + raise ValueError( + "Instance type should at least be a micro, to accommodate memory requirements." + ) + return v + + +env = EnvConfig() + + +class Settings(BaseModel): + """Wrapper for configuration settings. + + >>> Settings + + """ + + key_pair_file: FilePath = f"{env.key_pair}.pem" + entrypoint: str = None + if any((env.hosted_zone, env.subdomain)): + assert all((env.hosted_zone, env.subdomain)), "'subdomain' and 'hosted_zone' must co-exist" + entrypoint: str = f'{env.subdomain}.{env.hosted_zone}' + ami_deprecation: int = 30 + # todo: Try and use a config file instead of interactive ssh + openvpn_config_commands: List[ConfigurationSettings] = [ + ConfigurationSettings( + **{'request': "Please enter 'yes' to indicate your agreement \\[no\\]: ", 'response': 'yes', 'timeout': 5, + 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[yes\\]: ', 'response': 'yes', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press Enter for default \\[1\\]: ', 'response': '1', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[rsa\\]:', 'response': 'rsa', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[ 2048 \\]:', 'response': '2048', 'timeout': 1, + 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[rsa\\]:', 'response': 'rsa', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[ 2048 \\]:', 'response': '2048', 'timeout': 1, + 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[943\\]: ', 'response': env.vpn_port, 'timeout': 1, + 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[443\\]: ', 'response': '443', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[no\\]: ', 'response': 'yes', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[no\\]: ', 'response': 'yes', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for EC2 default \\[yes\\]: ', 'response': 'yes', 'timeout': 1, + 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Press ENTER for default \\[yes\\]: ', 'response': 'no', 'timeout': 1, 'critical': False} + ), + ConfigurationSettings( + **{'request': '> Specify the username for an existing user or for the new user account: ', + 'response': env.vpn_username, 'timeout': 1, 'critical': True} + ), + ConfigurationSettings( + **{ + 'request': f"Type a password for the '{env.vpn_username}' account (if left blank, a random password will be generated):", # noqa: E501 + 'response': env.vpn_password, 'timeout': 1, 'critical': True} + ), + ConfigurationSettings( + **{'request': f"Confirm the password for the '{env.vpn_username}' account:", 'response': env.vpn_password, + 'timeout': 1, 'critical': True} + ), + ConfigurationSettings( + **{'request': '> Please specify your Activation key (or leave blank to specify later): ', 'response': '\n', + 'timeout': 1, 'critical': False} + ) + ] + + +settings = Settings() diff --git a/vpn/models/exceptions.py b/vpn/models/exceptions.py new file mode 100644 index 0000000..ee4bcca --- /dev/null +++ b/vpn/models/exceptions.py @@ -0,0 +1,14 @@ +class NotImplementedWarning(Warning): + """Custom implementation warning.""" + + +class AWSResourceError(Exception): + """Custom resource error for AWS resources.""" + + def __init__(self, status_code: int, error_msg: str): + self.status_code = status_code + self.error_msg = error_msg + + def __str__(self): + """Returns string formatted text.""" + return f"\n\t[{self.status_code}] - {self.error_msg}" diff --git a/vpn/models/image_factory.py b/vpn/models/image_factory.py new file mode 100644 index 0000000..c6e1a4b --- /dev/null +++ b/vpn/models/image_factory.py @@ -0,0 +1,141 @@ +import logging +import operator +import warnings +from datetime import datetime, timezone + +import boto3 +from botocore.exceptions import ClientError +from dateutil import parser + +from vpn.models.config import ami_base, settings +from vpn.models.exceptions import AWSResourceError + + +def deprecation_warning(image_id: str, deprecation_time: str) -> None: + """Raises a deprecation warning if the chosen AMI is nearing (value is set in config) its DeprecationTime.""" + expired_utc = parser.parse(deprecation_time) + current_utc = datetime.utcnow().replace(tzinfo=timezone.utc) + if (expired_utc - current_utc).days < settings.ami_deprecation: + warnings.simplefilter('always', DeprecationWarning) + warnings.warn( + f"\nThe AMI ID {image_id} is set to be deprecated on {deprecation_time}" + "\nPlease raise an issue at https://github.com/thevickypedia/expose/issues/new " + "to update the AMI base", + DeprecationWarning + ) + + +class ImageFactory: + """Handles retrieving AMI ID from multiple sources. + + >>> ImageFactory + + """ + + def __init__(self, session: boto3.Session, logger: logging.Logger): + """Instantiates the ``ImageFactory`` object. + + Args: + session: boto3 session instantiated in the origin class. + logger: Custom logger. + """ + self.session = session + self.logger = logger + + self.name = ami_base.NAME + self.alias = ami_base.ALIAS + self.product_page = ami_base.PRODUCT_PAGE + self.product_code = ami_base.PRODUCT_CODE + + self.ssm_client = self.session.client('ssm') + self.ec2_client = self.session.client('ec2') + + def get_ami_id_ssm(self) -> str: + """Retrieve AMI ID using Ami Alias.""" + response = self.ssm_client.get_parameters(Names=[self.alias], WithDecryption=True) + if response.get('ResponseMetadata', {}).get('HTTPStatusCode', 400) == 200: + if params := response.get('Parameters'): + self.logger.info("Images fetched using AMI alias: %d", len(params)) + self.logger.debug(params) + params.sort(key=operator.itemgetter('LastModifiedDate')) # Sort by last modified date + ami_id = params[-1].get('Value') # Get the most recent AMI ID + img_response = self.ec2_client.describe_images(ImageIds=[ami_id]) + if valid_until := img_response.get('Images', [{}])[0].get('DeprecationTime'): + deprecation_warning(ami_id, valid_until) + return ami_id + + def get_ami_id_product_code(self) -> str: + """Retrieve AMI ID using Product Code.""" + filters = [{'Name': 'product-code', 'Values': [self.product_code]}] + response = self.ec2_client.describe_images(Filters=filters, Owners=['aws-marketplace']) + if images := response.get('Images'): + self.logger.info("Images fetched using product code: %d", len(images)) + self.logger.debug(images) + images.sort(key=operator.itemgetter('CreationDate')) + image = images[-1] + if valid_until := image.get('DeprecationTime'): + deprecation_warning(image.get('ImageId'), valid_until) + return image.get('ImageId') + + def get_ami_id_name(self) -> str: + """Retrieve AMI ID using Ami Name.""" + response = self.ec2_client.describe_images(Filters=[{'Name': 'name', 'Values': [self.name]}]) + if images := response.get('Images'): + self.logger.info("Images fetched using AMI name: %d", len(images)) + self.logger.debug(images) + images.sort(key=operator.itemgetter('CreationDate')) + image = images[-1] + if valid_until := image.get('DeprecationTime'): + deprecation_warning(image.get('ImageId'), valid_until) + return image.get('ImageId') + + def get_image_id(self) -> str: + """Tries to get image id from multiple sources, sequentially. + + See Also: + Executes in sequence as fastest first. + - Alias on SSM points to a single parameter that possibly contains a single AMI ID as its value. + - Lookup AMI with image name is specific and possibly points to a single AMI ID. + - Lookup AMI with product code will possibly return many images, so grabs the most recently created one. + + Returns: + str: + AMI image ID. + + Raises: + AWSResourceError: + If unable to fetch AMI ID from any source. + """ + try: + if image_id := self.get_ami_id_ssm(): + return image_id + except ClientError as error: + self.logger.error(error) + + try: + if image_id := self.get_ami_id_name(): + return image_id + except ClientError as error: + self.logger.error(error) + + try: + if image_id := self.get_ami_id_product_code(): + return image_id + except ClientError as error: + self.logger.error(error) + + if self.name == ami_base.S_NAME: + raise AWSResourceError( + status_code=404, + error_msg=f'Failed to retrieve AMI ID.\n\t' + f'Get AMI ID from {self.product_page} and set manually for {self.session.region_name!r}.\n\t' + 'Please raise an issue at https://github.com/thevickypedia/expose/issues/new' + ) + else: + # Retry mechanism to fall back to secondary AMI settings in marketplace before raising an error + self.logger.critical("Failed to fetch AMI ID via primary source, using secondary resources.") + self.name = ami_base.S_NAME + self.alias = ami_base.S_ALIAS + self.product_page = ami_base.S_PRODUCT_PAGE + self.product_code = ami_base.S_PRODUCT_CODE + return self.get_image_id() diff --git a/vpn/models/logger.py b/vpn/models/logger.py new file mode 100644 index 0000000..c922b8c --- /dev/null +++ b/vpn/models/logger.py @@ -0,0 +1,16 @@ +"""Loads a default logger with StreamHandler set to DEBUG mode. + +>>> LOGGER + +""" + +import logging + +LOGGER = logging.getLogger(__name__) +log_handler = logging.StreamHandler() +log_handler.setFormatter(fmt=logging.Formatter( + fmt='%(asctime)s - %(levelname)s - [%(module)s:%(lineno)d] - %(funcName)s - %(message)s', + datefmt='%b-%d-%Y %I:%M:%S %p' +)) +LOGGER.addHandler(hdlr=log_handler) +LOGGER.setLevel(level=logging.DEBUG) diff --git a/vpn/models/route53.py b/vpn/models/route53.py new file mode 100644 index 0000000..1056d50 --- /dev/null +++ b/vpn/models/route53.py @@ -0,0 +1,95 @@ +import logging +from http.client import responses as http_response +from typing import Dict, Union + +import boto3 +from botocore.exceptions import ClientError + +from vpn.models.exceptions import AWSResourceError + + +def get_zone_id(client: boto3.client, + logger: logging.Logger, + dns: str, + init: bool = False) -> Union[str, None]: + """Gets the zone ID of a DNS name registered in route53. + + Args: + client: Pre-instantiated boto3 client. + logger: Custom logger. + dns: Hosted zone name. + init: Boolean flag to raise an error in case of missing zone ID. + + Returns: + Union[str, None]: + Returns the zone ID. + + Raises: + AWSResourceError: + If unable to fetch the hosted zone ID by name. + """ + response = client.list_hosted_zones_by_name(DNSName=dns, MaxItems='10') + + if response.get('ResponseMetadata', {}).get('HTTPStatusCode') != 200: + logger.error(response) + if init: + status_code = response.get('ResponseMetadata', {}).get('HTTPStatusCode', 500) + raise AWSResourceError(status_code, http_response[status_code]) + return + + if hosted_zones := response.get('HostedZones'): + for hosted_zone in hosted_zones: + if hosted_zone['Name'] in (dns, f'{dns}.'): + return hosted_zone['Id'].split('/')[-1] + if init: + raise AWSResourceError(404, f'No HostedZones found for the DNSName: {dns}') + logger.error(f'No HostedZones found for the DNSName: {dns}\n{response}') + + +def change_record_set(client: boto3.client, + source: str, + destination: str, + logger: logging.Logger, + zone_id: str, + action: str) -> Union[Dict, None]: + """Changes a record set within an existing hosted zone. + + Args: + client: Pre-instantiated boto3 client. + source: Source DNS name. + destination: Destination hostname or IP address. + logger: Custom logger. + zone_id: Hosted zone ID. + action: Action to perform. Example: UPSERT or DELETE + + Returns: + Union[Dict, None]: + ChangeSet response from AWS. + """ + logger.info("%s `%s` record::%s -> %s", action, 'A', source, destination) + try: + response = client.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + 'Comment': f'A: {source} -> {destination}', + 'Changes': [ + { + 'Action': action, + 'ResourceRecordSet': { + 'Name': source, + 'Type': 'A', + 'TTL': 300, + 'ResourceRecords': [{'Value': destination}], + } + }, + ] + } + ) + except ClientError as error: + logger.error(error) + return + if response.get('ResponseMetadata', {}).get('HTTPStatusCode') != 200: + logger.error(response) + return + logger.info(response.get('ChangeInfo', {}).get('Comment')) + logger.debug(response.get('ChangeInfo')) diff --git a/vpn/models/server.py b/vpn/models/server.py new file mode 100644 index 0000000..dbe1342 --- /dev/null +++ b/vpn/models/server.py @@ -0,0 +1,117 @@ +import logging +import os +import sys +import time + +from paramiko import AutoAddPolicy, RSAKey, SSHClient +from paramiko.ssh_exception import AuthenticationException +from paramiko_expect import SSHClientInteraction + +from vpn.models.config import env, settings + + +class Server: + """Initiates ``Server`` object to create an SSH session to configure the server. + + >>> Server + + """ + + def __init__(self, hostname: str, username: str, logger: logging.Logger): + """Instantiates the session using RSAKey generated from a ``***.pem`` file. + + Args: + hostname: Hostname of the server. + """ + self.logger = logger + pem_key = RSAKey.from_private_key_file(filename=settings.key_pair_file) + self.ssh_client = SSHClient() + self.ssh_client.load_system_host_keys() + self.ssh_client.set_missing_host_key_policy(policy=AutoAddPolicy()) + if username == env.vpn_username: + try: + # todo: Manual config accepts username and password, but unable to get authentication pass via paramiko + self.ssh_client.connect(hostname=hostname, username=username, pkey=pem_key, password=env.vpn_password) + except AuthenticationException as error: + self.logger.warning(error) + self.ssh_client.connect(hostname=hostname, username='openvpnas', pkey=pem_key) + else: + self.ssh_client.connect(hostname=hostname, username=username, pkey=pem_key) + self.logger.info("Connected to %s as %s", hostname, username) + # Backup before modifying logger to compatible version + self._formatter = [] + self._level = self.logger.level + + def remove_formatter(self) -> None: + """Remove any logging formatters to allow room for OpenVPN configuration interaction.""" + for handler in self.logger.handlers: + self._formatter.append(handler.formatter) + handler.formatter = None + self.logger.setLevel(level=logging.INFO) + sys.stdout = open(os.devnull, 'w') + + def add_formatter(self) -> None: + """Re-add any formatters that were removed during instantiation.""" + for handler in self.logger.handlers: + assert len(self._formatter) == 1 + handler.formatter = self._formatter[0] + self.logger.setLevel(level=self._level) + sys.stdout.close() + sys.stdout = sys.__stdout__ + + def restart_service(self) -> None: + """Restarts the openvpn service.""" + self.ssh_client.exec_command("sudo service openvpnas stop") + self.ssh_client.exec_command("sudo service openvpnas start") + time.sleep(3) + + def test_service(self, timeout: int, display: bool) -> bool: + """Check status of the service running on remote server. + + Args: + timeout: Default interaction session timeout. + display: Boolean flag whether to display interaction data on screen. + + Returns: + bool: + Returns a boolean flag if test was successful. + """ + with SSHClientInteraction(client=self.ssh_client, + timeout=timeout, + display=display, + output_callback=lambda msg: self.logger.info(msg)) as interact: + self.remove_formatter() + interact.send("systemctl status openvpnas", '\n') + interact.expect(r".*Started OpenVPN Access Server\..*", timeout) + self.add_formatter() + return True + + def run_interactive_ssh(self, + display: bool = True, + timeout: int = 30) -> None: + """Runs interactive ssh commands to configure the VPN server. + + Args: + display: Boolean flag whether to display interaction data on screen. + timeout: Default interaction session timeout. + + Returns: + bool: + Flag to indicate whether the interactive session has completed successfully. + """ + self.remove_formatter() + with SSHClientInteraction(client=self.ssh_client, + timeout=timeout, + display=display, + output_callback=lambda msg: self.logger.info(msg)) as interact: + for setting in settings.openvpn_config_commands: + interact.expect(re_strings=setting.request, timeout=setting.timeout) + interact.send(send_string=str(setting.response)) + # Blank to await final steps of configuration + interact.expect(timeout=timeout) + self.restart_service() + interact.send("systemctl status openvpnas") + interact.expect(r".*Started OpenVPN Access Server\..*", timeout=5) + self.logger.info(interact.output_callback) + self.ssh_client.close() + self.add_formatter() diff --git a/vpn/models/util.py b/vpn/models/util.py new file mode 100644 index 0000000..383f251 --- /dev/null +++ b/vpn/models/util.py @@ -0,0 +1,32 @@ +from collections.abc import Generator + +import boto3 + + +def available_instance_types() -> Generator[str]: + """Get all available EC2 instance types looping through describe instances API call. + + Yields: + Generator[str]: + Instance type. + """ + ec2_client = boto3.client('ec2') + describe_args = {} + while True: + describe_result = ec2_client.describe_instance_types(**describe_args) + yield from [i['InstanceType'] for i in describe_result['InstanceTypes']] + if 'NextToken' not in describe_result: + break + describe_args['NextToken'] = describe_result['NextToken'] + + +def available_regions() -> Generator[str]: + """Get all available regions with describe regions API call. + + Yields: + Generator[str]: + Region name. + """ + ec2_client = boto3.client('ec2') + for region in ec2_client.describe_regions()['Regions']: + yield region['RegionName'] diff --git a/vpn/requirements.txt b/vpn/requirements.txt index 2c8755f..dccb2c7 100644 --- a/vpn/requirements.txt +++ b/vpn/requirements.txt @@ -1,7 +1,8 @@ +boto3 +inflect +botocore +requests paramiko==3.3.1 paramiko-expect==0.3.5 -boto3~=1.28.38 -botocore~=1.31.38 -python-dotenv~=1.0.0 -requests -gmail-connector +pydantic==2.3.0 +pydantic-settings==2.0.3 diff --git a/vpn/server.py b/vpn/server.py deleted file mode 100644 index a879474..0000000 --- a/vpn/server.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -import os -import sys -from typing import Dict, Optional - -from paramiko import AutoAddPolicy, RSAKey, SSHClient -from paramiko_expect import SSHClientInteraction - - -class Server: - """Initiates ``Server`` object to create an SSH session to configure the server. - - >>> Server - - """ - - def __init__(self, hostname: str, pem_file: str, username: str): - """Instantiates the session using RSAKey generated from a ``***.pem`` file. - - Args: - hostname: Hostname of the server. - username: Username to log in to the server. - pem_file: PEM filename to authenticate the login. - """ - pem_key = RSAKey.from_private_key_file(filename=pem_file) - self.ssh_client = SSHClient() - self.ssh_client.load_system_host_keys() - self.ssh_client.set_missing_host_key_policy(policy=AutoAddPolicy()) - self.ssh_client.connect(hostname=hostname, username=username, pkey=pem_key) - - def run_interactive_ssh(self, logger: logging.Logger, log_file: Optional[str] = None, - prompts_and_response: Optional[Dict] = None, - display: Optional[bool] = True, timeout: Optional[int] = 30) -> bool: - """Runs interactive ssh commands to configure the VPN server. - - Args: - prompts_and_response: Prompts and their responses. - logger: Logging module. - display: Boolean flag whether to display interaction data on screen. - timeout: Default session timeout. - log_file: To write clean console output to the log file. - - Returns: - bool: - Flag to indicate whether the interactive session has completed successfully. - """ - interact = SSHClientInteraction(client=self.ssh_client, timeout=timeout, display=display) - if not prompts_and_response: - self.ssh_client.close() - return True - - sys.stdout = open(log_file, 'a') if log_file else open(os.devnull, 'w') - n = 0 - for prompt, response in prompts_and_response.items(): - n += 1 - prompt = prompt.lstrip(f'{n}|') - replace_this = '\\' - None if log_file else logger.info(f"Expecting {prompt.replace(replace_this, '')}") - interact.expect(re_strings=prompt, timeout=response[1]) - if not log_file: # Log 'prompt and response' only if it is console - if isinstance(response, list): # Secure information that shouldn't be on the logs - logger.info(f"Sending {''.join(['*' for _ in range(len(response[0]))])}") - elif isinstance(response, tuple): - logger.info(f"Sending {response[0]}") - interact.send(send_string=response[0]) - if log_file: - interact.expect(timeout=timeout) - sys.stdout.close() - sys.stdout = sys.__stdout__ - self.ssh_client.close() - else: - sys.stdout.close() - sys.stdout = sys.__stdout__ - interact.expect(timeout=timeout) - self.ssh_client.close() - return True