diff --git a/plugins/clnrest/clnrest.py b/plugins/clnrest/clnrest.py index 5eab6310d608..5dc66cc0826d 100755 --- a/plugins/clnrest/clnrest.py +++ b/plugins/clnrest/clnrest.py @@ -60,7 +60,6 @@ def broadcast_from_message_queue(): msg = msgq.get() if msg is None: return - plugin.log(f"Emitting message: {msg}", "debug") socketio.emit("message", msg) # Wait for a second after processing all items in the queue time.sleep(1) @@ -81,8 +80,8 @@ def handle_message(message): def ws_connect(): try: plugin.log("Client Connecting...", "debug") - is_valid_rune = verify_rune(plugin, request) - + rune = request.headers.get("rune", None) + is_valid_rune = verify_rune(plugin, rune, "listclnrest-notifications", None) if "error" in is_valid_rune: # Logging as error/warn emits the event for all clients plugin.log(f"Error: {is_valid_rune}", "info") @@ -137,13 +136,14 @@ def set_application_options(plugin): else: cert_file = Path(f"{CERTS_PATH}/client.pem") key_file = Path(f"{CERTS_PATH}/client-key.pem") - if not cert_file.is_file() or not key_file.is_file(): - plugin.log(f"Certificate not found at {CERTS_PATH}. Generating a new certificate!", "debug") - generate_certs(plugin, CERTS_PATH) try: + if not cert_file.is_file() or not key_file.is_file(): + plugin.log(f"Certificate not found at {CERTS_PATH}. Generating a new certificate!", "debug") + generate_certs(plugin, REST_HOST, CERTS_PATH) plugin.log(f"Certs Path: {CERTS_PATH}", "debug") except Exception as err: raise Exception(f"{err}: Certificates do not exist at {CERTS_PATH}") + # Assigning only one worker due to added complexity between gunicorn's multiple worker process forks # and websocket connection's persistance with a single worker. options = { @@ -164,8 +164,8 @@ def __init__(self, app, options=None): from utilities.shared import REST_PROTOCOL, REST_HOST, REST_PORT self.application = app self.options = options or {} - plugin.log(f"REST server running at {REST_PROTOCOL}://{REST_HOST}:{REST_PORT}", "info") super().__init__() + plugin.log(f"REST server running at {REST_PROTOCOL}://{REST_HOST}:{REST_PORT}", "info") def load_config(self): config = {key: value for key, value in self.options.items() @@ -216,7 +216,7 @@ def on_any_notification(request, **kwargs): # A plugin which subscribes to shutdown is expected to exit itself. sys.exit(0) else: - msgq.put(str(kwargs)) + msgq.put(kwargs) try: diff --git a/plugins/clnrest/utilities/generate_certs.py b/plugins/clnrest/utilities/generate_certs.py index c433c99bdcd5..8f753850c6bf 100644 --- a/plugins/clnrest/utilities/generate_certs.py +++ b/plugins/clnrest/utilities/generate_certs.py @@ -5,98 +5,65 @@ from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import ec import datetime +from utilities.shared import validate_ip4 -def generate_ca_cert(certs_path): - # Generate CA Private Key - ca_private_key = ec.generate_private_key(ec.SECP256R1()) +def save_cert(entity_type, cert, private_key, certs_path): + """Serialize and save certificates and keys. + `entity_type` is either "ca", "client" or "server".""" + with open(os.path.join(certs_path, f"{entity_type}.pem"), "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + with open(os.path.join(certs_path, f"{entity_type}-key.pem"), "wb") as f: + f.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption())) - # Generate CA Public Key - ca_public_key = ca_private_key.public_key() - # Generate CA Certificate - ca_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, u"cln Root REST CA")]) +def create_cert_builder(subject_name, issuer_name, public_key, rest_host): + list_sans = [x509.DNSName("cln"), x509.DNSName("localhost")] + if validate_ip4(rest_host) is True: + list_sans.append(x509.IPAddress(ipaddress.IPv4Address(rest_host))) - ca_cert = ( + return ( x509.CertificateBuilder() - .subject_name(ca_subject) - .issuer_name(ca_subject) - .public_key(ca_public_key) + .subject_name(subject_name) + .issuer_name(issuer_name) + .public_key(public_key) .serial_number(x509.random_serial_number()) .not_valid_before(datetime.datetime.utcnow()) .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=10 * 365)) # Ten years validity - .add_extension(x509.SubjectAlternativeName([x509.DNSName(u"cln"), x509.DNSName(u'localhost'), x509.IPAddress(ipaddress.IPv4Address(u'127.0.0.1'))]), critical=False) - .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) - .sign(ca_private_key, hashes.SHA256()) + .add_extension(x509.SubjectAlternativeName(list_sans), critical=False) ) - # Create the certs directory if it does not exist - os.makedirs(certs_path, exist_ok=True) - - # Serialize CA certificate and write to disk - with open(os.path.join(certs_path, "ca.pem"), "wb") as f: - f.write(ca_cert.public_bytes(serialization.Encoding.PEM)) - - # Serialize and save the private key to a PEM file (CA) - with open(os.path.join(certs_path, "ca-key.pem"), "wb") as f: - f.write(ca_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) - - return ca_subject, ca_private_key - - -def generate_client_server_certs(certs_path, ca_subject, ca_private_key): - # Generate Server and Client Private Keys - server_private_key = ec.generate_private_key(ec.SECP256R1()) - client_private_key = ec.generate_private_key(ec.SECP256R1()) - # Generate Server and Client Public Keys - server_public_key = server_private_key.public_key() - client_public_key = client_private_key.public_key() - - # Generate Server and Client Certificates - for entity_type in ["server", "client"]: - public_key = server_public_key if entity_type == "server" else client_public_key +def generate_cert(entity_type, ca_subject, ca_private_key, rest_host, certs_path): + # Generate Key pair + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + # Generate Certificates + if isinstance(ca_subject, x509.Name): subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"cln rest {entity_type}")]) - + cert_builder = create_cert_builder(subject, ca_subject, public_key, rest_host) + cert = cert_builder.sign(ca_private_key, hashes.SHA256()) + else: + ca_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, u"cln Root REST CA")]) + ca_private_key, ca_public_key = private_key, public_key + cert_builder = create_cert_builder(ca_subject, ca_subject, ca_public_key, rest_host) cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(ca_subject) - .public_key(public_key) - .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=10 * 365)) # Ten years validity - .add_extension(x509.SubjectAlternativeName([x509.DNSName(u"cln"), x509.DNSName(u'localhost'), x509.IPAddress(ipaddress.IPv4Address(u'127.0.0.1'))]), critical=False) + cert_builder + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) .sign(ca_private_key, hashes.SHA256()) ) - # Serialize Server and Client certificates and write to disk - with open(os.path.join(certs_path, f"{entity_type}.pem"), "wb") as f: - f.write(cert.public_bytes(serialization.Encoding.PEM)) - - # Serialize Private Keys (Server) - with open(os.path.join(certs_path, "server-key.pem"), "wb") as f: - f.write(server_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) - - # Serialize Private Keys (Client) - with open(os.path.join(certs_path, "client-key.pem"), "wb") as f: - f.write(client_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) + os.makedirs(certs_path, exist_ok=True) + save_cert(entity_type, cert, private_key, certs_path) + return ca_subject, ca_private_key -def generate_certs(plugin, certs_path): - ca_subject, ca_private_key = generate_ca_cert(certs_path) - generate_client_server_certs(certs_path, ca_subject, ca_private_key) +def generate_certs(plugin, rest_host, certs_path): + ca_subject, ca_private_key = generate_cert("ca", None, None, rest_host, certs_path) + generate_cert("client", ca_subject, ca_private_key, rest_host, certs_path) + generate_cert("server", ca_subject, ca_private_key, rest_host, certs_path) plugin.log(f"Certificates Generated!", "debug") diff --git a/plugins/clnrest/utilities/rpc_plugin.py b/plugins/clnrest/utilities/rpc_plugin.py index 82c98d3b7d09..3266d2ff01e5 100644 --- a/plugins/clnrest/utilities/rpc_plugin.py +++ b/plugins/clnrest/utilities/rpc_plugin.py @@ -8,4 +8,4 @@ plugin.add_option(name="rest-host", default="127.0.0.1", description="REST server host", opt_type="string", deprecated=False) plugin.add_option(name="rest-port", default=None, description="REST server port to listen", opt_type="int", deprecated=False) plugin.add_option(name="rest-cors-origins", default="*", description="Cross origin resource sharing origins", opt_type="string", deprecated=False, multi=True) -plugin.add_option(name="rest-csp", default="default-src 'self'; font-src 'self'; img-src 'self' data:; frame-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';", description="Content security policy (CSP) for the server", opt_type="string", deprecated=False, multi=True) +plugin.add_option(name="rest-csp", default="default-src 'self'; font-src 'self'; img-src 'self' data:; frame-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';", description="Content security policy (CSP) for the server", opt_type="string", deprecated=False, multi=False) diff --git a/plugins/clnrest/utilities/rpc_routes.py b/plugins/clnrest/utilities/rpc_routes.py index d2d7d2d9a015..7b7d8333d18c 100644 --- a/plugins/clnrest/utilities/rpc_routes.py +++ b/plugins/clnrest/utilities/rpc_routes.py @@ -23,7 +23,7 @@ def get(self): return response except Exception as err: - plugin.log(f"Error: {err}", "error") + plugin.log(f"Error: {err}", "info") return json5.loads(str(err)), 500 @@ -37,25 +37,25 @@ class RpcMethodResource(Resource): def post(self, rpc_method): """Call any valid core lightning method (check list-methods response)""" try: - is_valid_rune = verify_rune(plugin, request) + rune = request.headers.get("rune", None) + rpc_method = request.view_args.get("rpc_method", None) + rpc_params = request.form.to_dict() if not request.is_json else request.get_json() if len(request.data) != 0 else {} - if "error" in is_valid_rune: - plugin.log(f"Error: {is_valid_rune}", "error") - raise Exception(is_valid_rune) + try: + is_valid_rune = verify_rune(plugin, rune, rpc_method, rpc_params) + if "error" in is_valid_rune: + plugin.log(f"Error: {is_valid_rune}", "error") + raise Exception(is_valid_rune) - except Exception as err: - return json5.loads(str(err)), 401 + except Exception as err: + return json5.loads(str(err)), 401 - try: - if request.is_json: - if len(request.data) != 0: - payload = request.get_json() - else: - payload = {} - else: - payload = request.form.to_dict() - return call_rpc_method(plugin, rpc_method, payload), 201 + try: + return call_rpc_method(plugin, rpc_method, rpc_params), 201 + + except Exception as err: + plugin.log(f"Error: {err}", "info") + return json5.loads(str(err)), 500 except Exception as err: - plugin.log(f"Error: {err}", "error") - return json5.loads(str(err)), 500 + return f"Unable to parse request: {err}", 500 diff --git a/plugins/clnrest/utilities/shared.py b/plugins/clnrest/utilities/shared.py index ef6aa9bde18a..eab00af3f11a 100644 --- a/plugins/clnrest/utilities/shared.py +++ b/plugins/clnrest/utilities/shared.py @@ -1,18 +1,56 @@ import json5 import re import json +import ipaddress + CERTS_PATH, REST_PROTOCOL, REST_HOST, REST_PORT, REST_CSP, REST_CORS_ORIGINS = "", "", "", "", "", [] +def validate_ip4(ip_str): + try: + # Create an IPv4 address object. + ipaddress.IPv4Address(ip_str) + return True + except ipaddress.AddressValueError: + return False + + +def validate_ip6(ip_str): + try: + # Create an IPv6 address object. + ipaddress.IPv6Address(ip_str) + return True + except ipaddress.AddressValueError: + return False + + +def validate_port(port): + try: + # Ports <= 1024 are reserved for system processes + return 1024 <= port <= 65535 + except ValueError: + return False + + def set_config(options): if 'rest-port' not in options: return "`rest-port` option is not configured" global CERTS_PATH, REST_PROTOCOL, REST_HOST, REST_PORT, REST_CSP, REST_CORS_ORIGINS - CERTS_PATH = str(options["rest-certs"]) - REST_PROTOCOL = str(options["rest-protocol"]) - REST_HOST = str(options["rest-host"]) + REST_PORT = int(options["rest-port"]) + if validate_port(REST_PORT) is False: + return f"`rest-port` {REST_PORT}, should be a valid available port between 1024 and 65535." + + REST_HOST = str(options["rest-host"]) + if REST_HOST != "localhost" and validate_ip4(REST_HOST) is False and validate_ip6(REST_HOST) is False: + return f"`rest-host` should be a valid IP." + + REST_PROTOCOL = str(options["rest-protocol"]) + if REST_PROTOCOL != "http" and REST_PROTOCOL != "https": + return f"`rest-protocol` can either be http or https." + + CERTS_PATH = str(options["rest-certs"]) REST_CSP = str(options["rest-csp"]) cors_origins = options["rest-cors-origins"] REST_CORS_ORIGINS.clear() @@ -30,13 +68,13 @@ def call_rpc_method(plugin, rpc_method, payload): else: plugin.log(f"{response}", "debug") if '"result":' in str(response).lower(): - # Use json5.loads ONLY when necessary, as it increases processing time significantly + # Use json5.loads ONLY when necessary, as it increases processing time return json.loads(response)["result"] else: return response except Exception as err: - plugin.log(f"Error: {err}", "error") + plugin.log(f"Error: {err}", "info") if "error" in str(err).lower(): match_err_obj = re.search(r'"error":\{.*?\}', str(err)) if match_err_obj is not None: @@ -48,23 +86,10 @@ def call_rpc_method(plugin, rpc_method, payload): raise Exception(err) -def verify_rune(plugin, request): - rune = request.headers.get("rune", None) - +def verify_rune(plugin, rune, rpc_method, rpc_params): if rune is None: raise Exception('{ "error": {"code": 403, "message": "Not authorized: Missing rune"} }') - if request.is_json: - if len(request.data) != 0: - rpc_params = request.get_json() - else: - rpc_params = {} - else: - rpc_params = request.form.to_dict() - - # None, if this isn't present. - rpc_method = request.view_args.get("rpc_method") - return call_rpc_method(plugin, "checkrune", {"rune": rune, "method": rpc_method,