diff --git a/src/server_enrollment.py b/src/server_enrollment.py index 9d35e4f..046659f 100644 --- a/src/server_enrollment.py +++ b/src/server_enrollment.py @@ -654,16 +654,157 @@ def sign_ca_csr_with_seats( return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") +def enroll_and_sync_license_ws( + cert_dir: str = "certs", + license_id: Optional[str] = None, + license_authority_ws_url: str = "wss://logar.eibl.tech/ws/license", + server_fingerprint: str = "", + hostname: str = "", + comm_cert_pem: Optional[str] = None, + master_root_ca_pem: Optional[str] = None, + timeout: int = 15, + verify_tls: bool = True +) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """ + Connects to the Master License Authority via WebSocket protocol, authenticating and + submitting the server Sub-CA CSR to receive a cryptographically signed Sub-CA license + certificate with embedded seat quotas. + """ + os.makedirs(cert_dir, exist_ok=True) + ca_key_path = os.path.join(cert_dir, "ca.key") + ca_cert_path = os.path.join(cert_dir, "ca.crt") + + # 1. Load or generate client Sub-CA private key + if os.path.exists(ca_key_path): + with open(ca_key_path, "r", encoding="utf-8") as f: + ca_key_pem = f.read() + ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None) + else: + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + ca_key_pem = ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode("utf-8") + with open(ca_key_path, "w", encoding="utf-8") as f: + f.write(ca_key_pem) + + # 2. Build Sub-CA CSR + csr_common_name = f"LOGAR-Server-CA-{license_id or 'Node'}" + subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR Subordinate Node"), + x509.NameAttribute(NameOID.COMMON_NAME, csr_common_name), + ]) + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(subject) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .sign(ca_key, hashes.SHA256()) + ) + csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") + + # 3. Connect via WebSocket + try: + from websockets.sync.client import connect as ws_connect + except ImportError: + return False, "websockets library not available", None + + ssl_context = None + if license_authority_ws_url.startswith("wss://"): + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + if not verify_tls: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + elif master_root_ca_pem: + ssl_context.load_verify_locations(cdata=master_root_ca_pem) + else: + upstream_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_path): + ssl_context.load_verify_locations(cafile=upstream_path) + + try: + with ws_connect( + license_authority_ws_url, + ssl=ssl_context, + open_timeout=timeout, + close_timeout=timeout + ) as ws: + payload = { + "action": "enroll_or_sync", + "license_id": license_id, + "communication_cert": comm_cert_pem, + "server_csr": csr_pem, + "server_fingerprint": server_fingerprint, + "hostname": hostname + } + ws.send(json.dumps(payload)) + raw_reply = ws.recv(timeout=timeout) + reply = json.loads(raw_reply) + + if reply.get("status") != "success": + return False, f"WebSocket licensing error: {reply.get('message', raw_reply)}", None + + new_ca_pem = reply.get("ca_cert") + if not new_ca_pem: + return False, "License response missing 'ca_cert'", None + + # Backup old ca.crt if present + if os.path.exists(ca_cert_path): + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") + try: + os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak") + except Exception: + pass + + with open(ca_cert_path, "w", encoding="utf-8") as f: + f.write(new_ca_pem) + + upstream_ca = reply.get("master_root_ca") or master_root_ca_pem + if upstream_ca: + with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: + f.write(upstream_ca) + + lic_info = extract_license_info_from_cert(new_ca_pem) + return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", { + "license_id": license_id, + "seats": reply.get("seats"), + "ca_cert_pem": new_ca_pem, + "master_root_ca_pem": upstream_ca, + "license_info": lic_info + } + + except Exception as e: + return False, f"WebSocket enrollment failed: {str(e)}", None + + def import_license_package( lic_content_or_path: str, - cert_dir: str = "certs" + cert_dir: str = "certs", + server_fingerprint: str = "", + hostname: str = "" ) -> Dict[str, Any]: """ Imports and validates a license package or certificate. Supports: - 1. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca' - 2. Raw PEM Subordinate CA certificate - 3. File path pointing to either of the above + 1. Bootstrap JSON .lic package (auto-enrolls via WebSocket to receive signed CA cert) + 2. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca' + 3. Raw PEM Subordinate CA certificate + 4. File path pointing to any of the above Saves the signed CA certificate to cert_dir/ca.crt (backing up any old ca.crt), and if 'master_root_ca' is present, saves it to cert_dir/upstream_ca.crt for mTLS trust. Returns extracted license attributes and PEM contents. @@ -681,10 +822,35 @@ def import_license_package( if trimmed.startswith("{"): try: data = json.loads(trimmed) - ca_pem = data.get("ca_cert") or data.get("cert_pem") - master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") except Exception as e: raise ValueError(f"Failed to parse JSON .lic package: {e}") + + # If this is a bootstrap license package, auto-enroll over WebSocket + if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")): + lic_id = data.get("license_id") + ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license") + comm_cert = data.get("communication_cert") + master_ca = data.get("master_root_ca") + + if master_ca: + with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: + f.write(master_ca) + + ok, msg, res = enroll_and_sync_license_ws( + cert_dir=cert_dir, + license_id=lic_id, + license_authority_ws_url=ws_url, + server_fingerprint=server_fingerprint or data.get("server_fingerprint", ""), + hostname=hostname or data.get("hostname", ""), + comm_cert_pem=comm_cert, + master_root_ca_pem=master_ca + ) + if not ok or not res: + raise ValueError(msg) + return res + + ca_pem = data.get("ca_cert") or data.get("cert_pem") + master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") elif "-----BEGIN CERTIFICATE-----" in trimmed: ca_pem = trimmed