diff --git a/LOGAR-LICENSING b/LOGAR-LICENSING index 38a41cf..c5ba1d6 160000 --- a/LOGAR-LICENSING +++ b/LOGAR-LICENSING @@ -1 +1 @@ -Subproject commit 38a41cfbacdc3d46c60e6ce4c9f93e3427525d0c +Subproject commit c5ba1d65b518fbc369ecd5c5d86ba68f7da9147a diff --git a/src/hub/Server.py b/src/hub/Server.py index 1a42aef..9a62813 100644 --- a/src/hub/Server.py +++ b/src/hub/Server.py @@ -2127,21 +2127,24 @@ def import_signed_license(req: LicenseImportRequest, request: Request = None, au license_id = res.get("license_id") or lic_info.get("license_id") expires_at = res.get("expires_at") or lic_info.get("not_valid_after") issued_at = res.get("issued_at") or lic_info.get("not_valid_before") - features = res.get("features") or lic_info.get("features", []) + features = res.get("features") if res.get("features") is not None else lic_info.get("features", []) + lic_authority_url = res.get("license_authority") or lic_info.get("issuer_url") or config.get("license_server_url") if new_seats: config["max_seats"] = new_seats if license_id: config["license_id"] = license_id + if lic_authority_url: + config["license_server_url"] = lic_authority_url db_path = config.get("db_path", DEFAULT_DB_FILE) try: conn = sqlite3.connect(db_path) conn.execute(""" UPDATE license_config - SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? + SET max_seats = ?, license_id = ?, license_server_url = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? WHERE id = 1 - """, (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) + """, (new_seats or config.get("max_seats", 10), license_id or "", lic_authority_url or "", issued_at or "", expires_at or "", json.dumps(features))) conn.commit() conn.close() except Exception as e: @@ -2179,6 +2182,133 @@ def import_signed_license(req: LicenseImportRequest, request: Request = None, au } +@app.post("/api/license/sync") +def api_sync_license(auth_user: str = Depends(require_admin_auth)): + cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") + config = SERVER_STATE.get("config", {}) + db_path = config.get("db_path", DEFAULT_DB_FILE) + + ca_path = os.path.join(cert_dir, "ca.crt") + ca_info = {} + if os.path.exists(ca_path): + try: + with open(ca_path, "r", encoding="utf-8") as f: + ca_info = enrollment.extract_license_info_from_cert(f.read()) + except Exception: + pass + + lic_id = ca_info.get("license_id") or config.get("license_id") + lic_server = ca_info.get("issuer_url") or config.get("license_server_url", "wss://logar.eibl.tech/ws/license") + + if not lic_id and os.path.exists(db_path): + try: + conn = sqlite3.connect(db_path) + row = conn.execute("SELECT license_id, license_server_url FROM license_config WHERE id = 1").fetchone() + if row: + if row[0]: + lic_id = row[0] + if row[1]: + lic_server = row[1] + conn.close() + except Exception: + pass + + if not lic_id or lic_id == "UNLICENSED": + raise HTTPException(status_code=400, detail="No active license registered to sync. Please import a license package (.lic) first.") + + ws_url = lic_server if ("ws://" in lic_server or "wss://" in lic_server) else f"{lic_server.replace('https://', 'wss://').replace('http://', 'ws://').rstrip('/')}/ws/license" + + active_seats = 0 + if os.path.exists(db_path): + try: + conn = sqlite3.connect(db_path) + row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone() + if row: + active_seats = row[0] + conn.close() + except Exception: + pass + + success, msg, res = enrollment.enroll_and_sync_license_ws( + cert_dir=cert_dir, + license_id=lic_id, + license_authority_ws_url=ws_url, + server_fingerprint=config.get("server_fingerprint", ""), + hostname=config.get("server_name", ""), + seats_taken=active_seats + ) + + if not success or not res: + http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "") + success, msg, new_pem = enrollment.renew_ca_cert_mtls( + cert_dir=cert_dir, + license_server_url=http_url, + seats_taken=active_seats, + active_seats=active_seats, + hostname=config.get("server_name", ""), + server_fingerprint=config.get("server_fingerprint", "") + ) + if not success or not new_pem: + raise HTTPException(status_code=502, detail=f"License sync failed: {msg}") + + info = enrollment.extract_license_info_from_cert(new_pem) + new_seats = info.get("seats") or config.get("max_seats", 10) + lic_id = info.get("license_id") or lic_id + features = info.get("features", []) + expires_at = info.get("not_valid_after") + issued_at = info.get("not_valid_before") + else: + info = res.get("license_info", {}) + new_seats = res.get("seats") or info.get("seats") or config.get("max_seats", 10) + lic_id = res.get("license_id") or info.get("license_id") or lic_id + features = res.get("features") if res.get("features") is not None else info.get("features", []) + expires_at = res.get("expires_at") or info.get("not_valid_after") + issued_at = res.get("issued_at") or info.get("not_valid_before") + + config["max_seats"] = new_seats + config["license_id"] = lic_id + + try: + conn = sqlite3.connect(db_path) + conn.execute(""" + UPDATE license_config + SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? + WHERE id = 1 + """, (new_seats, lic_id or "", issued_at or "", expires_at or "", json.dumps(features))) + conn.commit() + conn.close() + except Exception as e: + print(f"[!] Warning: Failed to update license_config in DB: {e}") + + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) + SERVER_STATE["ca_cert"] = ca_cert + SERVER_STATE["ca_key"] = ca_key + SERVER_STATE["ca_cert_pem"] = ca_pem + + enrollment.generate_server_cert_if_needed( + ca_cert, ca_key, + hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], + cert_dir=cert_dir, + force_renew=True + ) + + ssl_ctx = SERVER_STATE.get("ssl_ctx") + if ssl_ctx is not None: + try: + reload_mtls_context(ssl_ctx, cert_dir=cert_dir) + except Exception as e: + print(f"[!] Warning: Failed to reload in-flight SSLContext: {e}") + + return { + "status": "success", + "message": f"License '{lic_id}' successfully synchronized!", + "license_id": lic_id, + "seats": new_seats, + "features": features, + "expires_at": expires_at + } + + @app.get("/api/db/stats") def api_db_stats(auth_user: str = Depends(require_admin_auth)): return { diff --git a/src/hub/server_enrollment.py b/src/hub/server_enrollment.py index 0328b84..fad8892 100644 --- a/src/hub/server_enrollment.py +++ b/src/hub/server_enrollment.py @@ -785,6 +785,15 @@ def enroll_and_sync_license_ws( ) csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") + if not comm_cert_pem: + comm_crt_path = os.path.join(cert_dir, "comm.crt") + if os.path.exists(comm_crt_path): + try: + with open(comm_crt_path, "r", encoding="utf-8") as f: + comm_cert_pem = f.read() + except Exception: + pass + # 3. Connect via WebSocket try: from websockets.sync.client import connect as ws_connect @@ -797,12 +806,18 @@ def enroll_and_sync_license_ws( 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(cadata=master_root_ca_pem) else: + if master_root_ca_pem: + try: + ssl_context.load_verify_locations(cadata=master_root_ca_pem) + except Exception: + pass 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: + ssl_context.load_verify_locations(cafile=upstream_path) + except Exception: + pass try: with ws_connect( @@ -849,7 +864,7 @@ def enroll_and_sync_license_ws( f.write(upstream_ca) lic_info = extract_license_info_from_cert(new_ca_pem) - features = reply.get("features") or lic_info.get("features", []) + features = reply.get("features") if reply.get("features") is not None else lic_info.get("features", []) return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", { "license_id": license_id, "seats": reply.get("seats") or lic_info.get("seats"), @@ -858,7 +873,8 @@ def enroll_and_sync_license_ws( "license_info": lic_info, "features": features, "expires_at": lic_info.get("not_valid_after"), - "issued_at": lic_info.get("not_valid_before") + "issued_at": lic_info.get("not_valid_before"), + "license_authority": license_authority_ws_url } except Exception as e: @@ -923,11 +939,18 @@ def import_license_package( lic_id = data.get("license_id") ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license") comm_cert = data.get("communication_cert") + comm_key = data.get("communication_key") master_ca = data.get("master_root_ca") or data.get("upstream_ca") if master_ca: with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: f.write(master_ca) + if comm_cert: + with open(os.path.join(cert_dir, "comm.crt"), "w", encoding="utf-8") as f: + f.write(comm_cert) + if comm_key: + with open(os.path.join(cert_dir, "comm.key"), "w", encoding="utf-8") as f: + f.write(comm_key) ok, msg, res = enroll_and_sync_license_ws( cert_dir=cert_dir,