Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f999fc6c3 | ||
|
|
3399c6d57b | ||
|
|
c6d04f8896 | ||
|
|
0a67b43619 | ||
|
|
3234acf435 | ||
|
|
702ca985d2 | ||
|
|
97d92d1bee | ||
|
|
a8cd2cd83c | ||
|
|
6843b22ac1 | ||
|
|
bfd576bfdd | ||
|
|
9810220cb7 |
+1
-1
Submodule LOGAR-LICENSING updated: 119061721a...5a8d69c005
@@ -1,11 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo_banner.svg" alt="LOGAR Banner" width="750"/>
|
||||
<img src="https://gitea.eibl.tech/me0nline/LOGAR/raw/branch/main/assets/logo_banner.svg" alt="LOGAR Banner" width="750"/>
|
||||
</p>
|
||||
|
||||
# LOGAR: Fleet Log Aggregation & Centralized Provisioning System
|
||||
### *Secure Log Aggregation, PKI Fleet Licensing & Centralized Delivery for Autonomous AI Analysis*
|
||||
|
||||
[](RELEASE_NOTES.md)
|
||||
[](RELEASE_NOTES.md)
|
||||
[](tests/)
|
||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Branding-Guide)
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# LOGAR Release Notes
|
||||
|
||||
## [v0.3.4.2] - 2026-09-30
|
||||
|
||||
### 🔒 Exclusive Hardware Host-Binding & Reassignment Controls
|
||||
- **Automated Host Binding on First Activation**: Licenses now automatically lock to the specific server hardware fingerprint (`server_fingerprint`) and hostname upon first activation/enrollment, preventing multi-host cloning or license hijacking.
|
||||
- **Manual Host Reassignment Control**: Added `🔓 Reset Host Binding` button to the Master License Authority dashboard (`licenseDetailsModal`), allowing administrators to unbind a license so it can be cleanly transferred or reassigned to a new server instance.
|
||||
- **REST & CLI Management**: Added `POST /api/license/reset-binding` and CLI argument `--reset-binding LIC_ID` for automated provisioning pipelines.
|
||||
|
||||
---
|
||||
|
||||
## [v0.3.4.1] - 2026-09-27
|
||||
|
||||
### 🎨 Installer Dialog UI Spacing & Branding Enhancements
|
||||
- **NSIS Client Config Profile Dialog Fix**: Adjusted vertical coordinates and label boundaries in the Windows Client NSIS installer (`installer_client.nsi`) to prevent input field overlap and ensure multi-line instructional text renders without visual clipping.
|
||||
- **Installer Footer Branding Clean-Up**: Escaped ampersands in Windows installer branding labels (`BrandingText`) to avoid character truncation in native win32 footer panels.
|
||||
- **License Authority UI Clean-Up**: Simplified table ACTIONS column in the Licensing Authority portal to exclusively display `⚙️ Manage`, keeping secondary actions (`Reinstate`, `Delete`) inside the dedicated management modal.
|
||||
|
||||
---
|
||||
|
||||
## [v0.3.4] - 2026-09-27
|
||||
|
||||
### 🔄 In-Place Installer Upgrades & Mutual Exclusion Guards
|
||||
- **Seamless In-Place Upgrades**: Windows (Inno Setup & NSIS) and Linux installers now detect existing installations automatically. In upgrade mode, installers stop background services, update core binaries and runtime in-place, and restart services without overwriting configuration files (`server_config.json`, `client_config.json`), database state (`logar_state.db`), state tracking (`client_state.json`), or mTLS certificates.
|
||||
- **Host Mutual Exclusion Validation**: Added pre-installation validation across all installers to prevent accidental co-installation of LOGAR Server and LOGAR Client on the same host, showing descriptive guidance to administrators.
|
||||
|
||||
### 🛡️ WebUI 2FA & Telemetry Stability
|
||||
- **Server Hub TOTP Two-Factor Authentication**: Implemented RFC 6238 TOTP 2FA for the LOGAR Server Hub web portal (`:8443`), featuring interactive QR code setup, secret generation, and secure session validation.
|
||||
- **Telemetry Stream UI Buffer Capping**: Limited the live verified telemetry stream view to the latest 50 events in the Server Hub dashboard for enhanced browser performance and UI stability.
|
||||
|
||||
### 🔑 Licensing Authority Management & Registry Tooling
|
||||
- **Revocation, Reinstatement & License Deletion**: Enhanced the Master Licensing Authority (`logar.eibl.tech`) with instant license revocation, reinstatement of revoked licenses, and conditional permanent deletion of revoked records.
|
||||
- **Interactive Capability Explorer**: Added interactive implementation breakdown modals across core capabilities on the public project landing page.
|
||||
- **Automated Gitea Container Pruning**: Added `prune_images.py` utility to maintain clean container registries by keeping only the latest active image versions.
|
||||
|
||||
---
|
||||
|
||||
## [v0.3.3] - 2026-09-27
|
||||
|
||||
### 🎨 Brand Identity, Centralized Asset Library & Design System
|
||||
|
||||
@@ -7,6 +7,26 @@ ENROLL_SECRET="${2:-}"
|
||||
INSTALL_DIR="/opt/logar-client"
|
||||
CONFIG_DIR="/etc/logar"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Server is present
|
||||
if [ -f "/opt/logar-server/Server" ] || [ -f "/etc/systemd/system/logar-server.service" ]; then
|
||||
echo "[-] Error: LOGAR Server is already installed on this machine." >&2
|
||||
echo "[-] LOGAR Client cannot be installed on a host where LOGAR Server is present (LOGAR Server includes built-in log collection)." >&2
|
||||
echo "[-] Please uninstall LOGAR Server first before installing LOGAR Client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||
IS_UPDATE=0
|
||||
if [ -f "${INSTALL_DIR}/Linux_Client" ] || [ -f "/etc/systemd/system/logar-client.service" ]; then
|
||||
IS_UPDATE=1
|
||||
echo "[*] Existing LOGAR Client installation detected. Performing in-place upgrade..."
|
||||
fi
|
||||
|
||||
if [ "${IS_UPDATE}" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[*] Stopping LOGAR Client service for upgrade..."
|
||||
systemctl stop logar-client.service || true
|
||||
fi
|
||||
|
||||
echo "[+] Installing LOGAR Client..."
|
||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
||||
chmod 700 "${CONFIG_DIR}/certs"
|
||||
|
||||
@@ -21,14 +21,34 @@ echo "============================================================"
|
||||
echo " LOGAR Server Enterprise Linux Installer"
|
||||
echo "============================================================"
|
||||
|
||||
# Interactive configuration if running in a terminal without CLI arguments
|
||||
if [ -t 0 ]; then
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Client is present
|
||||
if [ -f "/opt/logar-client/Linux_Client" ] || [ -f "/etc/systemd/system/logar-client.service" ]; then
|
||||
echo "[-] Error: LOGAR Client is already installed on this machine." >&2
|
||||
echo "[-] LOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server includes built-in log collection)." >&2
|
||||
echo "[-] Please uninstall LOGAR Client first before installing LOGAR Server." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
IS_UPDATE=0
|
||||
if [ -f "${INSTALL_DIR}/Server" ] || [ -f "/etc/systemd/system/logar-server.service" ]; then
|
||||
IS_UPDATE=1
|
||||
echo "[*] Existing LOGAR Server installation detected. Performing in-place upgrade..."
|
||||
fi
|
||||
|
||||
# Interactive configuration if running in a terminal without CLI arguments (only for fresh install)
|
||||
if [ "${IS_UPDATE}" -eq 0 ] && [ -t 0 ]; then
|
||||
read -r -p "Enter Data and Configuration Directory [${CONFIG_DIR}]: " USER_CFG
|
||||
if [ -n "${USER_CFG}" ]; then
|
||||
CONFIG_DIR="${USER_CFG}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${IS_UPDATE}" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[*] Stopping LOGAR Server service for upgrade..."
|
||||
systemctl stop logar-server.service || true
|
||||
fi
|
||||
|
||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}" "/var/log/logar"
|
||||
|
||||
if [ -f "dist/Server.bin" ]; then
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
[Setup]
|
||||
AppId={{9C4A6F1D-8975-476E-8512-4217DE029A88}}
|
||||
AppName=LOGAR Client
|
||||
AppVersion=0.3.3
|
||||
AppVersion=0.3.4.2
|
||||
AppPublisher=LOGAR
|
||||
AppPublisherURL=https://logar.eibl.tech
|
||||
AppSupportURL=https://logar.eibl.tech
|
||||
AppUpdatesURL=https://logar.eibl.tech
|
||||
DefaultDirName={autopf}\LOGAR
|
||||
UsePreviousAppDir=yes
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=LOGAR-Client-Setup
|
||||
PrivilegesRequired=admin
|
||||
@@ -17,9 +19,9 @@ WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
|
||||
VersionInfoVersion=0.3.3.0
|
||||
VersionInfoVersion=0.3.4.2
|
||||
VersionInfoProductName=LOGAR Client
|
||||
VersionInfoProductVersion=0.3.3
|
||||
VersionInfoProductVersion=0.3.4.2
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
@@ -54,6 +56,58 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR"
|
||||
var
|
||||
DataDirPage: TInputDirWizardPage;
|
||||
ConfigFilePage: TInputFileWizardPage;
|
||||
IsUpdateMode: Boolean;
|
||||
ExistingDataDir: String;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
ServerFound: Boolean;
|
||||
Val: String;
|
||||
begin
|
||||
Result := True;
|
||||
IsUpdateMode := False;
|
||||
ExistingDataDir := '';
|
||||
ServerFound := False;
|
||||
|
||||
// 1. Mutual Exclusion Check: Deny installation if LOGAR Server is already present
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server') or
|
||||
RegValueExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR-Server\Server.exe')) then
|
||||
begin
|
||||
ServerFound := True;
|
||||
end;
|
||||
|
||||
if ServerFound then
|
||||
begin
|
||||
MsgBox('LOGAR Server is already installed on this machine.'#13#10#13#10 +
|
||||
'LOGAR Client cannot be installed on a LOGAR Server host (LOGAR Server already provides built-in log collection).'#13#10#13#10 +
|
||||
'Please uninstall LOGAR Server first if you wish to run a standalone forwarder client.',
|
||||
mbCriticalError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR\Win_Client.exe')) then
|
||||
begin
|
||||
IsUpdateMode := True;
|
||||
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', Val) then
|
||||
ExistingDataDir := Val;
|
||||
|
||||
MsgBox('An existing installation of LOGAR Client was detected.'#13#10#13#10 +
|
||||
'Setup will perform an in-place update: core binaries will be updated, while your existing configuration (client_config.json), state tracking, and certificates will be preserved.',
|
||||
mbInformation, MB_OK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure InitializeWizard;
|
||||
begin
|
||||
@@ -67,6 +121,9 @@ begin
|
||||
''
|
||||
);
|
||||
DataDirPage.Add('Data Directory:');
|
||||
if (ExistingDataDir <> '') then
|
||||
DataDirPage.Values[0] := ExistingDataDir
|
||||
else
|
||||
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR');
|
||||
|
||||
ConfigFilePage := CreateInputFilePage(
|
||||
@@ -80,8 +137,23 @@ begin
|
||||
ConfigFilePage.Add('Client Configuration (client_config.json):', 'JSON Files (*.json)|*.json|All Files (*.*)|*.*', '.json');
|
||||
end;
|
||||
|
||||
function ShouldSkipPage(PageID: Integer): Boolean;
|
||||
begin
|
||||
Result := False;
|
||||
if IsUpdateMode then
|
||||
begin
|
||||
if (PageID = wpSelectDir) or (PageID = DataDirPage.ID) or (PageID = ConfigFilePage.ID) then
|
||||
Result := True;
|
||||
end;
|
||||
end;
|
||||
|
||||
function GetDataDir(Param: String): String;
|
||||
begin
|
||||
if IsUpdateMode and (ExistingDataDir <> '') then
|
||||
begin
|
||||
Result := ExistingDataDir;
|
||||
Exit;
|
||||
end;
|
||||
Result := DataDirPage.Values[0];
|
||||
if Result = '' then
|
||||
Result := ExpandConstant('{commonappdata}\LOGAR');
|
||||
@@ -107,8 +179,11 @@ begin
|
||||
end
|
||||
else if CurStep = ssPostInstall then
|
||||
begin
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'InstallDir', ExpandConstant('{app}'));
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir(''));
|
||||
|
||||
if not IsUpdateMode then
|
||||
begin
|
||||
CfgPath := GetConfigFile('');
|
||||
if (CfgPath <> '') and FileExists(CfgPath) then
|
||||
begin
|
||||
@@ -117,6 +192,7 @@ begin
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
var
|
||||
|
||||
@@ -7,15 +7,15 @@ OutFile "../dist/LOGAR-Client-Setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\LOGAR"
|
||||
RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Centralized Log Aggregator & Forwarder"
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Forwarder"
|
||||
|
||||
VIProductVersion "0.3.3.0"
|
||||
VIProductVersion "0.3.4.2"
|
||||
VIAddVersionKey "ProductName" "LOGAR Client"
|
||||
VIAddVersionKey "CompanyName" "LOGAR"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup"
|
||||
VIAddVersionKey "FileVersion" "0.3.3.0"
|
||||
VIAddVersionKey "ProductVersion" "0.3.3.0"
|
||||
VIAddVersionKey "FileVersion" "0.3.4.2"
|
||||
VIAddVersionKey "ProductVersion" "0.3.4.2"
|
||||
|
||||
# --- Visual Graphics & Branding ---
|
||||
!define MUI_ICON "assets/logo.ico"
|
||||
@@ -28,6 +28,7 @@ VIAddVersionKey "ProductVersion" "0.3.3.0"
|
||||
|
||||
Var DATADIR
|
||||
Var CFGFILE
|
||||
Var IS_UPDATE
|
||||
Var CfgDialog
|
||||
Var CfgFileInput
|
||||
Var CfgBrowseBtn
|
||||
@@ -43,6 +44,7 @@ Var CfgBrowseBtn
|
||||
# Directory Page 1: Installation Directory
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose Installation Location"
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose the folder in which to install program binaries."
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $INSTDIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -51,6 +53,7 @@ Var CfgBrowseBtn
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose where state tracking, certificates, and configuration files should be stored."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder where LOGAR Client should store state tracking (client_state.json), configuration (client_config.json), and mTLS certificates.$\r$\n$\r$\nDefault is recommended for automatic background service access."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_DESTINATION "Data & Storage Folder"
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $DATADIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -71,13 +74,51 @@ Page custom ConfigPageCreate ConfigPageLeave
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Function SkipIfUpdate
|
||||
${If} $IS_UPDATE == "1"
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function .onInit
|
||||
SetShellVarContext all
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR"
|
||||
StrCpy $CFGFILE ""
|
||||
StrCpy $IS_UPDATE "0"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Server is present
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Server" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Server" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES64\LOGAR-Server\Server.exe"
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES\LOGAR-Server\Server.exe"
|
||||
MessageBox MB_ICONSTOP|MB_OK "LOGAR Server is already installed on this machine.$\r$\n$\r$\nLOGAR Client cannot be co-installed on a LOGAR Server host (LOGAR Server already provides built-in log collection).$\r$\n$\r$\nPlease uninstall LOGAR Server first if you wish to run a standalone forwarder client."
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
# 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Client" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Client" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$INSTDIR\Win_Client.exe"
|
||||
StrCpy $IS_UPDATE "1"
|
||||
${If} $0 != ""
|
||||
StrCpy $DATADIR $0
|
||||
${EndIf}
|
||||
${If} $1 != ""
|
||||
StrCpy $INSTDIR $1
|
||||
${EndIf}
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "An existing installation of LOGAR Client was detected.$\r$\n$\r$\nSetup will perform an in-place update: program files will be updated while your existing configuration (client_config.json), state tracking, and certificates are preserved."
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function ConfigPageCreate
|
||||
${If} $IS_UPDATE == "1"
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $CfgDialog
|
||||
${If} $CfgDialog == error
|
||||
@@ -86,13 +127,13 @@ Function ConfigPageCreate
|
||||
|
||||
!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."
|
||||
${NSD_CreateLabel} 0 0 100% 48u "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"
|
||||
${NSD_CreateText} 0 54u 75% 14u "$CFGFILE"
|
||||
Pop $CfgFileInput
|
||||
|
||||
${NSD_CreateButton} 78% 37u 22% 14u "Browse..."
|
||||
${NSD_CreateButton} 78% 53u 22% 16u "Browse..."
|
||||
Pop $CfgBrowseBtn
|
||||
${NSD_OnClick} $CfgBrowseBtn OnBrowseCfg
|
||||
|
||||
@@ -130,8 +171,9 @@ 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 != ""
|
||||
# If client config file was selected and not updating existing config, copy to data directory
|
||||
${If} $IS_UPDATE != "1"
|
||||
${AndIf} $CFGFILE != ""
|
||||
${AndIf} ${FileExists} "$CFGFILE"
|
||||
CopyFiles /SILENT "$CFGFILE" "$DATADIR\client_config.json"
|
||||
${EndIf}
|
||||
@@ -145,6 +187,7 @@ Section "Install"
|
||||
ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Client'
|
||||
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Client" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Client" "DataDir" "$DATADIR"
|
||||
SectionEnd
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
[Setup]
|
||||
AppId={{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}
|
||||
AppName=LOGAR Server
|
||||
AppVersion=0.3.3
|
||||
AppVersion=0.3.4.2
|
||||
AppPublisher=LOGAR
|
||||
AppPublisherURL=https://logar.eibl.tech
|
||||
AppSupportURL=https://logar.eibl.tech
|
||||
AppUpdatesURL=https://logar.eibl.tech
|
||||
DefaultDirName={autopf}\LOGAR-Server
|
||||
UsePreviousAppDir=yes
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=LOGAR-Server-Setup
|
||||
PrivilegesRequired=admin
|
||||
@@ -17,9 +19,9 @@ WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup
|
||||
VersionInfoVersion=0.3.3.0
|
||||
VersionInfoVersion=0.3.4.2
|
||||
VersionInfoProductName=LOGAR Server
|
||||
VersionInfoProductVersion=0.3.3
|
||||
VersionInfoProductVersion=0.3.4.2
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
@@ -54,6 +56,58 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR-Server"
|
||||
[Code]
|
||||
var
|
||||
DataDirPage: TInputDirWizardPage;
|
||||
IsUpdateMode: Boolean;
|
||||
ExistingDataDir: String;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
ClientFound: Boolean;
|
||||
Val: String;
|
||||
begin
|
||||
Result := True;
|
||||
IsUpdateMode := False;
|
||||
ExistingDataDir := '';
|
||||
ClientFound := False;
|
||||
|
||||
// 1. Mutual Exclusion Check: Deny installation if LOGAR Client is already present
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client') or
|
||||
RegValueExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR\Win_Client.exe')) then
|
||||
begin
|
||||
ClientFound := True;
|
||||
end;
|
||||
|
||||
if ClientFound then
|
||||
begin
|
||||
MsgBox('LOGAR Client is already installed on this machine.'#13#10#13#10 +
|
||||
'LOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server already includes built-in local log collection).'#13#10#13#10 +
|
||||
'Please uninstall LOGAR Client before installing LOGAR Server.',
|
||||
mbCriticalError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR-Server\Server.exe')) then
|
||||
begin
|
||||
IsUpdateMode := True;
|
||||
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', Val) then
|
||||
ExistingDataDir := Val;
|
||||
|
||||
MsgBox('An existing installation of LOGAR Server was detected.'#13#10#13#10 +
|
||||
'Setup will perform an in-place update: core binaries will be updated, while your existing configuration (server_config.json), database (logar_state.db), and certificates will be preserved.',
|
||||
mbInformation, MB_OK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure InitializeWizard;
|
||||
begin
|
||||
@@ -67,11 +121,29 @@ begin
|
||||
''
|
||||
);
|
||||
DataDirPage.Add('Data Directory:');
|
||||
if (ExistingDataDir <> '') then
|
||||
DataDirPage.Values[0] := ExistingDataDir
|
||||
else
|
||||
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR-Server');
|
||||
end;
|
||||
|
||||
function ShouldSkipPage(PageID: Integer): Boolean;
|
||||
begin
|
||||
Result := False;
|
||||
if IsUpdateMode then
|
||||
begin
|
||||
if (PageID = wpSelectDir) or (PageID = DataDirPage.ID) then
|
||||
Result := True;
|
||||
end;
|
||||
end;
|
||||
|
||||
function GetDataDir(Param: String): String;
|
||||
begin
|
||||
if IsUpdateMode and (ExistingDataDir <> '') then
|
||||
begin
|
||||
Result := ExistingDataDir;
|
||||
Exit;
|
||||
end;
|
||||
Result := DataDirPage.Values[0];
|
||||
if Result = '' then
|
||||
Result := ExpandConstant('{commonappdata}\LOGAR-Server');
|
||||
@@ -91,6 +163,7 @@ begin
|
||||
end
|
||||
else if CurStep = ssPostInstall then
|
||||
begin
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'InstallDir', ExpandConstant('{app}'));
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', GetDataDir(''));
|
||||
end;
|
||||
end;
|
||||
|
||||
@@ -6,15 +6,15 @@ OutFile "../dist/LOGAR-Server-Setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\LOGAR-Server"
|
||||
RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Centralized Log Aggregator & Ingestion Hub"
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Ingestion Hub"
|
||||
|
||||
VIProductVersion "0.3.3.0"
|
||||
VIProductVersion "0.3.4.2"
|
||||
VIAddVersionKey "ProductName" "LOGAR Server"
|
||||
VIAddVersionKey "CompanyName" "LOGAR"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup"
|
||||
VIAddVersionKey "FileVersion" "0.3.3.0"
|
||||
VIAddVersionKey "ProductVersion" "0.3.3.0"
|
||||
VIAddVersionKey "FileVersion" "0.3.4.2"
|
||||
VIAddVersionKey "ProductVersion" "0.3.4.2"
|
||||
|
||||
# --- Visual Graphics & Branding ---
|
||||
!define MUI_ICON "assets/logo.ico"
|
||||
@@ -26,6 +26,7 @@ VIAddVersionKey "ProductVersion" "0.3.3.0"
|
||||
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
|
||||
|
||||
Var DATADIR
|
||||
Var IS_UPDATE
|
||||
|
||||
# --- UI Pages ---
|
||||
!define MUI_ABORTWARNING
|
||||
@@ -38,6 +39,7 @@ Var DATADIR
|
||||
# Directory Page 1: Installation Directory
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose Installation Location"
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose the folder in which to install program binaries."
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $INSTDIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -46,6 +48,7 @@ Var DATADIR
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose where database, certificates, and configuration files should be stored."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder where LOGAR Server should store its database (logar_state.db), configuration (server_config.json), and mTLS certificates.$\r$\n$\r$\nDefault is recommended for automatic background service access."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_DESTINATION "Data & Storage Folder"
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $DATADIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -63,9 +66,43 @@ Var DATADIR
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Function SkipIfUpdate
|
||||
${If} $IS_UPDATE == "1"
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function .onInit
|
||||
SetShellVarContext all
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR-Server"
|
||||
StrCpy $IS_UPDATE "0"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Client is present
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Client" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Client" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES64\LOGAR\Win_Client.exe"
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES\LOGAR\Win_Client.exe"
|
||||
MessageBox MB_ICONSTOP|MB_OK "LOGAR Client is already installed on this machine.$\r$\n$\r$\nLOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server already includes built-in local log collection).$\r$\n$\r$\nPlease uninstall LOGAR Client before installing LOGAR Server."
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
# 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Server" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Server" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$INSTDIR\Server.exe"
|
||||
StrCpy $IS_UPDATE "1"
|
||||
${If} $0 != ""
|
||||
StrCpy $DATADIR $0
|
||||
${EndIf}
|
||||
${If} $1 != ""
|
||||
StrCpy $INSTDIR $1
|
||||
${EndIf}
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "An existing installation of LOGAR Server was detected.$\r$\n$\r$\nSetup will perform an in-place update: program files will be updated while your existing database, configuration (server_config.json), and certificates are preserved."
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Section "Install"
|
||||
@@ -95,6 +132,7 @@ Section "Install"
|
||||
ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Server'
|
||||
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Server" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Server" "DataDir" "$DATADIR"
|
||||
SectionEnd
|
||||
|
||||
|
||||
+393
-12
@@ -10,6 +10,8 @@ import asyncio
|
||||
import secrets
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import urllib.parse
|
||||
import time
|
||||
import html
|
||||
import warnings
|
||||
@@ -276,6 +278,89 @@ def change_user_password(username: str, new_password: str, db_path: str = DEFAUL
|
||||
return changed
|
||||
|
||||
|
||||
# --- Two-Factor Authentication (TOTP / RFC 6238) ---
|
||||
|
||||
def generate_totp_secret(length: int = 20) -> str:
|
||||
"""Generates a cryptographically random Base32-encoded TOTP secret key."""
|
||||
return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "")
|
||||
|
||||
|
||||
def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str:
|
||||
"""Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238."""
|
||||
if intervals_no is None:
|
||||
intervals_no = int(time.time()) // 30
|
||||
key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True)
|
||||
msg = struct.pack(">Q", intervals_no)
|
||||
h = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
o = h[19] & 15
|
||||
h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000
|
||||
return f"{h_int:06d}"
|
||||
|
||||
|
||||
def verify_totp_code(secret: str, code: str, window: int = 1) -> bool:
|
||||
"""Verifies a 6-digit TOTP code with standard drift window (+/- 30s)."""
|
||||
if not secret or not code:
|
||||
return False
|
||||
code = str(code).strip().replace(" ", "")
|
||||
if len(code) != 6 or not code.isdigit():
|
||||
return False
|
||||
now = int(time.time())
|
||||
current_step = now // 30
|
||||
for offset in range(-window, window + 1):
|
||||
if hmac.compare_digest(get_totp_token(secret, current_step + offset), code):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str:
|
||||
"""Generates standard otpauth URI for QR code generation."""
|
||||
user_enc = urllib.parse.quote(username)
|
||||
issuer_enc = urllib.parse.quote(issuer)
|
||||
return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30"
|
||||
|
||||
|
||||
def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
|
||||
"""Retrieves TOTP configuration for a user from the SQLite database."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,))
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
return {"secret": row[0] or "", "enabled": bool(row[1])}
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
return {"secret": "", "enabled": False}
|
||||
|
||||
|
||||
def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
||||
"""Enables and activates TOTP for a user in the SQLite database."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username))
|
||||
changed = c.rowcount > 0
|
||||
conn.commit()
|
||||
return changed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
||||
"""Disables TOTP for a user in the SQLite database."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,))
|
||||
changed = c.rowcount > 0
|
||||
conn.commit()
|
||||
return changed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --- Hermes Token Management ---
|
||||
|
||||
def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
|
||||
@@ -765,10 +850,22 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# Migrations for users table (2FA)
|
||||
for col, col_def in [
|
||||
("totp_secret", "TEXT"),
|
||||
("totp_enabled", "INTEGER DEFAULT 0")
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
# Ensure default admin user exists
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM users")
|
||||
@@ -1342,14 +1439,19 @@ def login_page(request: Request):
|
||||
</div>
|
||||
<div id="loginError" class="alert-error"></div>
|
||||
<form id="loginForm" onsubmit="handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="userGroup">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" id="username" class="form-control" placeholder="admin" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-group" id="passGroup">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" id="password" class="form-control" placeholder="••••••••" required>
|
||||
</div>
|
||||
<div class="form-group" id="totpGroup" style="display: none;">
|
||||
<label class="form-label">🛡️ 2FA Verification Code</label>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">Enter the 6-digit code from your authenticator app:</p>
|
||||
<input type="text" id="totpCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" style="font-family: 'JetBrains Mono', monospace; font-size: 18px; letter-spacing: 4px; text-align: center;">
|
||||
</div>
|
||||
<button type="submit" class="btn" id="submitBtn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1358,26 +1460,37 @@ def login_page(request: Request):
|
||||
e.preventDefault();
|
||||
const u = document.getElementById('username').value.trim();
|
||||
const p = document.getElementById('password').value;
|
||||
const totp = document.getElementById('totpCode').value.trim();
|
||||
const errEl = document.getElementById('loginError');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const totpGrp = document.getElementById('totpGroup');
|
||||
errEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Authenticating...';
|
||||
|
||||
const payload = { username: u, password: p };
|
||||
if (totp) payload.totp_code = totp;
|
||||
|
||||
try {
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u, password: p })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'success') {
|
||||
window.location.href = '/';
|
||||
} else if (data.requires_2fa || data.status === '2fa_required') {
|
||||
totpGrp.style.display = 'block';
|
||||
document.getElementById('totpCode').focus();
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Verify & Sign In';
|
||||
errEl.style.display = 'none';
|
||||
} else {
|
||||
errEl.textContent = data.detail || 'Invalid username or password';
|
||||
errEl.style.display = 'block';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign In';
|
||||
btn.textContent = totpGrp.style.display === 'block' ? 'Verify & Sign In' : 'Sign In';
|
||||
}
|
||||
} catch(ex) {
|
||||
errEl.textContent = 'Connection error: ' + ex.message;
|
||||
@@ -1393,24 +1506,26 @@ def login_page(request: Request):
|
||||
|
||||
@app.post("/login")
|
||||
async def process_login(request: Request):
|
||||
"""Processes login credentials and issues session cookie."""
|
||||
"""Processes login credentials and issues session cookie with 2FA TOTP enforcement."""
|
||||
client_ip = get_client_ip(request)
|
||||
if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60):
|
||||
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
|
||||
|
||||
username, password = None, None
|
||||
username, password, totp_code = None, None, None
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
username = body.get("username")
|
||||
password = body.get("password")
|
||||
totp_code = body.get("totp_code")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
form = await request.form()
|
||||
username = form.get("username")
|
||||
password = form.get("password")
|
||||
totp_code = form.get("totp_code")
|
||||
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password are required")
|
||||
@@ -1421,6 +1536,20 @@ async def process_login(request: Request):
|
||||
await asyncio.sleep(0.5)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# Check 2FA TOTP requirement
|
||||
totp_info = get_user_totp_info(username, db_path=db_path)
|
||||
if totp_info.get("enabled"):
|
||||
if not totp_code:
|
||||
return JSONResponse(status_code=200, content={
|
||||
"status": "2fa_required",
|
||||
"requires_2fa": True,
|
||||
"message": "Two-factor authentication code required"
|
||||
})
|
||||
if not verify_totp_code(totp_info["secret"], totp_code):
|
||||
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES)
|
||||
await asyncio.sleep(0.3)
|
||||
raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code")
|
||||
|
||||
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES)
|
||||
token = generate_session_token(username)
|
||||
is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
|
||||
@@ -1456,6 +1585,55 @@ async def api_change_password(req: ChangePasswordRequest, current_user: str = De
|
||||
return {"status": "success", "message": "Password updated successfully"}
|
||||
|
||||
|
||||
# --- 2FA Authentication Management Endpoints ---
|
||||
|
||||
@app.get("/api/user/2fa/status")
|
||||
def api_user_2fa_status(auth_user: str = Depends(require_admin_auth)):
|
||||
"""Returns 2FA status for currently logged in administrator."""
|
||||
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||
info = get_user_totp_info(auth_user, db_path=db_path)
|
||||
return {"enabled": info["enabled"], "username": auth_user}
|
||||
|
||||
|
||||
@app.get("/api/user/2fa/setup")
|
||||
def api_user_2fa_setup(auth_user: str = Depends(require_admin_auth)):
|
||||
"""Generates a new TOTP secret and otpauth URI for 2FA provisioning."""
|
||||
secret = generate_totp_secret()
|
||||
otpauth = get_totp_uri(auth_user, secret, issuer="LOGAR Server Hub")
|
||||
return {
|
||||
"secret": secret,
|
||||
"otpauth_uri": otpauth,
|
||||
"username": auth_user
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/user/2fa/enable")
|
||||
async def api_user_2fa_enable(request: Request, auth_user: str = Depends(require_admin_auth)):
|
||||
"""Enables TOTP for the current user after validating 6-digit code."""
|
||||
body = await request.json()
|
||||
secret = body.get("secret", "").strip()
|
||||
code = body.get("code", "").strip()
|
||||
if not secret or not code:
|
||||
raise HTTPException(status_code=400, detail="Secret and 6-digit verification code required")
|
||||
if not verify_totp_code(secret, code):
|
||||
raise HTTPException(status_code=400, detail="Invalid 6-digit TOTP verification code. Please check your device clock.")
|
||||
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||
enable_user_totp(auth_user, secret, db_path=db_path)
|
||||
return {"status": "success", "message": "2FA successfully activated"}
|
||||
|
||||
|
||||
@app.post("/api/user/2fa/disable")
|
||||
async def api_user_2fa_disable(request: Request, auth_user: str = Depends(require_admin_auth)):
|
||||
"""Disables TOTP for the current user after verifying password."""
|
||||
body = await request.json()
|
||||
password = body.get("password", "")
|
||||
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||
if not verify_user_credentials(auth_user, password, db_path=db_path):
|
||||
raise HTTPException(status_code=403, detail="Current password incorrect")
|
||||
disable_user_totp(auth_user, db_path=db_path)
|
||||
return {"status": "success", "message": "2FA successfully disabled"}
|
||||
|
||||
|
||||
@app.post("/api/client/enroll")
|
||||
def enroll_client(req: ClientEnrollRequest, request: Request = None):
|
||||
"""
|
||||
@@ -2303,7 +2481,7 @@ def serve_webui(request: Request = None):
|
||||
border-radius: 10px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
|
||||
min-width: 200px; z-index: 200; overflow: hidden;
|
||||
}}
|
||||
.user-menu-container:hover .user-dropdown, .user-menu-container:focus-within .user-dropdown {{ display: block; }}
|
||||
.user-menu-container:hover .user-dropdown, .user-menu-container:focus-within .user-dropdown, .user-dropdown.show {{ display: block; }}
|
||||
.user-dropdown-header {{ padding: 10px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); background: #0f172a; border-bottom: 1px solid var(--border); }}
|
||||
.user-dropdown-item {{
|
||||
display: flex; align-items: center; gap: 8px; padding: 10px 14px;
|
||||
@@ -2442,13 +2620,14 @@ def serve_webui(request: Request = None):
|
||||
<div class="header-actions">
|
||||
<button onclick="openExportProfileModal()" class="btn btn-primary">📥 Export Client Profile</button>
|
||||
<div class="user-menu-container" tabindex="0">
|
||||
<div class="user-tag">
|
||||
<div class="user-tag" onclick="toggleUserDropdown(event)">
|
||||
<span>👤 <strong>{auth_user}</strong></span>
|
||||
<span style="font-size: 10px; color: var(--text-muted);">▼</span>
|
||||
</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown" id="userDropdown">
|
||||
<div class="user-dropdown-header">Logged in as {auth_user}</div>
|
||||
<button class="user-dropdown-item" onclick="openPasswordModal()">🔑 Change Password</button>
|
||||
<button class="user-dropdown-item" onclick="openTwoFactorModal(); hideUserDropdown();">🛡️ Two-Factor Auth (2FA)</button>
|
||||
<button class="user-dropdown-item" onclick="openPasswordModal(); hideUserDropdown();">🔑 Change Password</button>
|
||||
<a href="/logout" class="user-dropdown-item danger">🚪 Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2583,7 +2762,7 @@ def serve_webui(request: Request = None):
|
||||
<div class="tab-pane" id="tabPane-anomalies">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title" style="font-size: 15px;">Verified Anomaly Stream (Rolling 12h Window)</span>
|
||||
<span class="card-title" style="font-size: 15px;">Verified Anomaly Stream (Latest 50 Events • Rolling 12h Window)</span>
|
||||
<span class="badge badge-active">Direct Hermes Intelligence</span>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
@@ -2732,7 +2911,92 @@ def serve_webui(request: Request = None):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-Factor Authentication Modal -->
|
||||
<div id="twoFactorModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 480px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 14px;">
|
||||
<div class="modal-title" style="margin-bottom: 0; display: flex; align-items: center; gap: 8px;">
|
||||
<span>🛡️</span> Two-Factor Authentication (2FA)
|
||||
</div>
|
||||
<div style="cursor: pointer; font-size: 22px; color: var(--text-muted); line-height: 1;" onclick="closeModal('twoFactorModal')">×</div>
|
||||
</div>
|
||||
|
||||
<div id="twoFactorLoading" style="text-align: center; padding: 24px; color: var(--text-muted);">
|
||||
Loading 2FA security status...
|
||||
</div>
|
||||
|
||||
<!-- 2FA Active Section -->
|
||||
<div id="twoFactorActiveSection" style="display: none;">
|
||||
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px;">
|
||||
<div style="font-size: 24px;">🟢</div>
|
||||
<div>
|
||||
<div style="font-weight: 700; color: #34d399;">2FA is Currently Active</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Your account is secured with TOTP authenticator verification.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px; background: #0f172a; padding: 16px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-size: 13px; font-weight: 700; color: #ef4444; margin-bottom: 8px;">Disable Two-Factor Authentication</div>
|
||||
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">Enter your account password to confirm disabling 2FA:</p>
|
||||
<div class="form-group" style="margin-bottom: 12px;">
|
||||
<input type="password" id="twoFactorDisablePassword" class="form-control" placeholder="Current Account Password">
|
||||
</div>
|
||||
<button onclick="submitDisable2FA()" class="btn btn-danger" style="width: 100%;">Disable 2FA</button>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 16px;">
|
||||
<button onclick="start2FASetup()" class="btn btn-outline" style="font-size: 12px;">🔄 Re-configure on New Device</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions" style="margin-top: 20px;">
|
||||
<button onclick="closeModal('twoFactorModal')" class="btn btn-primary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Setup Section -->
|
||||
<div id="twoFactorSetupSection" style="display: none;">
|
||||
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
|
||||
Scan this QR code with an authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, etc.):
|
||||
</p>
|
||||
|
||||
<div style="display: flex; justify-content: center; align-items: center; background: #ffffff; padding: 16px; border-radius: 12px; margin-bottom: 16px; min-height: 190px;">
|
||||
<div id="qrcodeCanvas"></div>
|
||||
</div>
|
||||
|
||||
<div style="background: #0f172a; padding: 12px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 18px;">
|
||||
<div style="font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px;">Manual Setup Secret Key</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
|
||||
<code id="twoFactorSecretText" style="font-size: 13px; color: #60a5fa; word-break: break-all; user-select: all;"></code>
|
||||
<button onclick="copy2FASecret()" class="btn btn-outline" style="padding: 4px 8px; font-size: 11px; white-space: nowrap;">📋 Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 18px;">
|
||||
<label class="form-label">Enter 6-Digit Authenticator Code</label>
|
||||
<input type="text" id="twoFactorVerifyCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" style="text-align: center; letter-spacing: 4px; font-size: 20px; font-family: 'JetBrains Mono', monospace; font-weight: 700;">
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('twoFactorModal')" class="btn btn-outline">Cancel</button>
|
||||
<button onclick="submitEnable2FA()" class="btn btn-accent">✅ Verify & Activate 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleUserDropdown(e) {{
|
||||
if (e) e.stopPropagation();
|
||||
const d = document.getElementById('userDropdown');
|
||||
if (d) d.classList.toggle('show');
|
||||
}}
|
||||
|
||||
function hideUserDropdown() {{
|
||||
const d = document.getElementById('userDropdown');
|
||||
if (d) d.classList.remove('show');
|
||||
}}
|
||||
|
||||
window.addEventListener('click', () => hideUserDropdown());
|
||||
function switchTab(tabId) {{
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
|
||||
@@ -2948,6 +3212,123 @@ def serve_webui(request: Request = None):
|
||||
alert(data.detail || 'Failed to change password');
|
||||
}}
|
||||
}}
|
||||
|
||||
// --- 2FA Management & QRCode Logic ---
|
||||
let current2FASecret = '';
|
||||
|
||||
async function openTwoFactorModal() {{
|
||||
openModal('twoFactorModal');
|
||||
document.getElementById('twoFactorLoading').style.display = 'block';
|
||||
document.getElementById('twoFactorActiveSection').style.display = 'none';
|
||||
document.getElementById('twoFactorSetupSection').style.display = 'none';
|
||||
|
||||
try {{
|
||||
const res = await fetch('/api/user/2fa/status');
|
||||
const data = await res.json();
|
||||
document.getElementById('twoFactorLoading').style.display = 'none';
|
||||
if (data.enabled) {{
|
||||
document.getElementById('twoFactorActiveSection').style.display = 'block';
|
||||
}} else {{
|
||||
start2FASetup();
|
||||
}}
|
||||
}} catch(ex) {{
|
||||
document.getElementById('twoFactorLoading').textContent = 'Failed to load 2FA status: ' + ex.message;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function start2FASetup() {{
|
||||
document.getElementById('twoFactorLoading').style.display = 'block';
|
||||
document.getElementById('twoFactorActiveSection').style.display = 'none';
|
||||
document.getElementById('twoFactorSetupSection').style.display = 'none';
|
||||
|
||||
try {{
|
||||
const res = await fetch('/api/user/2fa/setup');
|
||||
const data = await res.json();
|
||||
current2FASecret = data.secret;
|
||||
|
||||
document.getElementById('twoFactorSecretText').textContent = data.secret;
|
||||
document.getElementById('twoFactorVerifyCode').value = '';
|
||||
|
||||
// Render QR code
|
||||
renderQRCode('qrcodeCanvas', data.otpauth_uri);
|
||||
|
||||
document.getElementById('twoFactorLoading').style.display = 'none';
|
||||
document.getElementById('twoFactorSetupSection').style.display = 'block';
|
||||
}} catch(ex) {{
|
||||
document.getElementById('twoFactorLoading').textContent = 'Failed to generate 2FA setup: ' + ex.message;
|
||||
}}
|
||||
}}
|
||||
|
||||
function copy2FASecret() {{
|
||||
if (!current2FASecret) return;
|
||||
navigator.clipboard.writeText(current2FASecret);
|
||||
showAlert('2FA Secret copied to clipboard!');
|
||||
}}
|
||||
|
||||
async function submitEnable2FA() {{
|
||||
const code = document.getElementById('twoFactorVerifyCode').value.trim();
|
||||
if (!code || code.length !== 6) {{
|
||||
alert('Please enter a valid 6-digit verification code.');
|
||||
return;
|
||||
}}
|
||||
try {{
|
||||
const res = await fetch('/api/user/2fa/enable', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify({{ secret: current2FASecret, code: code }})
|
||||
}});
|
||||
const data = await res.json();
|
||||
if (res.ok) {{
|
||||
showAlert('Two-factor authentication (2FA) successfully activated!');
|
||||
closeModal('twoFactorModal');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}} else {{
|
||||
alert(data.detail || 'Failed to verify code. Please check your authenticator clock.');
|
||||
}}
|
||||
}} catch(ex) {{
|
||||
alert('Connection error: ' + ex.message);
|
||||
}}
|
||||
}}
|
||||
|
||||
async function submitDisable2FA() {{
|
||||
const pwd = document.getElementById('twoFactorDisablePassword').value;
|
||||
if (!pwd) {{
|
||||
alert('Please enter your account password to confirm.');
|
||||
return;
|
||||
}}
|
||||
if (!confirm('Are you sure you want to disable two-factor authentication?')) return;
|
||||
|
||||
try {{
|
||||
const res = await fetch('/api/user/2fa/disable', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'Content-Type': 'application/json' }},
|
||||
body: JSON.stringify({{ password: pwd }})
|
||||
}});
|
||||
const data = await res.json();
|
||||
if (res.ok) {{
|
||||
showAlert('2FA disabled successfully.');
|
||||
closeModal('twoFactorModal');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}} else {{
|
||||
alert(data.detail || 'Failed to disable 2FA. Incorrect password.');
|
||||
}}
|
||||
}} catch(ex) {{
|
||||
alert('Connection error: ' + ex.message);
|
||||
}}
|
||||
}}
|
||||
|
||||
function renderQRCode(containerId, text) {{
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.style.width = '180px';
|
||||
img.style.height = '180px';
|
||||
img.style.display = 'block';
|
||||
img.alt = '2FA QR Code';
|
||||
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(text);
|
||||
el.appendChild(img);
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -470,6 +470,46 @@ class TestServerComponent(unittest.TestCase):
|
||||
self.assertEqual(client_data["active_seats"], 0)
|
||||
self.assertEqual(len(client_data["clients"]), 1)
|
||||
|
||||
def test_two_factor_auth_and_totp_flow(self):
|
||||
"""Tests TOTP secret generation, token verification, URI generation, and enable/disable lifecycle."""
|
||||
Server.init_db(self.test_db)
|
||||
|
||||
# 1. Test TOTP helpers
|
||||
secret = Server.generate_totp_secret()
|
||||
self.assertTrue(len(secret) >= 16)
|
||||
token = Server.get_totp_token(secret)
|
||||
self.assertEqual(len(token), 6)
|
||||
self.assertTrue(token.isdigit())
|
||||
self.assertTrue(Server.verify_totp_code(secret, token))
|
||||
self.assertFalse(Server.verify_totp_code(secret, "000000" if token != "000000" else "999999"))
|
||||
self.assertFalse(Server.verify_totp_code(secret, "invalid"))
|
||||
|
||||
# 2. Test OTPAuth URI
|
||||
uri = Server.get_totp_uri("admin", secret, issuer="LOGAR Fleet Hub")
|
||||
self.assertTrue(uri.startswith("otpauth://totp/"))
|
||||
self.assertIn("secret=" + secret, uri)
|
||||
self.assertIn("LOGAR", uri)
|
||||
|
||||
# 3. Test initial user 2FA status
|
||||
info = Server.get_user_totp_info("admin", db_path=self.test_db)
|
||||
self.assertFalse(info["enabled"])
|
||||
self.assertEqual(info["secret"], "")
|
||||
|
||||
# 4. Test enabling 2FA
|
||||
enabled = Server.enable_user_totp("admin", secret, db_path=self.test_db)
|
||||
self.assertTrue(enabled)
|
||||
|
||||
info_active = Server.get_user_totp_info("admin", db_path=self.test_db)
|
||||
self.assertTrue(info_active["enabled"])
|
||||
self.assertEqual(info_active["secret"], secret)
|
||||
|
||||
# 5. Test disabling 2FA
|
||||
disabled = Server.disable_user_totp("admin", db_path=self.test_db)
|
||||
self.assertTrue(disabled)
|
||||
|
||||
info_disabled = Server.get_user_totp_info("admin", db_path=self.test_db)
|
||||
self.assertFalse(info_disabled["enabled"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user