10 Commits
9 changed files with 298 additions and 86 deletions
+10 -9
View File
@@ -104,13 +104,14 @@ graph TB
- `OID_LOGAR_SEATS (1.3.6.1.4.1.59999.1.1)`: Embeds the exact licensed seat quota directly inside the signed Subordinate CA certificate. - `OID_LOGAR_SEATS (1.3.6.1.4.1.59999.1.1)`: Embeds the exact licensed seat quota directly inside the signed Subordinate CA certificate.
- `OID_LOGAR_LICENSE_ID (1.3.6.1.4.1.59999.1.2)`: Uniquely tracks enterprise license entitlement. - `OID_LOGAR_LICENSE_ID (1.3.6.1.4.1.59999.1.2)`: Uniquely tracks enterprise license entitlement.
- `OID_LOGAR_ISSUER_URL (1.3.6.1.4.1.59999.1.3)`: Embedded endpoint coordinate (`https://logar.eibl.tech`). - `OID_LOGAR_ISSUER_URL (1.3.6.1.4.1.59999.1.3)`: Embedded endpoint coordinate (`https://logar.eibl.tech`).
- **Exportable `.lic` Request Files**: - **Exportable `.lic` Request & Activated `.lic` Packages**:
- The LOGAR server generates its Subordinate CA private key (`certs/ca.key`), Certificate Signing Request (`certs/ca.csr`), and packages them into an exportable `logar_server.lic` file containing the CSR, server fingerprint, and license authority coordinate. - **Request**: The LOGAR server generates its Subordinate CA private key (`certs/ca.key`), Certificate Signing Request (`certs/ca.csr`), and packages them into an exportable `logar_server.lic` file.
- Exportable directly from the WebUI or via CLI (`python src/Server.py --export-lic server.lic`). - **Activation Package**: The master license authority issues an activated `.lic` package containing the signed Subordinate CA, granted seat quota, server metadata, and the Master Root CA (`upstream_ca.crt`).
- **One-Time Pass & Zero-Touch mTLS Renewals**: - Importable via WebUI or CLI (`python src/Server.py --import-lic activated.lic` or `--import-ca`).
- The signed CA certificate only needs to be imported **once** into the LOGAR Server (via WebUI or CLI `--import-ca`). - **Self-Updating Client Servers & Dynamic Seat Expansion**:
- Once imported, the server hub's background watchdog monitors `ca.crt` expiration (< 30 days). - Client servers update themselves against the master authority (`logar.eibl.tech`) over mTLS (`python src/Server.py --sync-license` or via WebUI / automated watchdog).
- Renewals are performed **automatically over mTLS**: the LOGAR Server authenticates directly against `https://logar.eibl.tech/api/license/renew` using its existing active certificate and key, receives the renewed Subordinate CA certificate preserving its seat quota, and reloads its in-flight `ssl.SSLContext` without dropping client connections or restarting services. - Seat quotas can be increased with each renewal or updated on-demand on the master authority.
- The client server automatically detects the higher seat count in the signed certificate's OID, updates its database and runtime configuration, and reloads its in-flight `ssl.SSLContext` without dropping client connections or restarting services.
### 3. Integrated Fleet Licensing & WebUI Dashboard ### 3. Integrated Fleet Licensing & WebUI Dashboard
- **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`). - **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
@@ -118,8 +119,8 @@ graph TB
- **Licensing Actions**: - **Licensing Actions**:
- 1-click **Export .lic Request** file download. - 1-click **Export .lic Request** file download.
- Interactive **View & Copy CA CSR** modal. - Interactive **View & Copy CA CSR** modal.
- Drag-and-drop or paste **Import Signed CA Certificate** modal. - Drag-and-drop or paste **Import License (.lic / ca.crt)** modal.
- **Renew via mTLS Now** button for immediate upstream renewal. - **Sync & Renew via mTLS** button for immediate upstream synchronization and seat expansion.
- **Seat Management & Revocation Table**: - **Seat Management & Revocation Table**:
- Lists all connected Windows and Linux edge forwarders. - Lists all connected Windows and Linux edge forwarders.
- **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer. - **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer.
+1 -1
View File
@@ -14,8 +14,8 @@ Standalone compiled binary and automated systemd service distribution for Linux
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention. - **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours. - **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates. - **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated. - **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
--- ---
+9 -3
View File
@@ -110,8 +110,8 @@ http://<SERVER_IP>:8443/webui
The WebUI provides: The WebUI provides:
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota. 1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`. 2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
3. **Import Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight. 3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`. 4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access. 5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
### CLI Licensing Utilities ### CLI Licensing Utilities
@@ -119,10 +119,16 @@ Export a `.lic` request file via CLI:
```bash ```bash
./Server.bin --export-lic server.lic ./Server.bin --export-lic server.lic
``` ```
Import a signed CA certificate via CLI: Import an activated `.lic` license package or signed CA certificate via CLI:
```bash ```bash
./Server.bin --import-lic activated.lic
# Or alternatively with --import-ca:
./Server.bin --import-ca ca.crt ./Server.bin --import-ca ca.crt
``` ```
Synchronize license and check for seat quota increases against the master authority:
```bash
./Server.bin --sync-license
```
--- ---
+1 -1
View File
@@ -14,8 +14,8 @@ Standalone compiled executable and installer distribution for Windows Server and
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention. - **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours. - **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates. - **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated. - **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
--- ---
+9 -3
View File
@@ -110,8 +110,8 @@ http://localhost:8443/webui
The WebUI provides: The WebUI provides:
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota. 1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`. 2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
3. **Import Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight. 3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`. 4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access. 5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
### CLI Licensing Utilities ### CLI Licensing Utilities
@@ -119,10 +119,16 @@ Export a `.lic` request file via CLI:
```powershell ```powershell
.\Server.exe --export-lic C:\LOGAR-Server\server.lic .\Server.exe --export-lic C:\LOGAR-Server\server.lic
``` ```
Import a signed CA certificate via CLI: Import an activated `.lic` license package or signed CA certificate via CLI:
```powershell ```powershell
.\Server.exe --import-lic C:\LOGAR-Server\activated.lic
# Or alternatively with --import-ca:
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt .\Server.exe --import-ca C:\LOGAR-Server\ca.crt
``` ```
Synchronize license and check for seat quota increases against the master authority:
```powershell
.\Server.exe --sync-license
```
--- ---
+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(request: Request):
"""
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
try:
body = await request.json()
if isinstance(body, dict):
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)
+89 -2
View File
@@ -654,16 +654,90 @@ def sign_ca_csr_with_seats(
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
def import_license_package(
lic_content_or_path: str,
cert_dir: str = "certs"
) -> Dict[str, Any]:
"""
Imports and validates a license package or certificate.
Supports:
1. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca'
2. Raw PEM Subordinate CA certificate
3. File path pointing to either of the above
Saves the signed CA certificate to cert_dir/ca.crt (backing up any old ca.crt),
and if 'master_root_ca' is present, saves it to cert_dir/upstream_ca.crt for mTLS trust.
Returns extracted license attributes and PEM contents.
"""
os.makedirs(cert_dir, exist_ok=True)
raw = lic_content_or_path
if os.path.exists(lic_content_or_path) and os.path.isfile(lic_content_or_path):
with open(lic_content_or_path, "r", encoding="utf-8") as f:
raw = f.read()
ca_pem = None
master_root_ca_pem = None
trimmed = raw.strip()
if trimmed.startswith("{"):
try:
data = json.loads(trimmed)
ca_pem = data.get("ca_cert") or data.get("cert_pem")
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca")
except Exception as e:
raise ValueError(f"Failed to parse JSON .lic package: {e}")
elif "-----BEGIN CERTIFICATE-----" in trimmed:
ca_pem = trimmed
if not ca_pem:
raise ValueError("Provided license package does not contain a valid 'ca_cert' PEM certificate.")
info = extract_license_info_from_cert(ca_pem)
if not info.get("valid"):
raise ValueError(f"Invalid CA certificate in license: {info.get('error')}")
# Backup existing ca.crt if present
ca_path = os.path.join(cert_dir, "ca.crt")
if os.path.exists(ca_path):
ts = datetime.datetime.now(datetime.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)
# Save upstream_ca.crt if master_root_ca is present
if master_root_ca_pem:
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
with open(upstream_path, "w", encoding="utf-8") as f:
f.write(master_root_ca_pem)
return {
"ca_cert_pem": ca_pem,
"master_root_ca_pem": master_root_ca_pem,
"license_info": info,
"seats": info.get("seats"),
"license_id": info.get("license_id"),
"issuer_url": info.get("issuer_url"),
"fingerprint": info.get("fingerprint"),
"expires_at": info.get("not_valid_after")
}
def renew_ca_cert_mtls( def renew_ca_cert_mtls(
cert_dir: str = "certs", cert_dir: str = "certs",
license_server_url: str = "https://logar.eibl.tech", license_server_url: str = "https://logar.eibl.tech",
upstream_ca_pem: Optional[str] = None, upstream_ca_pem: Optional[str] = None,
requested_seats: Optional[int] = None,
seat_increment: int = 0,
timeout: int = 15, timeout: int = 15,
verify_tls: bool = True verify_tls: bool = True
) -> Tuple[bool, str, Optional[str]]: ) -> Tuple[bool, str, Optional[str]]:
""" """
Connects to the master License Authority using mutual TLS (mTLS), authenticating Connects to the master License Authority using mutual TLS (mTLS), authenticating
with the server's current ca.crt and ca.key, and requests an automated renewal. with the server's current ca.crt and ca.key, and requests an automated renewal.
Supports requesting seat quota increases or increments during renewal.
If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir. If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir.
Returns (success, message, new_ca_cert_pem). Returns (success, message, new_ca_cert_pem).
""" """
@@ -681,11 +755,17 @@ def renew_ca_cert_mtls(
current_pem = f.read() current_pem = f.read()
info = extract_license_info_from_cert(current_pem) info = extract_license_info_from_cert(current_pem)
payload = json.dumps({ payload_dict = {
"license_id": info.get("license_id"), "license_id": info.get("license_id"),
"fingerprint": info.get("fingerprint"), "fingerprint": info.get("fingerprint"),
"current_cert": current_pem "current_cert": current_pem
}).encode("utf-8") }
if requested_seats is not None:
payload_dict["requested_seats"] = requested_seats
if seat_increment:
payload_dict["seat_increment"] = seat_increment
payload = json.dumps(payload_dict).encode("utf-8")
path = parsed.path or "/api/license/renew" path = parsed.path or "/api/license/renew"
headers = { headers = {
@@ -730,6 +810,13 @@ def renew_ca_cert_mtls(
with open(ca_cert_path, "w", encoding="utf-8") as f: with open(ca_cert_path, "w", encoding="utf-8") as f:
f.write(new_cert_pem) f.write(new_cert_pem)
# Update upstream_ca.crt if master root CA certificate was provided
master_root_pem = result.get("master_root_ca")
if master_root_pem:
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
with open(upstream_path, "w", encoding="utf-8") as f:
f.write(master_root_pem)
return True, "Certificate renewed successfully via mTLS", new_cert_pem return True, "Certificate renewed successfully via mTLS", new_cert_pem
except Exception as e: except Exception as e:
+78 -2
View File
@@ -202,6 +202,82 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
self.assertEqual(summary2["active_seats"], 2) self.assertEqual(summary2["active_seats"], 2)
self.assertEqual(summary2["available_seats"], 0) self.assertEqual(summary2["available_seats"], 0)
def test_activated_lic_package_import_and_renewal_seat_increase(self):
"""Verifies that an activated .lic package can be imported, saving upstream_ca, and renewal expands seats."""
# 1. Generate CSR on server side
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
# 2. Authority signs it with 20 seats and produces activated .lic
issued = authority.issue_license_from_csr(
csr_pem=csr_pem,
seats=20,
server_fingerprint="FP_TEST_HUB",
hostname="hub.enterprise.local",
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
self.assertIn("lic_package", issued)
self.assertIn("lic_json", issued)
# Write .lic file
lic_file = os.path.join(self.test_dir, "test_activated.lic")
with open(lic_file, "w", encoding="utf-8") as f:
f.write(issued["lic_json"])
# 3. Server imports .lic package
import_res = enrollment.import_license_package(lic_file, cert_dir=self.server_cert_dir)
self.assertEqual(import_res["seats"], 20)
self.assertEqual(import_res["license_id"], issued["license_id"])
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "ca.crt")))
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "upstream_ca.crt")))
# 4. Initialize server state and verify summary
Server.init_db(self.server_db, max_seats=20)
Server.SERVER_STATE["config"] = {
"db_path": self.server_db,
"cert_dir": self.server_cert_dir,
"max_seats": 20,
"tcp_host": "127.0.0.1",
"tcp_port": 9443,
"hermes_host": "127.0.0.1",
"hermes_port": 8443,
"server_fingerprint": "FP_TEST_HUB",
"server_name": "Test-Hub",
"license_server_url": "https://logar.eibl.tech"
}
summary = Server.get_license_summary()
self.assertEqual(summary["max_seats"], 20)
# 5. Authority increments seats by +15 on renewal (total 35)
with open(os.path.join(self.server_cert_dir, "ca.crt"), "r", encoding="utf-8") as f:
current_ca_pem = f.read()
renewed = authority.renew_license_cert(
current_cert_pem=current_ca_pem,
seat_increment=15,
db_path=self.db_path,
cert_dir=self.master_ca_dir
)
self.assertEqual(renewed["seats"], 35)
# 6. Server processes the renewed certificate and saves it to ca.crt
ca_path = os.path.join(self.server_cert_dir, "ca.crt")
with open(ca_path, "w", encoding="utf-8") as f:
f.write(renewed["ca_cert"])
new_info = enrollment.extract_license_info_from_cert(renewed["ca_cert"])
self.assertEqual(new_info["seats"], 35)
# Server updates DB and config
Server.SERVER_STATE["config"]["max_seats"] = new_info["seats"]
conn = sqlite3.connect(self.server_db)
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (new_info["seats"],))
conn.commit()
conn.close()
summary_renewed = Server.get_license_summary()
self.assertEqual(summary_renewed["max_seats"], 35)
def test_webui_rendering(self): def test_webui_rendering(self):
"""Verifies that the WebUI HTML contains key components, stats, and download triggers.""" """Verifies that the WebUI HTML contains key components, stats, and download triggers."""
Server.init_db(self.server_db, max_seats=10) Server.init_db(self.server_db, max_seats=10)
@@ -220,8 +296,8 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
html = response.body.decode("utf-8") html = response.body.decode("utf-8")
self.assertIn("LOGAR Fleet Hub &amp; License Management", html.replace("&", "&amp;")) self.assertIn("LOGAR Fleet Hub &amp; License Management", html.replace("&", "&amp;"))
self.assertIn("Export .lic Request", html) self.assertIn("Export .lic Request", html)
self.assertIn("Import &amp; Activate Signed CA Certificate", html.replace("&", "&amp;")) self.assertIn("Import License (.lic / ca.crt)", html)
self.assertIn("Renew via mTLS Now", html) self.assertIn("Sync &amp; Renew via mTLS", html.replace("&", "&amp;"))
self.assertIn("logar.eibl.tech", html) self.assertIn("logar.eibl.tech", html)