Add WebSocket license auto-enrollment and bootstrap .lic package support
This commit is contained in:
+172
-6
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user