Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc2cc65571 | ||
|
|
4b74ea23af | ||
|
|
82eb10889d | ||
|
|
efa9ea7720 | ||
|
|
25a36c8092 | ||
|
|
4171649bd3 | ||
|
|
5670965e9d | ||
|
|
032a5a61b4 | ||
|
|
b1327a4336 | ||
|
|
6332dff670 |
+1
-1
Submodule LOGAR-LICENSING updated: 62474c69b7...8b6160ccac
@@ -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_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.
|
||||
- **Exportable `.lic` Request & Activated `.lic` Packages**:
|
||||
- **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.
|
||||
- **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`).
|
||||
- Importable via WebUI or CLI (`python src/Server.py --import-lic activated.lic` or `--import-ca`).
|
||||
- **Self-Updating Client Servers & Dynamic Seat Expansion**:
|
||||
- Client servers update themselves against the master authority (`logar.eibl.tech`) over mTLS (`python src/Server.py --sync-license` or via WebUI / automated watchdog).
|
||||
- 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
|
||||
- **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
|
||||
@@ -118,8 +119,8 @@ graph TB
|
||||
- **Licensing Actions**:
|
||||
- 1-click **Export .lic Request** file download.
|
||||
- Interactive **View & Copy CA CSR** modal.
|
||||
- Drag-and-drop or paste **Import Signed CA Certificate** modal.
|
||||
- **Renew via mTLS Now** button for immediate upstream renewal.
|
||||
- Drag-and-drop or paste **Import License (.lic / ca.crt)** modal.
|
||||
- **Sync & Renew via mTLS** button for immediate upstream synchronization and seat expansion.
|
||||
- **Seat Management & Revocation Table**:
|
||||
- Lists all connected Windows and Linux edge forwarders.
|
||||
- **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer.
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -110,8 +110,8 @@ 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`.
|
||||
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. **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.
|
||||
|
||||
### CLI Licensing Utilities
|
||||
@@ -119,10 +119,16 @@ Export a `.lic` request file via CLI:
|
||||
```bash
|
||||
./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
|
||||
./Server.bin --import-lic activated.lic
|
||||
# Or alternatively with --import-ca:
|
||||
./Server.bin --import-ca ca.crt
|
||||
```
|
||||
Synchronize license and check for seat quota increases against the master authority:
|
||||
```bash
|
||||
./Server.bin --sync-license
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -110,8 +110,8 @@ 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`.
|
||||
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. **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.
|
||||
|
||||
### CLI Licensing Utilities
|
||||
@@ -119,10 +119,16 @@ Export a `.lic` request file via CLI:
|
||||
```powershell
|
||||
.\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
|
||||
.\Server.exe --import-lic C:\LOGAR-Server\activated.lic
|
||||
# Or alternatively with --import-ca:
|
||||
.\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
@@ -866,7 +866,7 @@ def get_ca_csr():
|
||||
@app.post("/api/license/import")
|
||||
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,
|
||||
regenerates Server TLS certificate, and dynamically reloads in-flight mTLS context.
|
||||
"""
|
||||
@@ -875,36 +875,25 @@ async def import_signed_license(req: Request):
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||
|
||||
ca_cert_pem = body.get("ca_cert")
|
||||
if not ca_cert_pem and "lic" in body and isinstance(body["lic"], dict):
|
||||
ca_cert_pem = body["lic"].get("ca_cert")
|
||||
if not ca_cert_pem:
|
||||
raw_text = body.get("raw", "")
|
||||
if "-----BEGIN CERTIFICATE-----" in raw_text:
|
||||
ca_cert_pem = raw_text
|
||||
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw")
|
||||
if not raw_input:
|
||||
raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request")
|
||||
|
||||
if not ca_cert_pem:
|
||||
raise HTTPException(status_code=400, detail="Missing 'ca_cert' PEM certificate in request")
|
||||
|
||||
info = enrollment.extract_license_info_from_cert(ca_cert_pem)
|
||||
if not info.get("valid"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid CA certificate: {info.get('error')}")
|
||||
if isinstance(raw_input, dict):
|
||||
raw_text = json.dumps(raw_input)
|
||||
else:
|
||||
raw_text = str(raw_input)
|
||||
|
||||
config = SERVER_STATE.get("config", {})
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
|
||||
# Backup existing CA if present
|
||||
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_cert_pem)
|
||||
try:
|
||||
res = enrollment.import_license_package(raw_text, cert_dir=cert_dir)
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
|
||||
info = res["license_info"]
|
||||
ca_cert_pem = res["ca_cert_pem"]
|
||||
new_seats = info.get("seats") or config.get("max_seats", 10)
|
||||
config["max_seats"] = new_seats
|
||||
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")
|
||||
def trigger_license_renewal():
|
||||
"""Explicitly triggers automated mTLS renewal with logar.eibl.tech."""
|
||||
@app.post("/api/license/sync")
|
||||
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", {})
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
|
||||
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
@@ -980,7 +985,7 @@ def trigger_license_renewal():
|
||||
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
||||
if ssl_ctx:
|
||||
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 {
|
||||
"status": "success",
|
||||
@@ -1343,10 +1348,10 @@ def serve_webui():
|
||||
<span>📄</span> View / Copy CA CSR
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="openImportModal()">
|
||||
<span>🔑</span> Import Signed CA Certificate
|
||||
<span>🔑</span> Import License (.lic / ca.crt)
|
||||
</button>
|
||||
<button class="btn btn-success" onclick="triggerMtlsRenewal()">
|
||||
<span>🔄</span> Renew via mTLS Now
|
||||
<span>🔄</span> Sync & Renew via mTLS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1388,7 +1393,7 @@ def serve_webui():
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="anomaliesTableBody">
|
||||
__ANOMALIES_HTML__
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1403,7 +1408,7 @@ def serve_webui():
|
||||
<div class="modal-close" onclick="closeModal('csrModal')">×</div>
|
||||
</div>
|
||||
<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>
|
||||
<textarea id="csrContent" rows="10" readonly></textarea>
|
||||
<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 class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Import & Activate Signed CA Certificate</div>
|
||||
<div class="modal-title">Import & Activate Signed License (.lic / ca.crt)</div>
|
||||
<div class="modal-close" onclick="closeModal('importModal')">×</div>
|
||||
</div>
|
||||
<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>
|
||||
<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;">
|
||||
<button class="btn btn-secondary" onclick="closeModal('importModal')">Cancel</button>
|
||||
<button class="btn btn-success" onclick="submitImport()">Activate License</button>
|
||||
@@ -1467,7 +1472,7 @@ def serve_webui():
|
||||
|
||||
async function submitImport() {
|
||||
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');
|
||||
|
||||
const resp = await fetch('/api/license/import', {
|
||||
@@ -1485,15 +1490,15 @@ def serve_webui():
|
||||
}
|
||||
|
||||
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...');
|
||||
const resp = await fetch('/api/license/renew', { method: 'POST' });
|
||||
const resp = await fetch('/api/license/sync', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
showAlert('Renewal Successful! ' + data.message);
|
||||
showAlert('Sync Successful! ' + data.message + ' (Active Seats: ' + data.seats + ')');
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
} 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("--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-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()
|
||||
|
||||
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)}")
|
||||
sys.exit(0)
|
||||
|
||||
# Handle standalone CLI CA import
|
||||
if args.import_ca:
|
||||
with open(args.import_ca, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
ca_pem = content
|
||||
if content.strip().startswith("{"):
|
||||
data = json.loads(content)
|
||||
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')}")
|
||||
# Handle standalone CLI CA / .lic import
|
||||
lic_file = args.import_lic or args.import_ca
|
||||
if lic_file:
|
||||
try:
|
||||
res = enrollment.import_license_package(lic_file, cert_dir=cert_dir)
|
||||
except Exception as e:
|
||||
print(f"[-] Invalid license file {lic_file}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
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)
|
||||
|
||||
info = res["license_info"]
|
||||
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()
|
||||
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" - License ID: {info.get('license_id')}")
|
||||
print(f" - Expires: {info.get('not_valid_after')}")
|
||||
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)
|
||||
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
|
||||
|
||||
|
||||
@@ -654,16 +654,90 @@ def sign_ca_csr_with_seats(
|
||||
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(
|
||||
cert_dir: str = "certs",
|
||||
license_server_url: str = "https://logar.eibl.tech",
|
||||
upstream_ca_pem: Optional[str] = None,
|
||||
requested_seats: Optional[int] = None,
|
||||
seat_increment: int = 0,
|
||||
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.
|
||||
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.
|
||||
Returns (success, message, new_ca_cert_pem).
|
||||
"""
|
||||
@@ -681,11 +755,17 @@ def renew_ca_cert_mtls(
|
||||
current_pem = f.read()
|
||||
info = extract_license_info_from_cert(current_pem)
|
||||
|
||||
payload = json.dumps({
|
||||
payload_dict = {
|
||||
"license_id": info.get("license_id"),
|
||||
"fingerprint": info.get("fingerprint"),
|
||||
"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"
|
||||
headers = {
|
||||
@@ -730,6 +810,13 @@ def renew_ca_cert_mtls(
|
||||
with open(ca_cert_path, "w", encoding="utf-8") as f:
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -202,6 +202,82 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
||||
self.assertEqual(summary2["active_seats"], 2)
|
||||
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):
|
||||
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
||||
Server.init_db(self.server_db, max_seats=10)
|
||||
@@ -220,8 +296,8 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
||||
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("Import License (.lic / ca.crt)", html)
|
||||
self.assertIn("Sync & Renew via mTLS", html.replace("&", "&"))
|
||||
self.assertIn("logar.eibl.tech", html)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user