1) First draft of the TUI functionality

2) Added env_reader functions (env.hpp and env.cpp)
3) Program looks for a .env on startup and creates one based on user input if not found.
4) Refactored log_parsing and ip_to_geo to use the global env variables for parsing and ip lookup from the local .mmdb database
5) CMakeLists.txt is now cross platform functional
6) Added various cross platform checks for creating .env variables
This commit is contained in:
rapturate
2026-06-09 14:29:42 -04:00
parent bdaf8451a0
commit 42f50c9e9a
9 changed files with 537 additions and 62 deletions

7
.gitignore vendored
View File

@@ -41,4 +41,9 @@ compile_commands.json
# ========================================== # ==========================================
# If you download large MaxMind .mmdb files, ignore them so they don't bloat Git # If you download large MaxMind .mmdb files, ignore them so they don't bloat Git
*.mmdb *.mmdb
*.log *.log
# ==========================================
# Sensitive Files
# ==========================================
.env

View File

@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.15) cmake_minimum_required(VERSION 3.15)
project(ParseLogCLI LANGUAGES CXX) project(ParseLogCLI LANGUAGES CXX)
# System and compiler configurations # System and compiler configurations
@@ -6,14 +6,16 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
# ========================================== # ==========================================
# 1. CORE LIBRARY TARGET # 1. CORE LIBRARY TARGET
# ========================================== # ==========================================
# Compiles your core logic files into a shared library module add_library(parselog_core
add_library(parselog_core log_parsing/log_parsing.cpp
log_parsing/log_parsing.cpp ip_to_geo/ip_to_geo.cpp
ip_to_geo/ip_to_geo.cpp env_reader/env.cpp
third_party/src/GeoLite2PP.cpp third_party/src/GeoLite2PP.cpp
third_party/src/GeoLite2PP_error_category.cpp third_party/src/GeoLite2PP_error_category.cpp
) )
@@ -21,13 +23,45 @@ add_library(parselog_core
target_include_directories(parselog_core PRIVATE target_include_directories(parselog_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/log_parsing ${CMAKE_CURRENT_SOURCE_DIR}/log_parsing
${CMAKE_CURRENT_SOURCE_DIR}/ip_to_geo ${CMAKE_CURRENT_SOURCE_DIR}/ip_to_geo
${CMAKE_CURRENT_SOURCE_DIR}/third_party/include ${CMAKE_CURRENT_SOURCE_DIR}/third_party/include
) )
# Link the static third-party MaxMind binary # CROSS-PLATFORM FIXED: Automatically links appropriate binary dependencies based on OS
target_link_libraries(parselog_core PRIVATE if(WIN32)
${CMAKE_CURRENT_SOURCE_DIR}/third_party/lib/libmaxminddb.a if(MSVC)
) # Windows via Visual Studio Compiler
target_link_libraries(parselog_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/third_party/lib/maxminddb.lib
)
else()
# Windows via MinGW/GCC Toolchain
target_link_libraries(parselog_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/third_party/lib/libmaxminddb.a
)
endif()
# Windows requires Windows Sockets API linked for libmaxminddb network resolutions
target_link_libraries(parselog_core PRIVATE ws2_32)
elseif(APPLE)
# macOS Specific Path Integrations (Handles Intel /opt/local and Apple Silicon /opt/homebrew)
find_library(MAXMIND_LIB maxminddb HINTS /opt/homebrew/lib /usr/local/lib /opt/local/lib)
if(MAXMIND_LIB)
target_link_libraries(parselog_core PRIVATE ${MAXMIND_LIB})
else()
# Fallback to local static file repository boundary if brew package is missing
target_link_libraries(parselog_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/third_party/lib/libmaxminddb.a
)
endif()
else()
# Standard Linux (Ubuntu, Arch Linux, Fedora, etc.)
target_link_libraries(parselog_core PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/third_party/lib/libmaxminddb.a
)
endif()
# Version definitions needed by your GeoLite2PP wrapper # Version definitions needed by your GeoLite2PP wrapper
target_compile_definitions(parselog_core PRIVATE target_compile_definitions(parselog_core PRIVATE
@@ -35,24 +69,36 @@ target_compile_definitions(parselog_core PRIVATE
) )
# ========================================== # ==========================================
# 2. APPLICATION EXECUTABLE # 2. FTXUI Library Download
# ========================================== # ==========================================
# Compiles your main user-facing CLI binary FetchContent_Declare(
add_executable(parselog_cli main.cpp) ftxui
target_link_libraries(parselog_cli PRIVATE parselog_core) GIT_REPOSITORY https://github.com/ArthurSonzogni/FTXUI.git
GIT_TAG v6.1.9
)
FetchContent_MakeAvailable(ftxui)
set_target_properties(parselog_cli PROPERTIES # ==========================================
# 3. APPLICATION EXECUTABLE
# ==========================================
add_executable(parselog_cli main.cpp)
target_link_libraries(parselog_cli
PRIVATE
parselog_core
ftxui::screen
ftxui::dom
ftxui::component
)
# CROSS-PLATFORM FIXED: Output targets route uniformly into the root project space
set_target_properties(parselog_cli PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_CURRENT_SOURCE_DIR}"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_CURRENT_SOURCE_DIR}"
) )
# ========================================== # ==========================================
# 3. TEST SUITE # 4. TEST SUITE
# ========================================== # ==========================================
# Activates the built-in CTest engine
enable_testing() enable_testing()
# add_executable(parselog_test tests.cpp)
# target_link_libraries(parselog_test PRIVATE parselog_core)
# Registers the testing executable under the "RunAllTests" banner
# add_test(NAME RunAllTests COMMAND parselog_test)

