From abd01ca4a3faa9a926f52bdba5daae85e84c9e08 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 08:19:28 -0500 Subject: [PATCH 01/21] removed obselete ini file --- documentation/example.ini | 45 --------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 documentation/example.ini 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 From a8bcc2cc49997ac3e320f85c64a0f15578410e58 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 09:30:18 -0500 Subject: [PATCH 02/21] updated example config with server specification --- documentation/example.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/documentation/example.yml b/documentation/example.yml index 0a3c7d36..2cc06c1b 100644 --- a/documentation/example.yml +++ b/documentation/example.yml @@ -17,6 +17,25 @@ Network: ftp: directory: "/ftp" ftp-port: 20 + server: + # the prefered port, server will throw an exception if port is occupied on the specified internal interface above + 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 + # 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 + # 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 From e126e3fddc2590cb4961d6e116e9a9ed4767b467 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 09:30:31 -0500 Subject: [PATCH 03/21] minimal TLS server --- genisys/server.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 genisys/server.py diff --git a/genisys/server.py b/genisys/server.py new file mode 100644 index 00000000..ed16b442 --- /dev/null +++ b/genisys/server.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +import os +import ssl +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing_extensions import Self + +class GenisysHTTPRequestHandler(BaseHTTPRequestHandler): + def do_POST(self: Self): + print('posted') + +if __name__ == "__main__": + httpd = HTTPServer(('localhost', 4443), GenisysHTTPRequestHandler) + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain( + certfile="./certs/cert.pem", + keyfile="./certs/key.pem", + password=open("./certs/passwd", "r", encoding="utf-8").readline().strip('\n') + ) + httpd.socket = ssl_context.wrap_socket(httpd.socket) + httpd.serve_forever() From a7581e8a3221e1f085c225208d56e45a1187a0f8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 11:35:29 -0500 Subject: [PATCH 04/21] added extra documentation --- documentation/example.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/example.yml b/documentation/example.yml index 2cc06c1b..c522557d 100644 --- a/documentation/example.yml +++ b/documentation/example.yml @@ -18,7 +18,8 @@ Network: directory: "/ftp" ftp-port: 20 server: - # the prefered port, server will throw an exception if port is occupied on the specified internal interface above + # 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 @@ -29,6 +30,7 @@ Network: # 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 From 60525f193eeab811514a011abfbe9a12f907c178 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 11:35:54 -0500 Subject: [PATCH 05/21] full server setup for all config vars (with defaults) --- genisys/server.py | 164 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 11 deletions(-) diff --git a/genisys/server.py b/genisys/server.py index ed16b442..c176486d 100644 --- a/genisys/server.py +++ b/genisys/server.py @@ -1,20 +1,162 @@ #!/usr/bin/env python3 -import os import ssl +import pwd +import grp +import os +from datetime import datetime, timedelta +from pathlib import Path +from warnings import warn from http.server import HTTPServer, BaseHTTPRequestHandler -from typing_extensions import Self +from typing_extensions import Self, Dict, TypedDict, cast, Union +from genisys.config_parser import YAMLParser +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 + +DEFAULT_PORT = 15206 +CERTIFICATE_STORE_PATH = Path(os.getenv('GENISYS_CERT_STORE', '/etc/genisys/ssl')) + +class ServerOptions(TypedDict): + port: int + user: str + group: str + ssl: Dict[str, str] + +class CertChainArgs(TypedDict): + keyfile: str + certfile: str + password: Union[str, None] class GenisysHTTPRequestHandler(BaseHTTPRequestHandler): def do_POST(self: Self): print('posted') -if __name__ == "__main__": - httpd = HTTPServer(('localhost', 4443), GenisysHTTPRequestHandler) - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ssl_context.load_cert_chain( - certfile="./certs/cert.pem", - keyfile="./certs/key.pem", - password=open("./certs/passwd", "r", encoding="utf-8").readline().strip('\n') - ) - httpd.socket = ssl_context.wrap_socket(httpd.socket) +def run(config: YAMLParser): + # parse config + network = config.get_section("Network") + server_options = cast(ServerOptions, network.get("server")) + + # drop priviledges + if not drop_priviledges(server_options): + warn("Unable to drop privledges to the specified user. Continuing as current user.") + + # create a server + server_address = network.get('ip', '') + server_port = server_options.get("port", DEFAULT_PORT) + httpd = HTTPServer((server_address, server_port), GenisysHTTPRequestHandler) + + # 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 = get_keychain(server_options['ssl']) + ssl_context.load_cert_chain(**ssl_cert) + httpd.socket = ssl_context.wrap_socket(httpd.socket) + + # listen httpd.serve_forever() + +def drop_priviledges(config: ServerOptions) -> bool: + if 'user' not in config: + warn("No user specified. Continuing as current user.") + return True + + grpnam = config.get('group', config['user']) + uid = pwd.getpwnam(config['user']) + gid = grp.getgrnam(grpnam) + + try: + os.setuid(uid.pw_uid) + os.setgid(gid.gr_gid) + return True + except PermissionError: + return False + +def get_keychain(config: Dict[str, str]) -> CertChainArgs: + 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: + pwd = None + if 'password-file' in config: + with open(config['password-file'], 'r') as fd: + pwd = fd.readline().strip('\n') + return { + 'certfile': config['cert'], + 'keyfile': config['key'], + 'password': pwd + } + + # 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), 'password': None } + +def generate_keypair(cert_path: Path, key_path: Path): + # 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, u'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()) + +if __name__ == "__main__": + config = YAMLParser("./documentation/example.yml") + run(config) From 11b4f3dce800ff72d5fb53064310d45058d592bc Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 11:36:12 -0500 Subject: [PATCH 06/21] added the cryptography package for generating ssl keypairs --- poetry.lock | 132 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 132 insertions(+), 1 deletion(-) 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" From d81592be1ea953244632181e99420ffe2f5920e4 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 11:36:27 -0500 Subject: [PATCH 07/21] removed unsed daemon mode, added server mode --- genisys/main.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/genisys/main.py b/genisys/main.py index 17cf8b36..0d8dcf87 100644 --- a/genisys/main.py +++ b/genisys/main.py @@ -13,6 +13,7 @@ from genisys.modules.firstboot import Service from genisys.modules.script import Script import genisys.config_parser as cp +import genisys.server MODULES = [ OSDownload, @@ -23,7 +24,7 @@ Dnsmasq, VsftpdModule, Syslinux, - Service + Service, Script ] @@ -55,12 +56,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 @@ -72,6 +67,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(): @@ -90,12 +87,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", From 77e312208e867d45d4c1b4e134e9e5230f69e5d8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 12:07:22 -0500 Subject: [PATCH 08/21] fixed some linting errros --- genisys/server.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/genisys/server.py b/genisys/server.py index c176486d..d6fb5536 100644 --- a/genisys/server.py +++ b/genisys/server.py @@ -8,12 +8,12 @@ from warnings import warn from http.server import HTTPServer, BaseHTTPRequestHandler from typing_extensions import Self, Dict, TypedDict, cast, Union -from genisys.config_parser import YAMLParser 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 genisys.config_parser import YAMLParser DEFAULT_PORT = 15206 CERTIFICATE_STORE_PATH = Path(os.getenv('GENISYS_CERT_STORE', '/etc/genisys/ssl')) @@ -30,10 +30,13 @@ class CertChainArgs(TypedDict): password: Union[str, None] 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""" print('posted') 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")) @@ -59,10 +62,11 @@ def run(config: YAMLParser): httpd.serve_forever() def drop_priviledges(config: ServerOptions) -> bool: + """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 True - + grpnam = config.get('group', config['user']) uid = pwd.getpwnam(config['user']) gid = grp.getgrnam(grpnam) @@ -75,19 +79,22 @@ def drop_priviledges(config: ServerOptions) -> bool: return False 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: - pwd = None + passwd = None if 'password-file' in config: - with open(config['password-file'], 'r') as fd: - pwd = fd.readline().strip('\n') + with open(config['password-file'], 'r', encoding='utf-8') as fd: + passwd = fd.readline().strip('\n') return { 'certfile': config['cert'], 'keyfile': config['key'], - 'password': pwd + 'password': passwd } # generate or use the cert if applicable @@ -102,6 +109,7 @@ def get_keychain(config: Dict[str, str]) -> CertChainArgs: return { 'certfile': str(cert_path), 'keyfile': str(key_path), 'password': None } 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, @@ -110,7 +118,7 @@ def generate_keypair(cert_path: Path, key_path: Path): # Create a self-signed certificate subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, u'genisys.internal'), + x509.NameAttribute(NameOID.COMMON_NAME, 'genisys.internal'), ]) cert = x509.CertificateBuilder().subject_name( @@ -156,7 +164,3 @@ def generate_keypair(cert_path: Path, key_path: Path): cert_file.write(pem_cert.decode()) with key_path.open('w') as key_file: key_file.write(pem_private_key.decode()) - -if __name__ == "__main__": - config = YAMLParser("./documentation/example.yml") - run(config) From a259e599966f2edfe112c84f4792d13b94a91ab4 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 13:23:59 -0500 Subject: [PATCH 09/21] moved file for organiztional purposes --- genisys/{server.py => server/__init__.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename genisys/{server.py => server/__init__.py} (96%) diff --git a/genisys/server.py b/genisys/server/__init__.py similarity index 96% rename from genisys/server.py rename to genisys/server/__init__.py index d6fb5536..9280d9dc 100644 --- a/genisys/server.py +++ b/genisys/server/__init__.py @@ -7,7 +7,7 @@ from pathlib import Path from warnings import warn from http.server import HTTPServer, BaseHTTPRequestHandler -from typing_extensions import Self, Dict, TypedDict, cast, Union +from typing_extensions import Self, Dict, TypedDict, cast, Union, NotRequired from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import hashes @@ -27,7 +27,7 @@ class ServerOptions(TypedDict): class CertChainArgs(TypedDict): keyfile: str certfile: str - password: Union[str, None] + password: NotRequired[Union[str, None]] class GenisysHTTPRequestHandler(BaseHTTPRequestHandler): """Process client "hello"s by running ansible playbooks on a received POST""" @@ -39,7 +39,7 @@ 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")) + server_options = cast(ServerOptions, network.get("server", {}) or {}) # drop priviledges if not drop_priviledges(server_options): @@ -54,7 +54,7 @@ def run(config: YAMLParser): if 'ssl' in server_options: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 - ssl_cert = get_keychain(server_options['ssl']) + ssl_cert = get_keychain(server_options['ssl'] or {}) ssl_context.load_cert_chain(**ssl_cert) httpd.socket = ssl_context.wrap_socket(httpd.socket) @@ -106,7 +106,7 @@ def get_keychain(config: Dict[str, str]) -> CertChainArgs: CERTIFICATE_STORE_PATH.mkdir(parents=True, exist_ok=True) generate_keypair(cert_path, key_path) - return { 'certfile': str(cert_path), 'keyfile': str(key_path), 'password': None } + 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""" From 857cfe861b81f973a7b7f9014924588a5bc0e2f4 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 13:24:12 -0500 Subject: [PATCH 10/21] added ansible config example --- documentation/example.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/documentation/example.yml b/documentation/example.yml index c522557d..0704ae6d 100644 --- a/documentation/example.yml +++ b/documentation/example.yml @@ -52,9 +52,21 @@ Users: ssh-key: "" 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 + 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 From 3b7536a2e2f60ed03ed2dd67b4a4cb7fe0b26f17 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 13:30:25 -0500 Subject: [PATCH 11/21] refactored to two files --- genisys/server/__init__.py | 105 ++----------------------------------- genisys/server/tls.py | 103 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 102 deletions(-) create mode 100644 genisys/server/tls.py diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index 9280d9dc..7deb888f 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -3,20 +3,13 @@ import pwd import grp import os -from datetime import datetime, timedelta -from pathlib import Path from warnings import warn from http.server import HTTPServer, BaseHTTPRequestHandler -from typing_extensions import Self, Dict, TypedDict, cast, Union, NotRequired -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 Self, Dict, TypedDict, cast from genisys.config_parser import YAMLParser +import genisys.server.tls DEFAULT_PORT = 15206 -CERTIFICATE_STORE_PATH = Path(os.getenv('GENISYS_CERT_STORE', '/etc/genisys/ssl')) class ServerOptions(TypedDict): port: int @@ -24,11 +17,6 @@ class ServerOptions(TypedDict): group: str ssl: Dict[str, str] -class CertChainArgs(TypedDict): - keyfile: str - certfile: str - password: NotRequired[Union[str, None]] - class GenisysHTTPRequestHandler(BaseHTTPRequestHandler): """Process client "hello"s by running ansible playbooks on a received POST""" def do_POST(self: Self): @@ -54,7 +42,7 @@ def run(config: YAMLParser): if 'ssl' in server_options: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 - ssl_cert = get_keychain(server_options['ssl'] or {}) + 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) @@ -77,90 +65,3 @@ def drop_priviledges(config: ServerOptions) -> bool: return True except PermissionError: return False - -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/genisys/server/tls.py b/genisys/server/tls.py new file mode 100644 index 00000000..350af7b6 --- /dev/null +++ b/genisys/server/tls.py @@ -0,0 +1,103 @@ +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): + 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()) From 81ebd8b61fe658cbf0ff0045d3ced049c4f01f40 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 15:34:07 -0500 Subject: [PATCH 12/21] respond to resquests with always success --- genisys/server/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index 7deb888f..337806a7 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from io import TextIOWrapper import ssl import pwd import grp @@ -21,7 +22,15 @@ 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""" - print('posted') + content_length = int(self.headers['Content-Length']) + body = json.loads(self.rfile.read(content_length)) + print(body) + self.wfile.write(bytes(textwrap.dedent("""\ + HTTP/1.1 200 OK + Content-Type: text/plain + Content-Length: 7 + + Success"""), encoding='utf-8')) def run(config: YAMLParser): """Drops priviledges, creates the server (with SSL, if applicable), then waits for requests""" From d7453a3e176c66dc3ebeca4f0bc616d1793c0341 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Thu, 1 Feb 2024 15:34:24 -0500 Subject: [PATCH 13/21] install sigterm handler for server process --- genisys/server/__init__.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index 337806a7..c7843651 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -4,13 +4,18 @@ import pwd import grp import os +import sys +import json +import textwrap from warnings import warn +from signal import signal, SIGTERM from http.server import HTTPServer, BaseHTTPRequestHandler from typing_extensions import Self, Dict, TypedDict, cast from genisys.config_parser import YAMLParser import genisys.server.tls DEFAULT_PORT = 15206 +DEFAULT_INVENTORY = "/etc/ansible/hosts" class ServerOptions(TypedDict): port: int @@ -32,6 +37,10 @@ def do_POST(self: Self): Success"""), encoding='utf-8')) +class GenisysHTTPServer(HTTPServer): + """HTTP Server wrapper to allow server global variables to be used for processing requests""" + inventory_file: TextIOWrapper + def run(config: YAMLParser): """Drops priviledges, creates the server (with SSL, if applicable), then waits for requests""" # parse config @@ -45,7 +54,7 @@ def run(config: YAMLParser): # create a server server_address = network.get('ip', '') server_port = server_options.get("port", DEFAULT_PORT) - httpd = HTTPServer((server_address, server_port), GenisysHTTPRequestHandler) + httpd = GenisysHTTPServer((server_address, server_port), GenisysHTTPRequestHandler) # apply TLS if applicable if 'ssl' in server_options: @@ -55,7 +64,17 @@ def run(config: YAMLParser): ssl_context.load_cert_chain(**ssl_cert) httpd.socket = ssl_context.wrap_socket(httpd.socket) - # listen + # install additional data for the server to use + ansible_cfg = config.get_section("ansible") + inventory_path = ansible_cfg.get("inventory", DEFAULT_INVENTORY) + httpd.inventory_file = open(inventory_path, 'w', encoding='utf-8') + + # run until SIGTERM is caught + def sigterm_handler(*_): + print("killed") + httpd.inventory_file.close() + sys.exit(SIGTERM) + signal(SIGTERM, sigterm_handler) httpd.serve_forever() def drop_priviledges(config: ServerOptions) -> bool: From 218be5389a3bf11dd2f18407056bad2f3d5046e4 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 6 Feb 2024 18:05:03 -0500 Subject: [PATCH 14/21] added working-directory option to server config --- documentation/example.yml | 10 +++++++++- genisys/server/__init__.py | 33 +++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/documentation/example.yml b/documentation/example.yml index 0704ae6d..57c106cf 100644 --- a/documentation/example.yml +++ b/documentation/example.yml @@ -27,6 +27,12 @@ Network: # 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 @@ -62,7 +68,9 @@ Scripts: # 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 + # 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 diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index c7843651..a0f230f4 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -17,11 +17,6 @@ DEFAULT_PORT = 15206 DEFAULT_INVENTORY = "/etc/ansible/hosts" -class ServerOptions(TypedDict): - port: int - user: str - group: str - ssl: Dict[str, str] class GenisysHTTPRequestHandler(BaseHTTPRequestHandler): """Process client "hello"s by running ansible playbooks on a received POST""" @@ -40,6 +35,13 @@ def do_POST(self: Self): class GenisysHTTPServer(HTTPServer): """HTTP Server wrapper to allow server global variables to be used for processing requests""" inventory_file: TextIOWrapper +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""" @@ -48,9 +50,15 @@ def run(config: YAMLParser): server_options = cast(ServerOptions, network.get("server", {}) or {}) # drop priviledges - if not drop_priviledges(server_options): + 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) # create a server server_address = network.get('ip', '') server_port = server_options.get("port", DEFAULT_PORT) @@ -77,19 +85,16 @@ def sigterm_handler(*_): signal(SIGTERM, sigterm_handler) httpd.serve_forever() -def drop_priviledges(config: ServerOptions) -> bool: +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 True + return pwd.getpwuid(os.geteuid()) grpnam = config.get('group', config['user']) uid = pwd.getpwnam(config['user']) gid = grp.getgrnam(grpnam) - try: - os.setuid(uid.pw_uid) - os.setgid(gid.gr_gid) - return True - except PermissionError: - return False + os.setuid(uid.pw_uid) + os.setgid(gid.gr_gid) + return uid From 4c9a800da30221fff6719da0c10434345ed21129 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 6 Feb 2024 18:05:16 -0500 Subject: [PATCH 15/21] added ansible inventory updating to the server process --- genisys/server/__init__.py | 31 +++++------------------ genisys/server/http.py | 45 +++++++++++++++++++++++++++++++++ genisys/server/inventory.py | 50 +++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 genisys/server/http.py create mode 100644 genisys/server/inventory.py diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index a0f230f4..5ba1db0f 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -1,40 +1,20 @@ #!/usr/bin/env python3 -from io import TextIOWrapper import ssl import pwd import grp import os import sys -import json -import textwrap from warnings import warn from signal import signal, SIGTERM -from http.server import HTTPServer, BaseHTTPRequestHandler -from typing_extensions import Self, Dict, TypedDict, cast +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" - -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""" - content_length = int(self.headers['Content-Length']) - body = json.loads(self.rfile.read(content_length)) - print(body) - self.wfile.write(bytes(textwrap.dedent("""\ - HTTP/1.1 200 OK - Content-Type: text/plain - Content-Length: 7 - - Success"""), encoding='utf-8')) - -class GenisysHTTPServer(HTTPServer): - """HTTP Server wrapper to allow server global variables to be used for processing requests""" - inventory_file: TextIOWrapper ServerOptions = TypedDict("ServerOptions", { "port": int, "user": str, @@ -59,6 +39,7 @@ def run(config: YAMLParser): # change working directory workdir = server_options.get("working-directory", server_user.pw_dir) os.chdir(workdir) + # create a server server_address = network.get('ip', '') server_port = server_options.get("port", DEFAULT_PORT) @@ -75,12 +56,12 @@ def run(config: YAMLParser): # install additional data for the server to use ansible_cfg = config.get_section("ansible") inventory_path = ansible_cfg.get("inventory", DEFAULT_INVENTORY) - httpd.inventory_file = open(inventory_path, 'w', encoding='utf-8') + httpd.inventory = Inventory(inventory_path) # run until SIGTERM is caught def sigterm_handler(*_): print("killed") - httpd.inventory_file.close() + del httpd.inventory sys.exit(SIGTERM) signal(SIGTERM, sigterm_handler) httpd.serve_forever() diff --git a/genisys/server/http.py b/genisys/server/http.py new file mode 100644 index 00000000..9cd878ee --- /dev/null +++ b/genisys/server/http.py @@ -0,0 +1,45 @@ +import sys +import json +from typing_extensions import Self, cast +from http.server import HTTPServer, BaseHTTPRequestHandler +from genisys.server.inventory import Inventory + +def generate_response(status_code: int, body: str) -> bytes: + statuses = {200: "OK", 400: "Bad Request", 500: "Internal Server Error"} + response = f"HTTP/1.1 {status_code} {statuses[status_code]}\n" + response += f"Content-Length: {len(body)}\n" + response += "Content-Type: application/json\n" + response += "\n" + response += body + return bytes(response, encoding='utf-8') + +class GenisysHTTPServer(HTTPServer): + """Subclass to manage shared state between server requests""" + inventory: Inventory + +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)) + + # add the host to the inventory file + server.inventory.add_host(body['ip']) + + # send success back to the client + self.wfile.write(generate_response(200, '{"message": "success"}')) + except Exception as e: + print(f"ERROR: {e}", file=sys.stderr) + self.wfile.write( + generate_response( + 500, + '{"message": "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..cbc9b55b --- /dev/null +++ b/genisys/server/inventory.py @@ -0,0 +1,50 @@ +from typing_extensions import Self +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. Acts as a pseudo-singleton allowing only one + instance per filepath to exist. + """ + instances = {} + + def __init__(self: Self, filepath: str): + """Open a file handle and initialize the in-memory representation.""" + # check if a copy of this file is already in memory + if filepath in Inventory.instances: + this = Inventory.instances[filepath] + this['count'] += 1 + return this['instance'] + + # create a new instance + self.filename = filepath + self.fd = open(filepath, 'r+', encoding='utf-8') + self.contents = yaml.safe_load(self.fd) + Inventory.instances[filepath] = { + 'count': 1, + 'instance': self + } + + # 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.""" + this = Inventory.instances[self.filename] + this['count'] -= 1 + if this['count'] == 0: + self.fd.close() + + def add_host(self: Self, hostname: str): + """Adds the host to the inventory, updating the in-memory and on-disk file.""" + # Update the in-memory representation + self.contents['genisys']['hosts'][hostname] = None + + # Truncate the current inventory file + self.fd.seek(0) + + # Write the new invetory to disk + yaml.dump(self.contents, self.fd) + self.fd.flush() + From 4eb8aa878b3f1d5a3d9f8f98e00503d8a796b2ae Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 9 Feb 2024 13:01:35 -0500 Subject: [PATCH 16/21] added ansible command on hello receipt --- genisys/server/http.py | 32 ++++++++++++++++++++++++-------- genisys/server/inventory.py | 10 +++++++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/genisys/server/http.py b/genisys/server/http.py index 9cd878ee..9a4959a8 100644 --- a/genisys/server/http.py +++ b/genisys/server/http.py @@ -1,21 +1,27 @@ import sys import json -from typing_extensions import Self, cast +import subprocess +from typing_extensions import Self, Tuple, cast from http.server import HTTPServer, BaseHTTPRequestHandler +from genisys.config_parser import YAMLParser from genisys.server.inventory import Inventory -def generate_response(status_code: int, body: str) -> bytes: +def generate_response(status_code: int, message: str) -> bytes: 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(body)}\n" + response += f"Content-Length: {len(response_body)}\n" response += "Content-Type: application/json\n" response += "\n" - response += body + response += response_body return bytes(response, encoding='utf-8') class GenisysHTTPServer(HTTPServer): """Subclass to manage shared state between server requests""" - inventory: Inventory + 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""" @@ -29,17 +35,27 @@ def do_POST(self: Self): 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']) != None: + self.wfile.write(generate_response(400, 'Declared IP is not valid.')) + # add the host to the inventory file - server.inventory.add_host(body['ip']) + server.inventory.add_host(body['hostname'], { 'ansible_host': body['ip'] }) + + # run the playbooks + ansible_cmd = ['ansible', '--inventory', 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, '{"message": "success"}')) + self.wfile.write(generate_response(200, 'success')) except Exception as e: print(f"ERROR: {e}", file=sys.stderr) self.wfile.write( generate_response( 500, - '{"message": "Internal server error. Check logs for details."}' + 'Internal server error. Check logs for details.' ) ) diff --git a/genisys/server/inventory.py b/genisys/server/inventory.py index cbc9b55b..50cbda27 100644 --- a/genisys/server/inventory.py +++ b/genisys/server/inventory.py @@ -1,4 +1,4 @@ -from typing_extensions import Self +from typing_extensions import Self, Optional, Dict import yaml class Inventory: @@ -35,11 +35,15 @@ def __del__(self: Self): this['count'] -= 1 if this['count'] == 0: 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): + def add_host(self: Self, hostname: str, settings: Optional[Dict] = {}): """Adds the host to the inventory, updating the in-memory and on-disk file.""" # Update the in-memory representation - self.contents['genisys']['hosts'][hostname] = None + self.contents['genisys']['hosts'][hostname] = settings # Truncate the current inventory file self.fd.seek(0) From d10d5aacfad5204ac13a17b99239c6b56a522ac1 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 9 Feb 2024 13:07:44 -0500 Subject: [PATCH 17/21] forgot to update this constructor --- genisys/server/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index 5ba1db0f..a7b1eed8 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -40,10 +40,14 @@ def run(config: YAMLParser): 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), GenisysHTTPRequestHandler) + httpd = GenisysHTTPServer((server_address, server_port), Inventory(inventory_path), config) # apply TLS if applicable if 'ssl' in server_options: @@ -53,10 +57,6 @@ def run(config: YAMLParser): ssl_context.load_cert_chain(**ssl_cert) httpd.socket = ssl_context.wrap_socket(httpd.socket) - # install additional data for the server to use - ansible_cfg = config.get_section("ansible") - inventory_path = ansible_cfg.get("inventory", DEFAULT_INVENTORY) - httpd.inventory = Inventory(inventory_path) # run until SIGTERM is caught def sigterm_handler(*_): From 1bae000ff158aaf12bc97c98f1149a5ff6365af9 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 9 Feb 2024 13:11:23 -0500 Subject: [PATCH 18/21] fixed the ansible command --- genisys/server/http.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/genisys/server/http.py b/genisys/server/http.py index 9a4959a8..fabbb186 100644 --- a/genisys/server/http.py +++ b/genisys/server/http.py @@ -43,7 +43,13 @@ def do_POST(self: Self): server.inventory.add_host(body['hostname'], { 'ansible_host': body['ip'] }) # run the playbooks - ansible_cmd = ['ansible', '--inventory', body['hostname']] + 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) From 601963e0966e17dcb3c8885eb9d6c93e5b2df020 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 9 Feb 2024 13:18:02 -0500 Subject: [PATCH 19/21] fixed linter errors and removed singleton behavior from Inventory class --- genisys/server/http.py | 7 ++++--- genisys/server/inventory.py | 27 ++++++--------------------- genisys/server/tls.py | 1 + 3 files changed, 11 insertions(+), 24 deletions(-) diff --git a/genisys/server/http.py b/genisys/server/http.py index fabbb186..d3d5cbe6 100644 --- a/genisys/server/http.py +++ b/genisys/server/http.py @@ -1,12 +1,13 @@ import sys import json import subprocess -from typing_extensions import Self, Tuple, cast 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" @@ -36,7 +37,8 @@ def do_POST(self: Self): 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']) != None: + 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 @@ -64,4 +66,3 @@ def do_POST(self: Self): 'Internal server error. Check logs for details.' ) ) - diff --git a/genisys/server/inventory.py b/genisys/server/inventory.py index 50cbda27..61a07d7a 100644 --- a/genisys/server/inventory.py +++ b/genisys/server/inventory.py @@ -3,47 +3,33 @@ 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. Acts as a pseudo-singleton allowing only one - instance per filepath to exist. + 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.""" - # check if a copy of this file is already in memory - if filepath in Inventory.instances: - this = Inventory.instances[filepath] - this['count'] += 1 - return this['instance'] - # create a new instance self.filename = filepath self.fd = open(filepath, 'r+', encoding='utf-8') self.contents = yaml.safe_load(self.fd) - Inventory.instances[filepath] = { - 'count': 1, - 'instance': self - } - + # 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.""" - this = Inventory.instances[self.filename] - this['count'] -= 1 - if this['count'] == 0: - self.fd.close() - + 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] = {}): + 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 + self.contents['genisys']['hosts'][hostname] = settings or {} # Truncate the current inventory file self.fd.seek(0) @@ -51,4 +37,3 @@ def add_host(self: Self, hostname: str, settings: Optional[Dict] = {}): # 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 index 350af7b6..2859b218 100644 --- a/genisys/server/tls.py +++ b/genisys/server/tls.py @@ -11,6 +11,7 @@ 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]] From 70a80b036229bc579ce688351df951d8c5db21a7 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 9 Feb 2024 13:30:03 -0500 Subject: [PATCH 20/21] Added Initialization Process.drawio --- documentation/Initialization Process.drawio | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 documentation/Initialization Process.drawio diff --git a/documentation/Initialization Process.drawio b/documentation/Initialization Process.drawio new file mode 100644 index 00000000..44ec323a --- /dev/null +++ b/documentation/Initialization Process.drawio @@ -0,0 +1,10 @@ + + + + + + + + + + From 33ed4105c60337fc78aea142a01812061e9ac6ff Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 9 Feb 2024 13:40:05 -0500 Subject: [PATCH 21/21] Filled out diagram --- documentation/Initialization Process.drawio | 127 +++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/documentation/Initialization Process.drawio b/documentation/Initialization Process.drawio index 44ec323a..63727ce8 100644 --- a/documentation/Initialization Process.drawio +++ b/documentation/Initialization Process.drawio @@ -1,9 +1,132 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +