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]
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;
+49
View File
@@ -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"'
+17 -3
View File
@@ -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)
+8
View File
@@ -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")
+42 -7
View File
@@ -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")
+8
View File
@@ -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")
+17 -1
View File
@@ -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)
+76 -1
View File
@@ -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)
+2 -2
View File
@@ -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(