feat(server): support activated .lic import, mTLS sync for seat expansion, and WebUI controls

This commit is contained in:
2026-09-24 17:36:33 +02:00
parent 6332dff670
commit b1327a4336
+100 -64
View File
@@ -866,7 +866,7 @@ def get_ca_csr():
@app.post("/api/license/import") @app.post("/api/license/import")
async def import_signed_license(req: Request): async def import_signed_license(req: Request):
""" """
Imports and activates a signed CA certificate issued by logar.eibl.tech. Imports and activates a signed CA certificate or .lic file issued by logar.eibl.tech.
Extracts the seat quota from enterprise X.509 OIDs, stores in SQLite, Extracts the seat quota from enterprise X.509 OIDs, stores in SQLite,
regenerates Server TLS certificate, and dynamically reloads in-flight mTLS context. regenerates Server TLS certificate, and dynamically reloads in-flight mTLS context.
""" """
@@ -875,36 +875,25 @@ async def import_signed_license(req: Request):
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") raise HTTPException(status_code=400, detail="Invalid JSON body")
ca_cert_pem = body.get("ca_cert") raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw")
if not ca_cert_pem and "lic" in body and isinstance(body["lic"], dict): if not raw_input:
ca_cert_pem = body["lic"].get("ca_cert") raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request")
if not ca_cert_pem:
raw_text = body.get("raw", "")
if "-----BEGIN CERTIFICATE-----" in raw_text:
ca_cert_pem = raw_text
if not ca_cert_pem: if isinstance(raw_input, dict):
raise HTTPException(status_code=400, detail="Missing 'ca_cert' PEM certificate in request") raw_text = json.dumps(raw_input)
else:
info = enrollment.extract_license_info_from_cert(ca_cert_pem) raw_text = str(raw_input)
if not info.get("valid"):
raise HTTPException(status_code=400, detail=f"Invalid CA certificate: {info.get('error')}")
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
ca_path = os.path.join(cert_dir, "ca.crt")
# Backup existing CA if present try:
if os.path.exists(ca_path): res = enrollment.import_license_package(raw_text, cert_dir=cert_dir)
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") except ValueError as ve:
try: raise HTTPException(status_code=400, detail=str(ve))
os.replace(ca_path, f"{ca_path}.{ts}.bak")
except Exception:
pass
with open(ca_path, "w", encoding="utf-8") as f:
f.write(ca_cert_pem)
info = res["license_info"]
ca_cert_pem = res["ca_cert_pem"]
new_seats = info.get("seats") or config.get("max_seats", 10) new_seats = info.get("seats") or config.get("max_seats", 10)
config["max_seats"] = new_seats config["max_seats"] = new_seats
db_path = config.get("db_path", DEFAULT_DB_FILE) db_path = config.get("db_path", DEFAULT_DB_FILE)
@@ -946,14 +935,30 @@ async def import_signed_license(req: Request):
@app.post("/api/license/renew") @app.post("/api/license/renew")
def trigger_license_renewal(): @app.post("/api/license/sync")
"""Explicitly triggers automated mTLS renewal with logar.eibl.tech.""" async def trigger_license_renewal(req: Optional[Request] = None):
"""
Explicitly triggers automated mTLS sync and renewal with logar.eibl.tech.
Supports picking up seat quota increases and certificate extensions in-flight.
"""
requested_seats = None
seat_increment = 0
if req:
try:
body = await req.json()
requested_seats = body.get("requested_seats")
seat_increment = int(body.get("seat_increment", 0))
except Exception:
pass
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
lic_server = config.get("license_server_url", "https://logar.eibl.tech") lic_server = config.get("license_server_url", "https://logar.eibl.tech")
success, msg, new_pem = enrollment.renew_ca_cert_mtls( success, msg, new_pem = enrollment.renew_ca_cert_mtls(
cert_dir=cert_dir, cert_dir=cert_dir,
license_server_url=lic_server license_server_url=lic_server,
requested_seats=requested_seats,
seat_increment=seat_increment
) )
if not success or not new_pem: if not success or not new_pem:
raise HTTPException(status_code=400, detail=msg) raise HTTPException(status_code=400, detail=msg)
@@ -980,7 +985,7 @@ def trigger_license_renewal():
ssl_ctx = SERVER_STATE.get("ssl_ctx") ssl_ctx = SERVER_STATE.get("ssl_ctx")
if ssl_ctx: if ssl_ctx:
reload_mtls_context(ssl_ctx, cert_dir=cert_dir) reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
print("[+] In-flight mTLS SSLContext successfully reloaded after manual renewal.") print(f"[+] In-flight mTLS SSLContext successfully reloaded after renewal/sync (Seats: {info.get('seats')}).")
return { return {
"status": "success", "status": "success",
@@ -1343,10 +1348,10 @@ def serve_webui():
<span>📄</span> View / Copy CA CSR <span>📄</span> View / Copy CA CSR
</button> </button>
<button class="btn btn-secondary" onclick="openImportModal()"> <button class="btn btn-secondary" onclick="openImportModal()">
<span>🔑</span> Import Signed CA Certificate <span>🔑</span> Import License (.lic / ca.crt)
</button> </button>
<button class="btn btn-success" onclick="triggerMtlsRenewal()"> <button class="btn btn-success" onclick="triggerMtlsRenewal()">
<span>🔄</span> Renew via mTLS Now <span>🔄</span> Sync & Renew via mTLS
</button> </button>
</div> </div>
@@ -1388,7 +1393,7 @@ def serve_webui():
<th>Last Seen</th> <th>Last Seen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="anomaliesTableBody">
__ANOMALIES_HTML__ __ANOMALIES_HTML__
</tbody> </tbody>
</table> </table>
@@ -1403,7 +1408,7 @@ def serve_webui():
<div class="modal-close" onclick="closeModal('csrModal')">&times;</div> <div class="modal-close" onclick="closeModal('csrModal')">&times;</div>
</div> </div>
<p style="font-size: 0.85rem; color: var(--text-muted);"> <p style="font-size: 0.85rem; color: var(--text-muted);">
Submit this CSR or the exported <code>.lic</code> file to <strong>__ISSUER_URL__</strong> to receive a CA certificate embedding your seat quota. Submit this CSR or the exported <code>.lic</code> file to <strong>__ISSUER_URL__</strong> to receive an activated <code>.lic</code> package embedding your seat quota.
</p> </p>
<textarea id="csrContent" rows="10" readonly></textarea> <textarea id="csrContent" rows="10" readonly></textarea>
<div style="margin-top: 1rem; display: flex; justify-content: flex-end; gap: 8px;"> <div style="margin-top: 1rem; display: flex; justify-content: flex-end; gap: 8px;">
@@ -1417,13 +1422,13 @@ def serve_webui():
<div id="importModal" class="modal-overlay"> <div id="importModal" class="modal-overlay">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title">Import & Activate Signed CA Certificate</div> <div class="modal-title">Import & Activate Signed License (.lic / ca.crt)</div>
<div class="modal-close" onclick="closeModal('importModal')">&times;</div> <div class="modal-close" onclick="closeModal('importModal')">&times;</div>
</div> </div>
<p style="font-size: 0.85rem; color: var(--text-muted);"> <p style="font-size: 0.85rem; color: var(--text-muted);">
Paste the signed <code>ca.crt</code> PEM certificate or activation JSON issued by <strong>__ISSUER_URL__</strong>. Paste the activated <code>.lic</code> JSON package or <code>ca.crt</code> PEM certificate issued by <strong>__ISSUER_URL__</strong>.
</p> </p>
<textarea id="importContent" rows="8" placeholder="-----BEGIN CERTIFICATE----- ..."></textarea> <textarea id="importContent" rows="8" placeholder="Paste .lic JSON package or -----BEGIN CERTIFICATE----- ..."></textarea>
<div style="margin-top: 1rem; display: flex; justify-content: flex-end; gap: 8px;"> <div style="margin-top: 1rem; display: flex; justify-content: flex-end; gap: 8px;">
<button class="btn btn-secondary" onclick="closeModal('importModal')">Cancel</button> <button class="btn btn-secondary" onclick="closeModal('importModal')">Cancel</button>
<button class="btn btn-success" onclick="submitImport()">Activate License</button> <button class="btn btn-success" onclick="submitImport()">Activate License</button>
@@ -1467,7 +1472,7 @@ def serve_webui():
async function submitImport() { async function submitImport() {
const text = document.getElementById('importContent').value.trim(); const text = document.getElementById('importContent').value.trim();
if (!text) return alert('Please paste certificate content.'); if (!text) return alert('Please paste certificate or .lic content.');
closeModal('importModal'); closeModal('importModal');
const resp = await fetch('/api/license/import', { const resp = await fetch('/api/license/import', {
@@ -1485,15 +1490,15 @@ def serve_webui():
} }
async function triggerMtlsRenewal() { async function triggerMtlsRenewal() {
if (!confirm('Initiate immediate automated mTLS renewal with logar.eibl.tech?')) return; if (!confirm('Sync with license authority logar.eibl.tech to renew certificate and check for seat quota updates?')) return;
showAlert('Connecting to license authority via mTLS...'); showAlert('Connecting to license authority via mTLS...');
const resp = await fetch('/api/license/renew', { method: 'POST' }); const resp = await fetch('/api/license/sync', { method: 'POST' });
const data = await resp.json(); const data = await resp.json();
if (resp.ok) { if (resp.ok) {
showAlert('Renewal Successful! ' + data.message); showAlert('Sync Successful! ' + data.message + ' (Active Seats: ' + data.seats + ')');
setTimeout(() => location.reload(), 1200); setTimeout(() => location.reload(), 1200);
} else { } else {
showAlert('Renewal Failed: ' + (data.detail || 'Authority rejected renewal'), false); showAlert('Sync Failed: ' + (data.detail || 'Authority rejected sync'), false);
} }
} }
@@ -1615,6 +1620,8 @@ def main():
parser.add_argument("--server-port", type=int, default=None, help="TCP port to embed in client config") parser.add_argument("--server-port", type=int, default=None, help="TCP port to embed in client config")
parser.add_argument("--export-lic", type=str, help="Export a .lic license request file containing CA CSR and coordinates") parser.add_argument("--export-lic", type=str, help="Export a .lic license request file containing CA CSR and coordinates")
parser.add_argument("--import-ca", type=str, help="Import and activate a signed CA certificate or activation .lic file") parser.add_argument("--import-ca", type=str, help="Import and activate a signed CA certificate or activation .lic file")
parser.add_argument("--import-lic", type=str, help="Import and activate an activated .lic license file")
parser.add_argument("--sync-license", action="store_true", help="Sync license with master authority and check for seat increases via mTLS")
args = parser.parse_args() args = parser.parse_args()
config = load_or_init_config(args.config) config = load_or_init_config(args.config)
@@ -1634,42 +1641,71 @@ def main():
print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}") print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}")
sys.exit(0) sys.exit(0)
# Handle standalone CLI CA import # Handle standalone CLI CA / .lic import
if args.import_ca: lic_file = args.import_lic or args.import_ca
with open(args.import_ca, "r", encoding="utf-8") as f: if lic_file:
content = f.read() try:
ca_pem = content res = enrollment.import_license_package(lic_file, cert_dir=cert_dir)
if content.strip().startswith("{"): except Exception as e:
data = json.loads(content) print(f"[-] Invalid license file {lic_file}: {e}")
ca_pem = data.get("ca_cert") or data.get("cert_pem") or content
info = enrollment.extract_license_info_from_cert(ca_pem)
if not info.get("valid"):
print(f"[-] Invalid certificate in {args.import_ca}: {info.get('error')}")
sys.exit(1) sys.exit(1)
ca_path = os.path.join(cert_dir, "ca.crt") info = res["license_info"]
if os.path.exists(ca_path):
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
try:
os.replace(ca_path, f"{ca_path}.{ts}.bak")
except Exception:
pass
with open(ca_path, "w", encoding="utf-8") as f:
f.write(ca_pem)
new_seats = info.get("seats") or config.get("max_seats", 10) new_seats = info.get("seats") or config.get("max_seats", 10)
config["max_seats"] = new_seats config["max_seats"] = new_seats
conn = sqlite3.connect(config["db_path"]) conn = sqlite3.connect(config["db_path"])
conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id"))) conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id")))
conn.commit() conn.commit()
conn.close() conn.close()
print(f"[+] Successfully imported and activated signed CA certificate!")
# Regenerate server certificate signed by new CA
ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
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
)
print(f"[+] Successfully imported and activated signed license!")
print(f" - Granted Seats: {new_seats}") print(f" - Granted Seats: {new_seats}")
print(f" - License ID: {info.get('license_id')}") print(f" - License ID: {info.get('license_id')}")
print(f" - Expires: {info.get('not_valid_after')}") print(f" - Expires: {info.get('not_valid_after')}")
sys.exit(0) sys.exit(0)
# Handle standalone CLI license sync / update against master server
if args.sync_license:
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
print(f"[*] Connecting to master authority at {lic_server} via mTLS to sync license...")
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
cert_dir=cert_dir,
license_server_url=lic_server
)
if not success or not new_pem:
print(f"[-] License sync failed: {msg}")
sys.exit(1)
info = enrollment.extract_license_info_from_cert(new_pem)
new_seats = info.get("seats") or config.get("max_seats", 10)
config["max_seats"] = new_seats
conn = sqlite3.connect(config["db_path"])
conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id")))
conn.commit()
conn.close()
ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
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
)
print(f"[+] License successfully synchronized with master server!")
print(f" - Active Seat Quota: {new_seats}")
print(f" - License ID: {info.get('license_id')}")
print(f" - Expires: {info.get('not_valid_after')}")
sys.exit(0)
# Initialize dynamic PKI (Subordinate CA and Server TLS Certificate) # Initialize dynamic PKI (Subordinate CA and Server TLS Certificate)
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir) ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)