windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m9s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 10m27s

This commit is contained in:
2026-09-26 21:21:36 +02:00
parent 8287af3129
commit 157229a84e
9 changed files with 244 additions and 14 deletions
+25
View File
@@ -50,6 +50,7 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR"
[Code] [Code]
var var
DataDirPage: TInputDirWizardPage; DataDirPage: TInputDirWizardPage;
ConfigFilePage: TInputFileWizardPage;
procedure InitializeWizard; procedure InitializeWizard;
begin begin
@@ -64,6 +65,16 @@ begin
); );
DataDirPage.Add('Data Directory:'); DataDirPage.Add('Data Directory:');
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR'); 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; end;
function GetDataDir(Param: String): String; function GetDataDir(Param: String): String;
@@ -73,11 +84,25 @@ begin
Result := ExpandConstant('{commonappdata}\LOGAR'); Result := ExpandConstant('{commonappdata}\LOGAR');
end; end;
function GetConfigFile(Param: String): String;
begin
Result := ConfigFilePage.Values[0];
end;
procedure CurStepChanged(CurStep: TSetupStep); procedure CurStepChanged(CurStep: TSetupStep);
var
CfgPath, DestCfg: String;
begin begin
if CurStep = ssPostInstall then if CurStep = ssPostInstall then
begin begin
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir('')); 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;
end; end;
+49
View File
@@ -17,6 +17,10 @@ VIAddVersionKey "FileVersion" "0.3.2.0"
VIAddVersionKey "ProductVersion" "0.3.2.0" VIAddVersionKey "ProductVersion" "0.3.2.0"
Var DATADIR Var DATADIR
Var CFGFILE
Var CfgDialog
Var CfgFileInput
Var CfgBrowseBtn
# --- UI Pages --- # --- UI Pages ---
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
@@ -40,6 +44,9 @@ Var DATADIR
!define MUI_DIRECTORYPAGE_VARIABLE $DATADIR !define MUI_DIRECTORYPAGE_VARIABLE $DATADIR
!insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_DIRECTORY
# Custom Page: Config File Selection
Page custom ConfigPageCreate ConfigPageLeave
# Instfiles Page # Instfiles Page
!insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_INSTFILES
@@ -57,8 +64,44 @@ Var DATADIR
Function .onInit Function .onInit
SetShellVarContext all SetShellVarContext all
StrCpy $DATADIR "$APPDATA\LOGAR" StrCpy $DATADIR "$APPDATA\LOGAR"
StrCpy $CFGFILE ""
FunctionEnd 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" Section "Install"
SetShellVarContext all SetShellVarContext all
SetOutPath "$INSTDIR" SetOutPath "$INSTDIR"
@@ -69,6 +112,12 @@ Section "Install"
CreateDirectory "$DATADIR" CreateDirectory "$DATADIR"
ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q' 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 # Install and configure service via NSSM
ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"' ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"'
+17 -3
View File
@@ -15,10 +15,24 @@ BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp")
def clean_and_prep(): def clean_and_prep():
if os.path.exists(DIST_DIR): if os.path.exists(DIST_DIR):
shutil.rmtree(DIST_DIR) for item in os.listdir(DIST_DIR):
os.makedirs(DIST_DIR, exist_ok=True) 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): 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) os.makedirs(BUILD_TEMP, exist_ok=True)
+8
View File
@@ -11,6 +11,14 @@ import warnings
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional 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 # Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
+42 -7
View File
@@ -17,6 +17,14 @@ import threading
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional, Tuple 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 # Suppress cryptography / pgpy deprecation notices for a clean terminal output
warnings.filterwarnings("ignore") 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 = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
ctx.load_verify_locations(cafile=ca_file) 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.verify_mode = ssl.CERT_REQUIRED
ctx.minimum_version = ssl.TLSVersion.TLSv1_3 ctx.minimum_version = ssl.TLSVersion.TLSv1_3
return ctx 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_cert_chain(certfile=srv_cert, keyfile=srv_key)
ssl_ctx.load_verify_locations(cafile=ca_file) 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( def check_and_rotate_server_certs(
@@ -1347,6 +1367,18 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
conn.close() conn.close()
raise HTTPException(status_code=500, detail="Root CA not loaded on server") 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 # 2. Check if client_id already registered
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
client_row = c.fetchone() client_row = c.fetchone()
@@ -1766,14 +1798,17 @@ async def import_signed_license(req: Request):
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") raise HTTPException(status_code=400, detail="Invalid JSON body")
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw") or body.get("license") or body.get("license_file") if isinstance(body, dict):
if not raw_input: if "master_root_ca" in body or "upstream_ca" in body:
raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request") raw_text = json.dumps(body)
elif "lic" in body:
if isinstance(raw_input, dict): raw_text = json.dumps(body["lic"]) if isinstance(body["lic"], dict) else str(body["lic"])
raw_text = json.dumps(raw_input) 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: else:
raw_text = str(raw_input) raw_text = str(body)
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
+8
View File
@@ -10,6 +10,14 @@ import warnings
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional 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 # Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
+17 -1
View File
@@ -251,8 +251,24 @@ def generate_server_cert_if_needed(
encryption_algorithm=serialization.NoEncryption() encryption_algorithm=serialization.NoEncryption()
).decode("utf-8") ).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: 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: with open(server_key_path, "w", encoding="utf-8") as f:
f.write(server_key_pem) f.write(server_key_pem)
+76 -1
View File
@@ -8,6 +8,7 @@ import struct
import sqlite3 import sqlite3
import urllib.request import urllib.request
import urllib.error import urllib.error
import shutil
import warnings import warnings
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -51,9 +52,84 @@ def run_tests():
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}") print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs") cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
shutil.rmtree(cert_dir, ignore_errors=True)
os.makedirs(cert_dir, exist_ok=True) os.makedirs(cert_dir, exist_ok=True)
client_id = "test-edge-node.corp.internal" client_id = "test-edge-node.corp.internal"
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret") 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) ===") print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/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) linux_logs = get_recent_linux_logs(hours=24)
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.") print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
import shutil
if os.path.exists(cert_dir): if os.path.exists(cert_dir):
shutil.rmtree(cert_dir, ignore_errors=True) shutil.rmtree(cert_dir, ignore_errors=True)
+2 -2
View File
@@ -220,7 +220,7 @@ class TestServerComponent(unittest.TestCase):
test_cert_dir = "test_certs_enroll" test_cert_dir = "test_certs_enroll"
try: try:
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir) 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_cert"] = ca_cert
Server.SERVER_STATE["ca_key"] = ca_key Server.SERVER_STATE["ca_key"] = ca_key
Server.SERVER_STATE["ca_cert_pem"] = ca_pem Server.SERVER_STATE["ca_cert_pem"] = ca_pem
@@ -246,7 +246,7 @@ class TestServerComponent(unittest.TestCase):
resp1 = Server.enroll_client(req1) resp1 = Server.enroll_client(req1)
self.assertIn("client_cert", resp1) self.assertIn("client_cert", resp1)
self.assertIn("client_key", 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 # 3. Valid enrollment for client 2
req2 = Server.ClientEnrollRequest( req2 = Server.ClientEnrollRequest(