Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d331da0b13 | ||
|
|
378127c25b | ||
|
|
4970ef7c25 | ||
|
|
79a78ba1c1 | ||
|
|
14533e0427 | ||
|
|
d192b43aaf | ||
|
|
4a352337a5 | ||
|
|
2fba971aeb | ||
|
|
f605375639 |
@@ -88,40 +88,49 @@ graph TB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## mTLS Security, Dynamic PKI & Licensing
|
## mTLS Security, Dynamic PKI & Fleet Licensing
|
||||||
|
|
||||||
### 1. TLS 1.3 Mutual Authentication (mTLS)
|
### 1. TLS 1.3 Mutual Authentication (mTLS)
|
||||||
- **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`.
|
- **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`.
|
||||||
- Both the hub and edge clients verify each other's certificates:
|
- Both the hub and edge clients verify each other's certificates:
|
||||||
- Client verifies server certificate against `ca.crt`.
|
- Client verifies server certificate against `ca.crt`.
|
||||||
- Server verifies client certificate against the Root CA.
|
- Server verifies client certificate against the Subordinate/Root CA.
|
||||||
- **Client CN Identification**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately.
|
- **Client CN Identification & Revocation**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately.
|
||||||
|
|
||||||
### 2. Dynamic PKI Hub Engine (`src/server_enrollment.py`)
|
### 2. Master License Authority (`logar.eibl.tech`) & Seat-Bearing Subordinate CA
|
||||||
- **Root CA**: On first run, `Server.py` creates a self-signed Root CA (`ca.crt` / `ca.key`) valid for 10 years.
|
- **Master Root Authority**: Standalone PKI service ([`src/License_Authority.py`](file:///u:/max/Desktop/LOGAR/src/License_Authority.py)) maintained at `logar.eibl.tech`.
|
||||||
- **Server TLS Certificate**: Generated automatically with Subject Alternative Names (SANs) for `localhost`, `127.0.0.1`, server IP, and hostnames.
|
- **Custom Enterprise X.509 OIDs**:
|
||||||
- **Authority Key Identifiers**: Full compliance with OpenSSL 3.x and Python 3.12–3.14 via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` extensions.
|
- `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.
|
||||||
- **Dynamic Client Certificates**: RSA-2048 keys and X.509 client certificates are issued on the fly via the enrollment API.
|
- `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`).
|
||||||
|
- **Exportable `.lic` Request Files**:
|
||||||
|
- 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.
|
||||||
|
- Exportable directly from the WebUI or via CLI (`python src/Server.py --export-lic server.lic`).
|
||||||
|
- **One-Time Pass & Zero-Touch mTLS Renewals**:
|
||||||
|
- The signed CA certificate only needs to be imported **once** into the LOGAR Server (via WebUI or CLI `--import-ca`).
|
||||||
|
- Once imported, the server hub's background watchdog monitors `ca.crt` expiration (< 30 days).
|
||||||
|
- 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.
|
||||||
|
|
||||||
### 3. Seat Accounting & Licensing
|
### 3. Integrated Fleet Licensing & WebUI Dashboard
|
||||||
- Stored in SQLite table `license_config`:
|
- **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
|
||||||
- `max_seats`: Maximum concurrent active client licenses (default: 10).
|
- **Visual Seat Quota Meter**: Real-time progress bar displaying active forwarders vs. allocated seat limit.
|
||||||
- `enrollment_secret`: Cryptographic secret required for initial client enrollment.
|
- **Licensing Actions**:
|
||||||
- Stored in SQLite table `clients`:
|
- 1-click **Export .lic Request** file download.
|
||||||
- `client_id`: Unique client identifier (machine GUID or hardware hash).
|
- Interactive **View & Copy CA CSR** modal.
|
||||||
- `hostname`, `os_type`, `cert_fingerprint`, `status` (`active` / `revoked`), `first_seen`, `last_seen`.
|
- Drag-and-drop or paste **Import Signed CA Certificate** modal.
|
||||||
- When a new client enrolls:
|
- **Renew via mTLS Now** button for immediate upstream renewal.
|
||||||
- If `active_seats >= max_seats`, the hub rejects registration with `HTTP 403 (License seat limit reached)`.
|
- **Seat Management & Revocation Table**:
|
||||||
- Existing registered clients can re-enroll / renew seamlessly without consuming additional seats.
|
- Lists all connected Windows and Linux edge forwarders.
|
||||||
|
- **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer.
|
||||||
|
- **Reactivate Seat**: Restores the client to active status if within seat quota limits.
|
||||||
|
|
||||||
### 4. In-Flight Certificate Watchdog & Automated Self-Healing Renewal
|
### 4. Cloud-Side In-Flight Certificate Watchdog
|
||||||
- **Continuous Hub PKI Watchdog**:
|
- **Continuous Hub PKI Watchdog**:
|
||||||
- The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours) alongside startup checks.
|
- The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours).
|
||||||
- The hub automatically inspects expiration dates of both the Root CA (`ca.crt`) and the Server TLS certificate (`server.crt`).
|
- Automatically renews Subordinate CA certificates over mTLS with `logar.eibl.tech` and Server TLS certificates in-flight, dynamically reloading the active `ssl.SSLContext`.
|
||||||
- If either certificate is within 30 days of expiration, the server regenerates certificates (backing up previous keys as `ca.crt.<timestamp>.bak`) and dynamically reloads its active `ssl.SSLContext` in memory without dropping socket connections or restarting the service.
|
|
||||||
- **Client Proactive Check & Reactive Self-Healing**:
|
- **Client Proactive Check & Reactive Self-Healing**:
|
||||||
- **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If the certificate expires in less than 30 days, it automatically contacts `/api/client/enroll` to renew its certificate.
|
- **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If expiring within 30 days, they contact `/api/client/enroll` to renew.
|
||||||
- **Reactive Self-Healing**: If the server hub Root CA rotates or a handshake fails with `ssl.SSLError` / `SSLCertVerificationError`, edge clients catch the verification exception, re-bootstrap certificate enrollment against the hub, and re-establish the connection cleanly without human intervention.
|
- **Reactive Self-Healing**: If the server CA rotates or a handshake error occurs, edge clients catch the exception, re-bootstrap enrollment against the hub, and re-establish streaming cleanly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Standalone compiled binary and automated systemd service distribution for Linux
|
|||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ Standalone compiled binary and automated systemd service distribution for Linux
|
|||||||
|
|
||||||
### Key Architecture & Capabilities
|
### Key Architecture & Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
|
||||||
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Root CA and verifies active licensing in SQLite.
|
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
|
||||||
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`).
|
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
||||||
- **In-Flight Certificate Watchdog & Dynamic Reloading**: Continuously monitors Root CA (`ca.crt`) and Server TLS certificate (`server.crt`) validity in the background (every 12 hours). When nearing expiration (< 30 days), certificates are automatically regenerated with timestamped backups, and active `ssl.SSLContext` structures are reloaded dynamically without dropping socket connections or restarting the systemd service.
|
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
||||||
|
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
||||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window.
|
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window.
|
||||||
- **Embedded Hermes Reporting & Management API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -99,7 +100,33 @@ The output file contains the server coordinates, enrollment secret, and fingerpr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Manual Systemd Service Management
|
## 4. Fleet Licensing, .lic Files & WebUI Dashboard
|
||||||
|
|
||||||
|
### Accessing the WebUI
|
||||||
|
Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://<SERVER_IP>:8443/webui
|
||||||
|
```
|
||||||
|
The WebUI provides:
|
||||||
|
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`.
|
||||||
|
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.
|
||||||
|
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
|
||||||
|
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
|
||||||
|
Export a `.lic` request file via CLI:
|
||||||
|
```bash
|
||||||
|
./Server.bin --export-lic server.lic
|
||||||
|
```
|
||||||
|
Import a signed CA certificate via CLI:
|
||||||
|
```bash
|
||||||
|
./Server.bin --import-ca ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Manual Systemd Service Management
|
||||||
|
|
||||||
Check service status:
|
Check service status:
|
||||||
```bash
|
```bash
|
||||||
@@ -113,7 +140,7 @@ sudo journalctl -u logar-server.service -f -n 50
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Uninstallation & Removal
|
## 6. Uninstallation & Removal
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl disable --now logar-server.service
|
sudo systemctl disable --now logar-server.service
|
||||||
@@ -121,3 +148,4 @@ sudo rm -f /etc/systemd/system/logar-server.service
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
|
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Standalone compiled executable and installer distribution for Windows Server and
|
|||||||
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ Standalone compiled executable and installer distribution for Windows Server env
|
|||||||
|
|
||||||
### Key Architecture & Capabilities
|
### Key Architecture & Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
||||||
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Root CA and verifies active licensing in SQLite.
|
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
|
||||||
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`).
|
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
||||||
- **In-Flight Certificate Watchdog & Dynamic Reloading**: Continuously monitors Root CA (`ca.crt`) and Server TLS certificate (`server.crt`) validity in the background (every 12 hours). When nearing expiration (< 30 days), certificates are automatically regenerated with timestamped backups, and active `ssl.SSLContext` structures are reloaded dynamically without dropping socket connections or restarting the Windows service.
|
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
||||||
|
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
||||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window.
|
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window.
|
||||||
- **Embedded Hermes Reporting & Management API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -99,7 +100,33 @@ The output file contains the server coordinates, enrollment secret, and fingerpr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Manual Windows Service Setup (via NSSM)
|
## 4. Fleet Licensing, .lic Files & WebUI Dashboard
|
||||||
|
|
||||||
|
### Accessing the WebUI
|
||||||
|
Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:8443/webui
|
||||||
|
```
|
||||||
|
The WebUI provides:
|
||||||
|
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`.
|
||||||
|
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.
|
||||||
|
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
|
||||||
|
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
|
||||||
|
Export a `.lic` request file via CLI:
|
||||||
|
```powershell
|
||||||
|
.\Server.exe --export-lic C:\LOGAR-Server\server.lic
|
||||||
|
```
|
||||||
|
Import a signed CA certificate via CLI:
|
||||||
|
```powershell
|
||||||
|
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Manual Windows Service Setup (via NSSM)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
|
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
|
||||||
@@ -111,7 +138,7 @@ The output file contains the server coordinates, enrollment secret, and fingerpr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Uninstallation
|
## 6. Uninstallation
|
||||||
|
|
||||||
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
|
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
|
||||||
|
|
||||||
@@ -120,3 +147,4 @@ If installed manually via NSSM:
|
|||||||
.\nssm.exe stop LOGAR_Server
|
.\nssm.exe stop LOGAR_Server
|
||||||
.\nssm.exe remove LOGAR_Server confirm
|
.\nssm.exe remove LOGAR_Server confirm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
LOGAR Master License Authority (logar.eibl.tech)
|
||||||
|
|
||||||
|
Authoritative PKI service and CLI utility responsible for:
|
||||||
|
1. Maintaining the Master Root CA.
|
||||||
|
2. Processing intermediate CA CSRs (or .lic request files) and issuing signed
|
||||||
|
subordinate CA certificates embedding exact seat quotas via custom enterprise X.509 OIDs.
|
||||||
|
3. Providing an automated mTLS renewal endpoint (/api/license/renew) for active servers.
|
||||||
|
4. Serving an administrative WebUI and REST API to manage and revoke server licenses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
from typing import Tuple, Optional, Dict, Any
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
# Ensure src/ is importable
|
||||||
|
SRC_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if SRC_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_DIR)
|
||||||
|
|
||||||
|
import server_enrollment as enrollment
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Header
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
import uvicorn
|
||||||
|
FASTAPI_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
FASTAPI_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DB = "license_authority.db"
|
||||||
|
DEFAULT_MASTER_CERT_DIR = "master_ca_certs"
|
||||||
|
|
||||||
|
|
||||||
|
def init_authority_db(db_path: str = DEFAULT_DB):
|
||||||
|
"""Initializes the SQLite schema for tracking issued server licenses and revocations."""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
license_id TEXT PRIMARY KEY,
|
||||||
|
server_fingerprint TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
seats INTEGER NOT NULL,
|
||||||
|
issued_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
renewal_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cert_pem TEXT NOT NULL,
|
||||||
|
csr_pem TEXT,
|
||||||
|
client_cert_fingerprint TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_master_root_ca(
|
||||||
|
cert_dir: str = DEFAULT_MASTER_CERT_DIR,
|
||||||
|
common_name: str = "LOGAR-Master-Root-CA",
|
||||||
|
organization: str = "LOGAR Global Licensing",
|
||||||
|
country: str = "AT"
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""Generates the Master Root CA certificate and private key if not already existing."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
cert_path = os.path.join(cert_dir, "master_root_ca.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "master_root_ca.key")
|
||||||
|
|
||||||
|
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
with open(cert_path, "r", encoding="utf-8") as f:
|
||||||
|
cert_pem = f.read()
|
||||||
|
with open(key_path, "r", encoding="utf-8") as f:
|
||||||
|
key_pem = f.read()
|
||||||
|
return cert_pem, key_pem
|
||||||
|
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||||
|
subject = issuer = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||||
|
])
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(issuer)
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
key_encipherment=False,
|
||||||
|
key_cert_sign=True,
|
||||||
|
crl_sign=True,
|
||||||
|
content_commitment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False
|
||||||
|
),
|
||||||
|
critical=True
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
key_pem = ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(cert_pem)
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(key_pem)
|
||||||
|
|
||||||
|
return cert_pem, key_pem
|
||||||
|
|
||||||
|
|
||||||
|
def issue_license_from_csr(
|
||||||
|
csr_pem: str,
|
||||||
|
seats: int,
|
||||||
|
days_valid: int = 365,
|
||||||
|
license_id: Optional[str] = None,
|
||||||
|
server_fingerprint: str = "",
|
||||||
|
hostname: str = "",
|
||||||
|
db_path: str = DEFAULT_DB,
|
||||||
|
cert_dir: str = DEFAULT_MASTER_CERT_DIR,
|
||||||
|
license_server_url: str = "https://logar.eibl.tech"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Issues a subordinate CA certificate with the requested number of seats and registers it in the DB."""
|
||||||
|
init_authority_db(db_path)
|
||||||
|
master_cert_pem, master_key_pem = generate_master_root_ca(cert_dir)
|
||||||
|
|
||||||
|
if not license_id:
|
||||||
|
csr_obj = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
|
||||||
|
der_pub = csr_obj.public_key().public_bytes(
|
||||||
|
serialization.Encoding.DER,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||||
|
)
|
||||||
|
license_id = f"LIC-{der_pub.hex()[:12].upper()}"
|
||||||
|
|
||||||
|
ca_cert_pem = enrollment.sign_ca_csr_with_seats(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
master_ca_cert_pem=master_cert_pem,
|
||||||
|
master_ca_key_pem=master_key_pem,
|
||||||
|
seats=seats,
|
||||||
|
days_valid=days_valid,
|
||||||
|
license_id=license_id,
|
||||||
|
license_server_url=license_server_url
|
||||||
|
)
|
||||||
|
|
||||||
|
info = enrollment.extract_license_info_from_cert(ca_cert_pem)
|
||||||
|
issued_at = info["not_valid_before"]
|
||||||
|
expires_at = info["not_valid_after"]
|
||||||
|
fp = info["fingerprint"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""
|
||||||
|
INSERT INTO licenses (license_id, server_fingerprint, hostname, seats, issued_at, expires_at, status, renewal_count, cert_pem, csr_pem, client_cert_fingerprint)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'active', 0, ?, ?, ?)
|
||||||
|
ON CONFLICT(license_id) DO UPDATE SET
|
||||||
|
seats = excluded.seats,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
status = 'active',
|
||||||
|
cert_pem = excluded.cert_pem,
|
||||||
|
client_cert_fingerprint = excluded.client_cert_fingerprint
|
||||||
|
""", (license_id, server_fingerprint, hostname, seats, issued_at, expires_at, ca_cert_pem, csr_pem, fp))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"license_id": license_id,
|
||||||
|
"seats": seats,
|
||||||
|
"issued_at": issued_at,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"fingerprint": fp,
|
||||||
|
"ca_cert": ca_cert_pem,
|
||||||
|
"master_root_ca": master_cert_pem,
|
||||||
|
"license_server": license_server_url
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def renew_license_cert(
|
||||||
|
current_cert_pem: str,
|
||||||
|
days_valid: int = 365,
|
||||||
|
db_path: str = DEFAULT_DB,
|
||||||
|
cert_dir: str = DEFAULT_MASTER_CERT_DIR,
|
||||||
|
license_server_url: str = "https://logar.eibl.tech"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validates an existing intermediate CA cert presenting for mTLS renewal,
|
||||||
|
verifies that the license is active, and issues a renewed certificate preserving seat quota.
|
||||||
|
"""
|
||||||
|
init_authority_db(db_path)
|
||||||
|
master_cert_pem, master_key_pem = generate_master_root_ca(cert_dir)
|
||||||
|
master_cert = x509.load_pem_x509_certificate(master_cert_pem.encode("utf-8"))
|
||||||
|
|
||||||
|
current_cert = x509.load_pem_x509_certificate(current_cert_pem.encode("utf-8"))
|
||||||
|
|
||||||
|
# Extract license info
|
||||||
|
info = enrollment.extract_license_info_from_cert(current_cert_pem)
|
||||||
|
license_id = info.get("license_id")
|
||||||
|
seats = info.get("seats") or 10
|
||||||
|
|
||||||
|
# Verify license record in database
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT status, renewal_count, csr_pem, seats FROM licenses WHERE license_id = ?", (license_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
status, renewals, csr_pem, db_seats = row
|
||||||
|
if status == "revoked":
|
||||||
|
conn.close()
|
||||||
|
raise ValueError(f"License {license_id} is REVOKED and cannot be renewed.")
|
||||||
|
seats = db_seats or seats
|
||||||
|
else:
|
||||||
|
# Auto-enroll license if missing from DB (signed by our master CA)
|
||||||
|
renewals = 0
|
||||||
|
csr_pem = None
|
||||||
|
|
||||||
|
# Build renewed certificate using the same public key and subject
|
||||||
|
master_key = serialization.load_pem_private_key(master_key_pem.encode("utf-8"), password=None)
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
builder = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(current_cert.subject)
|
||||||
|
.issuer_name(master_cert.subject)
|
||||||
|
.public_key(current_cert.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=days_valid))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
key_encipherment=False,
|
||||||
|
key_cert_sign=True,
|
||||||
|
crl_sign=True,
|
||||||
|
content_commitment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False
|
||||||
|
),
|
||||||
|
critical=True
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectKeyIdentifier.from_public_key(current_cert.public_key()),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.AuthorityKeyIdentifier.from_issuer_public_key(master_key.public_key()),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.UnrecognizedExtension(enrollment.OID_LOGAR_SEATS, str(seats).encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.UnrecognizedExtension(enrollment.OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.UnrecognizedExtension(enrollment.OID_LOGAR_ISSUER_URL, license_server_url.encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
new_cert = builder.sign(master_key, hashes.SHA256())
|
||||||
|
new_pem = new_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
new_info = enrollment.extract_license_info_from_cert(new_pem)
|
||||||
|
new_fp = new_info["fingerprint"]
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
UPDATE licenses
|
||||||
|
SET renewal_count = renewal_count + 1,
|
||||||
|
expires_at = ?,
|
||||||
|
cert_pem = ?,
|
||||||
|
client_cert_fingerprint = ?
|
||||||
|
WHERE license_id = ?
|
||||||
|
""", (new_info["not_valid_after"], new_pem, new_fp, license_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "renewed",
|
||||||
|
"license_id": license_id,
|
||||||
|
"seats": seats,
|
||||||
|
"ca_cert": new_pem,
|
||||||
|
"expires_at": new_info["not_valid_after"],
|
||||||
|
"fingerprint": new_fp
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_license(license_id: str, db_path: str = DEFAULT_DB) -> bool:
|
||||||
|
"""Marks a license as revoked in the database."""
|
||||||
|
init_authority_db(db_path)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("UPDATE licenses SET status = 'revoked' WHERE license_id = ?", (license_id,))
|
||||||
|
changed = c.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def list_licenses(db_path: str = DEFAULT_DB) -> list:
|
||||||
|
"""Lists all registered licenses."""
|
||||||
|
init_authority_db(db_path)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT license_id, server_fingerprint, hostname, seats, issued_at, expires_at, status, renewal_count, client_cert_fingerprint FROM licenses ORDER BY issued_at DESC")
|
||||||
|
rows = [dict(r) for r in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# --- FastAPI Web Service (for deployment at logar.eibl.tech) ---
|
||||||
|
if FASTAPI_AVAILABLE:
|
||||||
|
app = FastAPI(title="LOGAR License Authority", version="2.0.1")
|
||||||
|
|
||||||
|
@app.post("/api/license/issue")
|
||||||
|
async def api_issue(request: Request):
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
csr_pem = body.get("csr")
|
||||||
|
seats = int(body.get("seats", 10))
|
||||||
|
server_fp = body.get("server_fingerprint", "")
|
||||||
|
hostname = body.get("hostname", "")
|
||||||
|
lic_id = body.get("license_id")
|
||||||
|
|
||||||
|
if not csr_pem:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'csr' in request")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = issue_license_from_csr(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
seats=seats,
|
||||||
|
license_id=lic_id,
|
||||||
|
server_fingerprint=server_fp,
|
||||||
|
hostname=hostname
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/license/renew")
|
||||||
|
async def api_renew(request: Request):
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
current_cert = body.get("current_cert")
|
||||||
|
if not current_cert:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'current_cert' in request")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = renew_license_cert(current_cert)
|
||||||
|
return result
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=403, detail=str(ve))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/licenses")
|
||||||
|
def api_list():
|
||||||
|
return {"licenses": list_licenses()}
|
||||||
|
|
||||||
|
@app.post("/api/license/revoke")
|
||||||
|
async def api_revoke(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
lic_id = body.get("license_id")
|
||||||
|
if not lic_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing 'license_id'")
|
||||||
|
ok = revoke_license(lic_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="License not found")
|
||||||
|
return {"status": "revoked", "license_id": lic_id}
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def web_dashboard():
|
||||||
|
licenses = list_licenses()
|
||||||
|
rows = []
|
||||||
|
for l in licenses:
|
||||||
|
badge_cls = "badge-active" if l["status"] == "active" else "badge-revoked"
|
||||||
|
rows.append(f"""
|
||||||
|
<tr>
|
||||||
|
<td><code>{l['license_id']}</code></td>
|
||||||
|
<td><strong>{l['seats']}</strong></td>
|
||||||
|
<td><span class="badge {badge_cls}">{l['status'].upper()}</span></td>
|
||||||
|
<td>{l['hostname'] or 'N/A'}</td>
|
||||||
|
<td><code>{l['client_cert_fingerprint'][:16]}...</code></td>
|
||||||
|
<td>{l['expires_at'][:10]}</td>
|
||||||
|
<td>{l['renewal_count']}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger" onclick="revokeLic('{l['license_id']}')">Revoke</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
""")
|
||||||
|
rows_html = "".join(rows) if rows else "<tr><td colspan='8' style='text-align: center; color: #64748b;'>No licenses issued yet.</td></tr>"
|
||||||
|
|
||||||
|
html_tpl = """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>LOGAR Master License Authority (logar.eibl.tech)</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 2rem; }
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; }
|
||||||
|
h1 { color: #38bdf8; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.card { background: #1e293b; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; border: 1px solid #334155; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||||
|
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid #334155; font-size: 0.9rem; }
|
||||||
|
th { color: #94a3b8; font-weight: 600; text-transform: uppercase; font-size: 0.75rem; }
|
||||||
|
.badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-weight: bold; font-size: 0.75rem; }
|
||||||
|
.badge-active { background: #065f46; color: #34d399; }
|
||||||
|
.badge-revoked { background: #7f1d1d; color: #f87171; }
|
||||||
|
code { background: #0f172a; padding: 2px 6px; border-radius: 4px; font-family: monospace; color: #a5b4fc; }
|
||||||
|
.btn { padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary { background: #0284c7; color: white; }
|
||||||
|
.btn-danger { background: #dc2626; color: white; }
|
||||||
|
textarea, input { background: #0f172a; border: 1px solid #475569; color: white; padding: 0.5rem; border-radius: 4px; width: 100%; box-sizing: border-box; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
label { display: block; margin-bottom: 0.5rem; color: #cbd5e1; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1><span>🛡️</span> LOGAR Master License Authority</h1>
|
||||||
|
<p style="color: #94a3b8;">Authoritative PKI for issuing seat-bearing Intermediate CA certificates and automatic mTLS renewals.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Issue Subordinate CA Certificate from CSR / .lic</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Seats Quota (Exact Seat Limit)</label>
|
||||||
|
<input type="number" id="seats" value="25" min="1" max="10000" style="max-width: 200px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CSR (PEM) or .lic JSON content</label>
|
||||||
|
<textarea id="csrText" rows="6" placeholder="-----BEGIN CERTIFICATE REQUEST----- ... or JSON .lic"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="issueLicense()">Sign & Issue CA Certificate</button>
|
||||||
|
<div id="issueOutput" style="margin-top: 1rem; display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Active & Issued Server Licenses</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>License ID</th>
|
||||||
|
<th>Seats</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Cert Fingerprint</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Renewals</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
__ROWS_HTML__
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function issueLicense() {
|
||||||
|
let raw = document.getElementById('csrText').value.trim();
|
||||||
|
const seats = parseInt(document.getElementById('seats').value, 10);
|
||||||
|
let csr = raw;
|
||||||
|
let fp = "";
|
||||||
|
let host = "";
|
||||||
|
|
||||||
|
if (raw.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
csr = parsed.csr;
|
||||||
|
fp = parsed.server_fingerprint || "";
|
||||||
|
host = parsed.hostname || "";
|
||||||
|
} catch(e) {
|
||||||
|
alert("Invalid JSON .lic file format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch('/api/license/issue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ csr: csr, seats: seats, server_fingerprint: fp, hostname: host })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
const out = document.getElementById('issueOutput');
|
||||||
|
out.style.display = 'block';
|
||||||
|
if (resp.ok) {
|
||||||
|
out.innerHTML = `
|
||||||
|
<div style="background: #064e3b; padding: 1rem; border-radius: 4px; border: 1px solid #059669;">
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0; color: #34d399;">License Issued Successfully! (Seats: ${data.seats})</h4>
|
||||||
|
<p style="margin: 0; font-size: 0.85rem;">License ID: <code>${data.license_id}</code></p>
|
||||||
|
<p style="margin: 0.5rem 0 0 0; font-size: 0.85rem;">Copy the signed CA certificate below or download it as <code>ca.crt</code>:</p>
|
||||||
|
<textarea rows="6" readonly style="margin-top: 0.5rem;">${data.ca_cert}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
out.innerHTML = `<div style="background: #7f1d1d; padding: 1rem; border-radius: 4px;">Error: ${data.detail}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeLic(licId) {
|
||||||
|
if (!confirm(`Are you sure you want to revoke license ${licId}?`)) return;
|
||||||
|
const resp = await fetch('/api/license/revoke', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ license_id: licId })
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert("Failed to revoke license");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
return html_tpl.replace("__ROWS_HTML__", rows_html)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="LOGAR Master License Authority (logar.eibl.tech)")
|
||||||
|
parser.add_argument("--init-master-ca", action="store_true", help="Initialize the Master Root CA")
|
||||||
|
parser.add_argument("--sign-lic", type=str, help="Path to .lic file or CSR PEM file to sign")
|
||||||
|
parser.add_argument("--seats", type=int, default=25, help="Number of seats to grant (default: 25)")
|
||||||
|
parser.add_argument("--out", type=str, default="ca.crt", help="Output path for signed CA cert")
|
||||||
|
parser.add_argument("--revoke", type=str, help="License ID to revoke")
|
||||||
|
parser.add_argument("--list", action="store_true", help="List all issued licenses")
|
||||||
|
parser.add_argument("--serve", action="store_true", help="Run the FastAPI / mTLS renewal service")
|
||||||
|
parser.add_argument("--host", type=str, default="0.0.0.0", help="Service host (default: 0.0.0.0)")
|
||||||
|
parser.add_argument("--port", type=int, default=8080, help="Service port (default: 8080)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.init_master_ca:
|
||||||
|
generate_master_root_ca()
|
||||||
|
print(f"[+] Master Root CA initialized in {DEFAULT_MASTER_CERT_DIR}/")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
lics = list_licenses()
|
||||||
|
print(f"\n{'License ID':<20} {'Seats':<8} {'Status':<10} {'Host':<20} {'Expires':<12} {'Renewals':<8}")
|
||||||
|
print("-" * 80)
|
||||||
|
for l in lics:
|
||||||
|
print(f"{l['license_id']:<20} {l['seats']:<8} {l['status']:<10} {l['hostname'] or 'N/A':<20} {l['expires_at'][:10]:<12} {l['renewal_count']:<8}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.revoke:
|
||||||
|
if revoke_license(args.revoke):
|
||||||
|
print(f"[+] License {args.revoke} revoked successfully.")
|
||||||
|
else:
|
||||||
|
print(f"[-] License {args.revoke} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.sign_lic:
|
||||||
|
with open(args.sign_lic, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
csr_pem = content
|
||||||
|
server_fp = ""
|
||||||
|
hostname = ""
|
||||||
|
if content.strip().startswith("{"):
|
||||||
|
data = json.loads(content)
|
||||||
|
csr_pem = data["csr"]
|
||||||
|
server_fp = data.get("server_fingerprint", "")
|
||||||
|
hostname = data.get("hostname", "")
|
||||||
|
|
||||||
|
res = issue_license_from_csr(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
seats=args.seats,
|
||||||
|
server_fingerprint=server_fp,
|
||||||
|
hostname=hostname
|
||||||
|
)
|
||||||
|
with open(args.out, "w", encoding="utf-8") as f:
|
||||||
|
f.write(res["ca_cert"])
|
||||||
|
|
||||||
|
print(f"[+] Signed CA certificate issued successfully!")
|
||||||
|
print(f" - License ID: {res['license_id']}")
|
||||||
|
print(f" - Granted Seats: {res['seats']}")
|
||||||
|
print(f" - Expires: {res['expires_at']}")
|
||||||
|
print(f" - Saved to: {args.out}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.serve:
|
||||||
|
if not FASTAPI_AVAILABLE:
|
||||||
|
print("[-] FastAPI/uvicorn not installed.")
|
||||||
|
sys.exit(1)
|
||||||
|
generate_master_root_ca()
|
||||||
|
print(f"[*] Starting LOGAR License Authority at http://{args.host}:{args.port}")
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
|
return
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1743
-781
File diff suppressed because it is too large
Load Diff
+359
-1
@@ -1,12 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
import datetime
|
import datetime
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from typing import Tuple, List, Optional
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import http.client
|
||||||
|
from typing import Tuple, List, Optional, Dict, Any
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
|
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
# Custom Enterprise X.509 OIDs for LOGAR Licensing
|
||||||
|
OID_LOGAR_SEATS = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.1")
|
||||||
|
OID_LOGAR_LICENSE_ID = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.2")
|
||||||
|
OID_LOGAR_ISSUER_URL = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.3")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_cert_fingerprint(cert_pem: str) -> str:
|
def calculate_cert_fingerprint(cert_pem: str) -> str:
|
||||||
"""Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate."""
|
"""Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate."""
|
||||||
@@ -378,3 +390,349 @@ def check_and_renew_hub_pki(
|
|||||||
|
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extracts embedded licensing attributes, validity, and metadata from a PEM X.509 certificate.
|
||||||
|
Supports decoding seat quota and license IDs embedded via LOGAR custom enterprise OIDs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
return {"valid": False, "error": f"Invalid certificate format: {e}"}
|
||||||
|
|
||||||
|
seats = None
|
||||||
|
license_id = None
|
||||||
|
issuer_url = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ext_seats = cert.extensions.get_extension_for_oid(OID_LOGAR_SEATS)
|
||||||
|
if ext_seats and ext_seats.value:
|
||||||
|
raw_val = ext_seats.value.value if hasattr(ext_seats.value, "value") else str(ext_seats.value)
|
||||||
|
if isinstance(raw_val, bytes):
|
||||||
|
seats = int(raw_val.decode("utf-8").strip())
|
||||||
|
else:
|
||||||
|
seats = int(str(raw_val).strip())
|
||||||
|
except (x509.ExtensionNotFound, ValueError, Exception):
|
||||||
|
seats = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ext_lic = cert.extensions.get_extension_for_oid(OID_LOGAR_LICENSE_ID)
|
||||||
|
if ext_lic and ext_lic.value:
|
||||||
|
raw_val = ext_lic.value.value if hasattr(ext_lic.value, "value") else str(ext_lic.value)
|
||||||
|
license_id = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip()
|
||||||
|
except (x509.ExtensionNotFound, Exception):
|
||||||
|
license_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ext_url = cert.extensions.get_extension_for_oid(OID_LOGAR_ISSUER_URL)
|
||||||
|
if ext_url and ext_url.value:
|
||||||
|
raw_val = ext_url.value.value if hasattr(ext_url.value, "value") else str(ext_url.value)
|
||||||
|
issuer_url = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip()
|
||||||
|
except (x509.ExtensionNotFound, Exception):
|
||||||
|
issuer_url = None
|
||||||
|
|
||||||
|
not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
is_expired = now > not_after
|
||||||
|
days_remaining = max(0, (not_after - now).days) if not is_expired else 0
|
||||||
|
|
||||||
|
is_ca = False
|
||||||
|
try:
|
||||||
|
bc_ext = cert.extensions.get_extension_for_oid(x509.OID_BASIC_CONSTRAINTS)
|
||||||
|
if bc_ext and bc_ext.value:
|
||||||
|
is_ca = bc_ext.value.ca
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
subject_cn = ""
|
||||||
|
for attr in cert.subject:
|
||||||
|
if attr.oid == NameOID.COMMON_NAME:
|
||||||
|
subject_cn = attr.value
|
||||||
|
break
|
||||||
|
|
||||||
|
issuer_cn = ""
|
||||||
|
for attr in cert.issuer:
|
||||||
|
if attr.oid == NameOID.COMMON_NAME:
|
||||||
|
issuer_cn = attr.value
|
||||||
|
break
|
||||||
|
|
||||||
|
is_self_signed = (cert.subject == cert.issuer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"seats": seats,
|
||||||
|
"license_id": license_id,
|
||||||
|
"issuer_url": issuer_url,
|
||||||
|
"subject_cn": subject_cn,
|
||||||
|
"issuer_cn": issuer_cn,
|
||||||
|
"is_ca": is_ca,
|
||||||
|
"is_self_signed": is_self_signed,
|
||||||
|
"fingerprint": cert.fingerprint(hashes.SHA256()).hex().upper(),
|
||||||
|
"not_valid_before": not_before.isoformat(),
|
||||||
|
"not_valid_after": not_after.isoformat(),
|
||||||
|
"is_expired": is_expired,
|
||||||
|
"days_remaining": days_remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ca_key_if_needed(cert_dir: str = "certs") -> Tuple[rsa.RSAPrivateKey, str]:
|
||||||
|
"""Ensures ca.key exists in cert_dir, generating a 4096-bit RSA key if not."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_key_path = os.path.join(cert_dir, "ca.key")
|
||||||
|
if os.path.exists(ca_key_path):
|
||||||
|
with open(ca_key_path, "r", encoding="utf-8") as f:
|
||||||
|
key_pem = f.read()
|
||||||
|
try:
|
||||||
|
ca_key = serialization.load_pem_private_key(key_pem.encode("utf-8"), password=None)
|
||||||
|
return ca_key, key_pem
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||||
|
key_pem = ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
|
).decode("utf-8")
|
||||||
|
with open(ca_key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(key_pem)
|
||||||
|
return ca_key, key_pem
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ca_csr(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
common_name: str = "LOGAR-Sub-CA",
|
||||||
|
organization: str = "LOGAR",
|
||||||
|
country: str = "AT"
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Generates a Certificate Signing Request (CSR) for a subordinate CA using ca.key.
|
||||||
|
Returns (csr_pem, ca_key_pem).
|
||||||
|
"""
|
||||||
|
ca_key, key_pem = generate_ca_key_if_needed(cert_dir)
|
||||||
|
subject = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||||
|
])
|
||||||
|
|
||||||
|
csr = (
|
||||||
|
x509.CertificateSigningRequestBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.add_extension(
|
||||||
|
x509.BasicConstraints(ca=True, path_length=0),
|
||||||
|
critical=True
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
key_encipherment=False,
|
||||||
|
key_cert_sign=True,
|
||||||
|
crl_sign=True,
|
||||||
|
content_commitment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False
|
||||||
|
),
|
||||||
|
critical=True
|
||||||
|
)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
|
||||||
|
csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
||||||
|
with open(csr_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(csr_pem)
|
||||||
|
|
||||||
|
return csr_pem, key_pem
|
||||||
|
|
||||||
|
|
||||||
|
def create_license_request_lic(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
license_server: str = "https://logar.eibl.tech",
|
||||||
|
server_fingerprint: str = "",
|
||||||
|
hostname: str = "",
|
||||||
|
requested_seats: int = 10,
|
||||||
|
common_name: str = "LOGAR-Sub-CA"
|
||||||
|
) -> Tuple[Dict[str, Any], str]:
|
||||||
|
"""
|
||||||
|
Packages a CA CSR and server metadata into an exportable .lic file dictionary and JSON string.
|
||||||
|
"""
|
||||||
|
csr_pem, _ = generate_ca_csr(cert_dir=cert_dir, common_name=common_name)
|
||||||
|
lic_data = {
|
||||||
|
"format": "LOGAR_LICENSE_REQUEST",
|
||||||
|
"version": "1.0",
|
||||||
|
"license_server": license_server,
|
||||||
|
"server_fingerprint": server_fingerprint,
|
||||||
|
"hostname": hostname,
|
||||||
|
"requested_seats": requested_seats,
|
||||||
|
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"csr": csr_pem
|
||||||
|
}
|
||||||
|
lic_json = json.dumps(lic_data, indent=2)
|
||||||
|
lic_path = os.path.join(cert_dir, "logar_server.lic")
|
||||||
|
with open(lic_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(lic_json)
|
||||||
|
return lic_data, lic_json
|
||||||
|
|
||||||
|
|
||||||
|
def sign_ca_csr_with_seats(
|
||||||
|
csr_pem: str,
|
||||||
|
master_ca_cert_pem: str,
|
||||||
|
master_ca_key_pem: str,
|
||||||
|
seats: int,
|
||||||
|
days_valid: int = 365,
|
||||||
|
license_id: Optional[str] = None,
|
||||||
|
license_server_url: str = "https://logar.eibl.tech"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Signs a subordinate CA CSR using the master license authority Root CA.
|
||||||
|
Embeds the approved seat count and license ID via custom X.509 extensions.
|
||||||
|
Returns the signed PEM certificate.
|
||||||
|
"""
|
||||||
|
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
|
||||||
|
if not csr.is_signature_valid:
|
||||||
|
raise ValueError("Invalid CSR signature")
|
||||||
|
|
||||||
|
master_ca_cert = x509.load_pem_x509_certificate(master_ca_cert_pem.encode("utf-8"))
|
||||||
|
master_ca_key = serialization.load_pem_private_key(master_ca_key_pem.encode("utf-8"), password=None)
|
||||||
|
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
if not license_id:
|
||||||
|
license_id = f"LIC-{csr.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo).hex()[:12].upper()}"
|
||||||
|
|
||||||
|
builder = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(csr.subject)
|
||||||
|
.issuer_name(master_ca_cert.subject)
|
||||||
|
.public_key(csr.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=days_valid))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
key_encipherment=False,
|
||||||
|
key_cert_sign=True,
|
||||||
|
crl_sign=True,
|
||||||
|
content_commitment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False
|
||||||
|
),
|
||||||
|
critical=True
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectKeyIdentifier.from_public_key(csr.public_key()),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.AuthorityKeyIdentifier.from_issuer_public_key(master_ca_key.public_key()),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.UnrecognizedExtension(OID_LOGAR_SEATS, str(seats).encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.UnrecognizedExtension(OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.UnrecognizedExtension(OID_LOGAR_ISSUER_URL, license_server_url.encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
signed_cert = builder.sign(master_ca_key, hashes.SHA256())
|
||||||
|
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def renew_ca_cert_mtls(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
license_server_url: str = "https://logar.eibl.tech",
|
||||||
|
upstream_ca_pem: Optional[str] = None,
|
||||||
|
timeout: int = 15,
|
||||||
|
verify_tls: bool = True
|
||||||
|
) -> Tuple[bool, str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir.
|
||||||
|
Returns (success, message, new_ca_cert_pem).
|
||||||
|
"""
|
||||||
|
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
ca_key_path = os.path.join(cert_dir, "ca.key")
|
||||||
|
|
||||||
|
if not os.path.exists(ca_cert_path) or not os.path.exists(ca_key_path):
|
||||||
|
return False, f"Missing ca.crt or ca.key in {cert_dir}", None
|
||||||
|
|
||||||
|
renew_url = f"{license_server_url.rstrip('/')}/api/license/renew"
|
||||||
|
parsed = urllib.parse.urlparse(renew_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(ca_cert_path, "r", encoding="utf-8") as f:
|
||||||
|
current_pem = f.read()
|
||||||
|
info = extract_license_info_from_cert(current_pem)
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"license_id": info.get("license_id"),
|
||||||
|
"fingerprint": info.get("fingerprint"),
|
||||||
|
"current_cert": current_pem
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
path = parsed.path or "/api/license/renew"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "LOGAR-Server-mTLS/2.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.scheme == "https":
|
||||||
|
ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||||
|
if not verify_tls:
|
||||||
|
ssl_ctx.check_hostname = False
|
||||||
|
ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
elif upstream_ca_pem:
|
||||||
|
ssl_ctx.load_verify_locations(cdata=upstream_ca_pem)
|
||||||
|
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
if os.path.exists(upstream_ca_path) and verify_tls and not upstream_ca_pem:
|
||||||
|
ssl_ctx.load_verify_locations(cafile=upstream_ca_path)
|
||||||
|
|
||||||
|
ssl_ctx.load_cert_chain(certfile=ca_cert_path, keyfile=ca_key_path)
|
||||||
|
conn = http.client.HTTPSConnection(parsed.netloc, context=ssl_ctx, timeout=timeout)
|
||||||
|
else:
|
||||||
|
conn = http.client.HTTPConnection(parsed.netloc, timeout=timeout)
|
||||||
|
|
||||||
|
conn.request("POST", path, body=payload, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
resp_data = resp.read().decode("utf-8")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if resp.status != 200:
|
||||||
|
return False, f"License server returned HTTP {resp.status}: {resp_data}", None
|
||||||
|
|
||||||
|
result = json.loads(resp_data)
|
||||||
|
new_cert_pem = result.get("ca_cert")
|
||||||
|
if not new_cert_pem:
|
||||||
|
return False, f"License server response missing 'ca_cert': {resp_data}", None
|
||||||
|
|
||||||
|
# Backup old ca.crt
|
||||||
|
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak")
|
||||||
|
|
||||||
|
# Write new ca.crt
|
||||||
|
with open(ca_cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_cert_pem)
|
||||||
|
|
||||||
|
return True, "Certificate renewed successfully via mTLS", new_cert_pem
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"mTLS renewal exception: {str(e)}", None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
# Ensure src/ is importable
|
||||||
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
||||||
|
if ROOT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT_DIR)
|
||||||
|
if SRC_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_DIR)
|
||||||
|
|
||||||
|
import server_enrollment as enrollment
|
||||||
|
import License_Authority as authority
|
||||||
|
import Server
|
||||||
|
|
||||||
|
|
||||||
|
class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = os.path.join(ROOT_DIR, "test_lic_tmp")
|
||||||
|
os.makedirs(self.test_dir, exist_ok=True)
|
||||||
|
self.master_ca_dir = os.path.join(self.test_dir, "master_ca")
|
||||||
|
self.server_cert_dir = os.path.join(self.test_dir, "server_certs")
|
||||||
|
self.db_path = os.path.join(self.test_dir, "license_auth.db")
|
||||||
|
self.server_db = os.path.join(self.test_dir, "server_state.db")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.test_dir):
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_master_root_ca_generation(self):
|
||||||
|
"""Verifies Master Root CA creation and structure."""
|
||||||
|
cert_pem, key_pem = authority.generate_master_root_ca(cert_dir=self.master_ca_dir)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", cert_pem)
|
||||||
|
self.assertIn("BEGIN RSA PRIVATE KEY", key_pem)
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.master_ca_dir, "master_root_ca.crt")))
|
||||||
|
|
||||||
|
def test_ca_csr_and_lic_request_generation(self):
|
||||||
|
"""Verifies subordinate CA CSR and .lic request file generation."""
|
||||||
|
lic_data, lic_json = enrollment.create_license_request_lic(
|
||||||
|
cert_dir=self.server_cert_dir,
|
||||||
|
license_server="https://logar.eibl.tech",
|
||||||
|
server_fingerprint="AABBCCDDEEFF00112233",
|
||||||
|
hostname="test-hub.corp.internal",
|
||||||
|
requested_seats=30
|
||||||
|
)
|
||||||
|
self.assertEqual(lic_data["format"], "LOGAR_LICENSE_REQUEST")
|
||||||
|
self.assertEqual(lic_data["license_server"], "https://logar.eibl.tech")
|
||||||
|
self.assertEqual(lic_data["requested_seats"], 30)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE REQUEST", lic_data["csr"])
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "ca.csr")))
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "logar_server.lic")))
|
||||||
|
|
||||||
|
def test_authority_issue_sub_ca_with_exact_seats(self):
|
||||||
|
"""Verifies that License Authority signs CSR, embeds exact seat quota via custom OID, and verifies in DB."""
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
result = authority.issue_license_from_csr(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
seats=42,
|
||||||
|
days_valid=365,
|
||||||
|
server_fingerprint="FP123456",
|
||||||
|
hostname="hub-node-1",
|
||||||
|
db_path=self.db_path,
|
||||||
|
cert_dir=self.master_ca_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "success")
|
||||||
|
self.assertEqual(result["seats"], 42)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", result["ca_cert"])
|
||||||
|
|
||||||
|
# Decode certificate to verify seat quota extraction
|
||||||
|
info = enrollment.extract_license_info_from_cert(result["ca_cert"])
|
||||||
|
self.assertTrue(info["valid"])
|
||||||
|
self.assertEqual(info["seats"], 42)
|
||||||
|
self.assertEqual(info["license_id"], result["license_id"])
|
||||||
|
self.assertEqual(info["issuer_url"], "https://logar.eibl.tech")
|
||||||
|
self.assertFalse(info["is_expired"])
|
||||||
|
self.assertTrue(info["is_ca"])
|
||||||
|
|
||||||
|
# Check authority database
|
||||||
|
lics = authority.list_licenses(db_path=self.db_path)
|
||||||
|
self.assertEqual(len(lics), 1)
|
||||||
|
self.assertEqual(lics[0]["license_id"], result["license_id"])
|
||||||
|
self.assertEqual(lics[0]["seats"], 42)
|
||||||
|
self.assertEqual(lics[0]["status"], "active")
|
||||||
|
|
||||||
|
def test_authority_renewal_and_revocation(self):
|
||||||
|
"""Verifies that an active license cert can be renewed, and revoked licenses are rejected."""
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
initial = authority.issue_license_from_csr(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
seats=15,
|
||||||
|
db_path=self.db_path,
|
||||||
|
cert_dir=self.master_ca_dir
|
||||||
|
)
|
||||||
|
lic_id = initial["license_id"]
|
||||||
|
|
||||||
|
# Successful renewal
|
||||||
|
renewed = authority.renew_license_cert(
|
||||||
|
current_cert_pem=initial["ca_cert"],
|
||||||
|
days_valid=365,
|
||||||
|
db_path=self.db_path,
|
||||||
|
cert_dir=self.master_ca_dir
|
||||||
|
)
|
||||||
|
self.assertEqual(renewed["status"], "renewed")
|
||||||
|
self.assertEqual(renewed["seats"], 15)
|
||||||
|
|
||||||
|
# Check renewal count in DB
|
||||||
|
lics = authority.list_licenses(db_path=self.db_path)
|
||||||
|
self.assertEqual(lics[0]["renewal_count"], 1)
|
||||||
|
|
||||||
|
# Revoke the license
|
||||||
|
revoked = authority.revoke_license(lic_id, db_path=self.db_path)
|
||||||
|
self.assertTrue(revoked)
|
||||||
|
|
||||||
|
# Renewal attempt after revocation must fail
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
authority.renew_license_cert(
|
||||||
|
current_cert_pem=renewed["ca_cert"],
|
||||||
|
db_path=self.db_path,
|
||||||
|
cert_dir=self.master_ca_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_server_license_import_and_seat_update(self):
|
||||||
|
"""Verifies that importing a signed CA cert updates the server's seat quota in DB and memory."""
|
||||||
|
# 1. Generate CSR on server side
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
|
||||||
|
# 2. Authority signs it with 50 seats
|
||||||
|
signed = authority.issue_license_from_csr(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
seats=50,
|
||||||
|
db_path=self.db_path,
|
||||||
|
cert_dir=self.master_ca_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Setup mock server state and DB
|
||||||
|
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,
|
||||||
|
"tcp_host": "127.0.0.1",
|
||||||
|
"tcp_port": 9443,
|
||||||
|
"hermes_host": "127.0.0.1",
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"server_fingerprint": "MOCK_FP",
|
||||||
|
"server_name": "Test-Hub",
|
||||||
|
"license_server_url": "https://logar.eibl.tech"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Extract summary before import
|
||||||
|
summary_before = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary_before["max_seats"], 5)
|
||||||
|
|
||||||
|
# 5. Save the signed CA certificate to cert_dir/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(signed["ca_cert"])
|
||||||
|
|
||||||
|
# 6. Extract summary after import
|
||||||
|
summary_after = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary_after["max_seats"], 50)
|
||||||
|
self.assertEqual(summary_after["license_id"], signed["license_id"])
|
||||||
|
self.assertEqual(summary_after["status"], "ACTIVE")
|
||||||
|
|
||||||
|
def test_server_client_revocation_and_reactivation(self):
|
||||||
|
"""Verifies revocation and reactivation of edge client seats."""
|
||||||
|
Server.init_db(self.server_db, max_seats=2)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Insert active clients
|
||||||
|
conn = sqlite3.connect(self.server_db)
|
||||||
|
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status) VALUES ('c1', 'win1', 'windows', 'FP1', 'active')")
|
||||||
|
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status) VALUES ('c2', 'lin1', 'linux', 'FP2', 'active')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Revoke c1
|
||||||
|
req = Server.ClientActionRequest(client_id="c1")
|
||||||
|
res = Server.revoke_client(req)
|
||||||
|
self.assertEqual(res["status"], "revoked")
|
||||||
|
|
||||||
|
summary = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary["active_seats"], 1)
|
||||||
|
self.assertEqual(summary["revoked_seats"], 1)
|
||||||
|
self.assertEqual(summary["available_seats"], 1)
|
||||||
|
|
||||||
|
# Reactivate c1
|
||||||
|
res_react = Server.reactivate_client(req)
|
||||||
|
self.assertEqual(res_react["status"], "active")
|
||||||
|
|
||||||
|
summary2 = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary2["active_seats"], 2)
|
||||||
|
self.assertEqual(summary2["available_seats"], 0)
|
||||||
|
|
||||||
|
def test_webui_rendering(self):
|
||||||
|
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
||||||
|
Server.init_db(self.server_db, max_seats=10)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 10,
|
||||||
|
"tcp_port": 9443,
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"server_name": "Test-Cloud-Hub",
|
||||||
|
"server_fingerprint": "1234567890ABCDEF",
|
||||||
|
"license_server_url": "https://logar.eibl.tech"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = Server.serve_webui()
|
||||||
|
html = response.body.decode("utf-8")
|
||||||
|
self.assertIn("LOGAR Fleet Hub & License Management", html.replace("&", "&"))
|
||||||
|
self.assertIn("Export .lic Request", html)
|
||||||
|
self.assertIn("Import & Activate Signed CA Certificate", html.replace("&", "&"))
|
||||||
|
self.assertIn("Renew via mTLS Now", html)
|
||||||
|
self.assertIn("logar.eibl.tech", html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user