fix(licensing): support bootstrap and activated JSON .lic imports, lock auto-update when unlicensed, and add upload confirmation warning
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 4m25s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m13s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 12m45s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 4m25s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m13s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 12m45s
This commit is contained in:
+64
-28
@@ -1503,6 +1503,14 @@ def check_client_update(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not is_feature_licensed("auto_update", db_path):
|
||||||
|
return {
|
||||||
|
"update_available": False,
|
||||||
|
"current_version": current_version or "0.0.0",
|
||||||
|
"target_version": current_version or "0.0.0",
|
||||||
|
"reason": "Fleet auto-update is not authorized by the server license."
|
||||||
|
}
|
||||||
|
|
||||||
return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch)
|
return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch)
|
||||||
|
|
||||||
|
|
||||||
@@ -1585,6 +1593,13 @@ async def upload_client_package(
|
|||||||
Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." }
|
Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." }
|
||||||
or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'.
|
or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'.
|
||||||
"""
|
"""
|
||||||
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||||
|
if not is_feature_licensed("auto_update", db_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Client package fleet distribution requires an active license with the 'auto_update' capability."
|
||||||
|
)
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import os as _os
|
import os as _os
|
||||||
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
|
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
|
||||||
@@ -1957,12 +1972,13 @@ def get_ca_csr(auth_user: str = Depends(require_admin_auth)):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/license/import")
|
@app.post("/api/license/import")
|
||||||
def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(require_admin_auth)):
|
def import_signed_license(req: LicenseImportRequest, request: Request = None, auth_user: str = Depends(require_admin_auth)):
|
||||||
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
||||||
ca_file = os.path.join(cert_dir, "ca.crt")
|
config = SERVER_STATE.get("config", {})
|
||||||
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
|
||||||
provided_upstream = req.upstream_ca or req.master_root_ca
|
provided_upstream = req.upstream_ca or req.master_root_ca
|
||||||
upstream_pem = provided_upstream.strip() if provided_upstream else None
|
upstream_pem = provided_upstream.strip() if provided_upstream else None
|
||||||
|
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
if not upstream_pem and os.path.exists(upstream_file):
|
if not upstream_pem and os.path.exists(upstream_file):
|
||||||
try:
|
try:
|
||||||
with open(upstream_file, "r", encoding="utf-8") as f:
|
with open(upstream_file, "r", encoding="utf-8") as f:
|
||||||
@@ -1970,43 +1986,61 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
|
|||||||
except Exception:
|
except Exception:
|
||||||
upstream_pem = None
|
upstream_pem = None
|
||||||
|
|
||||||
if upstream_pem:
|
if req.ca_cert and upstream_pem:
|
||||||
if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem):
|
if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem):
|
||||||
raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.")
|
raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.")
|
||||||
|
|
||||||
try:
|
raw_content = req.lic or req.ca_cert or req.data
|
||||||
info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem)
|
if not raw_content and request is not None:
|
||||||
except Exception as e:
|
try:
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}")
|
raw_content = req.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
with open(ca_file, "w", encoding="utf-8") as f:
|
if not raw_content:
|
||||||
f.write(req.ca_cert.strip() + "\n")
|
raise HTTPException(status_code=400, detail="No license data provided. Please provide a .lic package, JSON payload, or Sub-CA certificate.")
|
||||||
|
|
||||||
|
if isinstance(raw_content, dict):
|
||||||
|
raw_content = json.dumps(raw_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = enrollment.import_license_package(
|
||||||
|
str(raw_content),
|
||||||
|
cert_dir=cert_dir,
|
||||||
|
server_fingerprint=config.get("server_fingerprint", ""),
|
||||||
|
hostname=config.get("server_name", "")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"License validation or enrollment failed: {str(e)}")
|
||||||
|
|
||||||
if provided_upstream:
|
if provided_upstream:
|
||||||
with open(upstream_file, "w", encoding="utf-8") as f:
|
with open(upstream_file, "w", encoding="utf-8") as f:
|
||||||
f.write(provided_upstream.strip() + "\n")
|
f.write(provided_upstream.strip() + "\n")
|
||||||
|
|
||||||
new_seats = info.get("seats")
|
lic_info = res.get("license_info") or {}
|
||||||
license_id = info.get("license_id")
|
new_seats = res.get("seats") or lic_info.get("seats")
|
||||||
expires_at = info.get("not_valid_after")
|
license_id = res.get("license_id") or lic_info.get("license_id")
|
||||||
issued_at = info.get("not_valid_before")
|
expires_at = res.get("expires_at") or lic_info.get("not_valid_after")
|
||||||
features = info.get("features", [])
|
issued_at = res.get("issued_at") or lic_info.get("not_valid_before")
|
||||||
|
features = res.get("features") or lic_info.get("features", [])
|
||||||
|
|
||||||
config = SERVER_STATE.get("config", {})
|
|
||||||
if new_seats:
|
if new_seats:
|
||||||
config["max_seats"] = new_seats
|
config["max_seats"] = new_seats
|
||||||
if license_id:
|
if license_id:
|
||||||
config["license_id"] = license_id
|
config["license_id"] = license_id
|
||||||
|
|
||||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||||
conn = sqlite3.connect(db_path)
|
try:
|
||||||
conn.execute("""
|
conn = sqlite3.connect(db_path)
|
||||||
UPDATE license_config
|
conn.execute("""
|
||||||
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
|
UPDATE license_config
|
||||||
WHERE id = 1
|
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
|
||||||
""", (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features)))
|
WHERE id = 1
|
||||||
conn.commit()
|
""", (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features)))
|
||||||
conn.close()
|
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)
|
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_cert"] = ca_cert
|
||||||
@@ -2031,7 +2065,7 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Signed Subordinate CA certificate successfully activated!",
|
"message": f"License '{license_id}' successfully activated with {new_seats} seats!",
|
||||||
"license_id": license_id,
|
"license_id": license_id,
|
||||||
"seats": new_seats,
|
"seats": new_seats,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
@@ -2238,9 +2272,11 @@ async def update_checker_loop(interval_hours: int = 1):
|
|||||||
await asyncio.sleep(initial_delay)
|
await asyncio.sleep(initial_delay)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||||
loop = asyncio.get_running_loop()
|
if is_feature_licensed("auto_update", db_path):
|
||||||
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=True))
|
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=True))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[!] Warning in update checker background loop: {e}")
|
print(f"[!] Warning in update checker background loop: {e}")
|
||||||
await asyncio.sleep(interval_hours * 3600)
|
await asyncio.sleep(interval_hours * 3600)
|
||||||
|
|||||||
@@ -849,12 +849,16 @@ def enroll_and_sync_license_ws(
|
|||||||
f.write(upstream_ca)
|
f.write(upstream_ca)
|
||||||
|
|
||||||
lic_info = extract_license_info_from_cert(new_ca_pem)
|
lic_info = extract_license_info_from_cert(new_ca_pem)
|
||||||
|
features = reply.get("features") or lic_info.get("features", [])
|
||||||
return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", {
|
return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", {
|
||||||
"license_id": license_id,
|
"license_id": license_id,
|
||||||
"seats": reply.get("seats"),
|
"seats": reply.get("seats") or lic_info.get("seats"),
|
||||||
"ca_cert_pem": new_ca_pem,
|
"ca_cert_pem": new_ca_pem,
|
||||||
"master_root_ca_pem": upstream_ca,
|
"master_root_ca_pem": upstream_ca,
|
||||||
"license_info": lic_info
|
"license_info": lic_info,
|
||||||
|
"features": features,
|
||||||
|
"expires_at": lic_info.get("not_valid_after"),
|
||||||
|
"issued_at": lic_info.get("not_valid_before")
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -894,12 +898,32 @@ def import_license_package(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Failed to parse JSON .lic package: {e}")
|
raise ValueError(f"Failed to parse JSON .lic package: {e}")
|
||||||
|
|
||||||
|
# If data is wrapped in {"lic": "..."} or {"data": "..."}
|
||||||
|
if "lic" in data and isinstance(data["lic"], str):
|
||||||
|
nested = data["lic"].strip()
|
||||||
|
if nested.startswith("{"):
|
||||||
|
try:
|
||||||
|
data = json.loads(nested)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif "-----BEGIN CERTIFICATE-----" in nested:
|
||||||
|
ca_pem = nested
|
||||||
|
elif "data" in data and isinstance(data["data"], str):
|
||||||
|
nested = data["data"].strip()
|
||||||
|
if nested.startswith("{"):
|
||||||
|
try:
|
||||||
|
data = json.loads(nested)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif "-----BEGIN CERTIFICATE-----" in nested:
|
||||||
|
ca_pem = nested
|
||||||
|
|
||||||
# If this is a bootstrap license package, auto-enroll over WebSocket
|
# If this is a bootstrap license package, auto-enroll over WebSocket
|
||||||
if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")):
|
if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not (data.get("ca_cert") or data.get("cert_pem"))):
|
||||||
lic_id = data.get("license_id")
|
lic_id = data.get("license_id")
|
||||||
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
|
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
|
||||||
comm_cert = data.get("communication_cert")
|
comm_cert = data.get("communication_cert")
|
||||||
master_ca = data.get("master_root_ca")
|
master_ca = data.get("master_root_ca") or data.get("upstream_ca")
|
||||||
|
|
||||||
if master_ca:
|
if master_ca:
|
||||||
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
||||||
@@ -918,8 +942,10 @@ def import_license_package(
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
ca_pem = data.get("ca_cert") or data.get("cert_pem")
|
if not ca_pem:
|
||||||
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca")
|
ca_pem = data.get("ca_cert") or data.get("cert_pem") or data.get("certificate") or data.get("signed_certificate")
|
||||||
|
if not master_root_ca_pem:
|
||||||
|
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") or data.get("root_ca")
|
||||||
elif "-----BEGIN CERTIFICATE-----" in trimmed:
|
elif "-----BEGIN CERTIFICATE-----" in trimmed:
|
||||||
ca_pem = trimmed
|
ca_pem = trimmed
|
||||||
|
|
||||||
@@ -957,7 +983,8 @@ def import_license_package(
|
|||||||
"license_id": info.get("license_id"),
|
"license_id": info.get("license_id"),
|
||||||
"issuer_url": info.get("issuer_url"),
|
"issuer_url": info.get("issuer_url"),
|
||||||
"fingerprint": info.get("fingerprint"),
|
"fingerprint": info.get("fingerprint"),
|
||||||
"expires_at": info.get("not_valid_after")
|
"expires_at": info.get("not_valid_after"),
|
||||||
|
"issued_at": info.get("not_valid_before")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ LOGAR - Server Pydantic Models
|
|||||||
Provides structured request and response models for the LOGAR Server Hub API.
|
Provides structured request and response models for the LOGAR Server Hub API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Any, Dict, List, Union
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -31,9 +31,21 @@ class ClientActionRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LicenseImportRequest(BaseModel):
|
class LicenseImportRequest(BaseModel):
|
||||||
ca_cert: str
|
ca_cert: Optional[str] = None
|
||||||
upstream_ca: Optional[str] = None
|
upstream_ca: Optional[str] = None
|
||||||
master_root_ca: Optional[str] = None
|
master_root_ca: Optional[str] = None
|
||||||
|
lic: Optional[str] = None
|
||||||
|
data: Optional[str] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
license_id: Optional[str] = None
|
||||||
|
seats: Optional[int] = None
|
||||||
|
features: Optional[Any] = None
|
||||||
|
communication_cert: Optional[str] = None
|
||||||
|
communication_key: Optional[str] = None
|
||||||
|
license_authority: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "allow"
|
||||||
|
|
||||||
|
|
||||||
class HermesTokenCreateRequest(BaseModel):
|
class HermesTokenCreateRequest(BaseModel):
|
||||||
|
|||||||
+61
-9
@@ -455,6 +455,22 @@ class WebUIRenderer:
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
auto_update_lock_banner = "" if is_auto_update_unlocked else f"""
|
||||||
|
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.35); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 14px;">
|
||||||
|
<div style="font-size: 28px; line-height: 1;">🔒</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 700; color: #f87171; font-size: 14px; margin-bottom: 3px;">Fleet Auto-Update is Inactive & Locked (Unlicensed)</div>
|
||||||
|
<div style="font-size: 12px; color: #cbd5e1;">Automated background forwarder binary upgrades and package deployment require an active license with the <code>auto_update</code> capability flag.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="openImportLicModal()" class="btn btn-accent" style="padding: 7px 14px; font-size: 12px;">📥 Import License (.lic)</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
upload_disabled_attr = "" if is_auto_update_unlocked else "disabled"
|
||||||
|
upload_container_style = "" if is_auto_update_unlocked else "opacity: 0.55; cursor: not-allowed; background: rgba(15, 23, 42, 0.6);"
|
||||||
|
|
||||||
db_tab_pane_html = f"""
|
db_tab_pane_html = f"""
|
||||||
<!-- Tab 5: Database Engine -->
|
<!-- Tab 5: Database Engine -->
|
||||||
<div class="tab-pane" id="tabPane-db">
|
<div class="tab-pane" id="tabPane-db">
|
||||||
@@ -632,6 +648,7 @@ class WebUIRenderer:
|
|||||||
|
|
||||||
<!-- Tab 6: Updates & Fleet Distribution -->
|
<!-- Tab 6: Updates & Fleet Distribution -->
|
||||||
<div class="tab-pane" id="tabPane-updates">
|
<div class="tab-pane" id="tabPane-updates">
|
||||||
|
{auto_update_lock_banner}
|
||||||
<div class="card" style="margin-bottom: 24px;">
|
<div class="card" style="margin-bottom: 24px;">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -711,25 +728,32 @@ class WebUIRenderer:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border);">
|
<div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border); {upload_container_style}">
|
||||||
<div style="font-size: 13px; font-weight: 700; color: #60a5fa; margin-bottom: 12px;">📤 Upload Client Binary Package</div>
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
||||||
|
<div style="font-size: 13px; font-weight: 700; color: #60a5fa;">📤 Upload Client Binary Package</div>
|
||||||
|
{('<span class="badge badge-danger">🔒 Auto-Update Locked</span>' if not is_auto_update_unlocked else '')}
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 14px; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #fbbf24; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 16px;">⚠️</span>
|
||||||
|
<span><strong>Warning:</strong> Uploaded binary packages are immediately distributed to connected edge forwarders for automatic hot-upgrades. Ensure binaries are thoroughly tested and verified before uploading.</span>
|
||||||
|
</div>
|
||||||
<form onsubmit="submitClientPackageUpload(event)" style="display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;">
|
<form onsubmit="submitClientPackageUpload(event)" style="display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;">
|
||||||
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
|
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
|
||||||
<label class="form-label">OS Platform</label>
|
<label class="form-label">OS Platform</label>
|
||||||
<select id="uploadPkgOs" class="form-control" required>
|
<select id="uploadPkgOs" class="form-control" required {upload_disabled_attr}>
|
||||||
<option value="windows">Windows (.exe)</option>
|
<option value="windows">Windows (.exe)</option>
|
||||||
<option value="linux">Linux (.bin / .sh)</option>
|
<option value="linux">Linux (.bin / .sh)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
|
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
|
||||||
<label class="form-label">Version Tag</label>
|
<label class="form-label">Version Tag</label>
|
||||||
<input type="text" id="uploadPkgVer" class="form-control" placeholder="0.3.5.2" value="{hosted_client_ver}">
|
<input type="text" id="uploadPkgVer" class="form-control" placeholder="0.3.5.2" value="{hosted_client_ver}" {upload_disabled_attr}>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 220px;">
|
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 220px;">
|
||||||
<label class="form-label">Select Binary File</label>
|
<label class="form-label">Select Binary File</label>
|
||||||
<input type="file" id="uploadPkgFile" class="form-control" required>
|
<input type="file" id="uploadPkgFile" class="form-control" required {upload_disabled_attr}>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-accent" style="padding: 10px 18px; font-size: 13px;">Upload & Deploy to Fleet</button>
|
<button type="submit" class="btn btn-accent" style="padding: 10px 18px; font-size: 13px;" {upload_disabled_attr}>Upload & Deploy to Fleet</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1414,12 +1438,22 @@ class WebUIRenderer:
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}});
|
}});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {{
|
if (res.ok && data.status === 'success') {{
|
||||||
showAlert('License Activated! ' + (data.message || ''));
|
showAlert('License Activated! ' + (data.message || ''));
|
||||||
closeModal('importLicModal');
|
closeModal('importLicModal');
|
||||||
setTimeout(() => location.reload(), 1000);
|
setTimeout(() => location.reload(), 1000);
|
||||||
}} else {{
|
}} else {{
|
||||||
alert('License Import Failed: ' + (data.detail || 'Validation error'));
|
let errMsg = 'Validation or activation error';
|
||||||
|
if (typeof data.detail === 'string') {{
|
||||||
|
errMsg = data.detail;
|
||||||
|
}} else if (Array.isArray(data.detail)) {{
|
||||||
|
errMsg = data.detail.map(d => d.msg || (d.loc ? d.loc.join('.') + ': ' + d.msg : JSON.stringify(d))).join('; ');
|
||||||
|
}} else if (data.detail && typeof data.detail === 'object') {{
|
||||||
|
errMsg = data.detail.msg || data.detail.message || JSON.stringify(data.detail);
|
||||||
|
}} else if (data.message) {{
|
||||||
|
errMsg = data.message;
|
||||||
|
}}
|
||||||
|
alert('License Import Failed: ' + errMsg);
|
||||||
}}
|
}}
|
||||||
}} catch (err) {{
|
}} catch (err) {{
|
||||||
alert('Import request failed: ' + err.message);
|
alert('Import request failed: ' + err.message);
|
||||||
@@ -1866,6 +1900,14 @@ class WebUIRenderer:
|
|||||||
return;
|
return;
|
||||||
}}
|
}}
|
||||||
const file = fileInput.files[0];
|
const file = fileInput.files[0];
|
||||||
|
const confirmMsg = '⚠️ WARNING: FLEET-WIDE HOT UPGRADE\\n\\n' +
|
||||||
|
'Uploading this ' + osType.toUpperCase() + ' binary package (' + file.name + ', ' + Math.round(file.size / 1024) + ' KB, v' + (ver || 'target') + ') will immediately deploy it to your fleet repository.\\n\\n' +
|
||||||
|
'All connected and enrolled edge forwarders will download and hot-swap their binaries automatically.\\n\\n' +
|
||||||
|
'Are you sure you want to proceed with this fleet deployment?';
|
||||||
|
if (!confirm(confirmMsg)) {{
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
showAlert('Reading package file (' + Math.round(file.size / 1024) + ' KB)...');
|
showAlert('Reading package file (' + Math.round(file.size / 1024) + ' KB)...');
|
||||||
reader.onload = async function() {{
|
reader.onload = async function() {{
|
||||||
@@ -1887,7 +1929,17 @@ class WebUIRenderer:
|
|||||||
showAlert('Client package uploaded successfully! (SHA-256: ' + (data.sha256 ? data.sha256.substring(0, 16) : '') + '...)');
|
showAlert('Client package uploaded successfully! (SHA-256: ' + (data.sha256 ? data.sha256.substring(0, 16) : '') + '...)');
|
||||||
setTimeout(() => location.reload(), 1500);
|
setTimeout(() => location.reload(), 1500);
|
||||||
}} else {{
|
}} else {{
|
||||||
alert('Upload failed: ' + (data.detail || data.message || 'Error'));
|
let errMsg = 'Upload error';
|
||||||
|
if (typeof data.detail === 'string') {{
|
||||||
|
errMsg = data.detail;
|
||||||
|
}} else if (Array.isArray(data.detail)) {{
|
||||||
|
errMsg = data.detail.map(d => d.msg || JSON.stringify(d)).join('; ');
|
||||||
|
}} else if (data.detail && typeof data.detail === 'object') {{
|
||||||
|
errMsg = data.detail.msg || data.detail.message || JSON.stringify(data.detail);
|
||||||
|
}} else if (data.message) {{
|
||||||
|
errMsg = data.message;
|
||||||
|
}}
|
||||||
|
alert('Upload failed: ' + errMsg);
|
||||||
}}
|
}}
|
||||||
}} catch (err) {{
|
}} catch (err) {{
|
||||||
alert('Upload request failed: ' + err.message);
|
alert('Upload request failed: ' + err.message);
|
||||||
|
|||||||
@@ -307,14 +307,56 @@ class TestServerLicensingAndWebUI(unittest.TestCase):
|
|||||||
"license_server_url": "https://logar.eibl.tech"
|
"license_server_url": "https://logar.eibl.tech"
|
||||||
}
|
}
|
||||||
|
|
||||||
response = Server.serve_webui()
|
def test_auto_update_lock_and_package_upload_protection(self):
|
||||||
html = response.body.decode("utf-8")
|
"""Verifies that unlicensed servers block package upload with 403 and render locked UI."""
|
||||||
self.assertIn("LOGAR Fleet Hub", html)
|
from fastapi import HTTPException
|
||||||
self.assertNotIn("LOGAR Fleet Hub & License Management", html)
|
Server.init_db(self.server_db, max_seats=0)
|
||||||
self.assertNotIn("Export .lic Request", html)
|
Server.SERVER_STATE["config"] = {
|
||||||
self.assertNotIn("Import License (.lic / ca.crt)", html)
|
"db_path": self.server_db,
|
||||||
self.assertIn("Sync & Renew via mTLS", html)
|
"cert_dir": self.server_cert_dir,
|
||||||
self.assertIn("logar.eibl.tech", html)
|
"max_seats": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure license_config has no auto_update
|
||||||
|
conn = sqlite3.connect(self.server_db)
|
||||||
|
conn.execute("UPDATE license_config SET license_status = 'unlicensed', features = '[]' WHERE id = 1")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# WebUI should render locked banner and disabled upload controls
|
||||||
|
resp = Server.serve_webui()
|
||||||
|
html = resp.body.decode("utf-8")
|
||||||
|
self.assertIn("Fleet Auto-Update is Inactive & Locked", html)
|
||||||
|
self.assertIn("disabled", html)
|
||||||
|
|
||||||
|
def test_json_lic_package_import(self):
|
||||||
|
"""Verifies importing an activated .lic JSON package via /api/license/import."""
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
sub_pem, root_pem = _create_mock_signed_license_cert(
|
||||||
|
csr_pem, seats=30, license_id="LIC-TEST-JSON-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
Server.init_db(self.server_db, max_seats=5)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 5
|
||||||
|
}
|
||||||
|
|
||||||
|
# JSON package format
|
||||||
|
lic_pkg = {
|
||||||
|
"format": "LOGAR_LICENSE_PACKAGE",
|
||||||
|
"license_id": "LIC-TEST-JSON-01",
|
||||||
|
"seats": 30,
|
||||||
|
"features": ["enterprise_db", "auto_update"],
|
||||||
|
"ca_cert": sub_pem,
|
||||||
|
"master_root_ca": root_pem
|
||||||
|
}
|
||||||
|
|
||||||
|
req = Server.LicenseImportRequest(lic=json.dumps(lic_pkg))
|
||||||
|
res = Server.import_signed_license(req, auth_user="admin")
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertEqual(res["seats"], 30)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+17
-1
@@ -127,7 +127,23 @@ class TestUpdateSystem(unittest.TestCase):
|
|||||||
f.write(b"WIN_CLIENT_V0360")
|
f.write(b"WIN_CLIENT_V0360")
|
||||||
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
|
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
|
||||||
|
|
||||||
# 3. Client update check endpoint
|
# 3. Client update check endpoint (Unlicensed should block auto_update)
|
||||||
|
chk_blocked = Server.check_client_update(
|
||||||
|
client_id="win-client-01",
|
||||||
|
os="windows",
|
||||||
|
current_version="0.3.5.1"
|
||||||
|
)
|
||||||
|
self.assertFalse(chk_blocked["update_available"])
|
||||||
|
self.assertIn("not authorized", chk_blocked.get("reason", ""))
|
||||||
|
|
||||||
|
# 4. Enable auto_update feature flag in license config
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
conn.execute("UPDATE license_config SET license_status = 'active', features = '[\"auto_update\"]' WHERE id = 1")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 5. Client update check endpoint (Licensed)
|
||||||
chk_resp = Server.check_client_update(
|
chk_resp = Server.check_client_update(
|
||||||
client_id="win-client-01",
|
client_id="win-client-01",
|
||||||
os="windows",
|
os="windows",
|
||||||
|
|||||||
Reference in New Issue
Block a user