From 157229a84e953f1abd2af75b5c8600333cbbbc3c Mon Sep 17 00:00:00 2001 From: max Date: Sat, 26 Sep 2026 21:21:36 +0200 Subject: [PATCH] windows installer fixes --- compilation/installer_client.iss | 25 +++++++++++ compilation/installer_client.nsi | 49 ++++++++++++++++++++ compilation/package_dist.py | 20 +++++++-- src/Linux_Client.py | 8 ++++ src/Server.py | 49 +++++++++++++++++--- src/Win_Client.py | 8 ++++ src/server_enrollment.py | 18 +++++++- tests/test_pipeline.py | 77 +++++++++++++++++++++++++++++++- tests/test_server.py | 4 +- 9 files changed, 244 insertions(+), 14 deletions(-) diff --git a/compilation/installer_client.iss b/compilation/installer_client.iss index 000943c..770bcc4 100644 --- a/compilation/installer_client.iss +++ b/compilation/installer_client.iss @@ -50,6 +50,7 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR" [Code] var DataDirPage: TInputDirWizardPage; + ConfigFilePage: TInputFileWizardPage; procedure InitializeWizard; begin @@ -64,6 +65,16 @@ begin ); DataDirPage.Add('Data Directory:'); DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR'); + + ConfigFilePage := CreateInputFilePage( + DataDirPage.ID, + 'Import Client Configuration (Optional)', + 'Do you have a pre-exported client_config.json profile to import?', + 'Select your client_config.json profile exported from the LOGAR Server Hub.'#13#10#13#10 + + 'If selected, this configuration will be copied into the data directory and used for automated server connection and enrollment.'#13#10#13#10 + + 'You can also leave this blank to place client_config.json in the data directory manually later.' + ); + ConfigFilePage.Add('Client Configuration (client_config.json):', 'JSON Files (*.json)|*.json|All Files (*.*)|*.*', '.json'); end; function GetDataDir(Param: String): String; @@ -73,11 +84,25 @@ begin Result := ExpandConstant('{commonappdata}\LOGAR'); end; +function GetConfigFile(Param: String): String; +begin + Result := ConfigFilePage.Values[0]; +end; + procedure CurStepChanged(CurStep: TSetupStep); +var + CfgPath, DestCfg: String; begin if CurStep = ssPostInstall then begin RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir('')); + + CfgPath := GetConfigFile(''); + if (CfgPath <> '') and FileExists(CfgPath) then + begin + DestCfg := GetDataDir('') + '\client_config.json'; + FileCopy(CfgPath, DestCfg, False); + end; end; end; diff --git a/compilation/installer_client.nsi b/compilation/installer_client.nsi index 014fec6..0a082ec 100644 --- a/compilation/installer_client.nsi +++ b/compilation/installer_client.nsi @@ -17,6 +17,10 @@ VIAddVersionKey "FileVersion" "0.3.2.0" VIAddVersionKey "ProductVersion" "0.3.2.0" Var DATADIR +Var CFGFILE +Var CfgDialog +Var CfgFileInput +Var CfgBrowseBtn # --- UI Pages --- !define MUI_ABORTWARNING @@ -40,6 +44,9 @@ Var DATADIR !define MUI_DIRECTORYPAGE_VARIABLE $DATADIR !insertmacro MUI_PAGE_DIRECTORY +# Custom Page: Config File Selection +Page custom ConfigPageCreate ConfigPageLeave + # Instfiles Page !insertmacro MUI_PAGE_INSTFILES @@ -57,8 +64,44 @@ Var DATADIR Function .onInit SetShellVarContext all StrCpy $DATADIR "$APPDATA\LOGAR" + StrCpy $CFGFILE "" FunctionEnd +Function ConfigPageCreate + nsDialogs::Create 1018 + Pop $CfgDialog + ${If} $CfgDialog == error + Abort + ${EndIf} + + !insertmacro MUI_HEADER_TEXT "Import Client Configuration (Optional)" "Choose a client_config.json profile exported from LOGAR Server." + + ${NSD_CreateLabel} 0 0 100% 30u "If you have exported a client_config.json profile bundle from your LOGAR Server dashboard, select it below to configure the forwarder immediately.$\r$\n$\r$\nLeave this field blank to configure later." + Pop $0 + + ${NSD_CreateText} 0 38u 75% 12u "$CFGFILE" + Pop $CfgFileInput + + ${NSD_CreateButton} 78% 37u 22% 14u "Browse..." + Pop $CfgBrowseBtn + ${NSD_OnClick} $CfgBrowseBtn OnBrowseCfg + + nsDialogs::Show +Function + +Function OnBrowseCfg + nsDialogs::SelectFileDialog open "" "LOGAR Client Profiles (*.json)|*.json|All Files (*.*)|*.*" + Pop $0 + ${If} $0 != "" + StrCpy $CFGFILE $0 + ${NSD_SetText} $CfgFileInput $CFGFILE + ${EndIf} +Function + +Function ConfigPageLeave + ${NSD_GetText} $CfgFileInput $CFGFILE +Function + Section "Install" SetShellVarContext all SetOutPath "$INSTDIR" @@ -69,6 +112,12 @@ Section "Install" CreateDirectory "$DATADIR" ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q' + # If client config file was selected, copy to data directory + ${If} $CFGFILE != "" + ${AndIf} ${FileExists} "$CFGFILE" + CopyFiles /SILENT "$CFGFILE" "$DATADIR\client_config.json" + ${EndIf} + # Install and configure service via NSSM ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"' diff --git a/compilation/package_dist.py b/compilation/package_dist.py index 1f12563..98fbc35 100644 --- a/compilation/package_dist.py +++ b/compilation/package_dist.py @@ -15,10 +15,24 @@ BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp") def clean_and_prep(): if os.path.exists(DIST_DIR): - shutil.rmtree(DIST_DIR) - os.makedirs(DIST_DIR, exist_ok=True) + for item in os.listdir(DIST_DIR): + if item == "runtime": + continue + item_path = os.path.join(DIST_DIR, item) + try: + if os.path.isdir(item_path): + shutil.rmtree(item_path) + else: + os.remove(item_path) + except Exception: + pass + else: + os.makedirs(DIST_DIR, exist_ok=True) if os.path.exists(BUILD_TEMP): - shutil.rmtree(BUILD_TEMP) + try: + shutil.rmtree(BUILD_TEMP) + except Exception: + pass os.makedirs(BUILD_TEMP, exist_ok=True) diff --git a/src/Linux_Client.py b/src/Linux_Client.py index f5c2466..86202fe 100644 --- a/src/Linux_Client.py +++ b/src/Linux_Client.py @@ -11,6 +11,14 @@ import warnings from datetime import datetime, timezone, timedelta from typing import Optional +# Ensure script directory and repo root are in sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + # Suppress cryptography / pgpy deprecation notices warnings.filterwarnings("ignore") diff --git a/src/Server.py b/src/Server.py index eb0760a..ccf90bc 100644 --- a/src/Server.py +++ b/src/Server.py @@ -17,6 +17,14 @@ import threading from datetime import datetime, timezone, timedelta from typing import Dict, Any, List, Optional, Tuple +# Ensure script directory and repo root are in sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + # Suppress cryptography / pgpy deprecation notices for a clean terminal output warnings.filterwarnings("ignore") @@ -910,6 +918,12 @@ def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) ctx.load_verify_locations(cafile=ca_file) + upstream_file = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_file): + try: + ctx.load_verify_locations(cafile=upstream_file) + except Exception: + pass ctx.verify_mode = ssl.CERT_REQUIRED ctx.minimum_version = ssl.TLSVersion.TLSv1_3 return ctx @@ -926,6 +940,12 @@ def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"): ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) ssl_ctx.load_verify_locations(cafile=ca_file) + upstream_file = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_file): + try: + ssl_ctx.load_verify_locations(cafile=upstream_file) + except Exception: + pass def check_and_rotate_server_certs( @@ -1347,6 +1367,18 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): conn.close() raise HTTPException(status_code=500, detail="Root CA not loaded on server") + # Ensure full trust chain (subordinate CA + upstream root CA if present) is delivered to edge clients + cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") + upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_ca_path): + try: + with open(upstream_ca_path, "r", encoding="utf-8") as f: + upstream_pem = f.read().strip() + if upstream_pem and upstream_pem not in ca_cert_pem: + ca_cert_pem = ca_cert_pem.strip() + "\n" + upstream_pem + except Exception: + pass + # 2. Check if client_id already registered c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) client_row = c.fetchone() @@ -1766,14 +1798,17 @@ async def import_signed_license(req: Request): except Exception: raise HTTPException(status_code=400, detail="Invalid JSON body") - raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw") or body.get("license") or body.get("license_file") - if not raw_input: - raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request") - - if isinstance(raw_input, dict): - raw_text = json.dumps(raw_input) + if isinstance(body, dict): + if "master_root_ca" in body or "upstream_ca" in body: + raw_text = json.dumps(body) + elif "lic" in body: + raw_text = json.dumps(body["lic"]) if isinstance(body["lic"], dict) else str(body["lic"]) + elif "ca_cert" in body and not body.get("master_root_ca"): + raw_text = str(body["ca_cert"]) + else: + raw_text = json.dumps(body) else: - raw_text = str(raw_input) + raw_text = str(body) config = SERVER_STATE.get("config", {}) cert_dir = config.get("cert_dir", "certs") diff --git a/src/Win_Client.py b/src/Win_Client.py index 71d782b..982351d 100644 --- a/src/Win_Client.py +++ b/src/Win_Client.py @@ -10,6 +10,14 @@ import warnings from datetime import datetime, timezone, timedelta from typing import Optional +# Ensure script directory and repo root are in sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + # Suppress cryptography / pgpy deprecation notices warnings.filterwarnings("ignore") diff --git a/src/server_enrollment.py b/src/server_enrollment.py index f1db507..2adda6d 100644 --- a/src/server_enrollment.py +++ b/src/server_enrollment.py @@ -251,8 +251,24 @@ def generate_server_cert_if_needed( encryption_algorithm=serialization.NoEncryption() ).decode("utf-8") + # Bundle subordinate CA and upstream CA into server.crt for complete TLS handshake chain + full_server_cert_pem = server_cert_pem + ca_cert_path = os.path.join(cert_dir, "ca.crt") + if os.path.exists(ca_cert_path): + with open(ca_cert_path, "r", encoding="utf-8") as f: + ca_pem = f.read().strip() + if ca_pem and ca_pem not in full_server_cert_pem: + full_server_cert_pem = full_server_cert_pem.strip() + "\n" + ca_pem + + upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_ca_path): + with open(upstream_ca_path, "r", encoding="utf-8") as f: + upstream_pem = f.read().strip() + if upstream_pem and upstream_pem not in full_server_cert_pem: + full_server_cert_pem = full_server_cert_pem.strip() + "\n" + upstream_pem + with open(server_cert_path, "w", encoding="utf-8") as f: - f.write(server_cert_pem) + f.write(full_server_cert_pem) with open(server_key_path, "w", encoding="utf-8") as f: f.write(server_key_pem) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index f2890ba..249a9fd 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -8,6 +8,7 @@ import struct import sqlite3 import urllib.request import urllib.error +import shutil import warnings from datetime import datetime, timezone, timedelta @@ -51,9 +52,84 @@ def run_tests(): print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}") cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs") + shutil.rmtree(cert_dir, ignore_errors=True) os.makedirs(cert_dir, exist_ok=True) client_id = "test-edge-node.corp.internal" enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret") + auth_token = server_conf.get("auth_token", "") + + print("\n=== [1.5] Testing Server License Activation via Web API (/api/license/import) ===") + import server_enrollment + + # 1. Check current license status + req_status_init = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status") + with urllib.request.urlopen(req_status_init, timeout=5) as resp: + init_lic_stat = json.loads(resp.read().decode("utf-8")) + + if init_lic_stat.get("status") == "UNLICENSED": + unlicensed_enroll_payload = { + "client_id": client_id, + "hostname": client_id, + "os": "linux", + "enrollment_secret": enrollment_secret + } + req_unlicensed = urllib.request.Request( + f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll", + data=json.dumps(unlicensed_enroll_payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req_unlicensed, timeout=5): + assert False, "Expected HTTP 403 on unlicensed server enrollment" + except urllib.error.HTTPError as e: + assert e.code == 403, f"Expected HTTP 403 on unlicensed server, got {e.code}" + print("[OK] Unlicensed server correctly rejected edge client enrollment.") + + # 2. Fetch CA CSR from Server + req_csr = urllib.request.Request( + f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/csr", + headers={"Authorization": f"Bearer {auth_token}"} + ) + with urllib.request.urlopen(req_csr, timeout=5) as resp: + assert resp.status == 200 + csr_data = json.loads(resp.read().decode("utf-8")) + csr_pem = csr_data["csr"] + print("[OK] Fetched subordinate CA CSR from server.") + + # 3. Generate test master CA and sign subordinate CA with 10 seats + master_ca_dir = os.path.join(ROOT_DIR, "test_pipeline_master_ca") + master_cert, master_key, master_pem, master_key_pem = server_enrollment.generate_ca_if_needed(cert_dir=master_ca_dir, common_name="LOGAR-Master-Root-CA") + sub_pem = server_enrollment.sign_ca_csr_with_seats( + csr_pem=csr_pem, + master_ca_cert_pem=master_pem, + master_ca_key_pem=master_key_pem, + license_id="LIC-TEST-PIPELINE-10", + seats=10, + days_valid=365 + ) + + # 4. Import signed license package to server + import_payload = {"ca_cert": sub_pem, "master_root_ca": master_pem} + req_import = urllib.request.Request( + f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/import", + data=json.dumps(import_payload).encode("utf-8"), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {auth_token}"} + ) + with urllib.request.urlopen(req_import, timeout=5) as resp: + assert resp.status == 200 + import_res = json.loads(resp.read().decode("utf-8")) + assert import_res["status"] == "success" + assert import_res["seats"] == 10 + print("[OK] Successfully imported signed license (.lic). Server activated with 10 seats.") + + # 5. Verify license status + req_status = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status") + with urllib.request.urlopen(req_status, timeout=5) as resp: + assert resp.status == 200 + lic_stat = json.loads(resp.read().decode("utf-8")) + assert lic_stat["status"] == "ACTIVE" + assert lic_stat["max_seats"] == 10 + print("[OK] Verified server license status is ACTIVE.") print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===") enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll" @@ -244,7 +320,6 @@ def run_tests(): linux_logs = get_recent_linux_logs(hours=24) print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.") - import shutil if os.path.exists(cert_dir): shutil.rmtree(cert_dir, ignore_errors=True) diff --git a/tests/test_server.py b/tests/test_server.py index 8cff0e7..28ce0bd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -220,7 +220,7 @@ class TestServerComponent(unittest.TestCase): test_cert_dir = "test_certs_enroll" try: ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir) - Server.SERVER_STATE["config"] = {"db_path": self.test_db} + Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": test_cert_dir} Server.SERVER_STATE["ca_cert"] = ca_cert Server.SERVER_STATE["ca_key"] = ca_key Server.SERVER_STATE["ca_cert_pem"] = ca_pem @@ -246,7 +246,7 @@ class TestServerComponent(unittest.TestCase): resp1 = Server.enroll_client(req1) self.assertIn("client_cert", resp1) self.assertIn("client_key", resp1) - self.assertEqual(resp1["ca_cert"], ca_pem) + self.assertTrue(resp1["ca_cert"].strip().startswith(ca_pem.strip())) # 3. Valid enrollment for client 2 req2 = Server.ClientEnrollRequest(