fix(licensing): add /api/license/sync endpoint, support comm credentials, and wire auto_update capability toggle
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 3m52s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 3m52s
This commit is contained in:
+1
-1
Submodule LOGAR-LICENSING updated: 38a41cfbac...c5ba1d65b5
+133
-3
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user