Compare commits
7
Commits
d9cfb06fad
...
914db938c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914db938c3 | ||
|
|
506d3fc234 | ||
|
|
ff5caaf8c7 | ||
|
|
63e8ff5492 | ||
|
|
18e28e2797 | ||
|
|
0e9e3f0f64 | ||
|
|
ea8e98218c |
+1
-1
Submodule LOGAR-LICENSING updated: 163194331e...5806200546
@@ -104,18 +104,17 @@ 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 & 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.
|
||||
- **Automated Container Pipeline & Portainer GitOps Deployment**:
|
||||
- The license authority repository includes a dedicated CI/CD workflow ([`.gitea/workflows/build-container.yml`](file:///u:/max/Desktop/LOGAR/LOGAR-LICENSING/.gitea/workflows/build-container.yml)) compiling and publishing the container image to `gitea.eibl.tech/me0nline/logar-licensing:latest` (authenticated via `giteatoken` secret) and `10.30.0.3:5000/logar-licensing:latest`.
|
||||
- It deploys seamlessly in Portainer using a GitOps Stack connected directly to [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) via [`docker-compose.yml`](file:///u:/max/Desktop/LOGAR/LOGAR-LICENSING/docker-compose.yml) with automatic polling or webhook redeployment and persistent volume storage (`logar_licensing_data`) preserving Root CA keys and database.
|
||||
- Reverse proxy integration (e.g., Nginx Proxy Manager, Caddy) terminates HTTPS on port 443, verifies client mTLS against the downloadable Master Root CA (`/api/ca.crt`), and forwards client certificate headers (`X-SSL-Client-Cert`) to port 8090.
|
||||
- **Bootstrap `.lic` Packages & Instant WebSocket Auto-Enrollment**:
|
||||
- The License Authority (`logar.eibl.tech`) generates initial **Bootstrap `.lic` Packages** containing Master Root CA, dedicated communication credentials (`communication_cert` + `communication_key`), CSR templates, and WebSocket coordinates (`wss://logar.eibl.tech/ws/license`).
|
||||
- **1-Click Import & Live Signing**: Importing the `.lic` file on the client server (`python src/Server.py --import-lic <file.lic>` or via WebUI) connects directly via **WebSocket**, submits the server Sub-CA CSR, and immediately installs the officially signed Sub-CA license certificate.
|
||||
- **Self-Updating Client Servers & Live WebSocket Sync**:
|
||||
- Client servers update themselves against the master authority (`wss://logar.eibl.tech/ws/license`) over WebSocket (`python src/Server.py --sync-license` or via WebUI / automated watchdog).
|
||||
- Seat quotas can be increased with each renewal or updated on-demand in the License Authority WebUI.
|
||||
- The client server automatically receives the updated seat quota in its signed certificate, updates its SQLite database and runtime configuration, and reloads its in-flight `ssl.SSLContext` without dropping client connections or restarting services.
|
||||
- **Automated Container Pipeline & Ephemeral NPM Pass-Through**:
|
||||
- The license authority repository includes a dedicated CI/CD workflow ([`.gitea/workflows/build-container.yml`](file:///u:/max/Desktop/LOGAR/LOGAR-LICENSING/.gitea/workflows/build-container.yml)) publishing to `gitea.eibl.tech/me0nline/logar-licensing:latest` and `10.30.0.3:5000/logar-licensing:latest`.
|
||||
- Works seamlessly with ephemeral Nginx Proxy Manager (NPM) by enabling the standard **Websockets Support** toggle for `logar.eibl.tech`.
|
||||
- Admin WebUI is protected by session login (`/login`, default: `admin` / `admin_license_2026!`).
|
||||
|
||||
### 3. Integrated Fleet Licensing & WebUI Dashboard
|
||||
- **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
|
||||
|
||||
@@ -3,5 +3,6 @@ standard-imghdr>=3.13.0; python_version >= "3.13"
|
||||
cryptography>=42.0.0
|
||||
fastapi>=0.110.0
|
||||
uvicorn>=0.28.0
|
||||
websockets>=11.0.0
|
||||
pydantic>=2.6.0
|
||||
pywin32>=306; sys_platform == "win32"
|
||||
|
||||
@@ -131,7 +131,7 @@ Synchronize license and check for seat quota increases against the master author
|
||||
```
|
||||
|
||||
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `10.30.0.3:5000/logar-licensing:latest` (or `gitea.eibl.tech/me0nline/logar-licensing:latest`) with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager, Caddy) which validates mTLS client certificates against the Master Root CA and forwards client credentials.
|
||||
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `gitea.eibl.tech/me0nline/logar-licensing:latest` (or `10.30.0.3:5000/logar-licensing:latest`) with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager with **Websockets Support** enabled), handling WebSocket licensing handshakes (`wss://logar.eibl.tech/ws/license`) and authenticated WebUI dashboard access.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ Synchronize license and check for seat quota increases against the master author
|
||||
```
|
||||
|
||||
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `10.30.0.3:5000/logar-licensing:latest` (or `gitea.eibl.tech/me0nline/logar-licensing:latest`) with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager, Caddy) which validates mTLS client certificates against the Master Root CA and forwards client credentials.
|
||||
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `gitea.eibl.tech/me0nline/logar-licensing:latest` (or `10.30.0.3:5000/logar-licensing:latest`) with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager with **Websockets Support** enabled), handling WebSocket licensing handshakes (`wss://logar.eibl.tech/ws/license`) and authenticated WebUI dashboard access.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+42
-17
@@ -1645,16 +1645,21 @@ def main():
|
||||
lic_file = args.import_lic or args.import_ca
|
||||
if lic_file:
|
||||
try:
|
||||
res = enrollment.import_license_package(lic_file, cert_dir=cert_dir)
|
||||
res = enrollment.import_license_package(
|
||||
lic_file,
|
||||
cert_dir=cert_dir,
|
||||
server_fingerprint=config.get("server_fingerprint", ""),
|
||||
hostname=config.get("server_name", "")
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[-] Invalid license file {lic_file}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
info = res["license_info"]
|
||||
new_seats = info.get("seats") or config.get("max_seats", 10)
|
||||
info = res.get("license_info", {})
|
||||
new_seats = res.get("seats") or 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.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, res.get("license_id") or info.get("license_id")))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -1669,24 +1674,44 @@ def main():
|
||||
|
||||
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')}")
|
||||
print(f" - License ID: {res.get('license_id') or info.get('license_id')}")
|
||||
print(f" - Expires: {info.get('not_valid_after') or res.get('expires_at') or 'N/A'}")
|
||||
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)
|
||||
lic_server = config.get("license_server_url", "wss://logar.eibl.tech/ws/license")
|
||||
print(f"[*] Connecting to master authority at {lic_server} via WebSocket to sync license...")
|
||||
|
||||
ca_info = {}
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
if os.path.exists(ca_path):
|
||||
with open(ca_path, "r", encoding="utf-8") as f:
|
||||
ca_info = enrollment.extract_license_info_from_cert(f.read())
|
||||
|
||||
ws_url = lic_server if ("ws://" in lic_server or "wss://" in lic_server) else f"{lic_server.replace('https://', 'wss://').replace('http://', 'ws://').rstrip('/')}/ws/license"
|
||||
success, msg, res = enrollment.enroll_and_sync_license_ws(
|
||||
cert_dir=cert_dir,
|
||||
license_id=ca_info.get("license_id"),
|
||||
license_authority_ws_url=ws_url,
|
||||
server_fingerprint=config.get("server_fingerprint", ""),
|
||||
hostname=config.get("server_name", "")
|
||||
)
|
||||
|
||||
if not success or not res:
|
||||
# Fallback to mTLS HTTP sync
|
||||
print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...")
|
||||
http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "")
|
||||
success, msg, new_pem = enrollment.renew_ca_cert_mtls(cert_dir=cert_dir, license_server_url=http_url)
|
||||
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)
|
||||
else:
|
||||
info = res.get("license_info", {})
|
||||
new_seats = res.get("seats", config.get("max_seats", 10))
|
||||
|
||||
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")))
|
||||
|
||||
+172
-6
@@ -654,16 +654,157 @@ def sign_ca_csr_with_seats(
|
||||
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||
|
||||
|
||||
def enroll_and_sync_license_ws(
|
||||
cert_dir: str = "certs",
|
||||
license_id: Optional[str] = None,
|
||||
license_authority_ws_url: str = "wss://logar.eibl.tech/ws/license",
|
||||
server_fingerprint: str = "",
|
||||
hostname: str = "",
|
||||
comm_cert_pem: Optional[str] = None,
|
||||
master_root_ca_pem: Optional[str] = None,
|
||||
timeout: int = 15,
|
||||
verify_tls: bool = True
|
||||
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Connects to the Master License Authority via WebSocket protocol, authenticating and
|
||||
submitting the server Sub-CA CSR to receive a cryptographically signed Sub-CA license
|
||||
certificate with embedded seat quotas.
|
||||
"""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_key_path = os.path.join(cert_dir, "ca.key")
|
||||
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
||||
|
||||
# 1. Load or generate client Sub-CA private key
|
||||
if os.path.exists(ca_key_path):
|
||||
with open(ca_key_path, "r", encoding="utf-8") as f:
|
||||
ca_key_pem = f.read()
|
||||
ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None)
|
||||
else:
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
ca_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(ca_key_pem)
|
||||
|
||||
# 2. Build Sub-CA CSR
|
||||
csr_common_name = f"LOGAR-Server-CA-{license_id or 'Node'}"
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR Subordinate Node"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, csr_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")
|
||||
|
||||
# 3. Connect via WebSocket
|
||||
try:
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
except ImportError:
|
||||
return False, "websockets library not available", None
|
||||
|
||||
ssl_context = None
|
||||
if license_authority_ws_url.startswith("wss://"):
|
||||
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
if not verify_tls:
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
elif master_root_ca_pem:
|
||||
ssl_context.load_verify_locations(cdata=master_root_ca_pem)
|
||||
else:
|
||||
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||
if os.path.exists(upstream_path):
|
||||
ssl_context.load_verify_locations(cafile=upstream_path)
|
||||
|
||||
try:
|
||||
with ws_connect(
|
||||
license_authority_ws_url,
|
||||
ssl=ssl_context,
|
||||
open_timeout=timeout,
|
||||
close_timeout=timeout
|
||||
) as ws:
|
||||
payload = {
|
||||
"action": "enroll_or_sync",
|
||||
"license_id": license_id,
|
||||
"communication_cert": comm_cert_pem,
|
||||
"server_csr": csr_pem,
|
||||
"server_fingerprint": server_fingerprint,
|
||||
"hostname": hostname
|
||||
}
|
||||
ws.send(json.dumps(payload))
|
||||
raw_reply = ws.recv(timeout=timeout)
|
||||
reply = json.loads(raw_reply)
|
||||
|
||||
if reply.get("status") != "success":
|
||||
return False, f"WebSocket licensing error: {reply.get('message', raw_reply)}", None
|
||||
|
||||
new_ca_pem = reply.get("ca_cert")
|
||||
if not new_ca_pem:
|
||||
return False, "License response missing 'ca_cert'", None
|
||||
|
||||
# Backup old ca.crt if present
|
||||
if os.path.exists(ca_cert_path):
|
||||
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
try:
|
||||
os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(ca_cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_ca_pem)
|
||||
|
||||
upstream_ca = reply.get("master_root_ca") or master_root_ca_pem
|
||||
if upstream_ca:
|
||||
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
||||
f.write(upstream_ca)
|
||||
|
||||
lic_info = extract_license_info_from_cert(new_ca_pem)
|
||||
return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", {
|
||||
"license_id": license_id,
|
||||
"seats": reply.get("seats"),
|
||||
"ca_cert_pem": new_ca_pem,
|
||||
"master_root_ca_pem": upstream_ca,
|
||||
"license_info": lic_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return False, f"WebSocket enrollment failed: {str(e)}", None
|
||||
|
||||
|
||||
def import_license_package(
|
||||
lic_content_or_path: str,
|
||||
cert_dir: str = "certs"
|
||||
cert_dir: str = "certs",
|
||||
server_fingerprint: str = "",
|
||||
hostname: str = ""
|
||||
) -> 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
|
||||
1. Bootstrap JSON .lic package (auto-enrolls via WebSocket to receive signed CA cert)
|
||||
2. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca'
|
||||
3. Raw PEM Subordinate CA certificate
|
||||
4. File path pointing to any 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.
|
||||
@@ -681,10 +822,35 @@ def import_license_package(
|
||||
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}")
|
||||
|
||||
# If this is a bootstrap license package, auto-enroll over WebSocket
|
||||
if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")):
|
||||
lic_id = data.get("license_id")
|
||||
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
|
||||
comm_cert = data.get("communication_cert")
|
||||
master_ca = data.get("master_root_ca")
|
||||
|
||||
if master_ca:
|
||||
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
||||
f.write(master_ca)
|
||||
|
||||
ok, msg, res = enroll_and_sync_license_ws(
|
||||
cert_dir=cert_dir,
|
||||
license_id=lic_id,
|
||||
license_authority_ws_url=ws_url,
|
||||
server_fingerprint=server_fingerprint or data.get("server_fingerprint", ""),
|
||||
hostname=hostname or data.get("hostname", ""),
|
||||
comm_cert_pem=comm_cert,
|
||||
master_root_ca_pem=master_ca
|
||||
)
|
||||
if not ok or not res:
|
||||
raise ValueError(msg)
|
||||
return res
|
||||
|
||||
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")
|
||||
elif "-----BEGIN CERTIFICATE-----" in trimmed:
|
||||
ca_pem = trimmed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user