10
README.md Normal file
View File

@@ -0,0 +1,10 @@
This TUI program is for taking in an Apache2 access log file and providing an interactive interface to check your logs.
REQUIREMENTS:
- Access to a GeoLite2-City.mmdb database file.
* The signup page is provided here:
https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/
NOTES:
- The TUI will ask for the access.log file location and the GeoLite2-City.mmdb file location on startup.
* I have not tried using the GeoLite2-Country.mmdb file and do not know if it will crash the application...

83
env_reader/env.cpp Normal file
View File

@@ -0,0 +1,83 @@
/**
* @file env.cpp
* @author Lewis Price (lewis.e.price@outlook.com)
* @brief Implementation for environment utilities and cross-platform .env loader
* @version 1.0.0
* @date 2026-06-09
*
* @copyright Copyright (c) 2026
*
*/
#include "env.hpp"
#include <iostream>
#include <fstream>
#include <cstdlib>
#include <filesystem>
/**
* @brief Helper utility to set environment variables across operating systems
*/
static int set_env_variable(const std::string& key, const std::string& value) {
#if defined(_WIN32)
return _putenv_s(key.c_str(), value.c_str());
#else
return setenv(key.c_str(), value.c_str(), 1);
#endif
}
bool check_for_env() {
const std::string env_path = ".env";
// Returns true only if .env exists on disk and is a file (not a directory)
return std::filesystem::exists(env_path) && std::filesystem::is_regular_file(env_path);
}
void make_env() {
const std::string env_path = ".env";
if (std::filesystem::exists(env_path)) {
return;
}
std::ofstream file(env_path);
if (!file.is_open()) {
std::cerr << "Error: Could not generate a default " << env_path << " configuration template file." << std::endl;
return;
}
// Write out the clean template configuration block
file << "# ParseLogCLI Global Environment Configuration\n";
file << "# Generated on 2026-06-09\n\n";
file << "# Path to the MaxMind GeoLite2 City Database binary\n";
file << "DB_PATH=data/GeoLite2-City.mmdb\n\n";
file << "# Absolute or relative path to the server access logs target file\n";
file << "LOG_PATH=test_logs/access.log.txt\n";
file.close();
}
void load_env_file(const std::string& env_path) {
std::ifstream file(env_path);
if (!file.is_open()) {
std::cerr << "Warning: Could not open " << env_path << " file." << std::endl;
return;
}
std::string line;
while (std::getline(file, line)) {
// Skip completely empty lines or comment configurations
if (line.empty() || line[0] == '#') continue;
std::size_t delimiter_pos = line.find('=');
if (delimiter_pos != std::string::npos) {
std::string key = line.substr(0, delimiter_pos);
std::string value = line.substr(delimiter_pos + 1);
// Clean up hidden trailing Windows carriage returns (\r) safely
if (!value.empty() && value.back() == '\r') {
value.pop_back();
}
// Expose the key-value pair globally to the active process lifecycle
set_env_variable(key, value);
}
}
}

