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