feat(server): support activated .lic import, mTLS sync for seat expansion, and WebUI controls
This commit is contained in:
+100
-64
@@ -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')">×</div>
|
<div class="modal-close" onclick="closeModal('csrModal')">×</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')">×</div>
|
<div class="modal-close" onclick="closeModal('importModal')">×</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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user