feat(licensing): add WebUI license import modal, remove lic step from setup, default unlicensed to 0 seats
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m4s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m38s

This commit is contained in:
2026-09-26 20:50:57 +02:00
parent f1e5e8f986
commit 37f959e402
4 changed files with 149 additions and 119 deletions
+148 -22
View File
@@ -470,7 +470,7 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
if "enrollment_secret" not in config:
config["enrollment_secret"] = secrets.token_hex(24)
if "max_seats" not in config:
config["max_seats"] = 10
config["max_seats"] = 0
if "cert_dir" not in config:
config["cert_dir"] = "certs"
if "tls_enabled" not in config:
@@ -517,7 +517,8 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
"auth_token": auth_token,
"enrollment_secret": enrollment_secret,
"session_secret": session_sec,
"max_seats": 10,
"max_seats": 0,
"license_status": "unlicensed",
"license_server_url": "https://logar.eibl.tech",
"cert_dir": cert_dir,
"tls_enabled": True,
@@ -646,7 +647,7 @@ def format_relative_time(dt_str: Optional[str]) -> str:
return dt_str[:16] if dt_str else "N/A"
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10) -> str:
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str:
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, users, and license quota."""
def _execute_schema(target_path: str):
target_dir = os.path.dirname(os.path.abspath(target_path))
@@ -692,13 +693,13 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
conn.execute("""
CREATE TABLE IF NOT EXISTS license_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_seats INTEGER NOT NULL DEFAULT 10,
max_seats INTEGER NOT NULL DEFAULT 0,
enrollment_secret TEXT NOT NULL,
license_id TEXT,
license_server_url TEXT,
issued_at TEXT,
expires_at TEXT,
license_status TEXT DEFAULT 'active'
license_status TEXT DEFAULT 'unlicensed'
)
""")
@@ -708,7 +709,7 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
("license_server_url", "TEXT"),
("issued_at", "TEXT"),
("expires_at", "TEXT"),
("license_status", "TEXT DEFAULT 'active'")
("license_status", "TEXT DEFAULT 'unlicensed'")
]:
try:
conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}")
@@ -769,8 +770,8 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
if enrollment_secret:
conn.execute("""
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
VALUES (1, ?, ?)
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status)
VALUES (1, ?, ?, 'unlicensed')
""", (max_seats, enrollment_secret))
conn.commit()
@@ -1317,13 +1318,18 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
c = conn.cursor()
# 1. Validate enrollment secret against license_config
c.execute("SELECT enrollment_secret, max_seats FROM license_config WHERE id = 1")
c.execute("SELECT enrollment_secret, max_seats, license_status FROM license_config WHERE id = 1")
row = c.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=500, detail="License configuration not initialized")
expected_secret, max_seats = row
expected_secret, max_seats = row[0], row[1]
license_status = row[2] if len(row) > 2 and row[2] else "unlicensed"
if max_seats <= 0 or license_status != "active":
conn.close()
raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.")
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
conn.close()
record_failed_ip_attempt(client_ip, _ENROLL_FAILURES)
@@ -1647,14 +1653,18 @@ def get_license_summary() -> Dict[str, Any]:
ca_path = os.path.join(cert_dir, "ca.crt")
db_path = config.get("db_path", DEFAULT_DB_FILE)
max_seats = config.get("max_seats", 10)
max_seats = config.get("max_seats", 0)
license_status_db = "unlicensed"
try:
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
c.execute("SELECT max_seats, license_status FROM license_config WHERE id = 1")
row = c.fetchone()
if row and row[0]:
max_seats = row[0]
if row:
if row[0] is not None:
max_seats = row[0]
if len(row) > 1 and row[1]:
license_status_db = row[1]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
active_seats = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked'")
@@ -1679,13 +1689,15 @@ def get_license_summary() -> Dict[str, Any]:
else:
info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0}
license_id = info.get("license_id") or "UNLICENSED-LOCAL"
license_id = info.get("license_id") or "UNLICENSED"
issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech")
status_label = "ACTIVE"
if info.get("is_expired"):
status_label = "EXPIRED"
elif not info.get("seats") and info.get("is_self_signed"):
status_label = "SELF-SIGNED"
elif max_seats <= 0 or license_status_db != "active" or info.get("is_self_signed") or not info.get("seats"):
status_label = "UNLICENSED"
else:
status_label = "ACTIVE"
pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0
return {
@@ -1749,7 +1761,7 @@ async def import_signed_license(req: Request):
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw")
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw") or body.get("license") or body.get("license_file")
if not raw_input:
raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request")
@@ -1762,13 +1774,18 @@ async def import_signed_license(req: Request):
cert_dir = config.get("cert_dir", "certs")
try:
res = enrollment.import_license_package(raw_text, cert_dir=cert_dir)
res = enrollment.import_license_package(
raw_text,
cert_dir=cert_dir,
server_fingerprint=config.get("server_fingerprint", ""),
hostname=config.get("server_name", socket.gethostname())
)
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)
new_seats = info.get("seats") or config.get("max_seats", 0)
config["max_seats"] = new_seats
db_path = config.get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path)
@@ -2013,6 +2030,24 @@ def serve_webui(request: Request = None):
bar_color = "#10b981" if pct < 70 else ("#f59e0b" if pct < 90 else "#ef4444")
badge_status_cls = "badge-active" if summary["status"] == "ACTIVE" else ("badge-warning" if summary["status"] == "SELF-SIGNED" else "badge-revoked")
unlicensed_banner_html = ""
if summary["status"] == "UNLICENSED" or summary["max_seats"] == 0:
unlicensed_banner_html = f"""
<div style="background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.35); border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="font-size: 28px;">⚠️</div>
<div>
<div style="font-weight: 700; color: #f87171; font-size: 15px; margin-bottom: 2px;">LOGAR Server is Unlicensed (0 Active Seats)</div>
<div style="font-size: 13px; color: #cbd5e1;">Forwarder enrollment and log ingestion are restricted. Import an activated <code>.lic</code> package to enable edge client connections and seat provisioning.</div>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="downloadLicRequest()" class="btn btn-secondary" style="font-size: 12px; padding: 7px 14px;">📤 Export License Request</button>
<button onclick="openImportLicModal()" class="btn btn-accent" style="font-size: 12px; padding: 7px 14px;">📥 Import License (.lic)</button>
</div>
</div>
"""
detected_host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or config.get("tcp_host", "127.0.0.1")
if detected_host in ("0.0.0.0", "localhost", ""):
detected_host = socket.gethostname()
@@ -2232,6 +2267,8 @@ def serve_webui(request: Request = None):
<div id="statusAlert" class="alert"></div>
{unlicensed_banner_html}
<!-- Navigation Tabs -->
<div class="tabs-nav">
<button class="tab-btn active" id="tabBtn-fleet" onclick="switchTab('fleet')">💻 Fleet Forwarders ({len(clients)})</button>
@@ -2383,7 +2420,10 @@ def serve_webui(request: Request = None):
<div class="toolbar">
<div style="font-weight: 600; font-size: 13px; color: var(--text-muted);">PKI & Seat Synchronization:</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="btn btn-accent" onclick="triggerMtlsRenewal()">🔄 Sync & Renew via mTLS</button>
<button class="btn btn-accent" onclick="openImportLicModal()">📥 Import License (.lic)</button>
<button class="btn btn-secondary" onclick="downloadLicRequest()">📤 Export License Request (.lic)</button>
<button class="btn btn-outline" onclick="copyCaCsr()">📋 Copy CA CSR</button>
<button class="btn btn-secondary" onclick="triggerMtlsRenewal()">🔄 Sync & Renew via mTLS</button>
</div>
</div>
@@ -2459,6 +2499,28 @@ def serve_webui(request: Request = None):
</div>
</div>
<!-- Import License Modal -->
<div id="importLicModal" class="modal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-title">📥 Import Enterprise License (.lic)</div>
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
Select a signed <code>.lic</code> file from your computer or paste the raw license JSON / Sub-CA PEM certificate below to activate server seats.
</p>
<div class="form-group">
<label class="form-label">Upload .lic File</label>
<input type="file" id="licFileInput" class="form-control" accept=".lic,.json,.crt,.pem" onchange="handleLicFile(event)">
</div>
<div class="form-group">
<label class="form-label">Or Paste License JSON / PEM</label>
<textarea id="licRawText" class="form-control" rows="8" placeholder="Paste .lic JSON content or PEM certificate here..."></textarea>
</div>
<div class="modal-actions">
<button onclick="closeModal('importLicModal')" class="btn btn-outline">Cancel</button>
<button onclick="submitImportLic()" class="btn btn-accent">Activate License</button>
</div>
</div>
</div>
<!-- Password Modal -->
<div id="passwordModal" class="modal">
<div class="modal-content" style="max-width: 440px;">
@@ -2511,8 +2573,72 @@ def serve_webui(request: Request = None):
}}
function openExportProfileModal() {{ openModal('exportProfileModal'); }}
function openImportLicModal() {{ openModal('importLicModal'); }}
function openPasswordModal() {{ openModal('passwordModal'); }}
function downloadLicRequest() {{
window.location.href = '/api/license/export-lic';
}}
async function copyCaCsr() {{
try {{
const res = await fetch('/api/license/csr');
if (res.ok) {{
const data = await res.json();
navigator.clipboard.writeText(data.csr);
showAlert('CA CSR copied to clipboard!');
}} else {{
alert('Failed to fetch CA CSR');
}}
}} catch (err) {{
alert('Error fetching CA CSR: ' + err.message);
}}
}}
function handleLicFile(event) {{
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {{
document.getElementById('licRawText').value = e.target.result;
}};
reader.readAsText(file);
}}
async function submitImportLic() {{
const raw = document.getElementById('licRawText').value.trim();
if (!raw) return alert('Please select a .lic file or paste the license payload');
showAlert('Importing and activating license...');
try {{
let payload;
if (raw.startsWith('{{')) {{
try {{
payload = JSON.parse(raw);
}} catch (e) {{
payload = {{ "lic": raw }};
}}
}} else {{
payload = {{ "lic": raw }};
}}
const res = await fetch('/api/license/import', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(payload)
}});
const data = await res.json();
if (res.ok) {{
showAlert('License Activated! ' + (data.message || ''));
closeModal('importLicModal');
setTimeout(() => location.reload(), 1000);
}} else {{
alert('License Import Failed: ' + (data.detail || 'Validation error'));
}}
}} catch (err) {{
alert('Import request failed: ' + err.message);
}}
}}
function downloadClientProfile() {{
const host = document.getElementById('exportHost').value.trim();
const port = document.getElementById('exportPort').value.trim();