Add WebSocket license auto-enrollment and bootstrap .lic package support

This commit is contained in:
2026-09-25 16:01:06 +02:00
parent ea8e98218c
commit 0e9e3f0f64
+172 -6
View File
@@ -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