37
env_reader/env.hpp Normal file
View File

@@ -0,0 +1,37 @@
/**
* @file env.hpp
* @author Lewis Price (lewis.e.price@outlook.com)
* @brief
* @version 1.0.0
* @date 2026-06-09
*
* @copyright Copyright (c) 2026
*
*/
#pragma once
#include <string>
/**
* @brief Checks for an already existing .env file.
*
* @return true
* @return false
*/
bool check_for_env();
/**
* @brief Creates a new .env file for the user.
*
*/
void make_env();
/**
* @brief Loads the .env file information
*
* @param env_path
*/
void load_env_file(const std::string& env_path);

View File

@@ -1,43 +1,16 @@
#include "ip_to_geo.hpp"
#include <exception> #include <exception>
#include <iostream> #include <iostream>
#include <fstream>
#include <cstdlib> #include <cstdlib>
#include <map> #include <map>
#include <string> #include <string>
#include "ip_to_geo.hpp"
#include "../third_party/include/GeoLite2PP.hpp" #include "../third_party/include/GeoLite2PP.hpp"
void load_env_file(const std::string& env_path) {
std::ifstream file(env_path);
if (!file.is_open()) {
std::cerr << "Warning: Could not open " << env_path << " file." << std::endl;
return;
}
std::string line;
while (std::getline(file, line)) {
// Skip empty lines or comments
if (line.empty() || line[0] == '#') continue;
std::size_t delimiter_pos = line.find('=');
if (delimiter_pos != std::string::npos) {
std::string key = line.substr(0, delimiter_pos);
std::string value = line.substr(delimiter_pos + 1);
// Set the variable globally in the execution environment
setenv(key.c_str(), value.c_str(), 1);
}
}
}
loc_data iplookup(const std::string& ip) { loc_data iplookup(const std::string& ip) {
// 1. Load variables from the local .env file
load_env_file();
// 2. Fetch the path out of the environment variables
const char* env_db_path = std::getenv("DB_PATH"); const char* env_db_path = std::getenv("DB_PATH");
// Fallback to a default path if the environment variable wasn't set
std::string db_path = (env_db_path != nullptr) ? env_db_path : "data/GeoLite2-City.mmdb"; std::string db_path = (env_db_path != nullptr) ? env_db_path : "data/GeoLite2-City.mmdb";
@@ -53,7 +26,7 @@ loc_data iplookup(const std::string& ip) {
location.longitude = geo_data["longitude"]; location.longitude = geo_data["longitude"];
} }
catch (const std::exception& e) { catch (const std::exception& e) {
std::cerr << "Database failed to load: " << e.what() << std::endl; //std::cerr << "Database failed to load: " << e.what() << std::endl;
} }
return location; return location;
}; };

View File

