diff --git a/documentation/Initialization Process.drawio b/documentation/Initialization Process.drawio
new file mode 100644
index 00000000..63727ce8
--- /dev/null
+++ b/documentation/Initialization Process.drawio
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/documentation/example.ini b/documentation/example.ini
deleted file mode 100644
index 33792108..00000000
--- a/documentation/example.ini
+++ /dev/null
@@ -1,45 +0,0 @@
-[DEFAULT]
-test=val
-
-[Network]
-interface=eth0
-subnet=10.0.0.0/24
-netmask=255.255.255.0
-ip=10.0.0.1
-dhcp-ranges=10.0.0.100-10.0.0.254
-dhcp-lease=12h
-no-dhcp=false
-nat-requests=true
-nat-interface=eth1
-ftp_directory=/ftpboot
-tftp_directory=/tftpboot
-ftp_port=20
-tftp_port=69
-gateways=10.0.0.50
-dns-servers=1.1.1.1
-no-dns=false #
-
-[OS]
-os=debian
-version=12
-
-[UEFI]
-uefi=false
-
-[Users]
-root-login=true
-root-password=
-
-username=alice
-password=
-ssh-key=
-sudoer=true
-
-[Applications]
-
-ssh=
-sql=
-mongodb=
-
-[DNSMasq Overrides]
-authoritative=false
diff --git a/documentation/example.yml b/documentation/example.yml
index 30cba056..40689f11 100644
--- a/documentation/example.yml
+++ b/documentation/example.yml
@@ -19,6 +19,33 @@ Network:
ftp:
directory: "/ftp"
ftp-port: 20
+ server:
+ # the prefered port, server will throw an exception if port is occupied on
+ # the specified ip above. If no IP was specifed, attempts to bind to all interfaces
+ port: 15206
+ # the user and group the server should drop privileges to if ran as root
+ # if absent will run the server as the running user
+ # if not ran as root or the specified user, a warning will be emitted but the server will still run
+ # if group is absent, will attempt to use a group with the same name as user
+ user: genisys
+ group: genisys
+ # the working directory for the server process. Used to canonicalize any paths from the ansible section.
+ # The specified user needs, at minimum, execute (--x) access to the directory. If any of the ansible files
+ # are stored there the user will need read (r--) permissions on those files or read-write (rw-) access for
+ # the inventory file. They will also need write (-w-) access to the working-directory if the inventory file
+ # does not exist. Defaults to the user's home directory
+ working-directory: /srv/genisys
+ # run the server using SSL
+ # either both or neither of cert and key need specified if enabled
+ # if neither are present, genisys generates a self-signed key on first run
+ # the self-signed cert will be created at $GENISYS_CERT_STORE or /etc/genisys/ssl/
+ # if the ssl section is missing, the server will be ran without ssl
+ ssl:
+ cert: /path/to/fullchain.pem
+ key: /path/to/privkey.pem
+ # nessecary if the privkey is encrypted, the first line of the file will be used as the passphrase
+ # when decrytping the key, after stripping any newline
+ password-file: /path/to/key/password
OS:
os: "debian"
version-name: bookworm
@@ -35,9 +62,23 @@ Users:
- "test2.pub"
sudoer: true
Applications:
+ - curl
DNSMasq Overrides:
authoritative: false
Scripts:
script-dir: "/scripts"
move-all: true
script-list: ["script1.sh"]
+# configure settings for the ansible integration
+ansible:
+ # this specifies which inventory file is updated by the server process
+ # is created if it does not exist. If it does exist, must be writable by the server user.
+ # If the inventory file already exists, it should be defined in YAML format. A 'genisys'
+ # section will be added to the file if it does not already exist.
+ inventory: /var/genisys/inventory
+ # the ssh private key used to run the playbooks. The corresponding public key should be specified
+ # in the Users section above. Must be readable by the server user
+ ssh-key: /etc/genisys/ssh/id_rsa
+ # list of paths to playbooks to run when the server receives a HELLO
+ playbooks:
+ - /etc/genisys/playbooks/firstrun.yaml
diff --git a/genisys/main.py b/genisys/main.py
index acc32e85..a3400a53 100644
--- a/genisys/main.py
+++ b/genisys/main.py
@@ -14,6 +14,7 @@
from genisys.modules.firstboot.hello import Hello
from genisys.modules.firstboot.service import Service
import genisys.config_parser as cp
+import genisys.server
MODULES = [
OSDownload,
@@ -57,12 +58,6 @@ def generate_config(file, root="."):
mod = module(file)
mod.install(root)
-def daemon():
- """Monitor the config file for changes"""
- print("Starting daemon...")
-
- raise NotImplementedError
-
def run(subcommand, args):
"""Parse command line options and run the relevant helper method"""
# Config Parser
@@ -74,6 +69,8 @@ def run(subcommand, args):
install_config(yaml_parser, args.root)
elif subcommand == "generate":
generate_config(yaml_parser, args.root)
+ elif subcommand == "server":
+ genisys.server.run(yaml_parser)
def main():
@@ -92,12 +89,12 @@ def main():
generate_parser = subparsers.add_parser(
"generate", help="Generate the configuration files."
)
- daemon_parser = subparsers.add_parser(
- "daemon", help="Monitor the config file for changes."
+ server_parser = subparsers.add_parser(
+ "server", help="Run the server to listen for new clients."
)
# Flags for all subparsers
- for subparser in [validate_parser, install_parser, generate_parser]:
+ for subparser in [validate_parser, install_parser, generate_parser, server_parser]:
subparser.add_argument(
"-f",
"--file",
diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py
new file mode 100644
index 00000000..a7b1eed8
--- /dev/null
+++ b/genisys/server/__init__.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+import ssl
+import pwd
+import grp
+import os
+import sys
+from warnings import warn
+from signal import signal, SIGTERM
+from typing_extensions import Dict, TypedDict, cast
+from genisys.config_parser import YAMLParser
+from genisys.server.inventory import Inventory
+from genisys.server.http import GenisysHTTPServer, GenisysHTTPRequestHandler
+import genisys.server.tls
+
+DEFAULT_PORT = 15206
+DEFAULT_INVENTORY = "/etc/ansible/hosts"
+
+ServerOptions = TypedDict("ServerOptions", {
+ "port": int,
+ "user": str,
+ "group": str,
+ "working-directory": str,
+ "ssl": Dict[str, str]
+})
+
+def run(config: YAMLParser):
+ """Drops priviledges, creates the server (with SSL, if applicable), then waits for requests"""
+ # parse config
+ network = config.get_section("Network")
+ server_options = cast(ServerOptions, network.get("server", {}) or {})
+
+ # drop priviledges
+ try:
+ server_user = drop_priviledges(server_options)
+ except PermissionError:
+ warn("Unable to drop privledges to the specified user. Continuing as current user.")
+ server_user = pwd.getpwuid(os.getuid())
+
+ # change working directory
+ workdir = server_options.get("working-directory", server_user.pw_dir)
+ os.chdir(workdir)
+
+ # install additional data for the server to use
+ ansible_cfg = config.get_section("ansible")
+ inventory_path = ansible_cfg.get("inventory", DEFAULT_INVENTORY)
+
+ # create a server
+ server_address = network.get('ip', '')
+ server_port = server_options.get("port", DEFAULT_PORT)
+ httpd = GenisysHTTPServer((server_address, server_port), Inventory(inventory_path), config)
+
+ # apply TLS if applicable
+ if 'ssl' in server_options:
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
+ ssl_cert = genisys.server.tls.get_keychain(server_options['ssl'] or {})
+ ssl_context.load_cert_chain(**ssl_cert)
+ httpd.socket = ssl_context.wrap_socket(httpd.socket)
+
+
+ # run until SIGTERM is caught
+ def sigterm_handler(*_):
+ print("killed")
+ del httpd.inventory
+ sys.exit(SIGTERM)
+ signal(SIGTERM, sigterm_handler)
+ httpd.serve_forever()
+
+def drop_priviledges(config: ServerOptions) -> pwd.struct_passwd:
+ """Attempts to drop the priviledges to that of the specified users, returns false on failure"""
+ if 'user' not in config:
+ warn("No user specified. Continuing as current user.")
+ return pwd.getpwuid(os.geteuid())
+
+ grpnam = config.get('group', config['user'])
+ uid = pwd.getpwnam(config['user'])
+ gid = grp.getgrnam(grpnam)
+
+ os.setuid(uid.pw_uid)
+ os.setgid(gid.gr_gid)
+ return uid
diff --git a/genisys/server/http.py b/genisys/server/http.py
new file mode 100644
index 00000000..d3d5cbe6
--- /dev/null
+++ b/genisys/server/http.py
@@ -0,0 +1,68 @@
+import sys
+import json
+import subprocess
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from typing_extensions import Self, Tuple, cast
+from genisys.config_parser import YAMLParser
+from genisys.server.inventory import Inventory
+
+def generate_response(status_code: int, message: str) -> bytes:
+ """Generates a JSON formated HTTP response with the given status and message"""
+ statuses = {200: "OK", 400: "Bad Request", 500: "Internal Server Error"}
+ response_body = '{"message":"' + message + '"}'
+ response = f"HTTP/1.1 {status_code} {statuses[status_code]}\n"
+ response += f"Content-Length: {len(response_body)}\n"
+ response += "Content-Type: application/json\n"
+ response += "\n"
+ response += response_body
+ return bytes(response, encoding='utf-8')
+
+class GenisysHTTPServer(HTTPServer):
+ """Subclass to manage shared state between server requests"""
+ def __init__(self: Self, bind: Tuple[str, int], inventory: Inventory, config: YAMLParser):
+ super().__init__(bind, GenisysHTTPRequestHandler)
+ self.inventory = inventory
+ self.config = config
+
+class GenisysHTTPRequestHandler(BaseHTTPRequestHandler):
+ """Process client "hello"s by running ansible playbooks on a received POST"""
+ def do_POST(self: Self):
+ """On POST, store the client in the Ansible inventory and run playbooks"""
+ try:
+ # cast server since we know it will only be called from Genisys
+ server = cast(GenisysHTTPServer, self.server)
+
+ # get the request body
+ content_length = int(self.headers['Content-Length'])
+ body = json.loads(self.rfile.read(content_length))
+
+ # validate the declared IP and hostname
+ if body['ip'] != self.client_address[0] \
+ or server.inventory.get(body['hostname']) is not None:
+ self.wfile.write(generate_response(400, 'Declared IP is not valid.'))
+
+ # add the host to the inventory file
+ server.inventory.add_host(body['hostname'], { 'ansible_host': body['ip'] })
+
+ # run the playbooks
+ ansible_cmd = [
+ 'ansible-playbook',
+ '--inventory',
+ server.inventory.filename,
+ '--limit',
+ body['hostname']
+ ]
+ for playbook in server.config.get_section('ansible').get('playbooks', []):
+ ansible_cmd.append(playbook)
+ subprocess.run(ansible_cmd, check=True)
+
+ # send success back to the client
+ self.wfile.write(generate_response(200, 'success'))
+ except Exception as e:
+ print(f"ERROR: {e}", file=sys.stderr)
+ self.wfile.write(
+ generate_response(
+ 500,
+ 'Internal server error. Check logs for details.'
+ )
+ )
diff --git a/genisys/server/inventory.py b/genisys/server/inventory.py
new file mode 100644
index 00000000..61a07d7a
--- /dev/null
+++ b/genisys/server/inventory.py
@@ -0,0 +1,39 @@
+from typing_extensions import Self, Optional, Dict
+import yaml
+
+class Inventory:
+ """Manages a Yaml-formatted Ansible inventory file by keeping an in-memory "shadow" hosts file
+ up to date with the on-disk representation.
+ """
+ instances = {}
+
+ def __init__(self: Self, filepath: str):
+ """Open a file handle and initialize the in-memory representation."""
+ # create a new instance
+ self.filename = filepath
+ self.fd = open(filepath, 'r+', encoding='utf-8')
+ self.contents = yaml.safe_load(self.fd)
+
+ # ensure the inventory group exists
+ if 'genisys' not in self.contents:
+ self.contents['genisys'] = {'hosts': {}}
+
+ def __del__(self: Self):
+ """Close the file handle if this is the last remaining instance."""
+ self.fd.close()
+
+ def get(self: Self, hostname: str) -> Optional[Dict]:
+ """Returns None if the host doesn't exist or the settings if it does"""
+ return self.contents['genisys']['hosts'].get(hostname)
+
+ def add_host(self: Self, hostname: str, settings: Optional[Dict] = None):
+ """Adds the host to the inventory, updating the in-memory and on-disk file."""
+ # Update the in-memory representation
+ self.contents['genisys']['hosts'][hostname] = settings or {}
+
+ # Truncate the current inventory file
+ self.fd.seek(0)
+
+ # Write the new invetory to disk
+ yaml.dump(self.contents, self.fd)
+ self.fd.flush()
diff --git a/genisys/server/tls.py b/genisys/server/tls.py
new file mode 100644
index 00000000..2859b218
--- /dev/null
+++ b/genisys/server/tls.py
@@ -0,0 +1,104 @@
+import os
+from pathlib import Path
+from datetime import datetime, timedelta
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import hashes
+from cryptography import x509
+from cryptography.x509.oid import NameOID
+from typing_extensions import TypedDict, Union, NotRequired, Dict
+
+CERTIFICATE_STORE_PATH = Path(os.getenv('GENISYS_CERT_STORE', '/etc/genisys/ssl'))
+
+class CertChainArgs(TypedDict):
+ """Typed Dict for arguments to be applied to the SSLContext#load_cert_chain method"""
+ keyfile: str
+ certfile: str
+ password: NotRequired[Union[str, None]]
+
+def get_keychain(config: Dict[str, str]) -> CertChainArgs:
+ """Converts from the configured filenames to the nessecary format to pass to
+ SSLContext.load_cert_chain, creating the keypair if nessecary
+ """
+ if ('cert' not in config and 'key' in config) \
+ or ('cert' in config and 'key' not in config):
+ raise ValueError("Only one of SSL Certificate or Key have been specified")
+
+ if 'cert' in config:
+ passwd = None
+ if 'password-file' in config:
+ with open(config['password-file'], 'r', encoding='utf-8') as fd:
+ passwd = fd.readline().strip('\n')
+ return {
+ 'certfile': config['cert'],
+ 'keyfile': config['key'],
+ 'password': passwd
+ }
+
+ # generate or use the cert if applicable
+ cert_path = CERTIFICATE_STORE_PATH / 'cert.pem'
+ key_path = CERTIFICATE_STORE_PATH / 'key.pem'
+
+ if not (cert_path.exists() and key_path.exists()):
+ # generate new keys
+ CERTIFICATE_STORE_PATH.mkdir(parents=True, exist_ok=True)
+ generate_keypair(cert_path, key_path)
+
+ return { 'certfile': str(cert_path), 'keyfile': str(key_path) }
+
+def generate_keypair(cert_path: Path, key_path: Path):
+ """Generates a new x509 keypair into the specified cert and key files"""
+ # thanks ChatGPT
+ private_key = rsa.generate_private_key(
+ public_exponent=65537,
+ key_size=2048,
+ )
+
+ # Create a self-signed certificate
+ subject = issuer = x509.Name([
+ x509.NameAttribute(NameOID.COMMON_NAME, 'genisys.internal'),
+ ])
+
+ cert = x509.CertificateBuilder().subject_name(
+ subject
+ ).issuer_name(
+ issuer
+ ).public_key(
+ private_key.public_key()
+ ).serial_number(
+ x509.random_serial_number()
+ ).not_valid_before(
+ datetime.utcnow()
+ ).not_valid_after(
+ datetime.utcnow() + timedelta(days=365)
+ ).sign(
+ private_key, hashes.SHA256()
+ )
+
+ # Serialize the private key and certificate to PEM format
+ pem_private_key = private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption()
+ )
+
+ pem_cert = cert.public_bytes(encoding=serialization.Encoding.PEM)
+
+ # ensure files exist and have correct permissions
+ cert_perms = 0o644
+ if cert_path.exists():
+ cert_path.chmod(cert_perms)
+ else:
+ cert_path.touch(cert_perms)
+
+ key_perms = 0o600
+ if key_path.exists():
+ key_path.chmod(key_perms)
+ else:
+ key_path.touch(key_perms)
+
+ # write the certs to their files
+ with cert_path.open('w') as cert_file:
+ cert_file.write(pem_cert.decode())
+ with key_path.open('w') as key_file:
+ key_file.write(pem_private_key.decode())
diff --git a/poetry.lock b/poetry.lock
index d4494dc7..aa16b34f 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -25,6 +25,70 @@ files = [
{file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
]
+[[package]]
+name = "cffi"
+version = "1.16.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
+ {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
+ {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
+ {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
+ {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
+ {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
+ {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
+ {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
+ {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
+ {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
+ {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
+ {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
+ {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
+ {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
+ {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+]
+
+[package.dependencies]
+pycparser = "*"
+
[[package]]
name = "charset-normalizer"
version = "3.3.2"
@@ -135,6 +199,60 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+[[package]]
+name = "cryptography"
+version = "42.0.2"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be"},
+ {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d"},
+ {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4"},
+ {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2"},
+ {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529"},
+ {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1"},
+ {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1"},
+ {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929"},
+ {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9"},
+ {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2"},
+ {file = "cryptography-42.0.2-cp37-abi3-win32.whl", hash = "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee"},
+ {file = "cryptography-42.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee"},
+ {file = "cryptography-42.0.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242"},
+ {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a"},
+ {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446"},
+ {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90"},
+ {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3"},
+ {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589"},
+ {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a"},
+ {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea"},
+ {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33"},
+ {file = "cryptography-42.0.2-cp39-abi3-win32.whl", hash = "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635"},
+ {file = "cryptography-42.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6"},
+ {file = "cryptography-42.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380"},
+ {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6"},
+ {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2"},
+ {file = "cryptography-42.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f"},
+ {file = "cryptography-42.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008"},
+ {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12"},
+ {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a"},
+ {file = "cryptography-42.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65"},
+ {file = "cryptography-42.0.2.tar.gz", hash = "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888"},
+]
+
+[package.dependencies]
+cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
+nox = ["nox"]
+pep8test = ["check-sdist", "click", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
[[package]]
name = "dill"
version = "0.3.7"
@@ -306,6 +424,17 @@ files = [
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"]
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
+
[[package]]
name = "pylint"
version = "3.0.2"
@@ -361,6 +490,7 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
@@ -479,4 +609,4 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
-content-hash = "e452be00ca557e3ce23715a2c04400e24074aaf41e58abd1f785a0df0a12353f"
+content-hash = "b8ed7d08cf146fff304d0e123079b0bb4a4726510552b4279d5c2aba22197500"
diff --git a/pyproject.toml b/pyproject.toml
index a8185d65..ed8185e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ typing-extensions = "^4.8.0"
passlib = "^1.7.4"
textwrap3 = "^0.9.2"
requests = "^2.31.0"
+cryptography = "^42.0.2"
[tool.poetry.group.dev.dependencies]
pylint = "^3.0.1"