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
+99 -63
View File
@@ -866,7 +866,7 @@ def get_ca_csr():
@app.post("/api/license/import")
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,
regenerates Server TLS certificate, and dynamically reloads in-flight mTLS context.
"""
@@ -875,36 +875,25 @@ async def import_signed_license(req: Request):
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
ca_cert_pem = body.get("ca_cert")
if not ca_cert_pem and "lic" in body and isinstance(body["lic"], dict):
ca_cert_pem = body["lic"].get("ca_cert")
if not ca_cert_pem:
raw_text = body.get("raw", "")
if "-----BEGIN CERTIFICATE-----" in raw_text:
ca_cert_pem = raw_text
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw")
if not raw_input:
raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request")
if not ca_cert_pem:
raise HTTPException(status_code=400, detail="Missing 'ca_cert' PEM certificate in request")
info = enrollment.extract_license_info_from_cert(ca_cert_pem)
if not info.get("valid"):
raise HTTPException(status_code=400, detail=f"Invalid CA certificate: {info.get('error')}")
if isinstance(raw_input, dict):
raw_text = json.dumps(raw_input)
else:
raw_text = str(raw_input)
config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs")
ca_path = os.path.join(cert_dir, "ca.crt")
# Backup existing CA if present
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_cert_pem)
res = enrollment.import_license_package(raw_text, cert_dir=cert_dir)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
info = res["license_info"]
ca_cert_pem = res["ca_cert_pem"]
new_seats = info.get("seats") or config.get("max_seats", 10)
config["max_seats"] = new_seats
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")
def trigger_license_renewal():
"""Explicitly triggers automated mTLS renewal with logar.eibl.tech."""
@app.post("/api/license/sync")
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", {})
cert_dir = config.get("cert_dir", "certs")
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
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:
raise HTTPException(status_code=400, detail=msg)
@@ -980,7 +985,7 @@ def trigger_license_renewal():
ssl_ctx = SERVER_STATE.get("ssl_ctx")
if ssl_ctx:
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 {
"status": "success",
@@ -1343,10 +1348,10 @@ def serve_webui():
<span>📄</span> View / Copy CA CSR
</button>
<button class="btn btn-secondary" onclick="openImportModal()">
<span>🔑</span> Import Signed CA Certificate
<span>🔑</span> Import License (.lic / ca.crt)
</button>
<button class="btn btn-success" onclick="triggerMtlsRenewal()">
<span>🔄</span> Renew via mTLS Now
<span>🔄</span> Sync & Renew via mTLS
</button>
</div>
@@ -1388,7 +1393,7 @@ def serve_webui():
<th>Last Seen</th>
</tr>
</thead>
<tbody>
<tbody id="anomaliesTableBody">
__ANOMALIES_HTML__
</tbody>
</table>
@@ -1403,7 +1408,7 @@ def serve_webui():
<div class="modal-close" onclick="closeModal('csrModal')">&times;</div>
</div>
<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>
<textarea id="csrContent" rows="10" readonly></textarea>
<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 class="modal">
<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>
<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>
<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;">
<button class="btn btn-secondary" onclick="closeModal('importModal')">Cancel</button>
<button class="btn btn-success" onclick="submitImport()">Activate License</button>
@@ -1467,7 +1472,7 @@ def serve_webui():
async function submitImport() {
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');
const resp = await fetch('/api/license/import', {
@@ -1485,15 +1490,15 @@ def serve_webui():
}
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...');
const resp = await fetch('/api/license/renew', { method: 'POST' });
const resp = await fetch('/api/license/sync', { method: 'POST' });
const data = await resp.json();
if (resp.ok) {
showAlert('Renewal Successful! ' + data.message);
showAlert('Sync Successful! ' + data.message + ' (Active Seats: ' + data.seats + ')');
setTimeout(() => location.reload(), 1200);
} 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("--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-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()
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)}")
sys.exit(0)
# Handle standalone CLI CA import
if args.import_ca:
with open(args.import_ca, "r", encoding="utf-8") as f:
content = f.read()
ca_pem = content
if content.strip().startswith("{"):
data = json.loads(content)
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')}")
# Handle standalone CLI CA / .lic import
lic_file = args.import_lic or args.import_ca
if lic_file:
try:
res = enrollment.import_license_package(lic_file, cert_dir=cert_dir)
except Exception as e:
print(f"[-] Invalid license file {lic_file}: {e}")
sys.exit(1)
ca_path = os.path.join(cert_dir, "ca.crt")
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)
info = res["license_info"]
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()
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" - License ID: {info.get('license_id')}")
print(f" - Expires: {info.get('not_valid_after')}")
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)
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)