From 406befbab928397abaa8fe2c210dcbc731856ff0 Mon Sep 17 00:00:00 2001 From: jasonpcarroll <23126711+jasonpcarroll@users.noreply.github.com> Date: Tue, 21 Mar 2023 10:04:35 -0700 Subject: [PATCH] SSL credential creator, localhost MQTT broker, and localhost HTTP server actions, and Executable monitor actions (#59) Co-authored-by: Jason Carroll --- executable-monitor/action.yml | 27 ++++ executable-monitor/executable-monitor.py | 145 ++++++++++++++++++ executable-monitor/requirements.txt | 2 + localhost-http-1.1-server/action.yml | 23 +++ .../localhost_http_1.1_server.py | 109 +++++++++++++ localhost-http-1.1-server/requirements.txt | 1 + localhost-mqtt-broker/action.yml | 23 +++ .../localhost_mqtt_broker.py | 72 +++++++++ localhost-mqtt-broker/requirements.txt | 2 + ssl-credential-creator/action.yml | 40 +++++ ssl-credential-creator/requirements.txt | 1 + .../ssl_credential_creator.py | 74 +++++++++ 12 files changed, 519 insertions(+) create mode 100644 executable-monitor/action.yml create mode 100644 executable-monitor/executable-monitor.py create mode 100644 executable-monitor/requirements.txt create mode 100644 localhost-http-1.1-server/action.yml create mode 100644 localhost-http-1.1-server/localhost_http_1.1_server.py create mode 100644 localhost-http-1.1-server/requirements.txt create mode 100644 localhost-mqtt-broker/action.yml create mode 100644 localhost-mqtt-broker/localhost_mqtt_broker.py create mode 100644 localhost-mqtt-broker/requirements.txt create mode 100644 ssl-credential-creator/action.yml create mode 100644 ssl-credential-creator/requirements.txt create mode 100644 ssl-credential-creator/ssl_credential_creator.py diff --git a/executable-monitor/action.yml b/executable-monitor/action.yml new file mode 100644 index 00000000..d8d1eca1 --- /dev/null +++ b/executable-monitor/action.yml @@ -0,0 +1,27 @@ +name: 'executable-monitor' +description: 'Runs and executable until a termination line is hit or a timeout occurs. Reports if the executable completed successfully or failed.' +inputs: + exe-path: + description: 'Path to the executable to run.' + required: true + log-dir: + description: 'Path to directory to store logs.' + required: true + success-line: + description: 'Line of output from executable indicating success.' + required: false + default: "Demo completed successfully." + timeout-seconds: + description: 'Maximum amount of time to run the executable. Default is 600.' + required: false + default: 600 + +runs: + using: "composite" + steps: + - name: Install dependencies + run: pip install -r $GITHUB_ACTION_PATH/requirements.txt + shell: bash + - name: Run executable with monitoring script + run: python3 $GITHUB_ACTION_PATH/executable-monitor.py --exe-path=${{ inputs.exe-path }} --timeout-seconds=${{ inputs.timeout-seconds }} --success-line="${{ inputs.success-line }}" --log-dir=${{ inputs.log-dir }} + shell: bash diff --git a/executable-monitor/executable-monitor.py b/executable-monitor/executable-monitor.py new file mode 100644 index 00000000..105ec073 --- /dev/null +++ b/executable-monitor/executable-monitor.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +import os, sys +from argparse import ArgumentParser +import subprocess +import time +import logging + + +if __name__ == '__main__': + + # Set up logging + logging.getLogger().setLevel(logging.NOTSET) + + # Add stdout handler to logging + stdout_logging_handler = logging.StreamHandler(sys.stdout) + stdout_logging_handler.setLevel(logging.DEBUG) + stdout_logging_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + stdout_logging_handler.setFormatter(stdout_logging_formatter) + logging.getLogger().addHandler(stdout_logging_handler) + + # Parse arguments + parser = ArgumentParser(description='Executable monitor.') + parser.add_argument('--exe-path', + type=str, + required=True, + help='Path to the executable.') + parser.add_argument('--log-dir', + type=str, + required=True, + help='Path to directory to store logs in.') + parser.add_argument('--timeout-seconds', + type=int, + required=True, + help='Timeout for each executable run.') + parser.add_argument('--success-line', + type=str, + required=False, + help='Line that indicates executable completed successfully. Required if --success-exit-status is not used.') + parser.add_argument('--success-exit-status', + type=int, + required=False, + help='Exit status that indicates that the executable completed successfully. Required if --success-line is not used.') + + args = parser.parse_args() + + if args.success_exit_status is None and args.success_line is None: + logging.error("Must specify at least one of the following: --success-line, --success-exit-status.") + sys.exit(1) + + if not os.path.exists(args.exe_path): + logging.error(f'Input executable path \"{args.exe_path}\" does not exist.') + sys.exit(1) + + # Create log directory if it does not exist. + if not os.path.exists(args.log_dir): + os.makedirs(args.log_dir, exist_ok = True) + + # Convert any relative path (like './') in passed argument to absolute path. + exe_abs_path = os.path.abspath(args.exe_path) + log_dir = os.path.abspath(args.log_dir) + + # Add file handler to output logging to a log file + exe_name = os.path.basename(exe_abs_path) + log_file_path = f'{log_dir}/{exe_name}_output.txt' + file_logging_handler = logging.FileHandler(log_file_path) + file_logging_handler.setLevel(logging.DEBUG) + file_logging_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + file_logging_handler.setFormatter(file_logging_formatter) + logging.getLogger().addHandler(file_logging_handler) + + logging.info(f"Running executable: {exe_abs_path} ") + logging.info(f"Storing logs in: {log_dir}") + logging.info(f"Timeout (seconds): {args.timeout_seconds}") + + exe = subprocess.Popen([exe_abs_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) + + cur_time_seconds = time.time() + timeout_time_seconds = cur_time_seconds + args.timeout_seconds + timeout_occurred = False + + exe_exit_status = None + exe_exitted = False + + success_line_found = False + cur_line_ouput = 1 + + wait_for_exit = args.success_exit_status is not None + + logging.info("START OF DEVICE OUTPUT\n") + + while not (timeout_occurred or exe_exitted or (not wait_for_exit and success_line_found)): + + # Read executable's stdout and write to stdout and logfile + exe_stdout_line = exe.stdout.readline() + logging.info(exe_stdout_line) + + # Check if the executable printed out it's success line + if args.success_line is not None and args.success_line in exe_stdout_line: + success_line_found = True + + # Check if executable exitted + exe_exit_status = exe.poll() + if exe_exit_status is not None: + exe_exitted = True + + # Check for timeout + cur_time_seconds = time.time() + if cur_time_seconds >= timeout_time_seconds: + timeout_occurred = True + + if not exe_exitted: + exe.kill() + + # Capture remaining output and check for the successful line + for exe_stdout_line in exe.stdout.readlines(): + logging.info(exe_stdout_line) + if args.success_line is not None and args.success_line in exe_stdout_line: + success_line_found = True + + logging.info("END OF DEVICE OUTPUT\n") + + logging.info("EXECUTABLE RUN SUMMARY:\n") + + exit_status = 0 + + if args.success_line is not None: + if success_line_found: + logging.info("Success Line: Found.\n") + else: + logging.error("Success Line: Success line not output.\n") + exit_status = 1 + + if args.success_exit_status is not None: + if exe_exitted: + if exe_exit_status != args.success_exit_status: + exit_status = 1 + logging.info(f"Exit Status: {exe_exit_status}") + else: + logging.error("Exit Status: Executable did not exit.\n") + exe_status = 1 + + + # Report if executable executed successfully to workflow + sys.exit(exit_status) diff --git a/executable-monitor/requirements.txt b/executable-monitor/requirements.txt new file mode 100644 index 00000000..f83cdd5a --- /dev/null +++ b/executable-monitor/requirements.txt @@ -0,0 +1,2 @@ +pyyaml +gitpython \ No newline at end of file diff --git a/localhost-http-1.1-server/action.yml b/localhost-http-1.1-server/action.yml new file mode 100644 index 00000000..796ef662 --- /dev/null +++ b/localhost-http-1.1-server/action.yml @@ -0,0 +1,23 @@ +name: 'Localhost HTTP Server' +description: 'Starts an HTTP 1.1 server using Python. For TLS connections (including mutual authentication), connect to localhost:4443. For plaintext connections, connect to localhost:8080.' + +inputs: + root-ca-cert-path: + description: "Root CA certificate file path." + required: True + server-cert-path: + description: "Server certificate file path." + required: True + server-priv-key-path: + description: "Server private key file path." + required: True + +runs: + using: "composite" + steps: + - name: Install dependencies + run: pip install -r $GITHUB_ACTION_PATH/requirements.txt + shell: bash + - name: Run localhost HTTP broker + run: python3 $GITHUB_ACTION_PATH/localhost_http_1.1_server.py --root-ca-cert-path=${{ inputs.root-ca-cert-path }} --server-priv-key-path=${{ inputs.server-priv-key-path }} --server-cert-path=${{ inputs.server-cert-path }} & + shell: bash diff --git a/localhost-http-1.1-server/localhost_http_1.1_server.py b/localhost-http-1.1-server/localhost_http_1.1_server.py new file mode 100644 index 00000000..3e8d2590 --- /dev/null +++ b/localhost-http-1.1-server/localhost_http_1.1_server.py @@ -0,0 +1,109 @@ +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn +from threading import Thread +import socket +import ssl +from argparse import ArgumentParser + +LOCAL_HOST_IP = socket.gethostbyname("localhost") +PLAINTEXT_PORT = 8080 +SSL_PORT = 4443 + +# Define a threaded HTTP server class +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + pass + +class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def do_GET(self): + # Receive the body of the request - don't do anything with it, + # but this needs to be done to clear the receiving buffer. + if self.headers.get('Content-Length') is not None: + recv_content_len = int(self.headers.get('Content-Length')) + recv_body = self.rfile.read(recv_content_len) + + # Always send a 200 response with "Hello" in the body. + response_body = "Hello".encode('utf-8') + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + + def do_PUT(self): + # Receive the body of the request - don't do anything with it, + # but this needs to be done to clear the receiving buffer. + if self.headers.get('Content-Length') is not None: + recv_content_len = int(self.headers.get('Content-Length')) + recv_body = self.rfile.read(recv_content_len) + + # Always send a 200 response with "Hello" in the body. + response_body = "Hello".encode('utf-8') + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + + def do_POST(self): + # Receive the body of the request - don't do anything with it, + # but this needs to be done to clear the receiving buffer. + if self.headers.get('Content-Length') is not None: + recv_content_len = int(self.headers.get('Content-Length')) + recv_body = self.rfile.read(recv_content_len) + + # Always send a 200 response with "Hello" in the body. + response_body = "Hello".encode('utf-8') + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + self.wfile.write(response_body) + + def do_HEAD(self): + # Receive the body of the request - don't do anything with it, + # but this needs to be done to clear the receiving buffer. + if self.headers.get('Content-Length') is not None: + recv_content_len = int(self.headers.get('Content-Length')) + recv_body = self.rfile.read(recv_content_len) + + # Always send a 200 response with same headers as GET but without + # response body + response_body = "Hello".encode('utf-8') + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(response_body))) + self.end_headers() + +if __name__ == '__main__': + # Parse passed in credentials + parser = ArgumentParser(description='Localhost MQTT broker.') + + parser.add_argument('--root-ca-cert-path', + type=str, + required=True, + help='Path to the root CA certificate.') + parser.add_argument('--server-cert-path', + type=str, + required=True, + help='Path to the server certificate.') + parser.add_argument('--server-priv-key-path', + type=str, + required=True, + help='Path to the private key') + args = parser.parse_args() + + # Create a plaintext HTTP server thread + plaintext_http_server = ThreadedHTTPServer((LOCAL_HOST_IP, PLAINTEXT_PORT), SimpleHTTPRequestHandler) + plaintext_http_server_thread = Thread(target=plaintext_http_server.serve_forever) + plaintext_http_server_thread.start() + + # Create an SSL HTTP serve thread + ssl_http_server = ThreadedHTTPServer((LOCAL_HOST_IP, SSL_PORT), SimpleHTTPRequestHandler) + ssl_http_server.socket = ssl.wrap_socket(ssl_http_server.socket, keyfile=args.server_priv_key_path, certfile=args.server_cert_path, ca_certs=args.root_ca_cert_path, cert_reqs=ssl.CERT_OPTIONAL) + ssl_http_server_thread = Thread(target=ssl_http_server.serve_forever) + ssl_http_server_thread.start() + + plaintext_http_server_thread.join() + ssl_http_server_thread.join() diff --git a/localhost-http-1.1-server/requirements.txt b/localhost-http-1.1-server/requirements.txt new file mode 100644 index 00000000..2b8d1875 --- /dev/null +++ b/localhost-http-1.1-server/requirements.txt @@ -0,0 +1 @@ +pyOpenSSL \ No newline at end of file diff --git a/localhost-mqtt-broker/action.yml b/localhost-mqtt-broker/action.yml new file mode 100644 index 00000000..83911452 --- /dev/null +++ b/localhost-mqtt-broker/action.yml @@ -0,0 +1,23 @@ +name: 'Localhost MQTT Broker' +description: 'Starts an MQTT Broker using Python. For TLS connections (including mutual authentication), connect to localhost:8883. For plaintext connections, connect to localhost:1883.' + +inputs: + root-ca-cert-path: + description: "Root CA certificate file path." + required: True + server-cert-path: + description: "Server certificate file path." + required: True + server-priv-key-path: + description: "Server private key file path." + required: True + +runs: + using: "composite" + steps: + - name: Install dependencies + run: pip install -r $GITHUB_ACTION_PATH/requirements.txt + shell: bash + - name: Run localhost MQTT broker + run: python3 $GITHUB_ACTION_PATH/localhost_mqtt_broker.py --root-ca-cert-path=${{ inputs.root-ca-cert-path }} --server-priv-key-path=${{ inputs.server-priv-key-path }} --server-cert-path=${{ inputs.server-cert-path }} & + shell: bash diff --git a/localhost-mqtt-broker/localhost_mqtt_broker.py b/localhost-mqtt-broker/localhost_mqtt_broker.py new file mode 100644 index 00000000..60d40850 --- /dev/null +++ b/localhost-mqtt-broker/localhost_mqtt_broker.py @@ -0,0 +1,72 @@ +import logging +import asyncio +import os +import socket +from argparse import ArgumentParser +from amqtt.broker import Broker + +logger = logging.getLogger(__name__) + +LOCAL_HOST_IP = socket.gethostbyname("localhost") + +# Parse passed in credentials +parser = ArgumentParser(description='Localhost MQTT broker.') + +parser.add_argument('--root-ca-cert-path', + type=str, + required=True, + help='Path to the root CA certificate.') +parser.add_argument('--server-cert-path', + type=str, + required=True, + help='Path to the server certificate.') +parser.add_argument('--server-priv-key-path', + type=str, + required=True, + help='Path to the private key') +args = parser.parse_args() + +# Broker configuration +config = { + "listeners": { + "default": { + "type": "tcp", + "bind": f"{LOCAL_HOST_IP}:1883", + "max-connections": 1000, + }, + "tls": { + "type": "tcp", + "bind": f"{LOCAL_HOST_IP}:8883", + "max-connections": 1000, + "ssl": "on", + "cafile": args.root_ca_cert_path, + "certfile": args.server_cert_path, + "keyfile": args.server_priv_key_path, + }, + }, + "sys_interval": 10, + "auth": { + "allow-anonymous": True, + "password-file": os.path.join( + os.path.dirname(os.path.realpath(__file__)), "passwd" + ), + "plugins": ["auth_anonymous"], + }, + "topic-check": { + "enabled": True, + "plugins": [] + }, +} + +broker = Broker(config) + +async def broker_coroutine(): + await broker.start() + +if __name__ == "__main__": + formatter = "[%(asctime)s] :: %(levelname)s :: %(name)s :: %(message)s" + logging.basicConfig(level=logging.DEBUG, format=formatter) + + # Start the MQTT broker + asyncio.get_event_loop().run_until_complete(broker_coroutine()) + asyncio.get_event_loop().run_forever() diff --git a/localhost-mqtt-broker/requirements.txt b/localhost-mqtt-broker/requirements.txt new file mode 100644 index 00000000..1e7542b7 --- /dev/null +++ b/localhost-mqtt-broker/requirements.txt @@ -0,0 +1,2 @@ +amqtt==0.10.1 +pyOpenSSL diff --git a/ssl-credential-creator/action.yml b/ssl-credential-creator/action.yml new file mode 100644 index 00000000..a95ffd03 --- /dev/null +++ b/ssl-credential-creator/action.yml @@ -0,0 +1,40 @@ +name: 'SSL Credential Creator' +description: 'Creates a Root CA private key, a Root CA certificate, a server private key, a server certificate, a device private key, and certificate. These can be used to set up servers with authentication.' + +outputs: + root-ca-priv-key-path: + description: "Root CA private key file path." + value: ${{ steps.generate-credentials.outputs.root-ca-priv-key-path }} + root-ca-cert-path: + description: "Root CA certificate file path." + value: ${{ steps.generate-credentials.outputs.root-ca-cert-path }} + server-priv-key-path: + description: "Server private key file path." + value: ${{ steps.generate-credentials.outputs.server-priv-key-path }} + server-cert-path: + description: "Server certificate file path." + value: ${{ steps.generate-credentials.outputs.server-cert-path }} + device-priv-key-path: + description: "Device private key file path." + value: ${{ steps.generate-credentials.outputs.device-priv-key-path }} + device-cert-path: + description: "Device certificate file path." + value: ${{ steps.generate-credentials.outputs.device-cert-path }} + +runs: + using: "composite" + steps: + - name: Install dependencies + run: pip install -r $GITHUB_ACTION_PATH/requirements.txt + shell: bash + - name: Generate credentials + id: generate-credentials + run: | + python3 $GITHUB_ACTION_PATH/ssl_credential_creator.py + echo "root-ca-priv-key-path=$(pwd)/root_ca_priv_key.key" >> $GITHUB_OUTPUT + echo "root-ca-cert-path=$(pwd)/root_ca_cert.crt" >> $GITHUB_OUTPUT + echo "server-priv-key-path=$(pwd)/server_priv_key.key" >> $GITHUB_OUTPUT + echo "server-cert-path=$(pwd)/server_cert.crt" >> $GITHUB_OUTPUT + echo "device-priv-key-path=$(pwd)/device_priv_key.key" >> $GITHUB_OUTPUT + echo "device-cert-path=$(pwd)/device_cert.crt" >> $GITHUB_OUTPUT + shell: bash diff --git a/ssl-credential-creator/requirements.txt b/ssl-credential-creator/requirements.txt new file mode 100644 index 00000000..8c388faf --- /dev/null +++ b/ssl-credential-creator/requirements.txt @@ -0,0 +1 @@ +pyOpenSSL diff --git a/ssl-credential-creator/ssl_credential_creator.py b/ssl-credential-creator/ssl_credential_creator.py new file mode 100644 index 00000000..ab439768 --- /dev/null +++ b/ssl-credential-creator/ssl_credential_creator.py @@ -0,0 +1,74 @@ +import random +from OpenSSL import crypto, SSL + +# File names for generated credentials +ROOT_CA_PRIV_KEY_FILE = "root_ca_priv_key.key" +ROOT_CA_CERT_FILE = "root_ca_cert.crt" +SERVER_PRIV_KEY_FILE = "server_priv_key.key" +SERVER_CERT_FILE = "server_cert.crt" +DEVICE_PRIV_KEY_FILE = "device_priv_key.key" +DEVICE_CERT_FILE = "device_cert.crt" + +# Reusable certificate subject for testing +TEST_CERT_SUBJECT = crypto.X509Name(crypto.X509().get_subject()) +TEST_CERT_SUBJECT.C = 'US' +TEST_CERT_SUBJECT.ST = 'Test_ST' +TEST_CERT_SUBJECT.L = 'Test_L' +TEST_CERT_SUBJECT.O = 'Test_O' +TEST_CERT_SUBJECT.OU = 'Test_OU' +TEST_CERT_SUBJECT.CN = 'localhost' +TEST_CERT_SUBJECT.emailAddress = 'test@test.com' + +# Common name has to be different for the Root CA according to OpenSSL documents +TEST_CERT_SUBJECT_ROOT_CA = crypto.X509Name(TEST_CERT_SUBJECT) +TEST_CERT_SUBJECT_ROOT_CA.CN = 'localhostrootca' + +def generate_priv_keys_and_certs(): + + # Root CA generation + + ca_key_pair = crypto.PKey() + ca_key_pair.generate_key(crypto.TYPE_RSA, 2048) + ca_cert = crypto.X509() + ca_cert.set_subject(TEST_CERT_SUBJECT_ROOT_CA) + ca_cert.set_serial_number(random.getrandbits(64)) + ca_cert.gmtime_adj_notBefore(0) + ca_cert.gmtime_adj_notAfter(31536000) + ca_cert.set_issuer(TEST_CERT_SUBJECT_ROOT_CA) + ca_cert.set_pubkey(ca_key_pair) + ca_cert.sign(ca_key_pair, "sha256") + open(ROOT_CA_PRIV_KEY_FILE, "w").write(crypto.dump_privatekey(crypto.FILETYPE_PEM, ca_key_pair).decode("utf-8")) + open(ROOT_CA_CERT_FILE, "w").write(crypto.dump_certificate(crypto.FILETYPE_PEM, ca_cert).decode("utf-8")) + + # Server credential generation + + server_key_pair = crypto.PKey() + server_key_pair.generate_key(crypto.TYPE_RSA, 2048) + server_cert = crypto.X509() + server_cert.set_subject(TEST_CERT_SUBJECT) + server_cert.set_serial_number(random.getrandbits(64)) + server_cert.gmtime_adj_notBefore(0) + server_cert.gmtime_adj_notAfter(31536000) + server_cert.set_issuer(ca_cert.get_subject()) + server_cert.set_pubkey(server_key_pair) + server_cert.sign(ca_key_pair, "sha256") + open(SERVER_PRIV_KEY_FILE, "w").write(crypto.dump_privatekey(crypto.FILETYPE_PEM, server_key_pair).decode("utf-8")) + open(SERVER_CERT_FILE, "w").write(crypto.dump_certificate(crypto.FILETYPE_PEM, server_cert).decode("utf-8")) + + # Device credential generation + + device_key_pair = crypto.PKey() + device_key_pair.generate_key(crypto.TYPE_RSA, 2048) + device_cert = crypto.X509() + device_cert.set_subject(TEST_CERT_SUBJECT) + device_cert.set_serial_number(random.getrandbits(64)) + device_cert.gmtime_adj_notBefore(0) + device_cert.gmtime_adj_notAfter(31536000) + device_cert.set_issuer(ca_cert.get_subject()) + device_cert.set_pubkey(device_key_pair) + device_cert.sign(ca_key_pair, "sha256") + open(DEVICE_PRIV_KEY_FILE, "w").write(crypto.dump_privatekey(crypto.FILETYPE_PEM, device_key_pair).decode("utf-8")) + open(DEVICE_CERT_FILE, "w").write(crypto.dump_certificate(crypto.FILETYPE_PEM, device_cert).decode("utf-8")) + +if __name__ == "__main__": + generate_priv_keys_and_certs()