From 42f50c9e9a553c5bb561ffbf37286fb059e97a4b Mon Sep 17 00:00:00 2001 From: rapturate Date: Tue, 9 Jun 2026 14:29:42 -0400 Subject: [PATCH] 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 --- .gitignore | 7 +- CMakeLists.txt | 94 ++++++++--- README.md | 10 ++ env_reader/env.cpp | 83 ++++++++++ env_reader/env.hpp | 37 +++++ ip_to_geo/ip_to_geo.cpp | 37 +---- log_parsing/log_parsing.cpp | 11 +- log_parsing/log_parsing.hpp | 2 + main.cpp | 318 +++++++++++++++++++++++++++++++++++- 9 files changed, 537 insertions(+), 62 deletions(-) create mode 100644 README.md create mode 100644 env_reader/env.cpp create mode 100644 env_reader/env.hpp diff --git a/.gitignore b/.gitignore index a8f1349..cb5136d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,9 @@ compile_commands.json # ========================================== # If you download large MaxMind .mmdb files, ignore them so they don't bloat Git *.mmdb -*.log \ No newline at end of file +*.log + +# ========================================== +# Sensitive Files +# ========================================== +.env diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a6613c..25cfd5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.15) +cmake_minimum_required(VERSION 3.15) project(ParseLogCLI LANGUAGES CXX) # System and compiler configurations @@ -6,14 +6,16 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(FetchContent) + # ========================================== # 1. CORE LIBRARY TARGET # ========================================== -# Compiles your core logic files into a shared library module -add_library(parselog_core - log_parsing/log_parsing.cpp - ip_to_geo/ip_to_geo.cpp - third_party/src/GeoLite2PP.cpp +add_library(parselog_core + log_parsing/log_parsing.cpp + ip_to_geo/ip_to_geo.cpp + env_reader/env.cpp + third_party/src/GeoLite2PP.cpp third_party/src/GeoLite2PP_error_category.cpp ) @@ -21,13 +23,45 @@ add_library(parselog_core target_include_directories(parselog_core PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/log_parsing ${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 -target_link_libraries(parselog_core PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/third_party/lib/libmaxminddb.a -) +# CROSS-PLATFORM FIXED: Automatically links appropriate binary dependencies based on OS +if(WIN32) + 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 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 -add_executable(parselog_cli main.cpp) -target_link_libraries(parselog_cli PRIVATE parselog_core) +FetchContent_Declare( + ftxui + 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_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() - -# 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..04f4c61 --- /dev/null +++ b/README.md @@ -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... diff --git a/env_reader/env.cpp b/env_reader/env.cpp new file mode 100644 index 0000000..a112df5 --- /dev/null +++ b/env_reader/env.cpp @@ -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 +#include +#include +#include + +/** + * @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); + } + } +} diff --git a/env_reader/env.hpp b/env_reader/env.hpp new file mode 100644 index 0000000..b4ff117 --- /dev/null +++ b/env_reader/env.hpp @@ -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 + + +/** + * @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); + diff --git a/ip_to_geo/ip_to_geo.cpp b/ip_to_geo/ip_to_geo.cpp index e04f74a..e524350 100644 --- a/ip_to_geo/ip_to_geo.cpp +++ b/ip_to_geo/ip_to_geo.cpp @@ -1,43 +1,16 @@ -#include "ip_to_geo.hpp" + #include #include -#include #include #include #include + + +#include "ip_to_geo.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) { - - // 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"); - // 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"; @@ -53,7 +26,7 @@ loc_data iplookup(const std::string& ip) { location.longitude = geo_data["longitude"]; } 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; }; \ No newline at end of file diff --git a/log_parsing/log_parsing.cpp b/log_parsing/log_parsing.cpp index 0aac086..f14f63a 100644 --- a/log_parsing/log_parsing.cpp +++ b/log_parsing/log_parsing.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -9,7 +10,9 @@ #include "../ip_to_geo/ip_to_geo.hpp" p_logs::p_logs(std::string log_path) { - + if(log_path.empty()){ + log_path = std::getenv("LOG_PATH"); + } std::ifstream file(log_path); if (!file.is_open()) { std::cerr << "Error loading " << log_path << std::endl; @@ -59,6 +62,10 @@ p_logs::p_logs(std::string log_path) { file.close(); } +std::vector p_logs::get_all_logs(){ + return logs; +} + std::string p_logs::entryx_ip(int x){ return logs[x].ip; } @@ -96,7 +103,7 @@ void p_logs::print_logs() { std::ios_base::sync_with_stdio(false); for (const auto& log : logs) { std::cout << "IP: " << log.ip.c_str() << "\n" - << "Location:" << "\n" + << "Location Data:" << "\n" << "\tCountry: " << log.location.country << "\n" << "\tSubdivision: " << log.location.subdivision << "\n" << "\tCity: " << log.location.city << "\n" diff --git a/log_parsing/log_parsing.hpp b/log_parsing/log_parsing.hpp index d4afc8e..26e591c 100644 --- a/log_parsing/log_parsing.hpp +++ b/log_parsing/log_parsing.hpp @@ -38,6 +38,8 @@ public: * @param string */ p_logs(std::string); + + std::vector get_all_logs(); /** * @brief Getter function for a specific Entry's IP diff --git a/main.cpp b/main.cpp index c968d1b..4b94676 100644 --- a/main.cpp +++ b/main.cpp @@ -1,8 +1,320 @@ +#include +#include +#include +#include + +#include +#include +#include + #include "log_parsing/log_parsing.hpp" +#include "env_reader/env.hpp" -int main(){ - p_logs logs("test_logs/access.log.txt"); - logs.print_logs(); +int main() { + using namespace ftxui; + // ── 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 active_logs; + int selected_row = 0; + + // Order: IP | GEOLOCATION | STATUS | USER-AGENT + std::array 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 { + std::vector 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> 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; } \ No newline at end of file