diff --git a/.gitignore b/.gitignore index 33f669c..8bf317a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,11 @@ webserver/core/glue_generator webserver/core/openplc webserver/iec2c webserver/st_optimizer +webserver/active_program utils/libmodbus_src/src/.libs/* utils/libmodbus_src/tests/.libs/* +webserver/scripts/openplc_driver +webserver/scripts/openplc_platform # Runtime directories .venv \ No newline at end of file diff --git a/background_installer.sh b/background_installer.sh index dae1784..7e901d6 100755 --- a/background_installer.sh +++ b/background_installer.sh @@ -92,9 +92,9 @@ function install_py_deps { python3 -m venv "$VENV_DIR" "$VENV_DIR/bin/python3" -m pip install --upgrade pip if [ "$1" == "neuron" ]; then - "$VENV_DIR/bin/python3" -m pip install flask==2.2.5 werkzeug==2.2.2 flask-login==0.6.2 pyserial pymodbus==2.5.3 + "$VENV_DIR/bin/python3" -m pip install flask==2.2.5 werkzeug==2.2.2 flask-login==0.6.2 pyserial pymodbus==2.5.3 cryptography else - "$VENV_DIR/bin/python3" -m pip install flask==2.3.3 werkzeug==2.3.7 flask-login==0.6.2 pyserial pymodbus==2.5.3 + "$VENV_DIR/bin/python3" -m pip install flask==2.3.3 werkzeug==2.3.7 flask-login==0.6.2 pyserial pymodbus==2.5.3 cryptography fi python3 -m pip install pymodbus==2.5.3 } @@ -278,7 +278,7 @@ if [ "$1" == "win" ]; then #Setting up venv python3 -m venv "$VENV_DIR" "$VENV_DIR/bin/python3" get-pip3.py - "$VENV_DIR/bin/python3" -m pip install flask==2.3.3 werkzeug==2.3.7 flask-login==0.6.2 pyserial pymodbus==2.5.3 + "$VENV_DIR/bin/python3" -m pip install flask==2.3.3 werkzeug==2.3.7 flask-login==0.6.2 pyserial pymodbus==2.5.3 cryptography echo "" echo "[MATIEC COMPILER]" diff --git a/requirements.txt b/requirements.txt index 06cfa7e..84daaaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Flask==1.0.2 Flask-Login==0.4.1 pyserial==3.4 pymodbus==2.2.0 +cryptography diff --git a/webserver/credentials.py b/webserver/credentials.py new file mode 100644 index 0000000..6fac7d1 --- /dev/null +++ b/webserver/credentials.py @@ -0,0 +1,117 @@ +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend +import datetime +import ipaddress +import os + + +class CertGen(): + """ + Generates a self-signed TLS certificate and private key. + + Args: + hostname (str): The common name (CN) for the certificate. + ip_addresses (list, optional): A list of IP addresses to include in the SAN extension. + cert_file (str): The filename for the certificate (PEM format). + key_file (str): The filename for the private key (PEM format). + """ + def __init__(self, hostname, ip_addresses=None): + self.hostname = hostname + self.ip_addresses = ip_addresses + + # Certificate validity + self.now = datetime.datetime.utcnow() + # Subject and Issuer + self.subject = self.issuer = x509.Name([ + # x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), # TODO get device country + # x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"openplc"), + # x509.NameAttribute(NameOID.LOCALITY_NAME, u"openplc"), + # x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Autonomy"), + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ]) + + # Subject Alternative Names (SAN) + self.alt_names = [x509.DNSName(hostname)] + if ip_addresses: + for addr in ip_addresses: + self.alt_names.append(x509.IPAddress(ipaddress.ip_address(addr))) + + self.san_extension = x509.SubjectAlternativeName(self.alt_names) + + def generate_key(self): + # Generate our key + self.key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + def generate_self_signed_cert(self, cert_file="cert.pem", key_file="key.pem"): + print(f"Generating self-signed certificate for {self.hostname}...") + + self.generate_key() + + cert = ( + x509.CertificateBuilder() + .subject_name(self.subject) + .issuer_name(self.issuer) + .public_key(self.key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(self.now) + .not_valid_after(self.now + datetime.timedelta(days=365)) # Valid for 1 year + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension(self.san_extension, critical=False) + .sign(self.key, hashes.SHA256(), default_backend()) + ) + + # Write our certificate and key to disk + with open(cert_file, "wb+") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + with open(key_file, "wb+") as f: + f.write(self.key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption() + )) + print(f"Certificate saved to {cert_file}") + print(f"Private key saved to {key_file}") + + # TODO add a function to update the certificate on the client before expiration + def is_certificate_valid(self, cert_file): + """ + Checks if a certificate is valid (not expired and not yet valid). + + Args: + cert_file (str): The path to the certificate file (PEM format). + + Returns: + bool: True if the certificate is currently valid, False otherwise. + """ + if not os.path.exists(cert_file): + print(f"Certificate file not found: {cert_file}") + return False + + try: + with open(cert_file, "rb") as f: + cert_data = f.read() + cert = x509.load_pem_x509_certificate(cert_data, default_backend()) + + now = datetime.datetime.utcnow() + + if now < cert.not_valid_before_utc: + print(f"Certificate is not yet valid. Valid from: {cert.not_valid_before}") + return False + if now > cert.not_valid_after_utc: + print(f"Certificate has expired. Expired on: {cert.not_valid_after}") + return False + + print(f"Certificate is valid. Expires on: {cert.not_valid_after_utc}") + return True + + except Exception as e: + print(f"Error loading or parsing certificate: {e}") + return False diff --git a/webserver/restapi.py b/webserver/restapi.py index ac40755..c91a216 100644 --- a/webserver/restapi.py +++ b/webserver/restapi.py @@ -89,7 +89,7 @@ def restapi_plc_get(command): result = _handler_callback_get(command, data) return jsonify(result), 200 except Exception as e: - print(f"Error in restapi_start_plc: {e}") + print(f"Error in restapi_plc_get: {e}") return jsonify({"error": str(e)}), 500 @restapi_bp.route("/", methods=["POST"]) @@ -105,5 +105,5 @@ def restapi_plc_post(command): result = _handler_callback_post(command, data) return jsonify(result), 200 except Exception as e: - print(f"Error in restapi_start_plc_post: {e}") + print(f"Error in restapi_plc_post: {e}") return jsonify({"error": str(e)}), 500 diff --git a/webserver/st_files/Dockerfile b/webserver/st_files/Dockerfile deleted file mode 100644 index a6d8cb4..0000000 --- a/webserver/st_files/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM debian:bullseye-20240722 - -COPY . /workdir - -WORKDIR /workdir - -RUN mkdir /docker_persistent - -VOLUME /docker_persistent - -# setup docker -RUN ./install.sh docker \ - && touch /docker_persistent/mbconfig.cfg \ - && touch /docker_persistent/persistent.file \ - && mkdir /docker_persistent/st_files \ - && cp /workdir/webserver/openplc.db /docker_persistent/openplc.db \ - && mv /workdir/webserver/openplc.db /workdir/webserver/openplc_default.db \ - && cp /workdir/webserver/dnp3.cfg /docker_persistent/dnp3.cfg \ - && mv /workdir/webserver/dnp3.cfg /workdir/webserver/dnp3_default.cfg \ - && cp -r /workdir/webserver/st_files/ /docker_persistent/st_files/ \ - && mv /workdir/webserver/st_files /workdir/webserver/st_files_default \ - && cp /workdir/webserver/active_program /docker_persistent/active_program \ - && mv /workdir/webserver/active_program /workdir/webserver/active_program_default \ - && ln -s /docker_persistent/mbconfig.cfg /workdir/webserver/mbconfig.cfg \ - && ln -s /docker_persistent/persistent.file /workdir/webserver/persistent.file \ - && ln -s /docker_persistent/openplc.db /workdir/webserver/openplc.db \ - && ln -s /docker_persistent/dnp3.cfg /workdir/webserver/dnp3.cfg \ - && ln -s /docker_persistent/st_files /workdir/webserver/st_files \ - && ln -s /docker_persistent/active_program /workdir/webserver/active_program - -ENTRYPOINT ["./start_openplc.sh"] diff --git a/webserver/webserver.py b/webserver/webserver.py index 4c6689d..2242b43 100644 --- a/webserver/webserver.py +++ b/webserver/webserver.py @@ -14,8 +14,10 @@ import sys import ctypes import socket import mimetypes +import ssl +import threading -import flask +import flask import flask_login from credentials import CertGen @@ -931,7 +933,7 @@ def upload_program(): if (prog_file.filename == ''): return draw_blank_page() + "

Error

You need to select a file to be uploaded!

Use the back-arrow on your browser to return

" - # TODO colocar em outra funçao + # TODO realocate to another function filename = str(random.randint(1,1000000)) + ".st" prog_file.save(os.path.join('st_files', filename)) @@ -2579,9 +2581,41 @@ def run_https(): register_callback_get(restapi_callback_get) register_callback_post(restapi_callback_post) + try: + # CertGen class is used to generate SSL certificates and verify their validity + cert_gen = CertGen(hostname=HOSTNAME, ip_addresses=["127.0.0.1"]) + # Generate certificate if it doesn't exist + if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE): + cert_gen.generate_self_signed_cert(cert_file=CERT_FILE, key_file=KEY_FILE) + # Verify expiration date + elif cert_gen.is_certificate_valid(CERT_FILE): + print(cert_gen.generate_self_signed_cert(cert_file=CERT_FILE, key_file=KEY_FILE)) + # Credentials already created + else: + print("Credentials already generated!") + + try: + context = (CERT_FILE, KEY_FILE) + app_restapi.run(debug=False, host='0.0.0.0', threaded=True, port=8443, ssl_context=context) + except KeyboardInterrupt as e: + print(f"Exiting OpenPLC Webserver...{e}") + openplc_runtime.stop_runtime() + except Exception as e: + print(f"An error occurred: {e}") + openplc_runtime.stop_runtime() + except: + print("An unexpected error occurred.") + + # TODO handle file error + except FileNotFoundError as e: + print(f"Could not find SSL credentials! {e}") + except ssl.SSLError as e: + print(f"SSL credentials FAIL! {e}") + +def run_http(): #Load information about current program on the openplc_runtime object - file = open("active_program", "r") - st_file = file.read() + with open("active_program", "r") as file: + st_file = file.read() st_file = st_file.replace('\r','').replace('\n','') database = "openplc.db" @@ -2611,10 +2645,25 @@ def run_https(): time.sleep(1) configure_runtime() monitor.parse_st(openplc_runtime.project_file) - - app.run(debug=False, host='0.0.0.0', threaded=True, port=8080) + + try: + app.run(debug=False, host='0.0.0.0', threaded=True, port=8080) + except KeyboardInterrupt as e: + print(f"Exiting OpenPLC Webserver...{e}") + openplc_runtime.stop_runtime() + except Exception as e: + print(f"An error occurred: {e}") + openplc_runtime.stop_runtime() + except: + print("An unexpected error occurred.") except Error as e: print("error connecting to the database" + str(e)) else: print("error connecting to the database") + + +if __name__ == '__main__': + # Running webserver and RestAPI in separate threads + threading.Thread(target=run_http).start() + threading.Thread(target=run_https).start()