@@ -1,3 +1,4 @@
#include <cstdlib>
#include <iostream> #include <iostream>
#include <fstream> #include <fstream>
#include <ostream> #include <ostream>
@@ -9,7 +10,9 @@
#include "../ip_to_geo/ip_to_geo.hpp" #include "../ip_to_geo/ip_to_geo.hpp"
p_logs::p_logs(std::string log_path) { p_logs::p_logs(std::string log_path) {
if(log_path.empty()){
log_path = std::getenv("LOG_PATH");
}
std::ifstream file(log_path); std::ifstream file(log_path);
if (!file.is_open()) { if (!file.is_open()) {
std::cerr << "Error loading " << log_path << std::endl; std::cerr << "Error loading " << log_path << std::endl;
@@ -59,6 +62,10 @@ p_logs::p_logs(std::string log_path) {
file.close(); file.close();
} }
std::vector<Entry> p_logs::get_all_logs(){
return logs;
}
std::string p_logs::entryx_ip(int x){ std::string p_logs::entryx_ip(int x){
return logs[x].ip; return logs[x].ip;
} }
@@ -96,7 +103,7 @@ void p_logs::print_logs() {
std::ios_base::sync_with_stdio(false); std::ios_base::sync_with_stdio(false);
for (const auto& log : logs) { for (const auto& log : logs) {
std::cout << "IP: " << log.ip.c_str() << "\n" std::cout << "IP: " << log.ip.c_str() << "\n"
<< "Location:" << "\n" << "Location Data:" << "\n"
<< "\tCountry: " << log.location.country << "\n" << "\tCountry: " << log.location.country << "\n"
<< "\tSubdivision: " << log.location.subdivision << "\n" << "\tSubdivision: " << log.location.subdivision << "\n"
<< "\tCity: " << log.location.city << "\n" << "\tCity: " << log.location.city << "\n"

View File

@@ -38,6 +38,8 @@ public:
* @param string * @param string
*/ */
p_logs(std::string); p_logs(std::string);
std::vector<Entry> get_all_logs();
/** /**
* @brief Getter function for a specific Entry's IP * @brief Getter function for a specific Entry's IP

318
main.cpp
View File

@@ -1,8 +1,320 @@
#include <vector>
#include <string>
#include <algorithm>
#include <array>
#include <ftxui/component/component.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp>
#include "log_parsing/log_parsing.hpp" #include "log_parsing/log_parsing.hpp"
#include "env_reader/env.hpp"
int main(){ int main() {
p_logs logs("test_logs/access.log.txt"); using namespace ftxui;
logs.print_logs();
// ── 1. App State ─────────────────────────────────────────────────────────
bool setup_complete = false;
std::string log_file_path = "";
std::string mmdb_path = "";
std::string search_query = "";
if (check_for_env()) {
load_env_file(".env");
// Safely extract environment strings
char* env_log = std::getenv("LOG_PATH");
char* env_db = std::getenv("DB_PATH");
if (env_log) log_file_path = env_log;
if (env_db) mmdb_path = env_db;
}
std::vector<Entry> active_logs;
int selected_row = 0;
// Order: IP | GEOLOCATION | STATUS | USER-AGENT
std::array<int, 4> col_widths = {20, 35, 8, 26};
constexpr int COL_MIN_WIDTH = 6;
constexpr int COL_MAX_WIDTH = 60;
// Which column is currently targeted by Shift+Arrow resize.
// -1 means none highlighted.
int resize_col = 0;
// ── 2. Filtering helper ───────────────────────────────────────────────────
// Returns the filtered subset and resets selected_row if out-of-bounds.
auto get_filtered = [&]() -> std::vector<Entry> {
std::vector<Entry> out;
for (const auto& log : active_logs) {
std::string geo = log.location.city + ", " + log.location.country;
if (search_query.empty() ||
log.ip.find(search_query) != std::string::npos ||
log.status.find(search_query) != std::string::npos ||
log.request.find(search_query) != std::string::npos ||
geo.find(search_query) != std::string::npos) {
out.push_back(log);
}
}
if (!out.empty() && selected_row >= (int)out.size())
selected_row = (int)out.size() - 1;
return out;
};
// ── 3. Log parsing lambda ─────────────────────────────────────────────────
auto run_log_parsing = [&]() {
setup_complete = true;
//.env file check
if (!check_for_env()) {
// Update process environment variables first so make_env() saves them correctly
#if defined(_WIN32)
_putenv_s("LOG_PATH", log_file_path.c_str());
_putenv_s("DB_PATH", mmdb_path.c_str());
#else
setenv("LOG_PATH", log_file_path.c_str(), 1);
setenv("DB_PATH", mmdb_path.c_str(), 1);
#endif
make_env();
}
p_logs parsed_logs(log_file_path);
active_logs = parsed_logs.get_all_logs();
if (active_logs.empty()) {
loc_data mock_loc{"United States", "North Carolina", "Charlotte", "35.227", "-80.843"};
active_logs.push_back({
"192.168.1.50", mock_loc, "2026-06-09T12:00:00",
"GET /api/v1/status HTTP/1.1", "200", "512",
"https://github.com", "Linux", "Firefox"
});
active_logs.push_back({
"8.8.8.8", {"United States", "California", "Mountain View", "37.386", "-122.083"},
"2026-06-09T12:01:15",
"POST /login HTTP/1.1", "401", "1024",
"direct", "Android", "Chrome"
});
for (int i = 1; i <= 40; ++i) {
active_logs.push_back({
"10.0.0." + std::to_string(i),
{"Canada", "Ontario", "Toronto", "43.653", "-79.383"},
"2026-06-09T12:05:00",
"GET /static/style.css HTTP/1.1", "200", "2048",
"https://github.com", "macOS", "Safari"
});
}
}
};
//Checks for log file and mmdb path
if (!log_file_path.empty() && !mmdb_path.empty()) {
run_log_parsing();
}
// ── 4. Setup-screen components ────────────────────────────────────────────
Component input_log = Input(&log_file_path, "Enter path to log file...");
Component input_mmdb = Input(&mmdb_path, "Enter path to MaxMind MMDB...");
Component submit_button = Button(" Start Log Monitor ", run_log_parsing, ButtonOption::Animated());
auto setup_container = Container::Vertical({input_log, input_mmdb, submit_button});
// ── 5. Search input (dashboard) ───────────────────────────────────────────
Component search_input = Input(&search_query, "Type to filter records (IP, request, status, etc.)...");
// ── 6. Main renderer ──────────────────────────────────────────────────────
auto main_components = Container::Vertical({
setup_container,
search_input
});
auto main_renderer = Renderer(main_components, [&]() {
if (!setup_complete) {
// ── VIEW 1: Configuration ─────────────────────────────────────────
return vbox({
text(" ParseLogCLI Configuration ") | bold | color(Color::Blue) | border,
separator(),
vbox({
hbox({ text(" Log File Path: ") | bold | color(Color::Cyan), input_log->Render() }),
vbox() | size(HEIGHT, EQUAL, 1),
hbox({ text(" MaxMind DB Path: ") | bold | color(Color::Cyan), input_mmdb->Render() }),
}) | borderLight,
separator(),
center(submit_button->Render()),
});
}
// ── VIEW 2: Dashboard ─────────────────────────────────────────────────
auto filtered_logs = get_filtered();
std::vector<std::vector<Element>> grid_matrix;
// Column headers
auto header_cell = [&](int col_idx, const std::string& label) -> Element {
bool active = (col_idx == resize_col);
std::string display = active ? label + "" : label;
Element e = text(display)
| bold
| color(active ? Color::Yellow : Color::Cyan)
| size(WIDTH, EQUAL, col_widths[col_idx]);
if (active) e = e | underlined;
return e;
};
grid_matrix.push_back({
header_cell(0, " IP ADDRESS"),
header_cell(1, "GEOLOCATION"),
header_cell(2, "STATUS"),
header_cell(3, "CLIENT USER-AGENT"),
text("HTTP REQUEST") | bold | color(Color::Cyan) | flex,
});
// Data rows
for (int i = 0; i < (int)filtered_logs.size(); ++i) {
const auto& log = filtered_logs[i];
std::string geo = log.location.city.empty()
? log.location.country
: log.location.city + ", " + log.location.country;
bool sel = (i == selected_row);
Color status_color = Color::Red;
if (!log.status.empty()) {
char first = log.status[0];
if (first == '2') status_color = Color::Green;
else if (first == '3') status_color = Color::GrayLight;
}
auto ip_cell = text(" " + log.ip) | size(WIDTH, EQUAL, col_widths[0]);
auto geo_cell = text(geo) | size(WIDTH, EQUAL, col_widths[1]);
auto status_cell = text(log.status) | size(WIDTH, EQUAL, col_widths[2])
| color(sel ? Color::Cyan : status_color);
auto agent_cell = text(log.browser + " (" + log.os + ")") | size(WIDTH, EQUAL, col_widths[3]);
auto request_cell = text(log.request) | flex;
if (sel) {
ip_cell = ip_cell | inverted | color(Color::Cyan) | focus;
geo_cell = geo_cell | inverted | color(Color::Cyan);
status_cell = status_cell | inverted | color(Color::Cyan);
agent_cell = agent_cell | inverted | color(Color::Cyan);
request_cell = request_cell | inverted | color(Color::Cyan);
}
grid_matrix.push_back({ip_cell, geo_cell, status_cell, agent_cell, request_cell});
}
if (filtered_logs.empty()) {
grid_matrix.push_back({
text(" No records matching filter criteria found. ") | dim
});
}
auto table_grid = gridbox(std::move(grid_matrix));
auto box_layout = vbox({ table_grid }) | vscroll_indicator | frame | flex;
std::string resize_hint =
" Resize columns: Shift+← / Shift+→ "
"│ Switch column: ← / → "
"│ Scroll: ↑↓ or wheel "
"│ q = quit ";
return vbox({
text(" ParseLogCLI Live Monitor ") | bold | color(Color::Blue) | border,
vbox({
hbox({ text(" DATA SOURCE: ") | bold | color(Color::Green), text(log_file_path) }),
hbox({ text(" DATABASE: ") | bold | color(Color::Green), text(mmdb_path) }),
}) | borderLight,
hbox({
text(" FILTER: ") | bold | color(Color::Yellow),
search_input->Render() | flex,
}) | borderLight,
text(resize_hint) | dim,
separator(),
box_layout | flex,
});
});
// ── 7. Event loop ─────────────────────────────────────────────────────────
auto screen = ScreenInteractive::Fullscreen();
auto global_events = CatchEvent(main_renderer, [&](Event event) {
if (event == Event::Character('q') || event == Event::Escape) {
screen.Exit();
return true;
}
if (event == Event::Return && !setup_complete) {
run_log_parsing();
return true;
}
if (!setup_complete) return false;
// Mouse wheel scrolling
if (event.is_mouse()) {
auto& m = event.mouse();
auto filtered_size = [&]() {
int n = 0;
for (const auto& log : active_logs) {
std::string geo = log.location.city + ", " + log.location.country;
if (search_query.empty() ||
log.ip.find(search_query) != std::string::npos ||
log.status.find(search_query) != std::string::npos ||
log.request.find(search_query) != std::string::npos ||
geo.find(search_query) != std::string::npos)
++n;
}
return n;
};
if (m.button == Mouse::WheelDown) {
int sz = filtered_size();
if (sz > 0) selected_row = std::min(selected_row + 3, sz - 1);
return true;
}
if (m.button == Mouse::WheelUp) {
selected_row = std::max(selected_row - 3, 0);
return true;
}
return false;
}
// Keyboard row navigation
if (!active_logs.empty()) {
if (event == Event::ArrowDown) {
auto fl = get_filtered();
if (!fl.empty()) selected_row = std::min(selected_row + 1, (int)fl.size() - 1);
return true;
}
if (event == Event::ArrowUp) {
selected_row = std::max(selected_row - 1, 0);
return true;
}
}
// Column resizing
if (event == Event::Special("\033[1;2C")) {
col_widths[resize_col] = std::min(col_widths[resize_col] + 2, COL_MAX_WIDTH);
return true;
}
if (event == Event::Special("\033[1;2D")) {
col_widths[resize_col] = std::max(col_widths[resize_col] - 2, COL_MIN_WIDTH);
return true;
}
// Target columns selection
if (event == Event::Special("\033[1;3C") || event == Event::ArrowRight) {
resize_col = (resize_col + 1) % (int)col_widths.size();
return true;
}
if (event == Event::Special("\033[1;3D") || event == Event::ArrowLeft) {
resize_col = (resize_col - 1 + (int)col_widths.size()) % (int)col_widths.size();
return true;
}
return false;
});
screen.Loop(global_events);
return 0; return 0;
} }